import type { Slide, SlideImage, SlideListStyle, SlideSizeId, SlideTextAlign } from "../types"; import { officeTitleFromFileName, PPTX_MIME_TYPE, readOfficeFileAsArrayBuffer } from "./fileHelpers"; export interface ImportedPowerPointDeck { title: string; slideSize?: SlideSizeId; slides: Slide[]; } export interface ExportablePowerPointDeck { title: string; slideSize?: SlideSizeId; slides: Slide[]; } interface SlideTextBlock { text: string; listStyle?: SlideListStyle; hyperlink?: string; bold?: boolean; italics?: boolean; underline?: boolean; strike?: boolean; align?: SlideTextAlign; color?: string; fontSize?: number; fontFamily?: string; } type XmlParserConstructor = new (options?: Record) => { parse: (xml: string) => unknown }; type ZipEntry = { async(type: "string"): Promise; async(type: "base64"): Promise; }; type ZipArchive = { files?: Record; file: (path: string) => ZipEntry | null }; type WritableZipArchive = { file: { (path: string): ZipEntry | null; (path: string, data: string): WritableZipArchive; }; generateAsync: (options: { type: "blob"; mimeType: string }) => Promise; }; type ZipConstructor = { loadAsync: (data: ArrayBuffer) => Promise }; 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 hyperlinkRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"; const emuPerInch = 914400; const slideTransitions = ["fade", "push", "wipe"] as const; const slideSizeLayouts: Record = { wide: { width: 13.333, height: 7.5, cx: 12192000, cy: 6858000, pptxLayout: "LAYOUT_WIDE" }, standard: { width: 10, height: 7.5, cx: 9144000, cy: 6858000, pptxLayout: "LAYOUT_4x3" } }; const themeColors: Record = { 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 { 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); const slideSize = presentationSlideSize(presentationXml); 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 relationshipsPath = `${partDirectory(path)}/_rels/${partFileName(path)}.rels`; const slideRelationshipsXml = await readOptionalParsedXml(zip, parser, relationshipsPath); const hyperlinkTargets = relationshipTargetsById(slideRelationshipsXml, hyperlinkRelationshipType, path); const slideTextBlocks = mergeOrderedSlideTextBlocks(extractSlideTextBlocks(parsedSlide, hyperlinkTargets), extractOrderedTextBodyTexts(orderedSlide)); const images = await extractSlideImages(zip, parser, parsedSlide, path, index, slideRelationshipsXml); 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); const subtitleListStyle = slideTextBlocks .slice(1) .map((block) => block.listStyle) .find((style): style is SlideListStyle => Boolean(style)); return { id: `pptx-slide-${index}`, title: slideTextBlocks[0]?.text || `Слайд ${index}`, ...(slideTextBlocks[0]?.hyperlink ? { titleHyperlink: slideTextBlocks[0].hyperlink } : {}), subtitle: slideTextBlocks.slice(1).map((block) => block.text).join("\n"), ...(slideTextBlocks[1]?.hyperlink ? { subtitleHyperlink: slideTextBlocks[1].hyperlink } : {}), notes, theme: "classic" as const, ...(hidden ? { hidden } : {}), ...(transition ? { transition } : {}), ...(backgroundColor ? { backgroundColor } : {}), titleBold: slideTextBlocks[0]?.bold ?? false, ...(slideTextBlocks[0]?.italics ? { titleItalics: true } : {}), ...(slideTextBlocks[0]?.underline ? { titleUnderline: true } : {}), ...(slideTextBlocks[0]?.strike ? { titleStrike: true } : {}), ...(slideTextBlocks[0]?.align ? { titleAlign: slideTextBlocks[0].align } : {}), ...(slideTextBlocks[0]?.color ? { titleColor: slideTextBlocks[0].color } : {}), ...(slideTextBlocks[0]?.fontSize ? { titleFontSize: slideTextBlocks[0].fontSize } : {}), ...(slideTextBlocks[0]?.fontFamily ? { titleFontFamily: slideTextBlocks[0].fontFamily } : {}), ...(slideTextBlocks[1]?.bold ? { subtitleBold: true } : {}), ...(slideTextBlocks[1]?.italics ? { subtitleItalics: true } : {}), ...(slideTextBlocks[1]?.underline ? { subtitleUnderline: true } : {}), ...(slideTextBlocks[1]?.strike ? { subtitleStrike: true } : {}), ...(slideTextBlocks[1]?.align ? { subtitleAlign: slideTextBlocks[1].align } : {}), ...(slideTextBlocks[1]?.color ? { subtitleColor: slideTextBlocks[1].color } : {}), ...(slideTextBlocks[1]?.fontSize ? { subtitleFontSize: slideTextBlocks[1].fontSize } : {}), ...(slideTextBlocks[1]?.fontFamily ? { subtitleFontFamily: slideTextBlocks[1].fontFamily } : {}), ...(subtitleListStyle ? { subtitleListStyle } : {}), images }; }) ); return { title, ...(slideSize ? { slideSize } : {}), slides }; } export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise { const moduleName = "pptxgenjs"; const pptxModule = (await import(moduleName)) as { default?: new () => Record }; const PptxGenJS = pptxModule.default ?? (pptxModule as unknown as new () => Record); const pptx = new PptxGenJS() as Record & { layout?: string; author?: string; subject?: string; title?: string; company?: string; addSlide: () => { background?: { color: string }; addText: (text: string, options: Record) => void; addImage?: (options: Record) => void; addNotes?: (notes: string) => void; }; write: (options: { outputType: "blob" | "arraybuffer" }) => Promise; }; const slideSize = normalizedSlideSizeId(deck.slideSize); const slideLayout = slideSizeLayouts[slideSize]; pptx.layout = slideLayout.pptxLayout; 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 titleBold = sourceSlide.titleBold ?? true; const titleItalics = Boolean(sourceSlide.titleItalics); const titleUnderline = Boolean(sourceSlide.titleUnderline); const titleStrike = Boolean(sourceSlide.titleStrike); const titleAlign = normalizedSlideTextAlign(sourceSlide.titleAlign); const subtitleBold = Boolean(sourceSlide.subtitleBold); const subtitleItalics = Boolean(sourceSlide.subtitleItalics); const subtitleUnderline = Boolean(sourceSlide.subtitleUnderline); const subtitleStrike = Boolean(sourceSlide.subtitleStrike); const subtitleAlign = normalizedSlideTextAlign(sourceSlide.subtitleAlign); const titleHyperlink = normalizedExternalHyperlinkTarget(sourceSlide.titleHyperlink); const subtitleHyperlink = normalizedExternalHyperlinkTarget(sourceSlide.subtitleHyperlink); 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; } const imageHyperlink = normalizedExternalHyperlinkTarget(image.hyperlink); slide.addImage({ data, x: image.x, y: image.y, w: image.width, h: image.height, altText: image.alt || `Изображение ${index + 1}`, ...(imageHyperlink ? { hyperlink: { url: imageHyperlink } } : {}) }); }); slide.addText(sourceSlide.title || "Без заголовка", { x: 0.7, y: 0.65, w: Math.max(1, slideLayout.width - 1.4), h: 0.75, fontFace: titleFontFamily, fontSize: titleFontSize, ...(titleAlign ? { align: titleAlign } : {}), bold: titleBold, italic: titleItalics, ...(titleUnderline ? { underline: { style: "sng" } } : {}), ...(titleStrike ? { strike: "sngStrike" } : {}), color: titleColor, margin: 0.04, breakLine: false, ...(titleHyperlink ? { hyperlink: { url: titleHyperlink } } : {}) }); slide.addText(sourceSlide.subtitle || "", { x: 0.75, y: 1.65, w: Math.max(1, slideLayout.width - 1.5), h: Math.max(1, slideLayout.height - 3.15), fontFace: subtitleFontFamily, fontSize: subtitleFontSize, ...(subtitleAlign ? { align: subtitleAlign } : {}), bold: subtitleBold, italic: subtitleItalics, ...(subtitleUnderline ? { underline: { style: "sng" } } : {}), ...(subtitleStrike ? { strike: "sngStrike" } : {}), color: subtitleColor, valign: "top", fit: "shrink", breakLine: false, ...(subtitleHyperlink ? { hyperlink: { url: subtitleHyperlink } } : {}) }); 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 || slide.subtitleListStyle) ? 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, hyperlinkTargets: Record = {}): SlideTextBlock[] { const textBodies = slideTextBodyRecords(slideXml); const blocks = textBodies .map(({ textBody, hyperlinkSource }): SlideTextBlock | null => { const text = extractTextBodyText(textBody); if (!text) { return null; } const runProperties = firstRecordByKey(textBody, "rPr"); const hyperlink = textBodyHyperlink(hyperlinkSource, hyperlinkTargets); const listStyle = textBodyListStyle(textBody); const bold = runTextBold(runProperties); const italics = runTextItalics(runProperties); const underline = runTextUnderline(runProperties); const strike = runTextStrike(runProperties); const align = textBodyAlign(textBody); const color = runTextColor(runProperties); const fontSize = runTextFontSize(runProperties); const fontFamily = runTextFontFamily(runProperties); return { text, ...(listStyle ? { listStyle } : {}), ...(hyperlink ? { hyperlink } : {}), bold, italics, underline, strike, ...(align ? { align } : {}), ...(color ? { color } : {}), ...(fontSize ? { fontSize } : {}), ...(fontFamily ? { fontFamily } : {}) }; }) .filter((block): block is SlideTextBlock => Boolean(block)); return blocks.length > 0 ? blocks : extractTextBlocks(slideXml).map((text) => ({ text })); } function slideTextBodyRecords(slideXml: unknown): Array<{ textBody: Record; hyperlinkSource: Record }> { const shapeEntries = recordsByKey(slideXml, "sp") .map((shape) => { const textBody = childRecord(shape, "txBody"); return Object.keys(textBody).length > 0 ? { textBody, hyperlinkSource: shape } : null; }) .filter((entry): entry is { textBody: Record; hyperlinkSource: Record } => Boolean(entry)); return shapeEntries.length > 0 ? shapeEntries : recordsByKey(slideXml, "txBody").map((textBody) => ({ textBody, hyperlinkSource: textBody })); } function textBodyHyperlink(textBody: Record, hyperlinkTargets: Record) { const hyperlink = recordsByKey(textBody, "hlinkClick") .map((record) => { const id = relationshipId(record); return id ? normalizedExternalHyperlinkTarget(hyperlinkTargets[id]) : ""; }) .find(Boolean); return hyperlink ?? ""; } 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 textBodyListStyle(textBody: Record): SlideListStyle | undefined { const paragraphStyles = toArray(textBody.p) .map(paragraphListStyle) .filter((style): style is SlideListStyle => Boolean(style)); return paragraphStyles.includes("number") ? "number" : paragraphStyles[0]; } function textBodyAlign(textBody: Record): SlideTextAlign | undefined { return toArray(textBody.p).map(paragraphAlign).find((align): align is SlideTextAlign => Boolean(align)); } function paragraphAlign(paragraph: unknown): SlideTextAlign | undefined { const paragraphProperties = childRecord(asRecord(paragraph), "pPr"); return slideTextAlignFromXml(stringValue(paragraphProperties.algn ?? paragraphProperties["@_algn"])); } function paragraphListStyle(paragraph: unknown): SlideListStyle | undefined { const paragraphProperties = childRecord(asRecord(paragraph), "pPr"); if (Object.keys(childRecord(paragraphProperties, "buAutoNum")).length > 0) { return "number"; } if (Object.keys(childRecord(paragraphProperties, "buChar")).length > 0) { return "bullet"; } return undefined; } 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) { 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 presentationSlideSize(presentationXml: unknown): SlideSizeId | undefined { const slideSize = childRecord(childRecord(presentationXml, "presentation"), "sldSz"); const cx = numberAttribute(slideSize, "cx"); const cy = numberAttribute(slideSize, "cy"); if (!cx || !cy) { return undefined; } return Object.entries(slideSizeLayouts).find(([, layout]) => Math.abs(cx - layout.cx) <= 5000 && Math.abs(cy - layout.cy) <= 5000)?.[0] as | SlideSizeId | undefined; } export function relationshipTargetsById(relationshipsXml: unknown, type?: string, sourcePartPath = "ppt/presentation.xml"): Record { const relationships = childRecord(relationshipsXml, "Relationships"); const targets: Record = {}; 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, "/"); if (/^[a-z][a-z0-9+.-]*:/i.test(normalizedTarget)) { return normalizedTarget; } 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 normalizedSlideSizeId(value?: SlideSizeId): SlideSizeId { return value === "standard" ? "standard" : "wide"; } 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, relationshipsXml?: unknown ): Promise { const relationshipsPath = `${partDirectory(slidePath)}/_rels/${partFileName(slidePath)}.rels`; const parsedRelationshipsXml = relationshipsXml ?? (await readOptionalParsedXml(zip, parser, relationshipsPath)); const imageTargets = relationshipTargetsById(parsedRelationshipsXml, imageRelationshipType, slidePath); const hyperlinkTargets = relationshipTargetsById(parsedRelationshipsXml, hyperlinkRelationshipType, slidePath); const pictures = recordsByKey(slideXml, "pic"); const images = await Promise.all( pictures.map(async (picture, imageIndex) => slideImageFromPicture(zip, picture, imageTargets, hyperlinkTargets, slideIndex, imageIndex)) ); return images.filter((image): image is SlideImage => Boolean(image)); } async function slideImageFromPicture( zip: ZipArchive, picture: Record, imageTargets: Record, hyperlinkTargets: Record, slideIndex: number, imageIndex: number ): Promise { const relationshipId = pictureRelationshipId(picture); const target = relationshipId ? imageTargets[relationshipId] : ""; const src = target ? await zipImageDataUri(zip, target) : ""; if (!src) { return null; } const hyperlink = pictureHyperlink(picture, hyperlinkTargets); return { id: `pptx-slide-${slideIndex}-image-${imageIndex + 1}`, src, alt: pictureAltText(picture) || `Изображение ${imageIndex + 1}`, ...(hyperlink ? { hyperlink } : {}), ...pictureBounds(picture) }; } function pictureHyperlink(picture: Record, hyperlinkTargets: Record) { return ( recordsByKey(picture, "hlinkClick") .map((record) => { const id = relationshipId(record); return id ? normalizedExternalHyperlinkTarget(hyperlinkTargets[id]) : ""; }) .find(Boolean) ?? "" ); } function pictureRelationshipId(picture: Record) { 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) { const cNvPr = firstRecordByKey(picture, "cNvPr"); return stringValue(cNvPr.descr ?? cNvPr["@_descr"] ?? cNvPr.name ?? cNvPr["@_name"]); } function pictureBounds(picture: Record) { 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 = { 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 { 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 && !slide.subtitleListStyle) { 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; const withTransition = slide.transition ? pptxSlideXmlWithTransition(withHiddenState, slide.transition) : withHiddenState; return slide.subtitleListStyle ? pptxSlideXmlWithSubtitleList(withTransition, slide.subtitleListStyle) : withTransition; } function pptxSlideXmlWithHiddenState(xml: string) { return xml.replace(/]*)>/, (_match, attributes: string) => { const withoutShow = attributes.replace(/\sshow="[^"]*"/, ""); return ``; }); } function pptxSlideXmlWithTransition(xml: string, transition: NonNullable) { const transitionXml = ``; if (//.test(xml)) { return xml.replace(//, transitionXml); } if (/]*\/>/.test(xml)) { return xml.replace(/]*\/>/, transitionXml); } if (/<\/p:cSld>/.test(xml)) { return xml.replace("", `${transitionXml}`); } return xml.replace(/]*>/, (match) => `${match}${transitionXml}`); } function pptxSlideXmlWithSubtitleList(xml: string, listStyle: SlideListStyle) { let textBodyIndex = 0; return xml.replace(//g, (textBodyXml) => { textBodyIndex += 1; return textBodyIndex === 2 ? pptxTextBodyXmlWithList(textBodyXml, listStyle) : textBodyXml; }); } function pptxTextBodyXmlWithList(textBodyXml: string, listStyle: SlideListStyle) { return textBodyXml.replace(/[\s\S]*?<\/a:p>/g, (paragraphXml) => pptxParagraphXmlWithList(paragraphXml, listStyle)); } function pptxParagraphXmlWithList(paragraphXml: string, listStyle: SlideListStyle) { const listXml = pptxParagraphListXml(listStyle); if (/]*\/>/.test(paragraphXml)) { return paragraphXml.replace(/]*)\/>/, `${listXml}`); } if (/]*)>([\s\S]*?)<\/a:pPr>/, (_match, attributes: string, content: string) => { const contentWithoutBullet = content.replace(/]*\/>/g, ""); return `${listXml}${contentWithoutBullet}`; }); } return paragraphXml.replace("", `${listXml}`); } function pptxParagraphListXml(listStyle: SlideListStyle) { return listStyle === "number" ? '' : ''; } function normalizeHexColor(value?: string) { const raw = value?.trim().replace(/^#/, "").toUpperCase() ?? ""; return /^[0-9A-F]{6}$/.test(raw) ? raw : ""; } function normalizedExternalHyperlinkTarget(value?: string) { const raw = value?.trim(); if (!raw) { return ""; } try { const url = new URL(raw); return ["http:", "https:", "mailto:", "tel:", "ftp:"].includes(url.protocol.toLowerCase()) ? url.href : ""; } catch { return ""; } } function runTextColor(runProperties: Record) { const solidFill = childRecord(runProperties, "solidFill"); const srgbColor = childRecord(solidFill, "srgbClr"); return normalizeHexColor(stringValue(srgbColor.val ?? srgbColor["@_val"])); } function runTextBold(runProperties: Record) { return trueXmlBooleanAttribute(runProperties, "b"); } function runTextItalics(runProperties: Record) { return trueXmlBooleanAttribute(runProperties, "i"); } function runTextUnderline(runProperties: Record) { const value = stringValue(runProperties.u ?? runProperties["@_u"]).trim().toLowerCase(); return Boolean(value && value !== "none"); } function runTextStrike(runProperties: Record) { const value = stringValue(runProperties.strike ?? runProperties["@_strike"]).trim().toLowerCase(); return Boolean(value && value !== "nostrike"); } function runTextFontSize(runProperties: Record) { const rawSize = numberAttribute(runProperties, "sz"); return rawSize ? normalizeFontSize(rawSize / 100) : undefined; } function runTextFontFamily(runProperties: Record) { 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 normalizedSlideTextAlign(value?: string) { return value === "left" || value === "center" || value === "right" ? value : undefined; } function slideTextAlignFromXml(value: string): SlideTextAlign | undefined { switch (value.trim().toLowerCase()) { case "l": case "left": return "left"; case "ctr": case "center": return "center"; case "r": case "right": return "right"; default: return undefined; } } function numberAttribute(record: Record, 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 trueXmlBooleanAttribute(record: Record, name: string) { const value = record[name] ?? record[`@_${name}`]; const normalized = String(value ?? "").trim(); return Boolean(normalized && !isFalseXmlBoolean(value)); } function emuToInches(value: number) { return Math.max(0, Math.round((value / emuPerInch) * 1000) / 1000); } async function loadZip(file: File): Promise { const jsZip = await loadJsZip(); return jsZip.loadAsync(await readOfficeFileAsArrayBuffer(file)); } async function loadJsZip(): Promise { 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 { const xml = await readOptionalZipText(zip, path); return xml ? parser.parse(xml) : undefined; } async function readRequiredZipText(zip: ZipArchive, path: string, errorMessage: string): Promise { const text = await readOptionalZipText(zip, path); if (!text) { throw new Error(errorMessage); } return text; } async function readOptionalZipText(zip: ZipArchive, path: string): Promise { return (await zip.file(path)?.async("string")) ?? ""; } async function slideNotesPath(zip: ZipArchive, parser: { parse: (xml: string) => unknown }, slidePath: string): Promise { 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; 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; 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 { return stringValue(record["r:id"] ?? record.id ?? record.rId ?? record["@_r:id"] ?? record["@_id"] ?? record["@_rId"]); } function childRecord(parent: unknown, key: string): Record { const record = asRecord(parent); return asRecord(record[key]); } function asRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; } function toArray(value: unknown): unknown[] { if (value === undefined || value === null) { return []; } return Array.isArray(value) ? value : [value]; } function recordsByKey(node: unknown, key: string): Array> { const records: Array> = []; collectRecordsByKey(node, key, records); return records; } function firstRecordByKey(node: unknown, key: string): Record { return recordsByKey(node, key)[0] ?? {}; } function collectRecordsByKey(node: unknown, key: string, records: Array>): 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).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) : ""; }