'use client'; import { useState, useCallback, useRef } from 'react'; import pptxgen from 'pptxgenjs'; import tinycolor from 'tinycolor2'; import { saveAs } from 'file-saver'; import { toast } from 'sonner'; import { useStageStore } from '@/lib/store'; import { useCanvasStore } from '@/lib/store/canvas'; import { useMediaGenerationStore, isMediaPlaceholder } from '@/lib/store/media-generation'; import { useI18n } from '@/lib/hooks/use-i18n'; import type { Slide, PPTElementOutline, PPTElementShadow, PPTElementLink } from '@openmaic/dsl'; import type { Scene, SlideContent } from '@/lib/types/stage'; import type { SpeechAction } from '@/lib/types/action'; import { getElementRange, getLineElementPath, getTableSubThemeColor } from '@/lib/utils/element'; import { type AST, toAST } from '@/lib/export/html-parser'; import { type SvgPoints, toPoints, getSvgPathRange } from '@/lib/export/svg-path-parser'; import { svg2Base64 } from '@/lib/export/svg2base64'; import { latexToOmml } from '@/lib/export/latex-to-omml'; import { createLogger } from '@/lib/logger'; import { inlineHtmlAssets, createAssetFetcher } from './inline-assets'; import type { FetchAsset } from './inline-assets'; import { createProxiedFetch } from './proxied-fetch'; import { db, mediaFileKey } from '@/lib/utils/database'; import { withAssetUrl, type AssetUrlLeaseState } from '@/lib/media/use-asset-url'; import { MISSING_ASSET_LEASE, isConcreteMediaAddress, renderableMediaUrl, resolveMediaRef, type MediaTaskState, } from '@/lib/media/resolve-media-ref'; import { resolveVideoMediaForElement } from '@/lib/media/media-task-resolution'; const log = createLogger('ExportPPTX'); const DEFAULT_FONT_SIZE = 16; const DEFAULT_FONT_FAMILY = 'Microsoft YaHei'; // ── Color formatting ── function formatColor(_color: string) { if (!_color) { return { alpha: 0, color: '#000000' }; } const c = tinycolor(_color); const alpha = c.getAlpha(); const color = alpha === 0 ? '#ffffff' : c.setAlpha(1).toHexString(); return { alpha, color }; } type FormatColor = ReturnType; // ── HTML → pptxgenjs TextProps ── function formatHTML(html: string, ratioPx2Pt: number) { const ast = toAST(html); let bulletFlag = false; let indent = 0; const slices: pptxgen.TextProps[] = []; const parse = (obj: AST[], baseStyleObj: Record = {}) => { for (const item of obj) { const isBlockTag = 'tagName' in item && ['div', 'li', 'p'].includes(item.tagName); if (isBlockTag && slices.length) { const lastSlice = slices[slices.length - 1]; if (!lastSlice.options) lastSlice.options = {}; lastSlice.options.breakLine = true; } const styleObj = { ...baseStyleObj }; const styleAttr = 'attributes' in item ? item.attributes.find((attr) => attr.key === 'style') : null; if (styleAttr && styleAttr.value) { const styleArr = styleAttr.value.split(';'); for (const styleItem of styleArr) { const match = styleItem.match(/([^:]+):\s*(.+)/); if (match) { const [key, value] = [match[1].trim(), match[2].trim()]; if (key && value) styleObj[key] = value; } } } if ('tagName' in item) { if (item.tagName === 'em') styleObj['font-style'] = 'italic'; if (item.tagName === 'strong') styleObj['font-weight'] = 'bold'; if (item.tagName === 'sup') styleObj['vertical-align'] = 'super'; if (item.tagName === 'sub') styleObj['vertical-align'] = 'sub'; if (item.tagName === 'a') { const attr = item.attributes.find((a) => a.key === 'href'); styleObj['href'] = attr?.value || ''; } if (item.tagName === 'ul') styleObj['list-type'] = 'ul'; if (item.tagName === 'ol') styleObj['list-type'] = 'ol'; if (item.tagName === 'li') bulletFlag = true; if (item.tagName === 'p') { if ('attributes' in item) { const dataIndentAttr = item.attributes.find((a) => a.key === 'data-indent'); if (dataIndentAttr && dataIndentAttr.value) indent = +dataIndentAttr.value; } } } if ('tagName' in item && item.tagName === 'br') { slices.push({ text: '', options: { breakLine: true } }); } else if ('content' in item) { const text = item.content .replace(/ /g, ' ') .replace(/>/g, '>') .replace(/</g, '<') .replace(/&/g, '&') .replace(/\n/g, ''); const options: pptxgen.TextPropsOptions = {}; if (styleObj['font-size']) { options.fontSize = parseInt(styleObj['font-size']) / ratioPx2Pt; } if (styleObj['color']) { options.color = formatColor(styleObj['color']).color; } if (styleObj['background-color']) { options.highlight = formatColor(styleObj['background-color']).color; } if (styleObj['text-decoration-line']) { if (styleObj['text-decoration-line'].indexOf('underline') !== -1) { options.underline = { color: options.color || '#000000', style: 'sng', }; } if (styleObj['text-decoration-line'].indexOf('line-through') !== -1) { options.strike = 'sngStrike'; } } if (styleObj['text-decoration']) { if (styleObj['text-decoration'].indexOf('underline') !== -1) { options.underline = { color: options.color || '#000000', style: 'sng', }; } if (styleObj['text-decoration'].indexOf('line-through') !== -1) { options.strike = 'sngStrike'; } } if (styleObj['vertical-align']) { if (styleObj['vertical-align'] === 'super') options.superscript = true; if (styleObj['vertical-align'] === 'sub') options.subscript = true; } if (styleObj['text-align']) options.align = styleObj['text-align'] as pptxgen.HAlign; if (styleObj['font-weight']) options.bold = styleObj['font-weight'] === 'bold'; if (styleObj['font-style']) options.italic = styleObj['font-style'] === 'italic'; if (styleObj['font-family']) options.fontFace = styleObj['font-family']; if (styleObj['href']) options.hyperlink = { url: styleObj['href'] }; if (bulletFlag && styleObj['list-type'] === 'ol') { options.bullet = { type: 'number', indent: (options.fontSize || DEFAULT_FONT_SIZE) * 1.25, }; options.paraSpaceBefore = 0.1; bulletFlag = false; } if (bulletFlag && styleObj['list-type'] === 'ul') { options.bullet = { indent: (options.fontSize || DEFAULT_FONT_SIZE) * 1.25, }; options.paraSpaceBefore = 0.1; bulletFlag = false; } if (indent) { options.indentLevel = indent; indent = 0; } slices.push({ text, options }); } else if ('children' in item) parse(item.children, styleObj); } }; parse(ast); return slices; } // ── SVG path → pptxgenjs points ── type Points = Array< | { x: number; y: number; moveTo?: boolean } | { x: number; y: number; curve: { type: 'arc'; hR: number; wR: number; stAng: number; swAng: number; }; } | { x: number; y: number; curve: { type: 'quadratic'; x1: number; y1: number }; } | { x: number; y: number; curve: { type: 'cubic'; x1: number; y1: number; x2: number; y2: number }; } | { close: true } >; function formatPoints(points: SvgPoints, ratioPx2Inch: number, scale = { x: 1, y: 1 }): Points { return points.map((point) => { if (point.close !== undefined) { return { close: true }; } else if (point.type === 'M') { return { x: ((point.x as number) / ratioPx2Inch) * scale.x, y: ((point.y as number) / ratioPx2Inch) * scale.y, moveTo: true, }; } else if (point.curve) { if (point.curve.type === 'cubic') { return { x: ((point.x as number) / ratioPx2Inch) * scale.x, y: ((point.y as number) / ratioPx2Inch) * scale.y, curve: { type: 'cubic' as const, x1: ((point.curve.x1 as number) / ratioPx2Inch) * scale.x, y1: ((point.curve.y1 as number) / ratioPx2Inch) * scale.y, x2: ((point.curve.x2 as number) / ratioPx2Inch) * scale.x, y2: ((point.curve.y2 as number) / ratioPx2Inch) * scale.y, }, }; } else if (point.curve.type === 'quadratic') { return { x: ((point.x as number) / ratioPx2Inch) * scale.x, y: ((point.y as number) / ratioPx2Inch) * scale.y, curve: { type: 'quadratic' as const, x1: ((point.curve.x1 as number) / ratioPx2Inch) * scale.x, y1: ((point.curve.y1 as number) / ratioPx2Inch) * scale.y, }, }; } } return { x: ((point.x as number) / ratioPx2Inch) * scale.x, y: ((point.y as number) / ratioPx2Inch) * scale.y, }; }); } // ── Shadow config ── function getShadowOption(shadow: PPTElementShadow, ratioPx2Pt: number): pptxgen.ShadowProps { const c = formatColor(shadow.color); const { h, v } = shadow; let offset = 4; let angle = 45; if (h === 0 && v === 0) { offset = 4; angle = 45; } else if (h === 0) { if (v > 0) { offset = v; angle = 90; } else { offset = -v; angle = 270; } } else if (v === 0) { if (h > 0) { offset = h; angle = 1; } else { offset = -h; angle = 180; } } else if (h > 0 && v > 0) { offset = Math.max(h, v); angle = 45; } else if (h > 0 && v < 0) { offset = Math.max(h, -v); angle = 315; } else if (h < 0 && v > 0) { offset = Math.max(-h, v); angle = 135; } else if (h < 0 && v < 0) { offset = Math.max(-h, -v); angle = 225; } return { type: 'outer', color: c.color.replace('#', ''), opacity: c.alpha, // pptxgenjs shadow blur AND offset are in points; the source model is in // pixels. blur was converted but offset was not, so shadows exported ~33% // too far at the default viewport (ratioPx2Pt = 96/72 × viewportSize/960). blur: shadow.blur / ratioPx2Pt, offset: offset / ratioPx2Pt, angle, }; } // ── Outline config ── const dashTypeMap: Record = { solid: 'solid', dashed: 'dash', dotted: 'sysDot', }; function getOutlineOption(outline: PPTElementOutline, ratioPx2Pt: number): pptxgen.ShapeLineProps { const c = formatColor(outline?.color || '#000000'); return { color: c.color, transparency: (1 - c.alpha) * 100, width: (outline.width || 1) / ratioPx2Pt, dashType: outline.style ? (dashTypeMap[outline.style] as 'solid' | 'dash' | 'sysDot') : 'solid', }; } // ── Link config ── function getLinkOption(link: PPTElementLink, slides: Slide[]): pptxgen.HyperlinkProps | null { const { type, target } = link; if (type === 'web') return { url: target }; if (type === 'slide') { const index = slides.findIndex((slide) => slide.id === target); if (index !== -1) return { slide: index + 1 }; } return null; } // ── Image helpers ── function isBase64Image(url: string) { return /^data:image\/[^;]+;base64,/.test(url); } function isSVGImage(url: string) { return /^data:image\/svg\+xml;base64,/.test(url) || /\.svg$/.test(url); } // ── Main export hook ── // ── Build PPTX blob (reused by single-export and resource pack) ── /** * Extract speaker notes text from a scene's actions. * Concatenates speech text and action labels into plain text. */ function buildSpeakerNotes(scene: Scene): string { if (!scene.actions || scene.actions.length === 0) return ''; const parts: string[] = []; for (const action of scene.actions) { if (action.type === 'speech') { parts.push((action as SpeechAction).text); } } return parts.join('\n'); } async function fetchBlob(url: string): Promise { try { const response = await fetch(url); return response.ok ? await response.blob() : null; } catch { return null; } } async function blobToDataUrl(blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result as string); reader.onerror = reject; reader.readAsDataURL(blob); }); } export function resolvePptxMediaBinding( ref: string | undefined, task: MediaTaskState | undefined, lease: AssetUrlLeaseState = MISSING_ASSET_LEASE, mediaGenerationDisabled = false, ) { const resolution = resolveMediaRef(ref, task, lease, mediaGenerationDisabled); return { resolution, src: renderableMediaUrl(resolution) ?? '' }; } export function exportMediaResolution(ref: string | undefined, stageId?: string) { const tasks = useMediaGenerationStore.getState().tasks; const task = ref ? (tasks[ref] ?? Object.values(tasks).find( (candidate) => candidate.placeholderRef === ref && (!stageId || candidate.stageId === stageId), )) : undefined; const effectiveTask = task && (!stageId || task.stageId === stageId) ? task : undefined; return resolvePptxMediaBinding(ref, effectiveTask).resolution; } /** Resolve generated/allocated media without preventing the legacy source fetch fallback. */ async function resolveStoredMediaBlob(ref: string, stageId?: string): Promise { const tasks = useMediaGenerationStore.getState().tasks; const task = tasks[ref] ?? Object.values(tasks).find( (candidate) => candidate.placeholderRef === ref && (!stageId || candidate.stageId === stageId), ); const effectiveTask = task && (!stageId || task.stageId === stageId) ? task : undefined; if (!isConcreteMediaAddress(ref)) { try { const pooled = await withAssetUrl(ref, async (url) => { if (!url) return null; const state = resolveMediaRef(ref, effectiveTask, { status: 'resolved', url }); const resolved = renderableMediaUrl(state); return resolved ? fetchBlob(resolved) : null; }); if (pooled) return pooled; } catch { // The compatibility row remains the fallback when pool access fails. } } if (stageId) { const record = await db.mediaFiles.get(mediaFileKey(stageId, ref)).catch(() => undefined); if (record && !record.error && record.blob.size > 0) { const state = resolveMediaRef(ref, effectiveTask, { status: 'resolved', url: 'dexie:media', }); if (state.kind === 'url') return record.blob; } } const state = resolveMediaRef(ref, effectiveTask, MISSING_ASSET_LEASE); const resolved = renderableMediaUrl(state); return resolved ? fetchBlob(resolved) : null; } // Exported for the round-trip integration test harness — the test wires its // own slides + ratios in and inspects the resulting PPTX bytes via JSZip. // The hook below is still the only intended runtime caller. export async function buildPptxBlob( slides: Slide[], slideScenes: Scene[], viewportRatio: number, viewportSize: number, ratioPx2Inch: number, ratioPx2Pt: number, stageId?: string, ): Promise { const pptx = new pptxgen(); const documentElements = slides.flatMap((slide) => slide.elements); // Set layout based on aspect ratio if (viewportRatio === 0.625) pptx.layout = 'LAYOUT_16x10'; else if (viewportRatio === 0.75) pptx.layout = 'LAYOUT_4x3'; else pptx.layout = 'LAYOUT_16x9'; for (let slideIdx = 0; slideIdx < slides.length; slideIdx++) { const slide = slides[slideIdx]; const pptxSlide = pptx.addSlide(); // ── Speaker Notes ── const scene = slideScenes[slideIdx]; if (scene) { const notes = buildSpeakerNotes(scene); if (notes) pptxSlide.addNotes(notes); } // ── Background ── if (slide.background) { const bg = slide.background; if (bg.type === 'image' && bg.image) { let resolvedSrc = renderableMediaUrl(exportMediaResolution(bg.image.src, stageId)); if (!resolvedSrc) { const stored = await resolveStoredMediaBlob(bg.image.src, stageId); if (stored) resolvedSrc = await blobToDataUrl(stored); } if (!resolvedSrc) { // Missing generated backgrounds stay empty instead of leaking an opaque ref to pptxgen. } else if (isSVGImage(resolvedSrc)) { pptxSlide.addImage({ data: resolvedSrc, x: 0, y: 0, w: viewportSize / ratioPx2Inch, h: (viewportSize * viewportRatio) / ratioPx2Inch, }); } else if (isBase64Image(resolvedSrc)) { pptxSlide.background = { data: resolvedSrc }; } else { pptxSlide.background = { path: resolvedSrc }; } } else if (bg.type === 'solid' && bg.color) { const c = formatColor(bg.color); pptxSlide.background = { color: c.color, transparency: (1 - c.alpha) * 100, }; } else if (bg.type === 'gradient' && bg.gradient) { const colors = bg.gradient.colors; const color1 = colors[0].color; const color2 = colors[colors.length - 1].color; const mixed = tinycolor.mix(color1, color2).toHexString(); const c = formatColor(mixed); pptxSlide.background = { color: c.color, transparency: (1 - c.alpha) * 100, }; } } if (!slide.elements) continue; // ── Elements ── for (const el of slide.elements) { // ── TEXT ── if (el.type === 'text') { const textProps = formatHTML(el.content, ratioPx2Pt); const options: pptxgen.TextPropsOptions = { x: el.left / ratioPx2Inch, y: el.top / ratioPx2Inch, w: el.width / ratioPx2Inch, h: el.height / ratioPx2Inch, fontSize: DEFAULT_FONT_SIZE / ratioPx2Pt, fontFace: el.defaultFontName || DEFAULT_FONT_FAMILY, color: '#000000', valign: 'top', margin: 10 / ratioPx2Pt, paraSpaceBefore: 5 / ratioPx2Pt, lineSpacingMultiple: 1.5 / 1.25, autoFit: true, }; if (el.rotate) options.rotate = el.rotate; if (el.wordSpace) options.charSpacing = el.wordSpace / ratioPx2Pt; if (el.lineHeight) options.lineSpacingMultiple = el.lineHeight / 1.25; if (el.fill) { const c = formatColor(el.fill); const opacity = el.opacity === undefined ? 1 : el.opacity; options.fill = { color: c.color, transparency: (1 - c.alpha * opacity) * 100, }; } if (el.defaultColor) options.color = formatColor(el.defaultColor).color; if (el.defaultFontName) options.fontFace = el.defaultFontName; if (el.shadow) options.shadow = getShadowOption(el.shadow, ratioPx2Pt); if (el.outline?.width) options.line = getOutlineOption(el.outline, ratioPx2Pt); if (el.opacity !== undefined) options.transparency = (1 - el.opacity) * 100; if (el.paragraphSpace !== undefined) options.paraSpaceBefore = el.paragraphSpace / ratioPx2Pt; if (el.vertical) options.vert = 'eaVert'; pptxSlide.addText(textProps, options); } // ── IMAGE ── else if (el.type === 'image') { // Resolve placeholder src → actual image data let resolvedSrc = renderableMediaUrl(exportMediaResolution(el.src, stageId)); if (isMediaPlaceholder(el.src)) { const stored = await resolveStoredMediaBlob(el.src, stageId); if (stored) resolvedSrc = await blobToDataUrl(stored); } if (!resolvedSrc) continue; // Fetch and convert to base64 for embedding in PPTX // (blob: URLs and remote URLs won't work in offline PPTX) if (!isBase64Image(resolvedSrc)) { try { const resp = await fetch(resolvedSrc); if (!resp.ok) { log.warn( `Failed to fetch image (HTTP ${resp.status}), skipping element: ${resolvedSrc}`, ); continue; } resolvedSrc = await blobToDataUrl(await resp.blob()); } catch { log.warn('Failed to convert image to base64, skipping element'); continue; } } const options: pptxgen.ImageProps = { x: el.left / ratioPx2Inch, y: el.top / ratioPx2Inch, w: el.width / ratioPx2Inch, h: el.height / ratioPx2Inch, }; if (isBase64Image(resolvedSrc)) options.data = resolvedSrc; else options.path = resolvedSrc; if (el.flipH) options.flipH = el.flipH; if (el.flipV) options.flipV = el.flipV; if (el.rotate) options.rotate = el.rotate; if (el.link) { const linkOption = getLinkOption(el.link, slides); if (linkOption) options.hyperlink = linkOption; } if (el.filters?.opacity) options.transparency = 100 - parseInt(el.filters.opacity); if (el.clip) { if (el.clip.shape === 'ellipse') options.rounding = true; const [start, end] = el.clip.range; const [startX, startY] = start; const [endX, endY] = end; const originW = el.width / ((endX - startX) / ratioPx2Inch); const originH = el.height / ((endY - startY) / ratioPx2Inch); options.w = originW / ratioPx2Inch; options.h = originH / ratioPx2Inch; options.sizing = { type: 'crop', x: ((startX / ratioPx2Inch) * originW) / ratioPx2Inch, y: ((startY / ratioPx2Inch) * originH) / ratioPx2Inch, w: (((endX - startX) / ratioPx2Inch) * originW) / ratioPx2Inch, h: (((endY - startY) / ratioPx2Inch) * originH) / ratioPx2Inch, }; } pptxSlide.addImage(options); } // ── SHAPE ── else if (el.type === 'shape') { if (el.special) { // Special shapes: render as SVG image // Create a temporary SVG element from the path const svgNS = 'http://www.w3.org/2000/svg'; const svg = document.createElementNS(svgNS, 'svg'); svg.setAttribute('xmlns', svgNS); svg.setAttribute('viewBox', `0 0 ${el.viewBox[0]} ${el.viewBox[1]}`); svg.setAttribute('width', String(el.width)); svg.setAttribute('height', String(el.height)); const path = document.createElementNS(svgNS, 'path'); path.setAttribute('d', el.path); path.setAttribute('fill', el.fill || 'none'); if (el.outline?.color) { path.setAttribute('stroke', el.outline.color); path.setAttribute('stroke-width', String(el.outline.width || 1)); } svg.appendChild(path); const base64SVG = svg2Base64(svg); const imgOptions: pptxgen.ImageProps = { data: base64SVG, x: el.left / ratioPx2Inch, y: el.top / ratioPx2Inch, w: el.width / ratioPx2Inch, h: el.height / ratioPx2Inch, }; if (el.rotate) imgOptions.rotate = el.rotate; if (el.flipH) imgOptions.flipH = el.flipH; if (el.flipV) imgOptions.flipV = el.flipV; if (el.link) { const linkOption = getLinkOption(el.link, slides); if (linkOption) imgOptions.hyperlink = linkOption; } pptxSlide.addImage(imgOptions); } else { const scale = { x: el.width / el.viewBox[0], y: el.height / el.viewBox[1], }; const rawPoints = toPoints(el.path); if (!rawPoints.length) continue; // Malformed path — toPoints already logged. const points = formatPoints(rawPoints, ratioPx2Inch, scale); let fillColor = formatColor(el.fill); if (el.gradient) { const colors = el.gradient.colors; const color1 = colors[0].color; const color2 = colors[colors.length - 1].color; const mixed = tinycolor.mix(color1, color2).toHexString(); fillColor = formatColor(mixed); } if (el.pattern) fillColor = formatColor('#00000000'); const opacity = el.opacity === undefined ? 1 : el.opacity; const shapeOptions: pptxgen.ShapeProps = { x: el.left / ratioPx2Inch, y: el.top / ratioPx2Inch, w: el.width / ratioPx2Inch, h: el.height / ratioPx2Inch, fill: { color: fillColor.color, transparency: (1 - fillColor.alpha * opacity) * 100, }, points, }; if (el.flipH) shapeOptions.flipH = el.flipH; if (el.flipV) shapeOptions.flipV = el.flipV; if (el.shadow) shapeOptions.shadow = getShadowOption(el.shadow, ratioPx2Pt); if (el.outline?.width) shapeOptions.line = getOutlineOption(el.outline, ratioPx2Pt); if (el.rotate) shapeOptions.rotate = el.rotate; if (el.link) { const linkOption = getLinkOption(el.link, slides); if (linkOption) shapeOptions.hyperlink = linkOption; } pptxSlide.addShape('custGeom' as pptxgen.ShapeType, shapeOptions); } // Shape text overlay if (el.text) { const textProps = formatHTML(el.text.content, ratioPx2Pt); const textOptions: pptxgen.TextPropsOptions = { x: el.left / ratioPx2Inch, y: el.top / ratioPx2Inch, w: el.width / ratioPx2Inch, h: el.height / ratioPx2Inch, fontSize: DEFAULT_FONT_SIZE / ratioPx2Pt, fontFace: DEFAULT_FONT_FAMILY, color: '#000000', paraSpaceBefore: 5 / ratioPx2Pt, valign: el.text.align, }; if (el.rotate) textOptions.rotate = el.rotate; if (el.text.defaultColor) textOptions.color = formatColor(el.text.defaultColor).color; if (el.text.defaultFontName) textOptions.fontFace = el.text.defaultFontName; pptxSlide.addText(textProps, textOptions); } // Pattern overlay if (el.pattern) { const patternOptions: pptxgen.ImageProps = { x: el.left / ratioPx2Inch, y: el.top / ratioPx2Inch, w: el.width / ratioPx2Inch, h: el.height / ratioPx2Inch, }; if (isBase64Image(el.pattern)) patternOptions.data = el.pattern; else patternOptions.path = el.pattern; if (el.flipH) patternOptions.flipH = el.flipH; if (el.flipV) patternOptions.flipV = el.flipV; if (el.rotate) patternOptions.rotate = el.rotate; if (el.link) { const linkOption = getLinkOption(el.link, slides); if (linkOption) patternOptions.hyperlink = linkOption; } pptxSlide.addImage(patternOptions); } } // ── LINE ── else if (el.type === 'line') { const path = getLineElementPath(el); const points = formatPoints(toPoints(path), ratioPx2Inch); const { minX, maxX, minY, maxY } = getElementRange(el); const c = formatColor(el.color); const lineOptions: pptxgen.ShapeProps = { x: el.left / ratioPx2Inch, y: el.top / ratioPx2Inch, w: (maxX - minX) / ratioPx2Inch, h: (maxY - minY) / ratioPx2Inch, line: { color: c.color, transparency: (1 - c.alpha) * 100, width: el.width / ratioPx2Pt, dashType: dashTypeMap[el.style] as 'solid' | 'dash' | 'sysDot', beginArrowType: el.points[0] ? 'arrow' : 'none', endArrowType: el.points[1] ? 'arrow' : 'none', }, points, }; if (el.shadow) lineOptions.shadow = getShadowOption(el.shadow, ratioPx2Pt); pptxSlide.addShape('custGeom' as pptxgen.ShapeType, lineOptions); } // ── CHART ── else if (el.type === 'chart') { const chartData = []; for (let i = 0; i < el.data.series.length; i++) { const item = el.data.series[i]; chartData.push({ name: `Series ${i + 1}`, labels: el.data.labels, values: item, }); } let chartColors: string[] = []; if (el.themeColors.length === 10) { chartColors = el.themeColors.map((c) => formatColor(c).color); } else if (el.themeColors.length === 1) { chartColors = tinycolor(el.themeColors[0]) .analogous(10) .map((c) => formatColor(c.toHexString()).color); } else { const len = el.themeColors.length; const supplement = tinycolor(el.themeColors[len - 1]) .analogous(10 + 1 - len) .map((c) => c.toHexString()); chartColors = [...el.themeColors.slice(0, len - 1), ...supplement].map( (c) => formatColor(c).color, ); } const chartOptions: pptxgen.IChartOpts = { x: el.left / ratioPx2Inch, y: el.top / ratioPx2Inch, w: el.width / ratioPx2Inch, h: el.height / ratioPx2Inch, chartColors: el.chartType === 'pie' || el.chartType === 'ring' ? chartColors : chartColors.slice(0, el.data.series.length), }; const textColor = formatColor(el.textColor || '#000000').color; chartOptions.catAxisLabelColor = textColor; chartOptions.valAxisLabelColor = textColor; const fontSize = 14 / ratioPx2Pt; chartOptions.catAxisLabelFontSize = fontSize; chartOptions.valAxisLabelFontSize = fontSize; if (el.fill || el.outline) { const plotArea: pptxgen.IChartPropsFillLine = {}; if (el.fill) plotArea.fill = { color: formatColor(el.fill).color }; if (el.outline) { plotArea.border = { pt: el.outline.width! / ratioPx2Pt, color: formatColor(el.outline.color!).color, }; } chartOptions.plotArea = plotArea; } if ( (el.data.series.length > 1 && el.chartType !== 'scatter') || el.chartType === 'pie' || el.chartType === 'ring' ) { chartOptions.showLegend = true; chartOptions.legendPos = 'b'; chartOptions.legendColor = textColor; chartOptions.legendFontSize = fontSize; } let type = pptx.ChartType.bar; if (el.chartType === 'bar') { type = pptx.ChartType.bar; chartOptions.barDir = 'col'; if (el.options?.stack) chartOptions.barGrouping = 'stacked'; } else if (el.chartType === 'column') { type = pptx.ChartType.bar; chartOptions.barDir = 'bar'; if (el.options?.stack) chartOptions.barGrouping = 'stacked'; } else if (el.chartType === 'line') { type = pptx.ChartType.line; if (el.options?.lineSmooth) chartOptions.lineSmooth = true; } else if (el.chartType === 'area') { type = pptx.ChartType.area; } else if (el.chartType === 'radar') { type = pptx.ChartType.radar; } else if (el.chartType === 'scatter') { type = pptx.ChartType.scatter; chartOptions.lineSize = 0; } else if (el.chartType === 'pie') { type = pptx.ChartType.pie; } else if (el.chartType === 'ring') { type = pptx.ChartType.doughnut; chartOptions.holeSize = 60; } pptxSlide.addChart(type, chartData, chartOptions); } // ── TABLE ── else if (el.type === 'table') { const hiddenCells: string[] = []; for (let i = 0; i < el.data.length; i++) { const rowData = el.data[i]; for (let j = 0; j < rowData.length; j++) { const cell = rowData[j]; if (cell.colspan > 1 || cell.rowspan > 1) { for (let row = i; row < i + cell.rowspan; row++) { for (let col = row === i ? j + 1 : j; col < j + cell.colspan; col++) { hiddenCells.push(`${row}_${col}`); } } } } } const tableData: pptxgen.TableRow[] = []; const theme = el.theme; let themeColor: FormatColor | null = null; let subThemeColors: FormatColor[] = []; if (theme) { themeColor = formatColor(theme.color); subThemeColors = getTableSubThemeColor(theme.color).map((item) => formatColor(item)); } for (let i = 0; i < el.data.length; i++) { const row = el.data[i]; const _row: pptxgen.TableCell[] = []; for (let j = 0; j < row.length; j++) { const cell = row[j]; const cellOptions: pptxgen.TableCellProps = { colspan: cell.colspan, rowspan: cell.rowspan, bold: cell.style?.bold || false, italic: cell.style?.em || false, underline: { style: cell.style?.underline ? 'sng' : 'none' }, align: cell.style?.align || 'left', valign: 'middle', fontFace: cell.style?.fontname || DEFAULT_FONT_FAMILY, fontSize: (cell.style?.fontsize ? parseInt(cell.style.fontsize) : 14) / ratioPx2Pt, }; if (theme && themeColor) { let c: FormatColor; if (i % 2 === 0) c = subThemeColors[1]; else c = subThemeColors[0]; if (theme.rowHeader && i === 0) c = themeColor; else if (theme.rowFooter && i === el.data.length - 1) c = themeColor; else if (theme.colHeader && j === 0) c = themeColor; else if (theme.colFooter && j === row.length - 1) c = themeColor; cellOptions.fill = { color: c.color, transparency: (1 - c.alpha) * 100, }; } if (cell.style?.backcolor) { const c = formatColor(cell.style.backcolor); cellOptions.fill = { color: c.color, transparency: (1 - c.alpha) * 100, }; } if (cell.style?.color) cellOptions.color = formatColor(cell.style.color).color; if (!hiddenCells.includes(`${i}_${j}`)) { _row.push({ text: cell.text, options: cellOptions }); } } if (_row.length) tableData.push(_row); } const tableOptions: pptxgen.TableProps = { x: el.left / ratioPx2Inch, y: el.top / ratioPx2Inch, w: el.width / ratioPx2Inch, h: el.height / ratioPx2Inch, colW: el.colWidths.map((item) => (el.width * item) / ratioPx2Inch), }; if (el.theme) tableOptions.fill = { color: '#ffffff' }; if (el.outline.width && el.outline.color) { tableOptions.border = { type: el.outline.style === 'solid' ? 'solid' : 'dash', pt: el.outline.width / ratioPx2Pt, color: formatColor(el.outline.color).color, }; } pptxSlide.addTable(tableData, tableOptions); } // ── LATEX ── else if (el.type === 'latex') { // Try native OMML formula first (editable in PowerPoint) // Estimate line count from \\ line breaks to compute a fitting font size. // Formula rendered height ≈ lines * 1.5 * fontSize, so fontSize ≈ boxHeight / (lines * 1.5) const lineBreaks = (el.latex?.match(/\\\\/g) || []).length; const lines = lineBreaks + 1; const boxHeightPt = el.height / ratioPx2Pt; const fontSize = Math.round(boxHeightPt / (lines * 3)); const omml = el.latex ? latexToOmml(el.latex, fontSize) : null; if (omml) { pptxSlide.addFormula({ omml, x: el.left / ratioPx2Inch, y: el.top / ratioPx2Inch, w: el.width / ratioPx2Inch, h: el.height / ratioPx2Inch, fontSize, align: el.align, }); } else if (el.path) { // Fallback: render as SVG image (non-editable) const range = getSvgPathRange(el.path); const sw = el.strokeWidth || 0; const vbX = range.minX - sw; const vbY = range.minY - sw; const vbW = range.maxX - range.minX + sw * 2; const vbH = range.maxY - range.minY + sw * 2; const svgNS = 'http://www.w3.org/2000/svg'; const svg = document.createElementNS(svgNS, 'svg'); svg.setAttribute('xmlns', svgNS); svg.setAttribute('width', String(el.width)); svg.setAttribute('height', String(el.height)); svg.setAttribute('viewBox', `${vbX} ${vbY} ${vbW} ${vbH}`); svg.setAttribute('stroke', el.color || '#000000'); svg.setAttribute('stroke-width', String(sw)); svg.setAttribute('fill', 'none'); svg.setAttribute('stroke-linecap', 'round'); svg.setAttribute('stroke-linejoin', 'round'); const path = document.createElementNS(svgNS, 'path'); path.setAttribute('d', el.path); svg.appendChild(path); const base64SVG = svg2Base64(svg); if (!base64SVG) continue; const latexOptions: pptxgen.ImageProps = { data: base64SVG, x: el.left / ratioPx2Inch, y: el.top / ratioPx2Inch, w: el.width / ratioPx2Inch, h: el.height / ratioPx2Inch, }; if (el.link) { const linkOption = getLinkOption(el.link, slides); if (linkOption) latexOptions.hyperlink = linkOption; } pptxSlide.addImage(latexOptions); } } // ── VIDEO / AUDIO ── else if (el.type === 'video' || el.type === 'audio') { const videoBinding = el.type === 'video' ? resolveVideoMediaForElement( useMediaGenerationStore.getState().tasks, el, stageId, documentElements, ) : undefined; const sourceRef = videoBinding?.sourceRef ?? el.src; let resolvedSrc = renderableMediaUrl( videoBinding ? resolvePptxMediaBinding(sourceRef, videoBinding.task).resolution : exportMediaResolution(sourceRef, stageId), ); const mediaLookupKey = sourceRef && !isConcreteMediaAddress(sourceRef) && isMediaPlaceholder(sourceRef) ? sourceRef : undefined; if (mediaLookupKey) { const stored = await resolveStoredMediaBlob(mediaLookupKey, stageId); if (stored) resolvedSrc = await blobToDataUrl(stored); } if (!resolvedSrc) continue; // Fetch blob and convert to base64 for embedding in PPTX // (blob: URLs and remote URLs won't work in offline PPTX) try { const base64 = isBase64Image(resolvedSrc) ? resolvedSrc : await (async () => { const resp = await fetch(resolvedSrc); if (!resp.ok) { throw new Error(`Failed to fetch media (HTTP ${resp.status}): ${resolvedSrc}`); } return blobToDataUrl(await resp.blob()); })(); const mediaOptions: pptxgen.MediaProps = { x: el.left / ratioPx2Inch, y: el.top / ratioPx2Inch, w: el.width / ratioPx2Inch, h: el.height / ratioPx2Inch, data: base64, type: el.type, }; // Determine file extension const extMatch = resolvedSrc.match(/\.([a-zA-Z0-9]+)(?:[?#]|$)/); if (extMatch && extMatch[1]) mediaOptions.extn = extMatch[1]; else if (el.ext) mediaOptions.extn = el.ext; else mediaOptions.extn = el.type === 'video' ? 'mp4' : 'mp3'; // Generate cover image for video if (el.type === 'video') { let coverBase64: string | undefined; // 1. Try poster from element or media generation store let posterUrl = videoBinding?.posterRef; let posterBlob = posterUrl ? await resolveStoredMediaBlob(posterUrl, stageId) : null; if (posterUrl && !posterBlob) { posterUrl = renderableMediaUrl( resolvePptxMediaBinding(posterUrl, videoBinding?.posterTask).resolution, ); } if (posterBlob) { coverBase64 = await blobToDataUrl(posterBlob); } else if (posterUrl) { try { const posterResp = await fetch(posterUrl); if (!posterResp.ok) { log.warn(`Failed to fetch poster (HTTP ${posterResp.status}), skipping`); } else { posterBlob = await posterResp.blob(); coverBase64 = await blobToDataUrl(posterBlob); } } catch { // Poster fetch failed, fall through to video frame capture } } // 2. Fallback: capture first frame from video via canvas if (!coverBase64) { try { coverBase64 = await new Promise((resolve, reject) => { const video = document.createElement('video'); video.crossOrigin = 'anonymous'; video.muted = true; video.preload = 'auto'; video.onloadeddata = () => { video.currentTime = 0; }; video.onseeked = () => { try { const canvas = document.createElement('canvas'); canvas.width = video.videoWidth || el.width; canvas.height = video.videoHeight || el.height; const ctx = canvas.getContext('2d'); if (ctx) { ctx.drawImage(video, 0, 0, canvas.width, canvas.height); resolve(canvas.toDataURL('image/png')); } else { reject(new Error('No canvas context')); } video.src = ''; // Release } catch (e) { reject(e); } }; video.onerror = () => reject(new Error('Video load failed')); // Timeout to avoid hanging setTimeout(() => reject(new Error('Video frame capture timeout')), 10000); video.src = resolvedSrc; }); } catch { // Frame capture also failed, video will use default play button } } if (coverBase64) mediaOptions.cover = coverBase64; } pptxSlide.addMedia(mediaOptions); } catch (err) { log.warn(`Failed to embed ${el.type} element:`, err); } } } } return (await pptx.write({ outputType: 'blob' })) as Blob; } // ── Resource Pack assembly (PPTX + interactive HTML pages as ZIP) ── // Pure/async builder extracted from the hook so the assembly logic — which // scenes make it into the ZIP, whether the PPTX builder runs — is unit-testable // without a React/jsdom harness. The hook stays the only runtime caller. // // `getPptxBlob` is invoked only when slide scenes exist, so an interactive-only // deck never touches the PPTX builder. Returns `empty: true` (and a null blob) // when there is nothing to ship. export interface ResourcePackResult { /** Generated ZIP blob, or null when there is nothing to ship. */ blob: Blob | null; /** True when the deck has interactive pages but no slides (PPTX skipped). */ skippedPptx: boolean; /** True when neither slides nor interactive pages could be exported. */ empty: boolean; /** External asset URLs that could not be inlined into the HTML pages. */ failedAssetUrls: string[]; } export async function buildResourcePackZip( scenes: Scene[], slides: Slide[], slideScenes: Scene[], opts: { viewportRatio: number; viewportSize: number; ratioPx2Inch: number; ratioPx2Pt: number; fileName: string; /** Called only when `slides.length > 0`; produces the PPTX blob. */ getPptxBlob: () => Promise; /** Passed through to `inlineHtmlAssets`; tests inject a no-op fetcher. */ fetcher?: FetchAsset; }, ): Promise { const JSZip = (await import('jszip')).default; const zip = new JSZip(); const failedAssetUrls: string[] = []; // 1. Add interactive HTML pages (independent of slides) let interactiveIndex = 0; for (const scene of scenes) { if (scene.content.type === 'interactive' && scene.content.html) { interactiveIndex++; const safeName = scene.title.replace(/[\\/:*?"<>|]/g, '_'); const htmlFileName = `interactive/${String(interactiveIndex).padStart(2, '0')}_${safeName}.html`; const { html: inlinedHtml, report } = await inlineHtmlAssets(scene.content.html, { fetcher: opts.fetcher, }); for (const f of report.failed) { if (!failedAssetUrls.includes(f.url)) failedAssetUrls.push(f.url); } zip.file(htmlFileName, inlinedHtml); } } // Nothing to ship: no slides and no interactive pages. if (interactiveIndex === 0 && slides.length === 0) { return { blob: null, skippedPptx: false, empty: true, failedAssetUrls }; } // 2. Generate PPTX only when slide scenes exist. const skippedPptx = slides.length === 0; if (!skippedPptx) { const pptxBlob = await opts.getPptxBlob(); // Convert to ArrayBuffer so jszip stores a plain byte buffer rather than a // Blob (which it can't reliably round-trip outside the browser). zip.file(`${opts.fileName}.pptx`, await pptxBlob.arrayBuffer()); } const blob = await zip.generateAsync({ type: 'blob' }); return { blob, skippedPptx, empty: false, failedAssetUrls }; } // ── Hook ── export function useExportPPTX() { const [exporting, setExporting] = useState(false); const exportingRef = useRef(false); const { t } = useI18n(); const scenes = useStageStore((s) => s.scenes); const stage = useStageStore((s) => s.stage); const viewportSize = useCanvasStore.use.viewportSize(); const viewportRatio = useCanvasStore.use.viewportRatio(); const ratioPx2Inch = 96 * (viewportSize / 960); const ratioPx2Pt = (96 / 72) * (viewportSize / 960); const slideScenes = scenes.filter((s) => s.content.type === 'slide'); const slides = slideScenes.map((s) => (s.content as SlideContent).canvas); // Shared guard + state wrapper for export actions. // `requireSlides` controls whether the guard rejects a deck with no slide // scenes (PPTX export needs them; the resource pack can ship interactive // pages alone). When it rejects, callers get a toast instead of silence. const withExportGuard = useCallback( (action: () => Promise, requireSlides = true) => { if (exportingRef.current) return; if (requireSlides && slides.length === 0) { toast.warning(t('export.noSlides')); return; } exportingRef.current = true; setExporting(true); setTimeout(async () => { try { await action(); } catch (err) { log.error('Export failed:', err); toast.error(t('export.exportFailed')); } finally { exportingRef.current = false; setExporting(false); } }, 100); }, [slides.length, t], ); // ── Export PPTX only ── const exportPPTX = useCallback(() => { withExportGuard(async () => { const fileName = stage?.name || 'slides'; const blob = await buildPptxBlob( slides, slideScenes, viewportRatio, viewportSize, ratioPx2Inch, ratioPx2Pt, stage?.id, ); saveAs(blob, `${fileName}.pptx`); toast.success(t('export.exportSuccess')); }); }, [ withExportGuard, slides, slideScenes, stage, viewportSize, viewportRatio, ratioPx2Inch, ratioPx2Pt, t, ]); // ── Export Resource Pack (PPTX + interactive HTML pages as ZIP) ── // `requireSlides` is false: a deck with only interactive scenes still ships // its interactive pages as the resource pack (PPTX is skipped with a toast). const exportResourcePack = useCallback(() => { withExportGuard(async () => { const fileName = stage?.name || 'slides'; const sharedFetcher = createAssetFetcher({ fetchImpl: createProxiedFetch() }); const result = await buildResourcePackZip(scenes, slides, slideScenes, { viewportRatio, viewportSize, ratioPx2Inch, ratioPx2Pt, fileName, fetcher: sharedFetcher, getPptxBlob: () => buildPptxBlob( slides, slideScenes, viewportRatio, viewportSize, ratioPx2Inch, ratioPx2Pt, stage?.id, ), }); if (result.empty) { toast.warning(t('export.nothingToExport')); return; } if (result.skippedPptx) { toast.info(t('export.noSlidesSkipped')); } saveAs(result.blob!, `${fileName}.zip`); toast.success(t('export.exportSuccess')); if (result.failedAssetUrls.length > 0) { log.warn( 'Resource Pack: some interactive-scene assets could not be inlined:', result.failedAssetUrls, ); const hosts = [ ...new Set( result.failedAssetUrls.map((u) => { try { return new URL(u).host; } catch { return u; } }), ), ]; toast.warning(t('export.inlinePartial', { count: result.failedAssetUrls.length }), { description: hosts.join(', '), }); } }, false); }, [ withExportGuard, slides, slideScenes, scenes, stage, viewportSize, viewportRatio, ratioPx2Inch, ratioPx2Pt, t, ]); return { exporting, exportPPTX, exportResourcePack }; }