Files
QOffice/src/io/powerPointOffice.ts
T
2026-06-01 20:12:49 +03:00

817 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Slide, SlideImage } from "../types";
import { officeTitleFromFileName, PPTX_MIME_TYPE, readOfficeFileAsArrayBuffer } from "./fileHelpers";
export interface ImportedPowerPointDeck {
title: string;
slides: Slide[];
}
export interface ExportablePowerPointDeck {
title: string;
slides: Slide[];
}
interface SlideTextBlock {
text: string;
color?: string;
fontSize?: number;
fontFamily?: string;
}
type XmlParserConstructor = new (options?: Record<string, unknown>) => { parse: (xml: string) => unknown };
type ZipEntry = {
async(type: "string"): Promise<string>;
async(type: "base64"): Promise<string>;
};
type ZipArchive = { files?: Record<string, unknown>; file: (path: string) => ZipEntry | null };
type WritableZipArchive = {
file: {
(path: string): ZipEntry | null;
(path: string, data: string): WritableZipArchive;
};
generateAsync: (options: { type: "blob"; mimeType: string }) => Promise<Blob>;
};
type ZipConstructor = { loadAsync: (data: ArrayBuffer) => Promise<WritableZipArchive> };
const slidePathPattern = /^ppt\/slides\/slide(\d+)\.xml$/;
const slideRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide";
const notesRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide";
const imageRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
const emuPerInch = 914400;
const slideTransitions = ["fade", "push", "wipe"] as const;
const themeColors: Record<Slide["theme"], { background: string; title: string; subtitle: string }> = {
classic: { background: "FFFFFF", title: "202124", subtitle: "3C4043" },
ocean: { background: "EAF6FF", title: "075985", subtitle: "0F766E" },
graphite: { background: "1F2937", title: "F9FAFB", subtitle: "CBD5E1" }
};
export async function importPptxFile(file: File): Promise<ImportedPowerPointDeck> {
if (!file.name.toLowerCase().endsWith(".pptx")) {
throw new Error("QPowerPoint импортирует только файлы Microsoft PowerPoint .pptx.");
}
const zip = await loadZip(file);
const parser = await createXmlParser();
const orderedParser = await createOrderedXmlParser();
const title = officeTitleFromFileName(file.name, "Презентация QPowerPoint.pptx");
const presentationXml = await readOptionalParsedXml(zip, parser, "ppt/presentation.xml");
const presentationRelationshipsXml = await readOptionalParsedXml(zip, parser, "ppt/_rels/presentation.xml.rels");
const slidePaths = listSlidePaths(zip, presentationXml, presentationRelationshipsXml);
if (slidePaths.length === 0) {
throw new Error("В файле PowerPoint не найдены слайды.");
}
const slides = await Promise.all(
slidePaths.map(async ({ path, index }) => {
const slideXml = await readRequiredZipText(zip, path, "Не удалось прочитать слайд PowerPoint.");
const parsedSlide = parser.parse(slideXml);
const orderedSlide = orderedParser.parse(slideXml);
const slideTextBlocks = mergeOrderedSlideTextBlocks(extractSlideTextBlocks(parsedSlide), extractOrderedTextBodyTexts(orderedSlide));
const images = await extractSlideImages(zip, parser, parsedSlide, path, index);
const notesPath = await slideNotesPath(zip, parser, path);
const notesXml = notesPath ? await readOptionalZipText(zip, notesPath) : await readOptionalZipText(zip, `ppt/notesSlides/notesSlide${index}.xml`);
const notes = notesXml
? extractOrderedTextBodyTexts(orderedParser.parse(notesXml)).join("\n") || extractTextBlocks(parser.parse(notesXml)).join("\n")
: "";
const backgroundColor = slideBackgroundColor(parsedSlide);
const hidden = slideHidden(parsedSlide);
const transition = slideTransition(parsedSlide);
return {
id: `pptx-slide-${index}`,
title: slideTextBlocks[0]?.text || `Слайд ${index}`,
subtitle: slideTextBlocks.slice(1).map((block) => block.text).join("\n"),
notes,
theme: "classic" as const,
...(hidden ? { hidden } : {}),
...(transition ? { transition } : {}),
...(backgroundColor ? { backgroundColor } : {}),
...(slideTextBlocks[0]?.color ? { titleColor: slideTextBlocks[0].color } : {}),
...(slideTextBlocks[0]?.fontSize ? { titleFontSize: slideTextBlocks[0].fontSize } : {}),
...(slideTextBlocks[0]?.fontFamily ? { titleFontFamily: slideTextBlocks[0].fontFamily } : {}),
...(slideTextBlocks[1]?.color ? { subtitleColor: slideTextBlocks[1].color } : {}),
...(slideTextBlocks[1]?.fontSize ? { subtitleFontSize: slideTextBlocks[1].fontSize } : {}),
...(slideTextBlocks[1]?.fontFamily ? { subtitleFontFamily: slideTextBlocks[1].fontFamily } : {}),
images
};
})
);
return { title, slides };
}
export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Blob> {
const moduleName = "pptxgenjs";
const pptxModule = (await import(moduleName)) as { default?: new () => Record<string, unknown> };
const PptxGenJS = pptxModule.default ?? (pptxModule as unknown as new () => Record<string, unknown>);
const pptx = new PptxGenJS() as Record<string, unknown> & {
layout?: string;
author?: string;
subject?: string;
title?: string;
company?: string;
addSlide: () => {
background?: { color: string };
addText: (text: string, options: Record<string, unknown>) => void;
addImage?: (options: Record<string, unknown>) => void;
addNotes?: (notes: string) => void;
};
write: (options: { outputType: "blob" | "arraybuffer" }) => Promise<Blob | ArrayBuffer>;
};
pptx.layout = "LAYOUT_WIDE";
pptx.author = "QOffice";
pptx.company = "QOffice";
pptx.subject = "Экспорт QPowerPoint";
pptx.title = deck.title;
for (const sourceSlide of deck.slides) {
const colors = themeColors[sourceSlide.theme] ?? themeColors.classic;
const titleColor = normalizeHexColor(sourceSlide.titleColor) || colors.title;
const subtitleColor = normalizeHexColor(sourceSlide.subtitleColor) || colors.subtitle;
const titleFontSize = normalizeFontSize(sourceSlide.titleFontSize) || 30;
const subtitleFontSize = normalizeFontSize(sourceSlide.subtitleFontSize) || 18;
const titleFontFamily = normalizeFontFamily(sourceSlide.titleFontFamily) || "Arial";
const subtitleFontFamily = normalizeFontFamily(sourceSlide.subtitleFontFamily) || "Arial";
const slide = pptx.addSlide();
slide.background = { color: normalizeHexColor(sourceSlide.backgroundColor) || colors.background };
sourceSlide.images?.forEach((image, index) => {
const data = normalizedImageData(image.src);
if (!data || !slide.addImage) {
return;
}
slide.addImage({
data,
x: image.x,
y: image.y,
w: image.width,
h: image.height,
altText: image.alt || `Изображение ${index + 1}`
});
});
slide.addText(sourceSlide.title || "Без заголовка", {
x: 0.7,
y: 0.65,
w: 11.9,
h: 0.75,
fontFace: titleFontFamily,
fontSize: titleFontSize,
bold: true,
color: titleColor,
margin: 0.04,
breakLine: false
});
slide.addText(sourceSlide.subtitle || "", {
x: 0.75,
y: 1.65,
w: 11.5,
h: 4.35,
fontFace: subtitleFontFamily,
fontSize: subtitleFontSize,
color: subtitleColor,
valign: "top",
fit: "shrink",
breakLine: false
});
if (sourceSlide.notes.trim() && slide.addNotes) {
slide.addNotes(sourceSlide.notes);
}
}
const result = await pptx.write({ outputType: "blob" });
const blob = result instanceof Blob ? result : new Blob([result], { type: PPTX_MIME_TYPE });
return deck.slides.some((slide) => slide.hidden || slide.transition) ? applySlideXmlOverridesToPptxBlob(blob, deck.slides) : blob;
}
export function extractTextBlocks(xmlNode: unknown): string[] {
const blocks: string[] = [];
collectTextBlocks(xmlNode, blocks);
return blocks.map(normalizeWhitespace).filter(Boolean);
}
function extractSlideTextBlocks(slideXml: unknown): SlideTextBlock[] {
const textBodies = recordsByKey(slideXml, "txBody");
const blocks = textBodies
.map((textBody) => {
const text = extractTextBodyText(textBody);
if (!text) {
return null;
}
const runProperties = firstRecordByKey(textBody, "rPr");
const color = runTextColor(runProperties);
const fontSize = runTextFontSize(runProperties);
const fontFamily = runTextFontFamily(runProperties);
return {
text,
...(color ? { color } : {}),
...(fontSize ? { fontSize } : {}),
...(fontFamily ? { fontFamily } : {})
};
})
.filter((block): block is SlideTextBlock => Boolean(block));
return blocks.length > 0 ? blocks : extractTextBlocks(slideXml).map((text) => ({ text }));
}
function mergeOrderedSlideTextBlocks(styledBlocks: SlideTextBlock[], orderedTexts: string[]) {
if (orderedTexts.length === 0) {
return styledBlocks;
}
return orderedTexts.map((text, index) => {
const styledBlock = styledBlocks[index] ?? { text };
return { ...styledBlock, text };
});
}
function extractOrderedTextBodyTexts(xmlNode: unknown) {
return orderedElementsByName(xmlNode, "txBody").map(orderedTextBodyText).filter(Boolean);
}
function orderedTextBodyText(textBody: unknown) {
const paragraphs = orderedDirectElements(textBody, "p").map(orderedParagraphText).filter(Boolean);
return paragraphs.length > 0 ? paragraphs.join("\n") : "";
}
function orderedParagraphText(paragraph: unknown) {
return normalizeTextWithLineBreaks(
orderedElementChildren(paragraph)
.map((child) => {
const name = orderedElementName(child);
if (name === "r" || name === "fld") {
return orderedXmlText(child);
}
if (name === "br") {
return "\n";
}
return "";
})
.join("")
);
}
function extractTextBodyText(textBody: Record<string, unknown>) {
const paragraphs = toArray(textBody.p)
.map((paragraph) => normalizeWhitespace(extractTextBlocks(paragraph).join("")))
.filter(Boolean);
return paragraphs.length > 0 ? paragraphs.join("\n") : normalizeWhitespace(extractTextBlocks(textBody).join("\n"));
}
export function listSlidePaths(
zip: ZipArchive,
presentationXml?: unknown,
presentationRelationshipsXml?: unknown
): Array<{ path: string; index: number }> {
const orderedSlidePaths = presentationSlidePaths(presentationXml, presentationRelationshipsXml);
if (orderedSlidePaths.length > 0) {
return orderedSlidePaths.map((path, index) => ({ path, index: slideIndexFromPath(path) ?? index + 1 }));
}
return Object.keys(zip.files ?? {})
.map((path) => {
const match = slidePathPattern.exec(path);
return match ? { path, index: Number(match[1]) } : null;
})
.filter((entry): entry is { path: string; index: number } => entry !== null)
.sort((a, b) => a.index - b.index);
}
export function presentationSlidePaths(presentationXml: unknown, presentationRelationshipsXml: unknown): string[] {
const relationshipTargets = relationshipTargetsById(presentationRelationshipsXml, slideRelationshipType);
const slideIds = toArray(childRecord(childRecord(presentationXml, "presentation"), "sldIdLst").sldId)
.map((entry) => relationshipId(asRecord(entry)))
.filter(Boolean);
return slideIds.map((id) => relationshipTargets[id]).filter((target): target is string => Boolean(target));
}
export function relationshipTargetsById(relationshipsXml: unknown, type?: string, sourcePartPath = "ppt/presentation.xml"): Record<string, string> {
const relationships = childRecord(relationshipsXml, "Relationships");
const targets: Record<string, string> = {};
const baseDirectory = partDirectory(sourcePartPath);
toArray(relationships.Relationship).forEach((relationship) => {
const record = asRecord(relationship);
const id = stringValue(record.Id ?? record.id ?? record["@_Id"] ?? record["@_id"]);
const target = stringValue(record.Target ?? record.target ?? record["@_Target"] ?? record["@_target"]);
const relationshipType = stringValue(record.Type ?? record.type ?? record["@_Type"] ?? record["@_type"]);
if (id && target && (!type || relationshipType === type)) {
targets[id] = resolvePptTarget(baseDirectory, target);
}
});
return targets;
}
export function relationshipTargetByType(relationshipsXml: unknown, type: string, sourcePartPath: string): string {
const relationships = childRecord(relationshipsXml, "Relationships");
const relationship = toArray(relationships.Relationship)
.map(asRecord)
.find((record) => stringValue(record.Type ?? record.type ?? record["@_Type"] ?? record["@_type"]) === type);
return relationship ? resolvePptTarget(partDirectory(sourcePartPath), stringValue(relationship.Target ?? relationship.target ?? relationship["@_Target"] ?? relationship["@_target"])) : "";
}
export function resolvePptTarget(baseDirectory: string, target: string): string {
const normalizedBase = baseDirectory.replace(/\\/g, "/").replace(/\/+$/, "");
const normalizedTarget = target.replace(/\\/g, "/");
const rawPath = normalizedTarget.startsWith("/") ? normalizedTarget.replace(/^\/+/, "") : `${normalizedBase}/${normalizedTarget}`;
const segments: string[] = [];
rawPath.split("/").forEach((segment) => {
if (!segment || segment === ".") {
return;
}
if (segment === "..") {
segments.pop();
return;
}
segments.push(segment);
});
return segments.join("/");
}
export function normalizeWhitespace(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function normalizeTextWithLineBreaks(value: string): string {
return value
.split("\n")
.map(normalizeWhitespace)
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
async function extractSlideImages(
zip: ZipArchive,
parser: { parse: (xml: string) => unknown },
slideXml: unknown,
slidePath: string,
slideIndex: number
): Promise<SlideImage[]> {
const relationshipsPath = `${partDirectory(slidePath)}/_rels/${partFileName(slidePath)}.rels`;
const relationshipsXml = await readOptionalParsedXml(zip, parser, relationshipsPath);
const imageTargets = relationshipTargetsById(relationshipsXml, imageRelationshipType, slidePath);
const pictures = recordsByKey(slideXml, "pic");
const images = await Promise.all(
pictures.map(async (picture, imageIndex) => slideImageFromPicture(zip, picture, imageTargets, slideIndex, imageIndex))
);
return images.filter((image): image is SlideImage => Boolean(image));
}
async function slideImageFromPicture(
zip: ZipArchive,
picture: Record<string, unknown>,
imageTargets: Record<string, string>,
slideIndex: number,
imageIndex: number
): Promise<SlideImage | null> {
const relationshipId = pictureRelationshipId(picture);
const target = relationshipId ? imageTargets[relationshipId] : "";
const src = target ? await zipImageDataUri(zip, target) : "";
if (!src) {
return null;
}
return {
id: `pptx-slide-${slideIndex}-image-${imageIndex + 1}`,
src,
alt: pictureAltText(picture) || `Изображение ${imageIndex + 1}`,
...pictureBounds(picture)
};
}
function pictureRelationshipId(picture: Record<string, unknown>) {
const blip = firstRecordByKey(picture, "blip");
return stringValue(
blip["r:embed"] ??
blip.embed ??
blip["@_r:embed"] ??
blip["@_embed"] ??
blip["r:link"] ??
blip.link ??
blip["@_r:link"] ??
blip["@_link"]
);
}
function pictureAltText(picture: Record<string, unknown>) {
const cNvPr = firstRecordByKey(picture, "cNvPr");
return stringValue(cNvPr.descr ?? cNvPr["@_descr"] ?? cNvPr.name ?? cNvPr["@_name"]);
}
function pictureBounds(picture: Record<string, unknown>) {
const xfrm = firstRecordByKey(picture, "xfrm");
const off = childRecord(xfrm, "off");
const ext = childRecord(xfrm, "ext");
return {
x: emuToInches(numberAttribute(off, "x") ?? 914400),
y: emuToInches(numberAttribute(off, "y") ?? 3429000),
width: emuToInches(numberAttribute(ext, "cx") ?? 3657600),
height: emuToInches(numberAttribute(ext, "cy") ?? 2057400)
};
}
async function zipImageDataUri(zip: ZipArchive, path: string) {
const mimeType = imageMimeTypeFromPath(path);
if (!mimeType) {
return "";
}
const data = (await zip.file(path)?.async("base64")) ?? "";
return data ? `data:${mimeType};base64,${data}` : "";
}
function imageMimeTypeFromPath(path: string) {
const extension = path.toLowerCase().split(/[?#]/)[0].split(".").pop() ?? "";
const mimeTypes: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
bmp: "image/bmp",
svg: "image/svg+xml"
};
return mimeTypes[extension] ?? "";
}
function normalizedImageData(src: string) {
const value = src.trim();
return /^data:image\/(?:png|jpe?g|gif|bmp|svg\+xml);base64,/i.test(value) ? value : "";
}
function slideBackgroundColor(slideXml: unknown) {
const background = firstRecordByKey(slideXml, "bg");
const backgroundProperties = childRecord(background, "bgPr");
const solidFill = childRecord(backgroundProperties, "solidFill");
const srgbColor = childRecord(solidFill, "srgbClr");
return normalizeHexColor(stringValue(srgbColor.val ?? srgbColor["@_val"]));
}
function slideHidden(slideXml: unknown) {
const slide = childRecord(slideXml, "sld");
return isFalseXmlBoolean(slide.show ?? slide["@_show"]);
}
function slideTransition(slideXml: unknown): Slide["transition"] | undefined {
const transition = childRecord(childRecord(slideXml, "sld"), "transition");
const type = Object.keys(transition).find((key) => isSlideTransition(key));
return isSlideTransition(type) ? type : undefined;
}
function isSlideTransition(value: unknown): value is NonNullable<Slide["transition"]> {
return typeof value === "string" && (slideTransitions as readonly string[]).includes(value);
}
async function applySlideXmlOverridesToPptxBlob(blob: Blob, slides: Slide[]) {
const JSZip = await loadJsZip();
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
await Promise.all(
slides.map(async (slide, index) => {
if (!slide.hidden && !slide.transition) {
return;
}
const path = `ppt/slides/slide${index + 1}.xml`;
const xml = await zip.file(path)?.async("string");
if (xml) {
zip.file(path, pptxSlideXmlWithOverrides(xml, slide));
}
})
);
return zip.generateAsync({ type: "blob", mimeType: PPTX_MIME_TYPE });
}
function pptxSlideXmlWithOverrides(xml: string, slide: Slide) {
const withHiddenState = slide.hidden ? pptxSlideXmlWithHiddenState(xml) : xml;
return slide.transition ? pptxSlideXmlWithTransition(withHiddenState, slide.transition) : withHiddenState;
}
function pptxSlideXmlWithHiddenState(xml: string) {
return xml.replace(/<p:sld\b([^>]*)>/, (_match, attributes: string) => {
const withoutShow = attributes.replace(/\sshow="[^"]*"/, "");
return `<p:sld${withoutShow} show="0">`;
});
}
function pptxSlideXmlWithTransition(xml: string, transition: NonNullable<Slide["transition"]>) {
const transitionXml = `<p:transition><p:${transition}/></p:transition>`;
if (/<p:transition\b[\s\S]*?<\/p:transition>/.test(xml)) {
return xml.replace(/<p:transition\b[\s\S]*?<\/p:transition>/, transitionXml);
}
if (/<p:transition\b[^>]*\/>/.test(xml)) {
return xml.replace(/<p:transition\b[^>]*\/>/, transitionXml);
}
if (/<\/p:cSld>/.test(xml)) {
return xml.replace("</p:cSld>", `</p:cSld>${transitionXml}`);
}
return xml.replace(/<p:sld\b[^>]*>/, (match) => `${match}${transitionXml}`);
}
function normalizeHexColor(value?: string) {
const raw = value?.trim().replace(/^#/, "").toUpperCase() ?? "";
return /^[0-9A-F]{6}$/.test(raw) ? raw : "";
}
function runTextColor(runProperties: Record<string, unknown>) {
const solidFill = childRecord(runProperties, "solidFill");
const srgbColor = childRecord(solidFill, "srgbClr");
return normalizeHexColor(stringValue(srgbColor.val ?? srgbColor["@_val"]));
}
function runTextFontSize(runProperties: Record<string, unknown>) {
const rawSize = numberAttribute(runProperties, "sz");
return rawSize ? normalizeFontSize(rawSize / 100) : undefined;
}
function runTextFontFamily(runProperties: Record<string, unknown>) {
return normalizeFontFamily(
[childRecord(runProperties, "latin"), childRecord(runProperties, "ea"), childRecord(runProperties, "cs")]
.map((font) => stringValue(font.typeface ?? font["@_typeface"]))
.find(Boolean)
);
}
function normalizeFontSize(value?: number) {
if (!Number.isFinite(value) || !value || value <= 0) {
return undefined;
}
return Math.round(value * 2) / 2;
}
function normalizeFontFamily(value?: string) {
const normalized = value?.trim().replace(/["<>]/g, "").slice(0, 80) ?? "";
return normalized && normalized.toLowerCase() !== "arial" ? normalized : "";
}
function numberAttribute(record: Record<string, unknown>, name: string) {
const value = Number.parseFloat(stringValue(record[name] ?? record[`@_${name}`]));
return Number.isFinite(value) ? value : undefined;
}
function isFalseXmlBoolean(value: unknown) {
const normalized = String(value ?? "").trim().toLowerCase();
return normalized === "0" || normalized === "false";
}
function emuToInches(value: number) {
return Math.max(0, Math.round((value / emuPerInch) * 1000) / 1000);
}
async function loadZip(file: File): Promise<ZipArchive> {
const jsZip = await loadJsZip();
return jsZip.loadAsync(await readOfficeFileAsArrayBuffer(file));
}
async function loadJsZip(): Promise<ZipConstructor> {
const moduleName = "jszip";
const jsZipModule = (await import(moduleName)) as { default?: ZipConstructor } & ZipConstructor;
return jsZipModule.default ?? jsZipModule;
}
async function createXmlParser(): Promise<{ parse: (xml: string) => unknown }> {
const moduleName = "fast-xml-parser";
const parserModule = (await import(moduleName)) as { XMLParser: XmlParserConstructor };
return new parserModule.XMLParser({
ignoreAttributes: false,
removeNSPrefix: true,
textNodeName: "#text",
trimValues: true
});
}
async function createOrderedXmlParser(): Promise<{ parse: (xml: string) => unknown }> {
const moduleName = "fast-xml-parser";
const parserModule = (await import(moduleName)) as { XMLParser: XmlParserConstructor };
return new parserModule.XMLParser({
ignoreAttributes: false,
removeNSPrefix: true,
textNodeName: "#text",
trimValues: true,
preserveOrder: true
});
}
async function readOptionalParsedXml(zip: ZipArchive, parser: { parse: (xml: string) => unknown }, path: string): Promise<unknown> {
const xml = await readOptionalZipText(zip, path);
return xml ? parser.parse(xml) : undefined;
}
async function readRequiredZipText(zip: ZipArchive, path: string, errorMessage: string): Promise<string> {
const text = await readOptionalZipText(zip, path);
if (!text) {
throw new Error(errorMessage);
}
return text;
}
async function readOptionalZipText(zip: ZipArchive, path: string): Promise<string> {
return (await zip.file(path)?.async("string")) ?? "";
}
async function slideNotesPath(zip: ZipArchive, parser: { parse: (xml: string) => unknown }, slidePath: string): Promise<string> {
const relationshipsPath = `${partDirectory(slidePath)}/_rels/${partFileName(slidePath)}.rels`;
const relationshipsXml = await readOptionalParsedXml(zip, parser, relationshipsPath);
return relationshipTargetByType(relationshipsXml, notesRelationshipType, slidePath);
}
function collectTextBlocks(node: unknown, blocks: string[]): void {
if (node === null || node === undefined) {
return;
}
if (typeof node === "string" || typeof node === "number") {
blocks.push(String(node));
return;
}
if (Array.isArray(node)) {
for (const item of node) {
collectTextBlocks(item, blocks);
}
return;
}
if (typeof node !== "object") {
return;
}
const record = node as Record<string, unknown>;
if (typeof record.t === "string" || typeof record.t === "number") {
blocks.push(String(record.t));
return;
}
if (typeof record["#text"] === "string" || typeof record["#text"] === "number") {
blocks.push(String(record["#text"]));
return;
}
for (const [key, value] of Object.entries(record)) {
if (isXmlAttributeKey(key)) {
continue;
}
collectTextBlocks(value, blocks);
}
}
function isXmlAttributeKey(key: string) {
return key.startsWith("@_") || ["id", "name", "descr", "x", "y", "cx", "cy", "embed", "link"].includes(key);
}
function orderedElementsByName(node: unknown, name: string): unknown[] {
const elements: unknown[] = [];
visitOrderedElements(node, (element) => {
if (orderedElementName(element) === name) {
elements.push(element);
}
});
return elements;
}
function visitOrderedElements(node: unknown, visitor: (element: unknown) => void): void {
if (Array.isArray(node)) {
node.forEach((item) => visitOrderedElements(item, visitor));
return;
}
if (!node || typeof node !== "object") {
return;
}
visitor(node);
orderedElementChildren(node).forEach((child) => visitOrderedElements(child, visitor));
}
function orderedDirectElements(node: unknown, name: string) {
return orderedElementChildren(node).filter((child) => orderedElementName(child) === name);
}
function orderedElementName(node: unknown) {
return Object.keys(asRecord(node)).find((key) => key !== ":@") ?? "";
}
function orderedElementChildren(node: unknown): unknown[] {
const name = orderedElementName(node);
return name ? toArray(asRecord(node)[name]) : [];
}
function orderedXmlText(node: unknown): string {
if (node === null || node === undefined) {
return "";
}
if (typeof node === "string" || typeof node === "number") {
return String(node);
}
if (Array.isArray(node)) {
return node.map(orderedXmlText).join("");
}
if (typeof node !== "object") {
return "";
}
const record = node as Record<string, unknown>;
if (typeof record["#text"] === "string" || typeof record["#text"] === "number") {
return String(record["#text"]);
}
return orderedElementChildren(node).map(orderedXmlText).join("");
}
function slideIndexFromPath(path: string): number | undefined {
const match = slidePathPattern.exec(path);
return match ? Number(match[1]) : undefined;
}
function partDirectory(path: string): string {
return path.replace(/\\/g, "/").replace(/\/[^/]*$/, "");
}
function partFileName(path: string): string {
return path.replace(/\\/g, "/").split("/").pop() ?? path;
}
function relationshipId(record: Record<string, unknown>): string {
return stringValue(record["r:id"] ?? record.id ?? record.rId ?? record["@_r:id"] ?? record["@_id"] ?? record["@_rId"]);
}
function childRecord(parent: unknown, key: string): Record<string, unknown> {
const record = asRecord(parent);
return asRecord(record[key]);
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
function toArray(value: unknown): unknown[] {
if (value === undefined || value === null) {
return [];
}
return Array.isArray(value) ? value : [value];
}
function recordsByKey(node: unknown, key: string): Array<Record<string, unknown>> {
const records: Array<Record<string, unknown>> = [];
collectRecordsByKey(node, key, records);
return records;
}
function firstRecordByKey(node: unknown, key: string): Record<string, unknown> {
return recordsByKey(node, key)[0] ?? {};
}
function collectRecordsByKey(node: unknown, key: string, records: Array<Record<string, unknown>>): void {
if (node === null || node === undefined) {
return;
}
if (Array.isArray(node)) {
node.forEach((item) => collectRecordsByKey(item, key, records));
return;
}
if (typeof node !== "object") {
return;
}
Object.entries(node as Record<string, unknown>).forEach(([entryKey, value]) => {
if (entryKey === key) {
toArray(value).forEach((item) => {
const record = asRecord(item);
if (Object.keys(record).length > 0) {
records.push(record);
}
});
}
collectRecordsByKey(value, key, records);
});
}
function stringValue(value: unknown): string {
return typeof value === "string" || typeof value === "number" ? String(value) : "";
}