import { createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { designToCssVars } from './design'; import { SlidePageProvider } from './page-context'; import { isFrameAnimationSettled, waitForDataWaitfor, waitForFonts } from './print-ready'; import type { SlideModule } from './sdk'; const SLIDE_W = 1920; const SLIDE_H = 1080; // 16:9 widescreen in English Metric Units (914400 EMU per inch → 13.333in × 7.5in). const EMU_W = 12192000; const EMU_H = 6858000; const CAPTURE_PIXEL_RATIO = 2; const ANIMATION_TIMEOUT_MS = 15_000; const POLL_INTERVAL_MS = 100; const CAPTURE_CLASS = 'os-pptx-capture'; const CAPTURE_STYLE_ID = 'os-pptx-capture-style'; // Properties intro animations drive from a hidden start state to a visible end // state. We read them back once settled and pin them inline so the capture clone // can't re-run the keyframes from their invisible 0% frame (see freezeForCapture). const FROZEN_PROPS = ['opacity', 'transform', 'filter', 'clip-path'] as const; export type PptxExportProgress = { phase: 'processing' | 'generating' | 'done'; /** Number of pages captured so far (0..total). */ current: number; total: number; /** 0–95 while capturing, 98 while assembling, 100 when done. */ percent: number; }; export async function exportSlideAsImagePptx( slide: SlideModule, slideId: string, onProgress?: (progress: PptxExportProgress) => void, ): Promise { const pages = slide.default ?? []; if (pages.length === 0) return; const total = pages.length; onProgress?.({ phase: 'processing', current: 0, total, percent: 0 }); const container = document.createElement('div'); container.className = CAPTURE_CLASS; container.setAttribute('aria-hidden', 'true'); Object.assign(container.style, { position: 'fixed', left: '-99999px', top: '0', pointerEvents: 'none', }); document.body.appendChild(container); // html-to-image clones each frame and copies its computed style — including the // intro animation — into the clone, which then re-runs the keyframes from their // hidden 0% frame in the rasterised SVG. Fast-forward every animation to its end // frame in the live DOM (a large negative delay lands past a 1ms duration, so // even pseudo-elements paint their final state on the first frame). const captureStyle = document.createElement('style'); captureStyle.id = CAPTURE_STYLE_ID; captureStyle.textContent = `.${CAPTURE_CLASS} *, .${CAPTURE_CLASS} *::before, .${CAPTURE_CLASS} *::after { animation-delay: -1s !important; animation-duration: 1ms !important; animation-iteration-count: 1 !important; animation-fill-mode: forwards !important; transition: none !important; }`; document.head.appendChild(captureStyle); const designVars = slide.design ? designToCssVars(slide.design) : null; const reactRoots: Root[] = []; const frames: HTMLElement[] = []; for (let i = 0; i < pages.length; i++) { const Page = pages[i]; if (!Page) continue; const host = document.createElement('div'); host.setAttribute('data-osd-canvas', ''); host.style.width = `${SLIDE_W}px`; host.style.height = `${SLIDE_H}px`; host.style.overflow = 'hidden'; host.style.background = '#fff'; if (designVars) { for (const [k, v] of Object.entries(designVars)) host.style.setProperty(k, v); } container.appendChild(host); frames.push(host); const r = createRoot(host); r.render( createElement(SlidePageProvider, { index: i, total: pages.length }, createElement(Page)), ); reactRoots.push(r); } // Yield once so React commits all pages and intro animations actually start. await nextPaint(); try { await waitForFonts(); const deadline = performance.now() + ANIMATION_TIMEOUT_MS; while (performance.now() < deadline) { const settled = frames.every((frame) => isFrameAnimationSettled(frame)); if (settled) break; await sleep(POLL_INTERVAL_MS); } await waitForDataWaitfor(container); const { toBlob } = await import('html-to-image'); const images: Uint8Array[] = []; for (let i = 0; i < frames.length; i++) { freezeForCapture(frames[i]); const blob = await toBlob(frames[i], { width: SLIDE_W, height: SLIDE_H, pixelRatio: CAPTURE_PIXEL_RATIO, backgroundColor: '#ffffff', cacheBust: true, }); if (!blob) throw new Error(`failed to capture page ${i + 1}`); images.push(new Uint8Array(await blob.arrayBuffer())); onProgress?.({ phase: 'processing', current: i + 1, total, percent: Math.min(95, ((i + 1) / total) * 95), }); } onProgress?.({ phase: 'generating', current: total, total, percent: 98 }); const pptx = await buildImagePptx(images); downloadBlob( new Blob([pptx as BlobPart], { type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', }), `${slideId}.pptx`, ); } finally { onProgress?.({ phase: 'done', current: total, total, percent: 100 }); for (const r of reactRoots) r.unmount(); container.remove(); captureStyle.remove(); } } // Pin each element's settled visual state inline and remove its animation so the // clone html-to-image rasterises renders the final frame instead of replaying the // (initially invisible) keyframes. Pseudo-elements are handled by CAPTURE_STYLE_ID. function freezeForCapture(root: HTMLElement): void { for (const el of root.querySelectorAll('*')) { const cs = getComputedStyle(el); for (const prop of FROZEN_PROPS) { el.style.setProperty(prop, cs.getPropertyValue(prop), 'important'); } el.style.setProperty('animation', 'none', 'important'); el.style.setProperty('transition', 'none', 'important'); } } const XML_DECL = '\n'; const REL_NS = 'http://schemas.openxmlformats.org/package/2006/relationships'; const OD_REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'; async function buildImagePptx(images: Uint8Array[]): Promise { const { zipSync, strToU8 } = await import('fflate'); const n = images.length; const files: Record = {}; files['[Content_Types].xml'] = strToU8(contentTypesXml(n)); files['_rels/.rels'] = strToU8(rootRelsXml()); files['ppt/presentation.xml'] = strToU8(presentationXml(n)); files['ppt/_rels/presentation.xml.rels'] = strToU8(presentationRelsXml(n)); files['ppt/presProps.xml'] = strToU8(presPropsXml()); files['ppt/theme/theme1.xml'] = strToU8(themeXml()); files['ppt/slideMasters/slideMaster1.xml'] = strToU8(slideMasterXml()); files['ppt/slideMasters/_rels/slideMaster1.xml.rels'] = strToU8(slideMasterRelsXml()); files['ppt/slideLayouts/slideLayout1.xml'] = strToU8(slideLayoutXml()); files['ppt/slideLayouts/_rels/slideLayout1.xml.rels'] = strToU8(slideLayoutRelsXml()); for (let i = 0; i < n; i++) { const idx = i + 1; files[`ppt/slides/slide${idx}.xml`] = strToU8(slideXml()); files[`ppt/slides/_rels/slide${idx}.xml.rels`] = strToU8(slideRelsXml(idx)); files[`ppt/media/image${idx}.png`] = images[i]; } return zipSync(files); } function contentTypesXml(n: number): string { const slideOverrides = Array.from( { length: n }, (_, i) => ``, ).join(''); return `${XML_DECL}${slideOverrides}`; } function rootRelsXml(): string { return `${XML_DECL}`; } function presentationXml(n: number): string { const sldIds = Array.from( { length: n }, (_, i) => ``, ).join(''); return `${XML_DECL}${sldIds}`; } function presentationRelsXml(n: number): string { const rels = [ ``, ``, ]; for (let i = 0; i < n; i++) { rels.push( ``, ); } return `${XML_DECL}${rels.join('')}`; } function presPropsXml(): string { return `${XML_DECL}`; } function slideMasterXml(): string { return `${XML_DECL}`; } function slideMasterRelsXml(): string { return `${XML_DECL}`; } function slideLayoutXml(): string { return `${XML_DECL}`; } function slideLayoutRelsXml(): string { return `${XML_DECL}`; } function slideXml(): string { return `${XML_DECL}`; } function slideRelsXml(idx: number): string { return `${XML_DECL}`; } function themeXml(): string { return `${XML_DECL}`; } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function nextPaint(): Promise { return new Promise((resolve) => { let settled = false; const settle = () => { if (settled) return; settled = true; resolve(); }; requestAnimationFrame(settle); setTimeout(settle, 50); }); } function downloadBlob(blob: Blob, filename: string): void { 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(); setTimeout(() => URL.revokeObjectURL(url), 0); }