Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

1237 lines
44 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 { createProxiedFetch } from './proxied-fetch';
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<typeof formatColor>;
// ── 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<string, string> = {}) => {
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(/&nbsp;/g, ' ')
.replace(/&gt;/g, '>')
.replace(/&lt;/g, '<')
.replace(/&amp;/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<string, string> = {
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');
}
// 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,
): Promise<Blob> {
const pptx = new pptxgen();
// 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) {
if (isSVGImage(bg.image.src)) {
pptxSlide.addImage({
data: bg.image.src,
x: 0,
y: 0,
w: viewportSize / ratioPx2Inch,
h: (viewportSize * viewportRatio) / ratioPx2Inch,
});
} else if (isBase64Image(bg.image.src)) {
pptxSlide.background = { data: bg.image.src };
} else {
pptxSlide.background = { path: bg.image.src };
}
} 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 = el.src;
if (isMediaPlaceholder(el.src)) {
const task = useMediaGenerationStore.getState().tasks[el.src];
if (task?.status === 'done' && task.objectUrl) {
resolvedSrc = task.objectUrl;
} else {
continue; // Media not ready, skip
}
}
// 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;
}
const blob = await resp.blob();
resolvedSrc = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(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') {
// Resolve generated video mediaRef or legacy placeholder src → blob URL.
let resolvedSrc = el.src;
const mediaRef = el.type === 'video' ? el.mediaRef : undefined;
const mediaLookupKey =
mediaRef ||
(typeof el.src === 'string' && isMediaPlaceholder(el.src) ? el.src : undefined);
if (mediaLookupKey) {
const task = useMediaGenerationStore.getState().tasks[mediaLookupKey];
if (task?.status === 'done' && task.objectUrl) {
resolvedSrc = task.objectUrl;
} else if (!resolvedSrc || isMediaPlaceholder(resolvedSrc)) {
continue; // Media not ready, skip
}
}
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 resp = await fetch(resolvedSrc);
if (!resp.ok) {
log.warn(
`Failed to fetch media (HTTP ${resp.status}), skipping element: ${resolvedSrc}`,
);
continue;
}
const blob = await resp.blob();
const base64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(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 = 'poster' in el && el.poster ? el.poster : undefined;
if (!posterUrl && mediaLookupKey) {
const task = useMediaGenerationStore.getState().tasks[mediaLookupKey];
if (task?.poster) posterUrl = task.poster;
}
if (posterUrl) {
try {
const posterResp = await fetch(posterUrl);
if (!posterResp.ok) {
log.warn(`Failed to fetch poster (HTTP ${posterResp.status}), skipping`);
} else {
const posterBlob = await posterResp.blob();
coverBase64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(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<string>((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;
}
// ── 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
const withExportGuard = useCallback(
(action: () => Promise<void>) => {
if (exportingRef.current || slides.length === 0) 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,
);
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) ──
const exportResourcePack = useCallback(() => {
withExportGuard(async () => {
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
const fileName = stage?.name || 'slides';
// 1. Generate PPTX
const pptxBlob = await buildPptxBlob(
slides,
slideScenes,
viewportRatio,
viewportSize,
ratioPx2Inch,
ratioPx2Pt,
);
zip.file(`${fileName}.pptx`, pptxBlob);
// 2. Add interactive HTML pages
const sharedFetcher = createAssetFetcher({ fetchImpl: createProxiedFetch() });
let interactiveIndex = 0;
const failedAssetUrls = new Set<string>();
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: sharedFetcher,
});
if (report.failed.length > 0) {
log.warn(
'Resource Pack: some interactive-scene assets could not be inlined:',
report.failed,
);
for (const f of report.failed) failedAssetUrls.add(f.url);
}
zip.file(htmlFileName, inlinedHtml);
}
}
// 3. Download ZIP
const zipBlob = await zip.generateAsync({ type: 'blob' });
saveAs(zipBlob, `${fileName}.zip`);
toast.success(t('export.exportSuccess'));
if (failedAssetUrls.size > 0) {
const hosts = [
...new Set(
[...failedAssetUrls].map((u) => {
try {
return new URL(u).host;
} catch {
return u;
}
}),
),
];
toast.warning(t('export.inlinePartial', { count: failedAssetUrls.size }), {
description: hosts.join(', '),
});
}
});
}, [
withExportGuard,
slides,
slideScenes,
scenes,
stage,
viewportSize,
viewportRatio,
ratioPx2Inch,
ratioPx2Pt,
t,
]);
return { exporting, exportPPTX, exportResourcePack };
}