/** * lib/pptx/export.ts * * HTML -> PPTX export using Puppeteer + dom-to-pptx. * * Export strategy: * 1. Open the HTML in Chrome at a fixed 1920x1080 viewport * 2. Normalize reveal state and rasterize ECharts into * 3. Export each .slide-canvas as an isolated single-slide PPTX * 4. Merge the single-slide PPTX packages into one editable deck * * This avoids the unstable whole-deck batch export path while keeping slides * editable whenever dom-to-pptx can represent them. */ import { type Browser, type Page } from "puppeteer-core" import { DOMParser, XMLSerializer } from "@xmldom/xmldom" import { unzipSync, zipSync, strFromU8, strToU8 } from "fflate" import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs" import { createRequire } from "module" import { basename, dirname, extname, join, posix as pathPosix, resolve } from "path" import { randomBytes } from "crypto" import { pathToFileURL } from "url" import { findChromePath, launchChrome } from "../browser/chrome" import { withExportBaseHref } from "../export/html" const CANVAS_W = 1920 const CANVAS_H = 1080 const MIN_PPTX_FONT_SIZE_PT = 6 const PPT_REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" const requireFromExportModule = createRequire(import.meta.url) const IMAGE_EXTS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".avif", ".bmp"]) const MIME_TO_EXT: Record = { "image/jpeg": ".jpg", "image/jpg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp", "image/svg+xml": ".svg", "image/avif": ".avif", } const EXT_TO_MIME: Record = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml", ".avif": "image/avif", ".bmp": "image/bmp", } type ZipFiles = Record type XmlDoc = ReturnType interface SlideMeta { index: number pageNo: string | null title: string | null speakerNotes: string | null } interface ExportedSlide extends SlideMeta { bytes: Uint8Array } interface SlideFailure extends SlideMeta { error: string diagnostics: string[] } interface ExportAttemptOptions { autoEmbedFonts: boolean } interface PreparedPage { page: Page slideCount: number diagnostics: string[] } interface ContentTypesIndex { defaults: Map overrides: Map } export interface ExportPptxProgress { kind: "stage" | "slide" message: string current?: number total?: number } export interface ExportPptxOptions { onProgress?: (progress: ExportPptxProgress) => void | Promise speakerNotes?: Array } export interface ExportPptxTimings { prepareMs: number pageSetupMs: number slideExportMs: number mergeMs: number writeMs: number } export interface ExportPptxResult { outputPath: string slideCount: number durationMs: number timingsMs: ExportPptxTimings } interface LocalizeExternalImagesResult { html: string foundCount: number localizedCount: number } async function launchBrowser(): Promise { return await launchChrome({ width: CANVAS_W, height: CANVAS_H, allowFileAccess: true }) } export function derivePptxPath(htmlFilePath: string): string { const abs = resolve(htmlFilePath) const dir = dirname(abs) const name = basename(abs).replace(/\.html?$/i, "") return join(dir, `${name}.pptx`) } export function resolveDomToPptxBundlePath(): string { const entryPath = requireFromExportModule.resolve("dom-to-pptx") const bundlePath = join(dirname(entryPath), "dom-to-pptx.bundle.js") if (!existsSync(bundlePath)) { throw new Error(`dom-to-pptx browser bundle not found: ${bundlePath}`) } return bundlePath } function isLocalImageRef(ref: string): boolean { const pathPart = ref.split(/[?#]/)[0] return IMAGE_EXTS.has(extname(pathPart).toLowerCase()) } export function extractImageAssetRefsForPptx(htmlContent: string): string[] { const assetRefPattern = /\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))|url\(\s*(?:"([^"]*)"|'([^']*)'|([^\s)]+))\s*\)/g const refs = new Set() let match: RegExpExecArray | null while ((match = assetRefPattern.exec(htmlContent)) !== null) { const ref = match.slice(1).find((value): value is string => value !== undefined) if (ref) refs.add(ref.trim()) } return Array.from(refs) } async function toDataUrlFromRef(ref: string, baseDir: string): Promise { if (!ref || ref.startsWith("data:") || ref.startsWith("blob:") || ref.startsWith("#")) { return null } try { if (ref.startsWith("http://") || ref.startsWith("https://") || ref.startsWith("//") || ref.startsWith("file://")) { return null } let filePath: string | null = null if (isLocalImageRef(ref)) { filePath = resolve(baseDir, decodeURI(ref.split(/[?#]/)[0])) } if (!filePath || !existsSync(filePath)) return null const ext = extname(filePath).toLowerCase() const mime = EXT_TO_MIME[ext] if (!mime) return null const buf = readFileSync(filePath) return `data:${mime};base64,${buf.toString("base64")}` } catch { return null } } export async function inlineImageAssets(htmlContent: string, htmlFilePath: string): Promise { const baseDir = dirname(resolve(htmlFilePath)) const refs = extractImageAssetRefsForPptx(htmlContent) if (refs.length === 0) return htmlContent const replacements = new Map() await Promise.allSettled( refs.map(async (ref) => { const dataUrl = await toDataUrlFromRef(ref, baseDir) if (dataUrl) replacements.set(ref, dataUrl) }) ) let patched = htmlContent for (const [original, replacement] of replacements) { const escaped = original.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") patched = patched.replace(new RegExp(escaped, "g"), replacement) } return patched } export function enforceMinimumPptxFontSize( pptxBytes: Uint8Array, minFontSizePt = MIN_PPTX_FONT_SIZE_PT, ): Uint8Array { const files = unzipSync(pptxBytes) const minSz = Math.round(minFontSizePt * 100) const textPropertyTags = ["a:rPr", "a:defRPr", "a:endParaRPr"] for (const path of Object.keys(files)) { if (!/^ppt\/slides\/slide\d+\.xml$/.test(path)) continue const doc = parseXml(strFromU8(files[path])) let changed = false for (const tag of textPropertyTags) { for (const node of Array.from(doc.getElementsByTagName(tag))) { const raw = node.getAttribute("sz") if (!raw) continue const sz = Number(raw) if (!Number.isFinite(sz) || sz >= minSz) continue node.setAttribute("sz", String(minSz)) changed = true } } if (changed) files[path] = xmlToBytes(doc) } return zipSync(files) } async function localizeExternalImages(htmlContent: string, tmpDir: string): Promise { const uniqueUrls = new Set( extractImageAssetRefsForPptx(htmlContent).filter((ref) => ref.startsWith("http://") || ref.startsWith("https://")) ) if (uniqueUrls.size === 0) { return { html: htmlContent, foundCount: 0, localizedCount: 0, } } const urlToLocal = new Map() await Promise.allSettled( Array.from(uniqueUrls).map(async (url, i) => { try { const controller = new AbortController() const timer = setTimeout(() => controller.abort(), 10000) const res = await fetch(url, { signal: controller.signal, headers: { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", }, }) clearTimeout(timer) if (!res.ok) return const contentType = res.headers.get("content-type") ?? "" const mimeBase = contentType.split(";")[0].trim().toLowerCase() let ext = extname(new URL(url).pathname).toLowerCase() if (!ext || ext.length > 6) { ext = MIME_TO_EXT[mimeBase] ?? ".bin" } const localPath = join(tmpDir, `img-${i}${ext}`) const buf = new Uint8Array(await res.arrayBuffer()) writeFileSync(localPath, buf) urlToLocal.set(url, pathToFileURL(localPath).href) } catch { // Preserve original URL on per-image failure. } }) ) let patched = htmlContent for (const [original, local] of urlToLocal) { const escaped = original.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") patched = patched.replace(new RegExp(escaped, "g"), local) } return { html: patched, foundCount: uniqueUrls.size, localizedCount: urlToLocal.size, } } async function emitProgress( options: ExportPptxOptions | undefined, progress: ExportPptxProgress, ): Promise { await options?.onProgress?.(progress) } function attachDiagnostics(page: Page, diagnostics: string[]): void { page.on("pageerror", (error) => { const message = error instanceof Error ? error.message : String(error) diagnostics.push(`pageerror: ${message}`) }) page.on("error", (error) => { const message = error instanceof Error ? error.message : String(error) diagnostics.push(`error: ${message}`) }) page.on("console", (msg) => { if (msg.type() === "error" || msg.type() === "warn") { diagnostics.push(`console.${msg.type()}: ${msg.text()}`) } }) } async function preparePage( browser: Browser, htmlPath: string, domToPptxBundlePath: string, ): Promise { const page = await browser.newPage() const diagnostics: string[] = [] attachDiagnostics(page, diagnostics) await page.setViewport({ width: CANVAS_W, height: CANVAS_H }) await page.goto(pathToFileURL(htmlPath).href, { waitUntil: "domcontentloaded", timeout: 60000 }) await page.waitForSelector(".slide", { timeout: 30000 }) await page.addScriptTag({ path: domToPptxBundlePath }) const slideCount = await page.evaluate(async () => { await document.fonts.ready document.documentElement.style.scrollSnapType = "none" document.documentElement.style.overflow = "visible" document.body.style.overflow = "visible" document.body.style.margin = "0" const slides = Array.from(document.querySelectorAll(".slide")) as HTMLElement[] if (slides.length === 0) { throw new Error( "No .slide elements found in the HTML file.\nMake sure this is a revela-generated slide deck." ) } slides.forEach((slide, index) => { slide.setAttribute("data-export-slide-index", String(index)) slide.querySelectorAll(".reveal").forEach((el) => { const htmlEl = el as HTMLElement htmlEl.dataset.exportOriginalVisible = htmlEl.classList.contains("visible") ? "1" : "0" }) }) const canvases = Array.from(document.querySelectorAll(".slide-canvas")) as HTMLElement[] let exportStyle = document.getElementById("revela-pptx-export-style") as HTMLStyleElement | null if (!exportStyle) { exportStyle = document.createElement("style") exportStyle.id = "revela-pptx-export-style" exportStyle.textContent = ` html, body { scroll-snap-type: none !important; overflow: visible !important; } .slide { width: 1920px !important; min-width: 1920px !important; height: 1080px !important; min-height: 1080px !important; display: flex !important; align-items: center !important; justify-content: center !important; overflow: hidden !important; scroll-snap-align: none !important; } .slide-canvas { width: 1920px !important; height: 1080px !important; transform: none !important; transform-origin: top left !important; transition: none !important; animation: none !important; } .slide-canvas * { transition: none !important; animation: none !important; } ` document.head.appendChild(exportStyle) } canvases.forEach((canvas, index) => { canvas.setAttribute("data-export-canvas-index", String(index)) }) const rasterizeNodeAsImage = (node: HTMLElement, dataUrl: string) => { const img = document.createElement("img") img.src = dataUrl img.alt = "Rasterized chart" img.width = node.clientWidth || node.scrollWidth || 1 img.height = node.clientHeight || node.scrollHeight || 1 img.style.width = "100%" img.style.height = "100%" img.style.display = "block" img.style.objectFit = "contain" node.replaceChildren(img) } const echartsApi = (window as any).echarts const chartNodes = Array.from(document.querySelectorAll(".echart-container")) as HTMLElement[] for (const node of chartNodes) { let dataUrl: string | null = null if (echartsApi?.getInstanceByDom) { const chart = echartsApi.getInstanceByDom(node) if (chart?.getDataURL) { try { dataUrl = chart.getDataURL({ type: "png", pixelRatio: 2, backgroundColor: "transparent", excludeComponents: ["toolbox"], }) } catch { dataUrl = null } } } if (!dataUrl) { const fallbackCanvas = node.querySelector("canvas") as HTMLCanvasElement | null if (fallbackCanvas) { try { dataUrl = fallbackCanvas.toDataURL("image/png") } catch { dataUrl = null } } } if (dataUrl) rasterizeNodeAsImage(node, dataUrl) } return canvases.length }) return { page, slideCount, diagnostics } } async function readSlideMeta( page: Page, slideCount: number, ): Promise { const meta = await page.evaluate(() => { return Array.from(document.querySelectorAll(".slide")).map((slide, index) => { const pageNo = Array.from(slide.querySelectorAll("div")) .map((el) => el.textContent?.trim() ?? "") .find((text) => /^\d{2}$/.test(text)) ?? null const title = slide.querySelector("h1,h2,h3")?.textContent?.trim()?.slice(0, 120) ?? null const directChildren = Array.from(slide.children) const notesEl = directChildren.find((el) => el.matches("template[data-revela-speaker-notes]")) ?? directChildren.find((el) => el.hasAttribute("data-revela-speaker-notes")) ?? directChildren.find((el) => el.classList.contains("speaker-notes")) const notesText = notesEl instanceof HTMLTemplateElement ? notesEl.content.textContent : notesEl?.textContent const speakerNotes = notesText?.replace(/\r\n?/g, "\n").trim() || null return { index, pageNo, title, speakerNotes } }) }) return Array.from({ length: slideCount }, (_, index) => { return meta[index] ?? { index, pageNo: null, title: null, speakerNotes: null } }) } async function rasterizeFormulaNodes(page: Page, diagnostics: string[]): Promise { const handles = await page.$$(".template-text-panel-formula, .text-panel-formula") let rasterized = 0 for (const handle of handles) { try { const box = await handle.boundingBox() if (!box || box.width <= 0 || box.height <= 0) continue const base64 = await (handle as any).screenshot({ encoding: "base64", omitBackground: true, }) await handle.evaluate((node, payload) => { const element = node as HTMLElement const img = document.createElement("img") img.src = `data:image/png;base64,${payload.base64}` img.alt = element.getAttribute("data-latex") || "Formula" img.setAttribute("data-revela-pptx-formula-raster", "true") img.style.width = `${payload.width}px` img.style.height = `${payload.height}px` img.style.maxWidth = "100%" img.style.display = "block" img.style.objectFit = "contain" element.replaceChildren(img) }, { base64, width: Math.ceil(box.width), height: Math.ceil(box.height), }) rasterized += 1 } catch (error) { const message = error instanceof Error ? error.message : String(error) diagnostics.push(`formula rasterize failed: ${message}`) } } return rasterized } async function exportSlidePptx( page: Page, diagnostics: string[], slide: SlideMeta, options: ExportAttemptOptions, ): Promise { const diagStart = diagnostics.length try { const pptxBytes = await page.evaluate(async ({ index, autoEmbedFonts }) => { const domToPptx = (window as any).domToPptx const slides = Array.from(document.querySelectorAll(".slide")) as HTMLElement[] const targetSlide = slides[index] const target = document.querySelectorAll(".slide-canvas")[index] as HTMLElement | undefined if (!domToPptx?.exportToPptx) { throw new Error("dom-to-pptx bundle did not initialize correctly.") } if (!targetSlide) { throw new Error(`Missing .slide for slide ${index + 1}`) } if (!target) { throw new Error(`Missing .slide-canvas for slide ${index + 1}`) } for (const slideEl of slides) { const isTargetSlide = slideEl === targetSlide slideEl.dataset.exportActive = isTargetSlide ? "1" : "0" slideEl.querySelectorAll(".reveal").forEach((el) => { const htmlEl = el as HTMLElement const wasVisible = htmlEl.dataset.exportOriginalVisible === "1" htmlEl.classList.toggle("visible", wasVisible || isTargetSlide) }) } target.style.transform = "none" target.style.transformOrigin = "top left" target.style.transition = "none" target.style.animation = "none" await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))) const host = document.createElement("div") host.id = "revela-pptx-export-root" host.style.position = "fixed" host.style.left = "0" host.style.top = "0" host.style.width = "1920px" host.style.height = "1080px" host.style.overflow = "hidden" host.style.zIndex = "2147483647" host.style.background = "transparent" const exportTarget = targetSlide.cloneNode(true) as HTMLElement exportTarget.style.width = "1920px" exportTarget.style.minWidth = "1920px" exportTarget.style.height = "1080px" exportTarget.style.minHeight = "1080px" exportTarget.style.position = "relative" exportTarget.style.left = "0" exportTarget.style.top = "0" exportTarget.style.margin = "0" exportTarget.style.display = "flex" exportTarget.style.alignItems = "center" exportTarget.style.justifyContent = "center" exportTarget.style.overflow = "hidden" exportTarget.style.transform = "none" exportTarget.style.transformOrigin = "top left" exportTarget.style.transition = "none" exportTarget.style.animation = "none" const exportCanvas = exportTarget.querySelector(".slide-canvas") as HTMLElement | null if (!exportCanvas) { host.remove() throw new Error(`Missing cloned .slide-canvas for slide ${index + 1}`) } exportCanvas.style.width = "1920px" exportCanvas.style.minWidth = "1920px" exportCanvas.style.height = "1080px" exportCanvas.style.minHeight = "1080px" exportCanvas.style.position = "relative" exportCanvas.style.left = "0" exportCanvas.style.top = "0" exportCanvas.style.flexShrink = "0" exportCanvas.style.transform = "none" exportCanvas.style.transformOrigin = "top left" exportCanvas.style.transition = "none" exportCanvas.style.animation = "none" const splitCssList = (value: string): string[] => { const parts: string[] = [] let depth = 0 let quote: string | null = null let start = 0 for (let i = 0; i < value.length; i += 1) { const char = value[i] if (quote) { if (char === quote && value[i - 1] !== "\\") quote = null continue } if (char === "\"" || char === "'") { quote = char continue } if (char === "(") depth += 1 if (char === ")") depth = Math.max(0, depth - 1) if (char === "," && depth === 0) { parts.push(value.slice(start, i).trim()) start = i + 1 } } parts.push(value.slice(start).trim()) return parts.filter(Boolean) } const extractCssUrl = (value: string): string | null => { const match = value.match(/url\((["']?)(.*?)\1\)/i) return match?.[2] ?? null } const normalizeBackgroundLayersForPptx = (canvas: HTMLElement, sourceStyle: CSSStyleDeclaration) => { const computed = sourceStyle const imageLayers = splitCssList(computed.backgroundImage) const hasUrlLayer = imageLayers.some((layer) => /url\(/i.test(layer)) const paddingLeft = Number.parseFloat(computed.paddingLeft) || 0 const paddingRight = Number.parseFloat(computed.paddingRight) || 0 const paddingTop = Number.parseFloat(computed.paddingTop) || 0 const paddingBottom = Number.parseFloat(computed.paddingBottom) || 0 const hasPadding = paddingLeft > 0 || paddingRight > 0 || paddingTop > 0 || paddingBottom > 0 if (!hasUrlLayer && !hasPadding) return const sizeLayers = splitCssList(computed.backgroundSize) const positionLayers = splitCssList(computed.backgroundPosition) const repeatLayers = splitCssList(computed.backgroundRepeat) const layerCount = imageLayers.length const layers = hasUrlLayer ? document.createElement("div") : null if (layers) { layers.setAttribute("data-revela-pptx-background-layers", "true") layers.style.position = "absolute" layers.style.inset = "0" layers.style.pointerEvents = "none" layers.style.zIndex = "0" layers.style.overflow = "hidden" } if (layers) imageLayers.forEach((layer, layerIndex) => { if (layer === "none") return const zIndex = String(layerCount - layerIndex) const url = extractCssUrl(layer) const size = sizeLayers[layerIndex] ?? sizeLayers[0] ?? "auto" const position = positionLayers[layerIndex] ?? positionLayers[0] ?? "50% 50%" const repeat = repeatLayers[layerIndex] ?? repeatLayers[0] ?? "repeat" if (url) { const img = document.createElement("img") img.src = url img.alt = "" img.setAttribute("aria-hidden", "true") img.style.position = "absolute" img.style.inset = "0" img.style.width = "100%" img.style.height = "100%" img.style.display = "block" img.style.zIndex = zIndex img.style.objectFit = size.includes("contain") ? "contain" : "cover" img.style.objectPosition = position if (!repeat.includes("no-repeat") && !size.includes("cover") && !size.includes("contain")) { img.style.objectFit = "fill" } layers.appendChild(img) return } const gradient = document.createElement("div") gradient.style.position = "absolute" gradient.style.inset = "0" gradient.style.zIndex = zIndex gradient.style.backgroundImage = layer gradient.style.backgroundSize = size gradient.style.backgroundPosition = position gradient.style.backgroundRepeat = repeat layers.appendChild(gradient) }) if (layers && !layers.childElementCount) return const content = document.createElement("div") content.setAttribute("data-revela-pptx-content-layer", "true") content.style.position = "absolute" content.style.zIndex = String(layerCount + 1) content.style.left = `${paddingLeft}px` content.style.top = `${paddingTop}px` content.style.width = `${Math.max(1, 1920 - paddingLeft - paddingRight)}px` content.style.height = `${Math.max(1, 1080 - paddingTop - paddingBottom)}px` content.style.minHeight = "0" content.style.boxSizing = "border-box" while (canvas.firstChild) { content.appendChild(canvas.firstChild) } canvas.style.padding = "0" if (layers) { canvas.style.backgroundImage = "none" canvas.style.backgroundColor = computed.backgroundColor canvas.appendChild(layers) } canvas.appendChild(content) } normalizeBackgroundLayersForPptx(exportCanvas, window.getComputedStyle(target)) host.appendChild(exportTarget) document.body.appendChild(host) await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))) const rect = exportTarget.getBoundingClientRect() const canvasRect = exportCanvas.getBoundingClientRect() if (Math.abs(rect.width - 1920) > 2 || Math.abs(rect.height - 1080) > 2) { host.remove() throw new Error( `Slide ${index + 1} export slide is ${Math.round(rect.width)}x${Math.round(rect.height)}, expected 1920x1080.` ) } if (Math.abs(canvasRect.width - 1920) > 2 || Math.abs(canvasRect.height - 1080) > 2) { host.remove() throw new Error( `Slide ${index + 1} export canvas is ${Math.round(canvasRect.width)}x${Math.round(canvasRect.height)}, expected 1920x1080.` ) } await Promise.all( Array.from(exportTarget.querySelectorAll("img")).map(async (img) => { if (img.complete) return await new Promise((resolve) => { img.addEventListener("load", resolve, { once: true }) img.addEventListener("error", resolve, { once: true }) }) }) ) try { const blob: Blob = await domToPptx.exportToPptx(exportTarget, { fileName: `slide-${index + 1}.pptx`, skipDownload: true, svgAsVector: false, autoEmbedFonts, width: 10, height: 5.625, }) return Array.from(new Uint8Array(await blob.arrayBuffer())) } finally { host.remove() } }, { index: slide.index, autoEmbedFonts: options.autoEmbedFonts }) return { ...slide, bytes: enforceMinimumPptxFontSize(Uint8Array.from(pptxBytes)), } } catch (error) { throw formatSlideFailure(error, diagnostics.slice(diagStart ?? 0), slide) } } function applySpeakerNotesOverride( slides: SlideMeta[], speakerNotes?: Array, ): SlideMeta[] { if (!speakerNotes) return slides return slides.map((slide) => { if (speakerNotes[slide.index] === undefined) return slide const notes = speakerNotes[slide.index] return { ...slide, speakerNotes: notes?.replace(/\r\n?/g, "\n").trim() || null, } }) } function parseXml(xml: string) { return new DOMParser().parseFromString(xml, "text/xml") } function xmlToBytes(doc: XmlDoc): Uint8Array { return strToU8(new XMLSerializer().serializeToString(doc)) } function getFileText(files: ZipFiles, path: string): string { const file = files[path] if (!file) throw new Error(`Missing PPTX part: ${path}`) return strFromU8(file) } function readContentTypes(files: ZipFiles): ContentTypesIndex { const doc = parseXml(getFileText(files, "[Content_Types].xml")) const defaults = new Map() const overrides = new Map() for (const node of Array.from(doc.getElementsByTagName("Default"))) { const ext = node.getAttribute("Extension") const contentType = node.getAttribute("ContentType") if (ext && contentType) defaults.set(ext, contentType) } for (const node of Array.from(doc.getElementsByTagName("Override"))) { const partName = node.getAttribute("PartName") const contentType = node.getAttribute("ContentType") if (partName && contentType) overrides.set(partName, contentType) } return { defaults, overrides } } function upsertDefault(doc: XmlDoc, ext: string, contentType: string): void { const types = doc.getElementsByTagName("Types")[0] const existing = Array.from(doc.getElementsByTagName("Default")).find( (node) => node.getAttribute("Extension") === ext ) if (existing) { existing.setAttribute("ContentType", contentType) return } const node = doc.createElement("Default") node.setAttribute("Extension", ext) node.setAttribute("ContentType", contentType) types.appendChild(node) } function upsertOverride(doc: XmlDoc, partName: string, contentType: string): void { const types = doc.getElementsByTagName("Types")[0] const existing = Array.from(doc.getElementsByTagName("Override")).find( (node) => node.getAttribute("PartName") === partName ) if (existing) { existing.setAttribute("ContentType", contentType) return } const node = doc.createElement("Override") node.setAttribute("PartName", partName) node.setAttribute("ContentType", contentType) types.appendChild(node) } function relsPathForPart(partPath: string): string { const dir = pathPosix.dirname(partPath) const base = pathPosix.basename(partPath) return pathPosix.join(dir, "_rels", `${base}.rels`) } function ownerPartForRels(relsPath: string): string { return relsPath.replace(/\/_[Rr]els\//, "/").replace(/\.rels$/, "") } function resolveRelationshipTarget(ownerPartPath: string, target: string): string { const baseDir = pathPosix.dirname(ownerPartPath) return pathPosix.normalize(pathPosix.join(baseDir, target)) } function relativeTarget(ownerPartPath: string, targetPartPath: string): string { const baseDir = pathPosix.dirname(ownerPartPath) return pathPosix.relative(baseDir, targetPartPath) } function isSharedPart(partPath: string): boolean { return ( /^ppt\/(slideLayouts|slideMasters|theme|notesMasters)\//.test(partPath) || partPath === "ppt/presProps.xml" || partPath === "ppt/viewProps.xml" || partPath === "ppt/tableStyles.xml" ) } function maxIndexedFile(files: ZipFiles, pattern: RegExp): number { let max = 0 for (const path of Object.keys(files)) { const match = path.match(pattern) if (!match) continue max = Math.max(max, Number(match[1])) } return max } function nextAvailablePath(files: ZipFiles, originalPath: string): string { const dir = pathPosix.dirname(originalPath) const ext = pathPosix.extname(originalPath) const base = pathPosix.basename(originalPath, ext) let counter = 1 let candidate = originalPath while (files[candidate]) { candidate = pathPosix.join(dir, `${base}-import${counter}${ext}`) counter += 1 } return candidate } function getMaxPresentationRelId(doc: XmlDoc): number { let max = 0 for (const rel of Array.from(doc.getElementsByTagName("Relationship"))) { const id = rel.getAttribute("Id") const match = id?.match(/^rId(\d+)$/) if (match) max = Math.max(max, Number(match[1])) } return max } function setSlideName(files: ZipFiles, slidePath: string, label: string): void { const doc = parseXml(getFileText(files, slidePath)) const cSld = doc.getElementsByTagName("p:cSld")[0] if (cSld) cSld.setAttribute("name", label) files[slidePath] = xmlToBytes(doc) } function setNotesSlideNumber(files: ZipFiles, notesPath: string, number: number): void { const doc = parseXml(getFileText(files, notesPath)) const fields = Array.from(doc.getElementsByTagName("a:fld")) for (const field of fields) { if (field.getAttribute("type") !== "slidenum") continue const textNode = Array.from(field.childNodes).find((node) => node.nodeName === "a:t") if (textNode) { textNode.textContent = String(number) } } files[notesPath] = xmlToBytes(doc) } function setSpeakerNotes(files: ZipFiles, notesPath: string, notes: string | null): void { if (!files[notesPath]) return const doc = parseXml(getFileText(files, notesPath)) const shapes = Array.from(doc.getElementsByTagName("p:sp")) const notesShape = shapes.find((shape) => { return Array.from(shape.getElementsByTagName("p:ph")).some((ph) => ph.getAttribute("type") === "body") }) const textNode = notesShape?.getElementsByTagName("a:t")[0] if (!textNode) return textNode.textContent = notes ?? "" files[notesPath] = xmlToBytes(doc) } function stripSvgBlipFallbacks(pptxBytes: Uint8Array): Uint8Array { const files = unzipSync(pptxBytes) const orphanedSvgParts = new Set() for (const slidePath of Object.keys(files).filter((path) => /^ppt\/slides\/slide\d+\.xml$/.test(path))) { const slideXml = getFileText(files, slidePath) if (!slideXml.includes("asvg:svgBlip")) continue const slideDoc = parseXml(slideXml) const relsPath = relsPathForPart(slidePath) const relsDoc = files[relsPath] ? parseXml(getFileText(files, relsPath)) : null const svgRelIds = new Set() for (const svgBlip of Array.from(slideDoc.getElementsByTagName("asvg:svgBlip"))) { const relId = svgBlip.getAttribute("r:embed") if (relId) svgRelIds.add(relId) } for (const blip of Array.from(slideDoc.getElementsByTagName("a:blip"))) { for (const extLst of Array.from(blip.getElementsByTagName("a:extLst"))) { if (Array.from(extLst.getElementsByTagName("asvg:svgBlip")).length === 0) continue blip.removeChild(extLst) } } if (relsDoc && svgRelIds.size > 0) { const relRoot = relsDoc.getElementsByTagName("Relationships")[0] for (const rel of Array.from(relsDoc.getElementsByTagName("Relationship"))) { const relId = rel.getAttribute("Id") if (!relId || !svgRelIds.has(relId)) continue const target = rel.getAttribute("Target") if (target) orphanedSvgParts.add(resolveRelationshipTarget(slidePath, target)) relRoot?.removeChild(rel) } files[relsPath] = xmlToBytes(relsDoc) } files[slidePath] = xmlToBytes(slideDoc) } for (const partPath of orphanedSvgParts) { if (/^ppt\/media\/.+\.svg$/i.test(partPath)) { delete files[partPath] } } return zipSync(files, { level: 0 }) } export function applySpeakerNotesToPptx(pptxBytes: Uint8Array, notesBySlide: Array): Uint8Array { const files = unzipSync(pptxBytes) notesBySlide.forEach((notes, index) => { setSpeakerNotes(files, `ppt/notesSlides/notesSlide${index + 1}.xml`, notes ?? null) }) return stripSvgBlipFallbacks(zipSync(files)) } function updateAppProperties(files: ZipFiles, slideCount: number): void { const doc = parseXml(getFileText(files, "docProps/app.xml")) const setText = (tag: string, value: string) => { const node = doc.getElementsByTagName(tag)[0] if (node) node.textContent = value } setText("Slides", String(slideCount)) setText("Notes", String(slideCount)) const titlesNode = doc.getElementsByTagName("TitlesOfParts")[0] const titlesVector = titlesNode?.getElementsByTagName("vt:vector")[0] if (titlesVector) { const preserved = Array.from(titlesVector.getElementsByTagName("vt:lpstr")) .map((node) => node.textContent ?? "") .filter((text) => !/^Slide \d+$/.test(text)) while (titlesVector.firstChild) titlesVector.removeChild(titlesVector.firstChild) for (const text of [...preserved, ...Array.from({ length: slideCount }, (_, i) => `Slide ${i + 1}`)]) { const node = doc.createElement("vt:lpstr") node.appendChild(doc.createTextNode(text)) titlesVector.appendChild(node) } titlesVector.setAttribute("size", String(preserved.length + slideCount)) } const variants = Array.from(doc.getElementsByTagName("vt:variant")) for (let i = 0; i < variants.length - 1; i += 1) { const label = variants[i].textContent?.trim() if (label !== "Slide Titles") continue const countNode = variants[i + 1].getElementsByTagName("vt:i4")[0] if (countNode) countNode.textContent = String(slideCount) break } files["docProps/app.xml"] = xmlToBytes(doc) } function formatBrowserCrash(error: unknown, diagnostics: string[]): string { const message = error instanceof Error ? error.message : String(error) const detail = diagnostics.length > 0 ? ` [${diagnostics.join(" | ")}]` : "" if (/Target closed|Session closed|Target page, context or browser has been closed/i.test(message)) { return "Chrome page crashed during PPTX export." + detail } return `${message}${detail}` } function formatSlideFailure(error: unknown, diagnostics: string[], slide: SlideMeta): Error { const label = slide.pageNo ? `slide ${slide.pageNo}` : `slide #${slide.index + 1}` const title = slide.title ? ` (${slide.title})` : "" return new Error(`${label}${title}: ${formatBrowserCrash(error, diagnostics)}`) } function mergeSingleSlidePptx(slides: ExportedSlide[]): Uint8Array { if (slides.length === 0) { throw new Error("No slides were exported.") } const mergedFiles: ZipFiles = { ...unzipSync(slides[0].bytes) } const contentTypesDoc = parseXml(getFileText(mergedFiles, "[Content_Types].xml")) const presentationDoc = parseXml(getFileText(mergedFiles, "ppt/presentation.xml")) const presentationRelsDoc = parseXml(getFileText(mergedFiles, "ppt/_rels/presentation.xml.rels")) const contentTypes = readContentTypes(mergedFiles) const sldIdLst = presentationDoc.getElementsByTagName("p:sldIdLst")[0] const relRoot = presentationRelsDoc.getElementsByTagName("Relationships")[0] if (!sldIdLst || !relRoot) { throw new Error("PPTX merge failed: missing presentation slide list.") } let nextSlideNumber = maxIndexedFile(mergedFiles, /^ppt\/slides\/slide(\d+)\.xml$/) + 1 let nextNotesNumber = maxIndexedFile(mergedFiles, /^ppt\/notesSlides\/notesSlide(\d+)\.xml$/) + 1 let nextSlideId = Math.max(maxIndexedFile(mergedFiles, /^ppt\/slides\/slide(\d+)\.xml$/) + 255, 256) for (const node of Array.from(sldIdLst.getElementsByTagName("p:sldId"))) { const id = Number(node.getAttribute("id") ?? "0") nextSlideId = Math.max(nextSlideId, id + 1) } let nextPresentationRelId = getMaxPresentationRelId(presentationRelsDoc) + 1 setSlideName(mergedFiles, "ppt/slides/slide1.xml", "Slide 1") setNotesSlideNumber(mergedFiles, "ppt/notesSlides/notesSlide1.xml", 1) setSpeakerNotes(mergedFiles, "ppt/notesSlides/notesSlide1.xml", slides[0].speakerNotes) for (let slideIdx = 1; slideIdx < slides.length; slideIdx += 1) { const sourceFiles: ZipFiles = { ...unzipSync(slides[slideIdx].bytes) } const sourceTypes = readContentTypes(sourceFiles) const pathMap = new Map() const importPart = (sourcePartPath: string): string => { if (pathMap.has(sourcePartPath)) return pathMap.get(sourcePartPath)! if (isSharedPart(sourcePartPath)) return sourcePartPath let destPath: string if (sourcePartPath === "ppt/slides/slide1.xml") { destPath = `ppt/slides/slide${nextSlideNumber}.xml` } else if (sourcePartPath === "ppt/notesSlides/notesSlide1.xml") { destPath = `ppt/notesSlides/notesSlide${nextNotesNumber}.xml` } else { destPath = nextAvailablePath(mergedFiles, sourcePartPath) } pathMap.set(sourcePartPath, destPath) const file = sourceFiles[sourcePartPath] if (!file) { throw new Error(`PPTX merge failed: missing source part ${sourcePartPath}`) } mergedFiles[destPath] = file const overrideType = sourceTypes.overrides.get(`/${sourcePartPath}`) if (overrideType) { upsertOverride(contentTypesDoc, `/${destPath}`, overrideType) } else { const ext = pathPosix.extname(destPath).slice(1) if (ext) { const contentType = sourceTypes.defaults.get(ext) ?? contentTypes.defaults.get(ext) if (contentType) upsertDefault(contentTypesDoc, ext, contentType) } } const sourceRelsPath = relsPathForPart(sourcePartPath) if (sourceFiles[sourceRelsPath]) { const ownerDestPath = destPath const relsDoc = parseXml(getFileText(sourceFiles, sourceRelsPath)) for (const rel of Array.from(relsDoc.getElementsByTagName("Relationship"))) { if (rel.getAttribute("TargetMode") === "External") continue const target = rel.getAttribute("Target") if (!target) continue const targetSourcePath = resolveRelationshipTarget(sourcePartPath, target) const targetDestPath = isSharedPart(targetSourcePath) ? targetSourcePath : importPart(targetSourcePath) rel.setAttribute("Target", relativeTarget(ownerDestPath, targetDestPath)) } mergedFiles[relsPathForPart(destPath)] = xmlToBytes(relsDoc) } return destPath } const slidePath = importPart("ppt/slides/slide1.xml") const notesPath = pathMap.get("ppt/notesSlides/notesSlide1.xml") ?? null setSlideName(mergedFiles, slidePath, `Slide ${slideIdx + 1}`) if (notesPath && mergedFiles[notesPath]) { setNotesSlideNumber(mergedFiles, notesPath, slideIdx + 1) setSpeakerNotes(mergedFiles, notesPath, slides[slideIdx].speakerNotes) } const relId = `rId${nextPresentationRelId}` const relNode = presentationRelsDoc.createElement("Relationship") relNode.setAttribute("Id", relId) relNode.setAttribute("Type", `${PPT_REL_NS}/slide`) relNode.setAttribute("Target", pathPosix.relative("ppt", slidePath)) relRoot.appendChild(relNode) const slideNode = presentationDoc.createElement("p:sldId") slideNode.setAttribute("id", String(nextSlideId)) slideNode.setAttribute("r:id", relId) sldIdLst.appendChild(slideNode) nextSlideNumber += 1 nextNotesNumber += notesPath ? 1 : 0 nextSlideId += 1 nextPresentationRelId += 1 } mergedFiles["[Content_Types].xml"] = xmlToBytes(contentTypesDoc) mergedFiles["ppt/presentation.xml"] = xmlToBytes(presentationDoc) mergedFiles["ppt/_rels/presentation.xml.rels"] = xmlToBytes(presentationRelsDoc) updateAppProperties(mergedFiles, slides.length) return stripSvgBlipFallbacks(zipSync(mergedFiles, { level: 0 })) } export async function exportToPptx( htmlFilePath: string, options?: ExportPptxOptions, ): Promise { const startMs = Date.now() const abs = resolve(htmlFilePath) const domToPptxBundlePath = resolveDomToPptxBundlePath() if (!existsSync(abs)) { throw new Error(`File not found: ${abs}`) } if (!/\.html?$/i.test(abs)) { throw new Error(`Not an HTML file: ${abs}`) } const outputPath = derivePptxPath(abs) const executablePath = findChromePath() const tmpDir = join("/tmp", `revela-pptx-${randomBytes(6).toString("hex")}`) mkdirSync(tmpDir, { recursive: true }) const timingsMs: ExportPptxTimings = { prepareMs: 0, pageSetupMs: 0, slideExportMs: 0, mergeMs: 0, writeMs: 0, } let tmpHtmlPath = abs try { await emitProgress(options, { kind: "stage", message: "Preparing HTML and localizing remote assets...", }) const prepareStart = Date.now() const originalHtml = readFileSync(abs, "utf-8") const localized = await localizeExternalImages(originalHtml, tmpDir) const patchedHtml = withExportBaseHref(await inlineImageAssets(localized.html, abs), abs) tmpHtmlPath = join(tmpDir, "index.html") writeFileSync(tmpHtmlPath, patchedHtml, "utf-8") timingsMs.prepareMs = Date.now() - prepareStart await emitProgress(options, { kind: "stage", message: localized.foundCount > 0 ? `Prepared export HTML. Localized ${localized.localizedCount}/${localized.foundCount} remote image assets.` : "Prepared export HTML.", }) } catch { tmpHtmlPath = abs timingsMs.prepareMs = 0 } await emitProgress(options, { kind: "stage", message: "Launching Chrome and preparing slide DOM...", }) const browser = await launchBrowser() try { const pageSetupStart = Date.now() const { page, slideCount, diagnostics } = await preparePage(browser, tmpHtmlPath, domToPptxBundlePath) const rasterizedFormulaCount = await rasterizeFormulaNodes(page, diagnostics) timingsMs.pageSetupMs = Date.now() - pageSetupStart const exported: ExportedSlide[] = [] const failures: SlideFailure[] = [] try { const slides = applySpeakerNotesOverride(await readSlideMeta(page, slideCount), options?.speakerNotes) await emitProgress(options, { kind: "stage", message: rasterizedFormulaCount > 0 ? `Deck ready. Rasterized ${rasterizedFormulaCount} formula text member(s). Exporting ${slides.length} slide(s) to editable PPTX parts...` : `Deck ready. Exporting ${slides.length} slide(s) to editable PPTX parts...`, }) const slideExportStart = Date.now() for (const slide of slides) { await emitProgress(options, { kind: "slide", message: `Exporting slide ${slide.index + 1}/${slides.length}${slide.title ? `: ${slide.title}` : ""}`, current: slide.index + 1, total: slides.length, }) try { exported.push( await exportSlidePptx(page, diagnostics, slide, { autoEmbedFonts: false, }) ) } catch (error) { failures.push({ ...slide, error: error instanceof Error ? error.message : String(error), diagnostics: [], }) } } timingsMs.slideExportMs = Date.now() - slideExportStart if (failures.length > 0) { const summary = failures .map((slide) => `- ${slide.error}`) .join("\n") throw new Error( `Editable PPTX export failed on ${failures.length} slide(s):\n${summary}\n\n` + "No screenshot fallback was used. Fix the failing slide styles/resources and retry." ) } await emitProgress(options, { kind: "stage", message: "Merging slide parts into final PPTX package...", }) const mergeStart = Date.now() const pptxBytes = mergeSingleSlidePptx(exported) timingsMs.mergeMs = Date.now() - mergeStart await emitProgress(options, { kind: "stage", message: "Writing PPTX file to disk...", }) const writeStart = Date.now() writeFileSync(outputPath, pptxBytes) timingsMs.writeMs = Date.now() - writeStart await emitProgress(options, { kind: "stage", message: `PPTX export complete: ${exported.length} slide(s) written.`, }) return { outputPath, slideCount: exported.length, durationMs: Date.now() - startMs, timingsMs, } } finally { await page.close().catch(() => undefined) } } finally { await browser.close().catch(() => undefined) try { rmSync(tmpDir, { recursive: true, force: true }) } catch { // Non-fatal. } } }