Files
QOffice/src/io/powerPointOffice.ts
T
2026-06-04 22:24:33 +03:00

1072 lines
40 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, 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<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 hyperlinkRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
const emuPerInch = 914400;
const slideTransitions = ["fade", "push", "wipe"] as const;
const slideSizeLayouts: Record<SlideSizeId, { width: number; height: number; cx: number; cy: number; pptxLayout: string }> = {
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<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);
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<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>;
};
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<string, string> = {}): 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<string, unknown>; hyperlinkSource: Record<string, unknown> }> {
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<string, unknown>; hyperlinkSource: Record<string, unknown> } => Boolean(entry));
return shapeEntries.length > 0 ? shapeEntries : recordsByKey(slideXml, "txBody").map((textBody) => ({ textBody, hyperlinkSource: textBody }));
}
function textBodyHyperlink(textBody: Record<string, unknown>, hyperlinkTargets: Record<string, string>) {
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<string, unknown>): 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<string, unknown>): 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<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 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<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, "/");
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<SlideImage[]> {
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<string, unknown>,
imageTargets: Record<string, string>,
hyperlinkTargets: 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;
}
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<string, unknown>, hyperlinkTargets: Record<string, string>) {
return (
recordsByKey(picture, "hlinkClick")
.map((record) => {
const id = relationshipId(record);
return id ? normalizedExternalHyperlinkTarget(hyperlinkTargets[id]) : "";
})
.find(Boolean) ?? ""
);
}
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 && !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(/<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 pptxSlideXmlWithSubtitleList(xml: string, listStyle: SlideListStyle) {
let textBodyIndex = 0;
return xml.replace(/<p:txBody\b[\s\S]*?<\/p:txBody>/g, (textBodyXml) => {
textBodyIndex += 1;
return textBodyIndex === 2 ? pptxTextBodyXmlWithList(textBodyXml, listStyle) : textBodyXml;
});
}
function pptxTextBodyXmlWithList(textBodyXml: string, listStyle: SlideListStyle) {
return textBodyXml.replace(/<a:p>[\s\S]*?<\/a:p>/g, (paragraphXml) => pptxParagraphXmlWithList(paragraphXml, listStyle));
}
function pptxParagraphXmlWithList(paragraphXml: string, listStyle: SlideListStyle) {
const listXml = pptxParagraphListXml(listStyle);
if (/<a:pPr\b[^>]*\/>/.test(paragraphXml)) {
return paragraphXml.replace(/<a:pPr\b([^>]*)\/>/, `<a:pPr$1>${listXml}</a:pPr>`);
}
if (/<a:pPr\b/.test(paragraphXml)) {
return paragraphXml.replace(/<a:pPr\b([^>]*)>([\s\S]*?)<\/a:pPr>/, (_match, attributes: string, content: string) => {
const contentWithoutBullet = content.replace(/<a:bu[A-Za-z]+\b[^>]*\/>/g, "");
return `<a:pPr${attributes}>${listXml}${contentWithoutBullet}</a:pPr>`;
});
}
return paragraphXml.replace("<a:p>", `<a:p><a:pPr>${listXml}</a:pPr>`);
}
function pptxParagraphListXml(listStyle: SlideListStyle) {
return listStyle === "number" ? '<a:buAutoNum type="arabicPeriod"/>' : '<a:buChar char="•"/>';
}
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<string, unknown>) {
const solidFill = childRecord(runProperties, "solidFill");
const srgbColor = childRecord(solidFill, "srgbClr");
return normalizeHexColor(stringValue(srgbColor.val ?? srgbColor["@_val"]));
}
function runTextBold(runProperties: Record<string, unknown>) {
return trueXmlBooleanAttribute(runProperties, "b");
}
function runTextItalics(runProperties: Record<string, unknown>) {
return trueXmlBooleanAttribute(runProperties, "i");
}
function runTextUnderline(runProperties: Record<string, unknown>) {
const value = stringValue(runProperties.u ?? runProperties["@_u"]).trim().toLowerCase();
return Boolean(value && value !== "none");
}
function runTextStrike(runProperties: Record<string, unknown>) {
const value = stringValue(runProperties.strike ?? runProperties["@_strike"]).trim().toLowerCase();
return Boolean(value && value !== "nostrike");
}
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 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<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 trueXmlBooleanAttribute(record: Record<string, unknown>, 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<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) : "";
}