import { type AspectRatio, getAspectRatioDims } from "./aspect-ratios"; import { importExportModule } from "./dynamic-import"; import { findSlideExportSource, preloadImagesWithCors, } from "./export-pdf-client"; interface PptxExportSlide { id: string; notes?: string; } function safePptxName(title: string) { const safeName = title.replace(/[^a-zA-Z0-9]/g, "-") || "deck"; return `${safeName}.pptx`; } function triggerBlobDownload(blob: Blob, filename: string) { const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; a.rel = "noopener"; document.body.appendChild(a); a.click(); a.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 60_000); } function escapeXml(value: string) { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function addRelationship(xml: string, relationship: string) { if (xml.includes(relationship)) return xml; return xml.replace("", `${relationship}`); } function addContentTypeOverride(xml: string, partName: string, type: string) { if (xml.includes(`PartName="${partName}"`)) return xml; return xml.replace( "", ``, ); } function nextRelationshipId(xml: string) { const ids = Array.from(xml.matchAll(/\bId="rId(\d+)"/g)).map((match) => Number(match[1]), ); return `rId${Math.max(0, ...ids) + 1}`; } function notesTextBody(notes: string) { const lines = notes.split(/\r?\n/); return lines .map( (line) => `${escapeXml(line)}`, ) .join(""); } function notesSlideXml(notes: string, slideNumber: number) { return ` ${notesTextBody(notes)}${slideNumber}`; } const NOTES_MASTER_XML = ` `; const NOTES_MASTER_RELS_XML = ` `; export async function addSpeakerNotesToPptxBlob( blob: Blob, slides: PptxExportSlide[], ): Promise { const hasNotes = slides.some((slide) => slide.notes?.trim()); if (!hasNotes) return blob; const { default: JSZip } = await importExportModule(() => import("jszip")); const zip = await JSZip.loadAsync(blob); const contentTypesFile = zip.file("[Content_Types].xml"); const presentationFile = zip.file("ppt/presentation.xml"); const presentationRelsFile = zip.file("ppt/_rels/presentation.xml.rels"); if (!contentTypesFile || !presentationFile || !presentationRelsFile) { return blob; } let contentTypes = await contentTypesFile.async("string"); let presentationXml = await presentationFile.async("string"); let presentationRels = await presentationRelsFile.async("string"); if (!zip.file("ppt/notesMasters/notesMaster1.xml")) { zip.file("ppt/notesMasters/notesMaster1.xml", NOTES_MASTER_XML); } if (!zip.file("ppt/notesMasters/_rels/notesMaster1.xml.rels")) { zip.file( "ppt/notesMasters/_rels/notesMaster1.xml.rels", NOTES_MASTER_RELS_XML, ); } contentTypes = addContentTypeOverride( contentTypes, "/ppt/notesMasters/notesMaster1.xml", "application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml", ); if (!presentationRels.includes("relationships/notesMaster")) { const relId = nextRelationshipId(presentationRels); presentationRels = addRelationship( presentationRels, ``, ); if (!presentationXml.includes("")) { presentationXml = presentationXml.replace( "", ``, ); } } if (!presentationXml.includes("", '', ); } for (let i = 0; i < slides.length; i++) { const notes = slides[i].notes?.trim(); if (!notes) continue; const slideNumber = i + 1; const slideRelsPath = `ppt/slides/_rels/slide${slideNumber}.xml.rels`; const slideRelsFile = zip.file(slideRelsPath); if (!slideRelsFile) continue; let slideRels = await slideRelsFile.async("string"); if (!slideRels.includes("relationships/notesSlide")) { const relId = nextRelationshipId(slideRels); slideRels = addRelationship( slideRels, ``, ); zip.file(slideRelsPath, slideRels); } zip.file( `ppt/notesSlides/notesSlide${slideNumber}.xml`, notesSlideXml(notes, slideNumber), ); zip.file( `ppt/notesSlides/_rels/notesSlide${slideNumber}.xml.rels`, ` `, ); contentTypes = addContentTypeOverride( contentTypes, `/ppt/notesSlides/notesSlide${slideNumber}.xml`, "application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml", ); } zip.file("[Content_Types].xml", contentTypes); zip.file("ppt/presentation.xml", presentationXml); zip.file("ppt/_rels/presentation.xml.rels", presentationRels); return zip.generateAsync({ type: "blob", compression: "DEFLATE", compressionOptions: { level: 6 }, }); } function createUnscaledExportClone( source: HTMLElement, dims: { width: number; height: number }, ) { const stage = document.createElement("div"); stage.setAttribute("aria-hidden", "true"); Object.assign(stage.style, { height: `${dims.height}px`, left: "-100000px", overflow: "hidden", pointerEvents: "none", position: "fixed", top: "0", width: `${dims.width}px`, zIndex: "-1", }); const clone = source.cloneNode(true) as HTMLElement; Object.assign(clone.style, { height: `${dims.height}px`, maxHeight: `${dims.height}px`, maxWidth: `${dims.width}px`, position: "relative", transform: "none", width: `${dims.width}px`, }); stage.appendChild(clone); document.body.appendChild(stage); return { element: clone, cleanup: () => stage.remove(), }; } function svgDataUrl(svg: SVGSVGElement) { const copy = svg.cloneNode(true) as SVGSVGElement; if (!copy.getAttribute("xmlns")) { copy.setAttribute("xmlns", "http://www.w3.org/2000/svg"); } const serialized = new XMLSerializer().serializeToString(copy); return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(serialized)}`; } async function rasterizeSvgElement( svg: SVGSVGElement, width: number, height: number, ) { const fallback = svgDataUrl(svg); const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); if (!ctx || typeof Image === "undefined") return fallback; const scale = Math.max(2, window.devicePixelRatio || 1); canvas.width = Math.max(1, Math.ceil(width * scale)); canvas.height = Math.max(1, Math.ceil(height * scale)); const image = new Image(); const loaded = new Promise((resolve, reject) => { image.onload = () => resolve(); image.onerror = () => reject(new Error("Could not rasterize SVG")); }); image.src = fallback; try { await loaded; ctx.drawImage(image, 0, 0, canvas.width, canvas.height); return canvas.toDataURL("image/png"); } catch { return fallback; } } async function replaceInlineSvgsWithImages(root: HTMLElement) { const svgs = Array.from(root.querySelectorAll("svg")); for (const svg of svgs) { const rect = svg.getBoundingClientRect(); const viewBox = svg.viewBox?.baseVal; const width = rect.width || Number(svg.getAttribute("width")) || viewBox?.width || 1; const height = rect.height || Number(svg.getAttribute("height")) || viewBox?.height || 1; const dataUrl = await rasterizeSvgElement(svg, width, height); const img = document.createElement("img"); const style = window.getComputedStyle(svg); img.src = dataUrl; img.alt = svg.getAttribute("aria-label") ?? ""; Object.assign(img.style, { alignSelf: style.alignSelf, display: style.display === "inline" ? "inline-block" : style.display, flex: style.flex, height: `${height}px`, justifySelf: style.justifySelf, left: style.left, marginBottom: style.marginBottom, marginLeft: style.marginLeft, marginRight: style.marginRight, marginTop: style.marginTop, objectFit: "contain", opacity: style.opacity, position: style.position, right: style.right, top: style.top, transform: style.transform === "none" ? "" : style.transform, width: `${width}px`, zIndex: style.zIndex, }); svg.replaceWith(img); } } function widenNoWrapTextElements(root: HTMLElement) { const elements = Array.from(root.querySelectorAll("*")); for (const element of elements) { if (!element.textContent?.trim()) continue; if (element.querySelector("img,svg,video,canvas")) continue; const style = window.getComputedStyle(element); if (style.whiteSpace !== "nowrap" && style.whiteSpace !== "pre") continue; const rect = element.getBoundingClientRect(); if (!rect.width || !rect.height) continue; const buffer = Math.max(24, rect.width * 0.25); element.style.boxSizing = "border-box"; if (style.display === "inline") { element.style.display = "inline-block"; } element.style.width = `${Math.ceil(rect.width + buffer)}px`; } } export async function exportDeckAsPptx( deckTitle: string, slides: PptxExportSlide[], aspectRatio?: AspectRatio, ): Promise { const { exportToPptx } = await importExportModule( () => import("dom-to-pptx"), ); if (typeof document !== "undefined" && document.fonts?.ready) { await document.fonts.ready; } const dims = getAspectRatioDims(aspectRatio); const exportClones: Array<{ element: HTMLElement; cleanup: () => void; }> = []; try { for (let i = 0; i < slides.length; i++) { const exportSlide = slides[i]; const source = findSlideExportSource(exportSlide.id, i, slides.length); const clone = createUnscaledExportClone(source, { width: dims.width, height: dims.height, }); exportClones.push(clone); widenNoWrapTextElements(clone.element); await replaceInlineSvgsWithImages(clone.element); await preloadImagesWithCors(clone.element); } const initialBlob = await exportToPptx( exportClones.map((clone) => clone.element), { autoEmbedFonts: true, fileName: safePptxName(deckTitle), height: dims.pptxInches.h, skipDownload: true, svgAsVector: false, width: dims.pptxInches.w, }, ); const blob = await addSpeakerNotesToPptxBlob(initialBlob, slides); triggerBlobDownload(blob, safePptxName(deckTitle)); } finally { for (const clone of exportClones) { clone.cleanup(); } } }