| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181 |
- '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 '@/lib/types/slides';
- 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';
- 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(/ /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,
- blur: shadow.blur / ratioPx2Pt,
- offset,
- 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');
- }
- 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);
- 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 points = formatPoints(toPoints(el.path), 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 placeholder src → blob URL from media generation store
- 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 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);
- 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 && isMediaPlaceholder(el.src)) {
- const task = useMediaGenerationStore.getState().tasks[el.src];
- if (task?.poster) posterUrl = task.poster;
- }
- if (posterUrl) {
- try {
- const posterResp = await fetch(posterUrl);
- 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
- 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`;
- zip.file(htmlFileName, scene.content.html);
- }
- }
- // 3. Download ZIP
- const zipBlob = await zip.generateAsync({ type: 'blob' });
- saveAs(zipBlob, `${fileName}.zip`);
- toast.success(t('export.exportSuccess'));
- });
- }, [
- withExportGuard,
- slides,
- slideScenes,
- scenes,
- stage,
- viewportSize,
- viewportRatio,
- ratioPx2Inch,
- ratioPx2Pt,
- t,
- ]);
- return { exporting, exportPPTX, exportResourcePack };
- }
|