use-export-pptx.ts 41 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181
  1. 'use client';
  2. import { useState, useCallback, useRef } from 'react';
  3. import pptxgen from 'pptxgenjs';
  4. import tinycolor from 'tinycolor2';
  5. import { saveAs } from 'file-saver';
  6. import { toast } from 'sonner';
  7. import { useStageStore } from '@/lib/store';
  8. import { useCanvasStore } from '@/lib/store/canvas';
  9. import { useMediaGenerationStore, isMediaPlaceholder } from '@/lib/store/media-generation';
  10. import { useI18n } from '@/lib/hooks/use-i18n';
  11. import type {
  12. Slide,
  13. PPTElementOutline,
  14. PPTElementShadow,
  15. PPTElementLink,
  16. } from '@/lib/types/slides';
  17. import type { Scene, SlideContent } from '@/lib/types/stage';
  18. import type { SpeechAction } from '@/lib/types/action';
  19. import { getElementRange, getLineElementPath, getTableSubThemeColor } from '@/lib/utils/element';
  20. import { type AST, toAST } from '@/lib/export/html-parser';
  21. import { type SvgPoints, toPoints, getSvgPathRange } from '@/lib/export/svg-path-parser';
  22. import { svg2Base64 } from '@/lib/export/svg2base64';
  23. import { latexToOmml } from '@/lib/export/latex-to-omml';
  24. import { createLogger } from '@/lib/logger';
  25. const log = createLogger('ExportPPTX');
  26. const DEFAULT_FONT_SIZE = 16;
  27. const DEFAULT_FONT_FAMILY = 'Microsoft YaHei';
  28. // ── Color formatting ──
  29. function formatColor(_color: string) {
  30. if (!_color) {
  31. return { alpha: 0, color: '#000000' };
  32. }
  33. const c = tinycolor(_color);
  34. const alpha = c.getAlpha();
  35. const color = alpha === 0 ? '#ffffff' : c.setAlpha(1).toHexString();
  36. return { alpha, color };
  37. }
  38. type FormatColor = ReturnType<typeof formatColor>;
  39. // ── HTML → pptxgenjs TextProps ──
  40. function formatHTML(html: string, ratioPx2Pt: number) {
  41. const ast = toAST(html);
  42. let bulletFlag = false;
  43. let indent = 0;
  44. const slices: pptxgen.TextProps[] = [];
  45. const parse = (obj: AST[], baseStyleObj: Record<string, string> = {}) => {
  46. for (const item of obj) {
  47. const isBlockTag = 'tagName' in item && ['div', 'li', 'p'].includes(item.tagName);
  48. if (isBlockTag && slices.length) {
  49. const lastSlice = slices[slices.length - 1];
  50. if (!lastSlice.options) lastSlice.options = {};
  51. lastSlice.options.breakLine = true;
  52. }
  53. const styleObj = { ...baseStyleObj };
  54. const styleAttr =
  55. 'attributes' in item ? item.attributes.find((attr) => attr.key === 'style') : null;
  56. if (styleAttr && styleAttr.value) {
  57. const styleArr = styleAttr.value.split(';');
  58. for (const styleItem of styleArr) {
  59. const match = styleItem.match(/([^:]+):\s*(.+)/);
  60. if (match) {
  61. const [key, value] = [match[1].trim(), match[2].trim()];
  62. if (key && value) styleObj[key] = value;
  63. }
  64. }
  65. }
  66. if ('tagName' in item) {
  67. if (item.tagName === 'em') styleObj['font-style'] = 'italic';
  68. if (item.tagName === 'strong') styleObj['font-weight'] = 'bold';
  69. if (item.tagName === 'sup') styleObj['vertical-align'] = 'super';
  70. if (item.tagName === 'sub') styleObj['vertical-align'] = 'sub';
  71. if (item.tagName === 'a') {
  72. const attr = item.attributes.find((a) => a.key === 'href');
  73. styleObj['href'] = attr?.value || '';
  74. }
  75. if (item.tagName === 'ul') styleObj['list-type'] = 'ul';
  76. if (item.tagName === 'ol') styleObj['list-type'] = 'ol';
  77. if (item.tagName === 'li') bulletFlag = true;
  78. if (item.tagName === 'p') {
  79. if ('attributes' in item) {
  80. const dataIndentAttr = item.attributes.find((a) => a.key === 'data-indent');
  81. if (dataIndentAttr && dataIndentAttr.value) indent = +dataIndentAttr.value;
  82. }
  83. }
  84. }
  85. if ('tagName' in item && item.tagName === 'br') {
  86. slices.push({ text: '', options: { breakLine: true } });
  87. } else if ('content' in item) {
  88. const text = item.content
  89. .replace(/&nbsp;/g, ' ')
  90. .replace(/&gt;/g, '>')
  91. .replace(/&lt;/g, '<')
  92. .replace(/&amp;/g, '&')
  93. .replace(/\n/g, '');
  94. const options: pptxgen.TextPropsOptions = {};
  95. if (styleObj['font-size']) {
  96. options.fontSize = parseInt(styleObj['font-size']) / ratioPx2Pt;
  97. }
  98. if (styleObj['color']) {
  99. options.color = formatColor(styleObj['color']).color;
  100. }
  101. if (styleObj['background-color']) {
  102. options.highlight = formatColor(styleObj['background-color']).color;
  103. }
  104. if (styleObj['text-decoration-line']) {
  105. if (styleObj['text-decoration-line'].indexOf('underline') !== -1) {
  106. options.underline = {
  107. color: options.color || '#000000',
  108. style: 'sng',
  109. };
  110. }
  111. if (styleObj['text-decoration-line'].indexOf('line-through') !== -1) {
  112. options.strike = 'sngStrike';
  113. }
  114. }
  115. if (styleObj['text-decoration']) {
  116. if (styleObj['text-decoration'].indexOf('underline') !== -1) {
  117. options.underline = {
  118. color: options.color || '#000000',
  119. style: 'sng',
  120. };
  121. }
  122. if (styleObj['text-decoration'].indexOf('line-through') !== -1) {
  123. options.strike = 'sngStrike';
  124. }
  125. }
  126. if (styleObj['vertical-align']) {
  127. if (styleObj['vertical-align'] === 'super') options.superscript = true;
  128. if (styleObj['vertical-align'] === 'sub') options.subscript = true;
  129. }
  130. if (styleObj['text-align']) options.align = styleObj['text-align'] as pptxgen.HAlign;
  131. if (styleObj['font-weight']) options.bold = styleObj['font-weight'] === 'bold';
  132. if (styleObj['font-style']) options.italic = styleObj['font-style'] === 'italic';
  133. if (styleObj['font-family']) options.fontFace = styleObj['font-family'];
  134. if (styleObj['href']) options.hyperlink = { url: styleObj['href'] };
  135. if (bulletFlag && styleObj['list-type'] === 'ol') {
  136. options.bullet = {
  137. type: 'number',
  138. indent: (options.fontSize || DEFAULT_FONT_SIZE) * 1.25,
  139. };
  140. options.paraSpaceBefore = 0.1;
  141. bulletFlag = false;
  142. }
  143. if (bulletFlag && styleObj['list-type'] === 'ul') {
  144. options.bullet = {
  145. indent: (options.fontSize || DEFAULT_FONT_SIZE) * 1.25,
  146. };
  147. options.paraSpaceBefore = 0.1;
  148. bulletFlag = false;
  149. }
  150. if (indent) {
  151. options.indentLevel = indent;
  152. indent = 0;
  153. }
  154. slices.push({ text, options });
  155. } else if ('children' in item) parse(item.children, styleObj);
  156. }
  157. };
  158. parse(ast);
  159. return slices;
  160. }
  161. // ── SVG path → pptxgenjs points ──
  162. type Points = Array<
  163. | { x: number; y: number; moveTo?: boolean }
  164. | {
  165. x: number;
  166. y: number;
  167. curve: {
  168. type: 'arc';
  169. hR: number;
  170. wR: number;
  171. stAng: number;
  172. swAng: number;
  173. };
  174. }
  175. | {
  176. x: number;
  177. y: number;
  178. curve: { type: 'quadratic'; x1: number; y1: number };
  179. }
  180. | {
  181. x: number;
  182. y: number;
  183. curve: { type: 'cubic'; x1: number; y1: number; x2: number; y2: number };
  184. }
  185. | { close: true }
  186. >;
  187. function formatPoints(points: SvgPoints, ratioPx2Inch: number, scale = { x: 1, y: 1 }): Points {
  188. return points.map((point) => {
  189. if (point.close !== undefined) {
  190. return { close: true };
  191. } else if (point.type === 'M') {
  192. return {
  193. x: ((point.x as number) / ratioPx2Inch) * scale.x,
  194. y: ((point.y as number) / ratioPx2Inch) * scale.y,
  195. moveTo: true,
  196. };
  197. } else if (point.curve) {
  198. if (point.curve.type === 'cubic') {
  199. return {
  200. x: ((point.x as number) / ratioPx2Inch) * scale.x,
  201. y: ((point.y as number) / ratioPx2Inch) * scale.y,
  202. curve: {
  203. type: 'cubic' as const,
  204. x1: ((point.curve.x1 as number) / ratioPx2Inch) * scale.x,
  205. y1: ((point.curve.y1 as number) / ratioPx2Inch) * scale.y,
  206. x2: ((point.curve.x2 as number) / ratioPx2Inch) * scale.x,
  207. y2: ((point.curve.y2 as number) / ratioPx2Inch) * scale.y,
  208. },
  209. };
  210. } else if (point.curve.type === 'quadratic') {
  211. return {
  212. x: ((point.x as number) / ratioPx2Inch) * scale.x,
  213. y: ((point.y as number) / ratioPx2Inch) * scale.y,
  214. curve: {
  215. type: 'quadratic' as const,
  216. x1: ((point.curve.x1 as number) / ratioPx2Inch) * scale.x,
  217. y1: ((point.curve.y1 as number) / ratioPx2Inch) * scale.y,
  218. },
  219. };
  220. }
  221. }
  222. return {
  223. x: ((point.x as number) / ratioPx2Inch) * scale.x,
  224. y: ((point.y as number) / ratioPx2Inch) * scale.y,
  225. };
  226. });
  227. }
  228. // ── Shadow config ──
  229. function getShadowOption(shadow: PPTElementShadow, ratioPx2Pt: number): pptxgen.ShadowProps {
  230. const c = formatColor(shadow.color);
  231. const { h, v } = shadow;
  232. let offset = 4;
  233. let angle = 45;
  234. if (h === 0 && v === 0) {
  235. offset = 4;
  236. angle = 45;
  237. } else if (h === 0) {
  238. if (v > 0) {
  239. offset = v;
  240. angle = 90;
  241. } else {
  242. offset = -v;
  243. angle = 270;
  244. }
  245. } else if (v === 0) {
  246. if (h > 0) {
  247. offset = h;
  248. angle = 1;
  249. } else {
  250. offset = -h;
  251. angle = 180;
  252. }
  253. } else if (h > 0 && v > 0) {
  254. offset = Math.max(h, v);
  255. angle = 45;
  256. } else if (h > 0 && v < 0) {
  257. offset = Math.max(h, -v);
  258. angle = 315;
  259. } else if (h < 0 && v > 0) {
  260. offset = Math.max(-h, v);
  261. angle = 135;
  262. } else if (h < 0 && v < 0) {
  263. offset = Math.max(-h, -v);
  264. angle = 225;
  265. }
  266. return {
  267. type: 'outer',
  268. color: c.color.replace('#', ''),
  269. opacity: c.alpha,
  270. blur: shadow.blur / ratioPx2Pt,
  271. offset,
  272. angle,
  273. };
  274. }
  275. // ── Outline config ──
  276. const dashTypeMap: Record<string, string> = {
  277. solid: 'solid',
  278. dashed: 'dash',
  279. dotted: 'sysDot',
  280. };
  281. function getOutlineOption(outline: PPTElementOutline, ratioPx2Pt: number): pptxgen.ShapeLineProps {
  282. const c = formatColor(outline?.color || '#000000');
  283. return {
  284. color: c.color,
  285. transparency: (1 - c.alpha) * 100,
  286. width: (outline.width || 1) / ratioPx2Pt,
  287. dashType: outline.style ? (dashTypeMap[outline.style] as 'solid' | 'dash' | 'sysDot') : 'solid',
  288. };
  289. }
  290. // ── Link config ──
  291. function getLinkOption(link: PPTElementLink, slides: Slide[]): pptxgen.HyperlinkProps | null {
  292. const { type, target } = link;
  293. if (type === 'web') return { url: target };
  294. if (type === 'slide') {
  295. const index = slides.findIndex((slide) => slide.id === target);
  296. if (index !== -1) return { slide: index + 1 };
  297. }
  298. return null;
  299. }
  300. // ── Image helpers ──
  301. function isBase64Image(url: string) {
  302. return /^data:image\/[^;]+;base64,/.test(url);
  303. }
  304. function isSVGImage(url: string) {
  305. return /^data:image\/svg\+xml;base64,/.test(url) || /\.svg$/.test(url);
  306. }
  307. // ── Main export hook ──
  308. // ── Build PPTX blob (reused by single-export and resource pack) ──
  309. /**
  310. * Extract speaker notes text from a scene's actions.
  311. * Concatenates speech text and action labels into plain text.
  312. */
  313. function buildSpeakerNotes(scene: Scene): string {
  314. if (!scene.actions || scene.actions.length === 0) return '';
  315. const parts: string[] = [];
  316. for (const action of scene.actions) {
  317. if (action.type === 'speech') {
  318. parts.push((action as SpeechAction).text);
  319. }
  320. }
  321. return parts.join('\n');
  322. }
  323. async function buildPptxBlob(
  324. slides: Slide[],
  325. slideScenes: Scene[],
  326. viewportRatio: number,
  327. viewportSize: number,
  328. ratioPx2Inch: number,
  329. ratioPx2Pt: number,
  330. ): Promise<Blob> {
  331. const pptx = new pptxgen();
  332. // Set layout based on aspect ratio
  333. if (viewportRatio === 0.625) pptx.layout = 'LAYOUT_16x10';
  334. else if (viewportRatio === 0.75) pptx.layout = 'LAYOUT_4x3';
  335. else pptx.layout = 'LAYOUT_16x9';
  336. for (let slideIdx = 0; slideIdx < slides.length; slideIdx++) {
  337. const slide = slides[slideIdx];
  338. const pptxSlide = pptx.addSlide();
  339. // ── Speaker Notes ──
  340. const scene = slideScenes[slideIdx];
  341. if (scene) {
  342. const notes = buildSpeakerNotes(scene);
  343. if (notes) pptxSlide.addNotes(notes);
  344. }
  345. // ── Background ──
  346. if (slide.background) {
  347. const bg = slide.background;
  348. if (bg.type === 'image' && bg.image) {
  349. if (isSVGImage(bg.image.src)) {
  350. pptxSlide.addImage({
  351. data: bg.image.src,
  352. x: 0,
  353. y: 0,
  354. w: viewportSize / ratioPx2Inch,
  355. h: (viewportSize * viewportRatio) / ratioPx2Inch,
  356. });
  357. } else if (isBase64Image(bg.image.src)) {
  358. pptxSlide.background = { data: bg.image.src };
  359. } else {
  360. pptxSlide.background = { path: bg.image.src };
  361. }
  362. } else if (bg.type === 'solid' && bg.color) {
  363. const c = formatColor(bg.color);
  364. pptxSlide.background = {
  365. color: c.color,
  366. transparency: (1 - c.alpha) * 100,
  367. };
  368. } else if (bg.type === 'gradient' && bg.gradient) {
  369. const colors = bg.gradient.colors;
  370. const color1 = colors[0].color;
  371. const color2 = colors[colors.length - 1].color;
  372. const mixed = tinycolor.mix(color1, color2).toHexString();
  373. const c = formatColor(mixed);
  374. pptxSlide.background = {
  375. color: c.color,
  376. transparency: (1 - c.alpha) * 100,
  377. };
  378. }
  379. }
  380. if (!slide.elements) continue;
  381. // ── Elements ──
  382. for (const el of slide.elements) {
  383. // ── TEXT ──
  384. if (el.type === 'text') {
  385. const textProps = formatHTML(el.content, ratioPx2Pt);
  386. const options: pptxgen.TextPropsOptions = {
  387. x: el.left / ratioPx2Inch,
  388. y: el.top / ratioPx2Inch,
  389. w: el.width / ratioPx2Inch,
  390. h: el.height / ratioPx2Inch,
  391. fontSize: DEFAULT_FONT_SIZE / ratioPx2Pt,
  392. fontFace: el.defaultFontName || DEFAULT_FONT_FAMILY,
  393. color: '#000000',
  394. valign: 'top',
  395. margin: 10 / ratioPx2Pt,
  396. paraSpaceBefore: 5 / ratioPx2Pt,
  397. lineSpacingMultiple: 1.5 / 1.25,
  398. autoFit: true,
  399. };
  400. if (el.rotate) options.rotate = el.rotate;
  401. if (el.wordSpace) options.charSpacing = el.wordSpace / ratioPx2Pt;
  402. if (el.lineHeight) options.lineSpacingMultiple = el.lineHeight / 1.25;
  403. if (el.fill) {
  404. const c = formatColor(el.fill);
  405. const opacity = el.opacity === undefined ? 1 : el.opacity;
  406. options.fill = {
  407. color: c.color,
  408. transparency: (1 - c.alpha * opacity) * 100,
  409. };
  410. }
  411. if (el.defaultColor) options.color = formatColor(el.defaultColor).color;
  412. if (el.defaultFontName) options.fontFace = el.defaultFontName;
  413. if (el.shadow) options.shadow = getShadowOption(el.shadow, ratioPx2Pt);
  414. if (el.outline?.width) options.line = getOutlineOption(el.outline, ratioPx2Pt);
  415. if (el.opacity !== undefined) options.transparency = (1 - el.opacity) * 100;
  416. if (el.paragraphSpace !== undefined)
  417. options.paraSpaceBefore = el.paragraphSpace / ratioPx2Pt;
  418. if (el.vertical) options.vert = 'eaVert';
  419. pptxSlide.addText(textProps, options);
  420. }
  421. // ── IMAGE ──
  422. else if (el.type === 'image') {
  423. // Resolve placeholder src → actual image data
  424. let resolvedSrc = el.src;
  425. if (isMediaPlaceholder(el.src)) {
  426. const task = useMediaGenerationStore.getState().tasks[el.src];
  427. if (task?.status === 'done' && task.objectUrl) {
  428. resolvedSrc = task.objectUrl;
  429. } else {
  430. continue; // Media not ready, skip
  431. }
  432. }
  433. // Fetch and convert to base64 for embedding in PPTX
  434. // (blob: URLs and remote URLs won't work in offline PPTX)
  435. if (!isBase64Image(resolvedSrc)) {
  436. try {
  437. const resp = await fetch(resolvedSrc);
  438. const blob = await resp.blob();
  439. resolvedSrc = await new Promise<string>((resolve, reject) => {
  440. const reader = new FileReader();
  441. reader.onloadend = () => resolve(reader.result as string);
  442. reader.onerror = reject;
  443. reader.readAsDataURL(blob);
  444. });
  445. } catch {
  446. log.warn('Failed to convert image to base64, skipping element');
  447. continue;
  448. }
  449. }
  450. const options: pptxgen.ImageProps = {
  451. x: el.left / ratioPx2Inch,
  452. y: el.top / ratioPx2Inch,
  453. w: el.width / ratioPx2Inch,
  454. h: el.height / ratioPx2Inch,
  455. };
  456. if (isBase64Image(resolvedSrc)) options.data = resolvedSrc;
  457. else options.path = resolvedSrc;
  458. if (el.flipH) options.flipH = el.flipH;
  459. if (el.flipV) options.flipV = el.flipV;
  460. if (el.rotate) options.rotate = el.rotate;
  461. if (el.link) {
  462. const linkOption = getLinkOption(el.link, slides);
  463. if (linkOption) options.hyperlink = linkOption;
  464. }
  465. if (el.filters?.opacity) options.transparency = 100 - parseInt(el.filters.opacity);
  466. if (el.clip) {
  467. if (el.clip.shape === 'ellipse') options.rounding = true;
  468. const [start, end] = el.clip.range;
  469. const [startX, startY] = start;
  470. const [endX, endY] = end;
  471. const originW = el.width / ((endX - startX) / ratioPx2Inch);
  472. const originH = el.height / ((endY - startY) / ratioPx2Inch);
  473. options.w = originW / ratioPx2Inch;
  474. options.h = originH / ratioPx2Inch;
  475. options.sizing = {
  476. type: 'crop',
  477. x: ((startX / ratioPx2Inch) * originW) / ratioPx2Inch,
  478. y: ((startY / ratioPx2Inch) * originH) / ratioPx2Inch,
  479. w: (((endX - startX) / ratioPx2Inch) * originW) / ratioPx2Inch,
  480. h: (((endY - startY) / ratioPx2Inch) * originH) / ratioPx2Inch,
  481. };
  482. }
  483. pptxSlide.addImage(options);
  484. }
  485. // ── SHAPE ──
  486. else if (el.type === 'shape') {
  487. if (el.special) {
  488. // Special shapes: render as SVG image
  489. // Create a temporary SVG element from the path
  490. const svgNS = 'http://www.w3.org/2000/svg';
  491. const svg = document.createElementNS(svgNS, 'svg');
  492. svg.setAttribute('xmlns', svgNS);
  493. svg.setAttribute('viewBox', `0 0 ${el.viewBox[0]} ${el.viewBox[1]}`);
  494. svg.setAttribute('width', String(el.width));
  495. svg.setAttribute('height', String(el.height));
  496. const path = document.createElementNS(svgNS, 'path');
  497. path.setAttribute('d', el.path);
  498. path.setAttribute('fill', el.fill || 'none');
  499. if (el.outline?.color) {
  500. path.setAttribute('stroke', el.outline.color);
  501. path.setAttribute('stroke-width', String(el.outline.width || 1));
  502. }
  503. svg.appendChild(path);
  504. const base64SVG = svg2Base64(svg);
  505. const imgOptions: pptxgen.ImageProps = {
  506. data: base64SVG,
  507. x: el.left / ratioPx2Inch,
  508. y: el.top / ratioPx2Inch,
  509. w: el.width / ratioPx2Inch,
  510. h: el.height / ratioPx2Inch,
  511. };
  512. if (el.rotate) imgOptions.rotate = el.rotate;
  513. if (el.flipH) imgOptions.flipH = el.flipH;
  514. if (el.flipV) imgOptions.flipV = el.flipV;
  515. if (el.link) {
  516. const linkOption = getLinkOption(el.link, slides);
  517. if (linkOption) imgOptions.hyperlink = linkOption;
  518. }
  519. pptxSlide.addImage(imgOptions);
  520. } else {
  521. const scale = {
  522. x: el.width / el.viewBox[0],
  523. y: el.height / el.viewBox[1],
  524. };
  525. const points = formatPoints(toPoints(el.path), ratioPx2Inch, scale);
  526. let fillColor = formatColor(el.fill);
  527. if (el.gradient) {
  528. const colors = el.gradient.colors;
  529. const color1 = colors[0].color;
  530. const color2 = colors[colors.length - 1].color;
  531. const mixed = tinycolor.mix(color1, color2).toHexString();
  532. fillColor = formatColor(mixed);
  533. }
  534. if (el.pattern) fillColor = formatColor('#00000000');
  535. const opacity = el.opacity === undefined ? 1 : el.opacity;
  536. const shapeOptions: pptxgen.ShapeProps = {
  537. x: el.left / ratioPx2Inch,
  538. y: el.top / ratioPx2Inch,
  539. w: el.width / ratioPx2Inch,
  540. h: el.height / ratioPx2Inch,
  541. fill: {
  542. color: fillColor.color,
  543. transparency: (1 - fillColor.alpha * opacity) * 100,
  544. },
  545. points,
  546. };
  547. if (el.flipH) shapeOptions.flipH = el.flipH;
  548. if (el.flipV) shapeOptions.flipV = el.flipV;
  549. if (el.shadow) shapeOptions.shadow = getShadowOption(el.shadow, ratioPx2Pt);
  550. if (el.outline?.width) shapeOptions.line = getOutlineOption(el.outline, ratioPx2Pt);
  551. if (el.rotate) shapeOptions.rotate = el.rotate;
  552. if (el.link) {
  553. const linkOption = getLinkOption(el.link, slides);
  554. if (linkOption) shapeOptions.hyperlink = linkOption;
  555. }
  556. pptxSlide.addShape('custGeom' as pptxgen.ShapeType, shapeOptions);
  557. }
  558. // Shape text overlay
  559. if (el.text) {
  560. const textProps = formatHTML(el.text.content, ratioPx2Pt);
  561. const textOptions: pptxgen.TextPropsOptions = {
  562. x: el.left / ratioPx2Inch,
  563. y: el.top / ratioPx2Inch,
  564. w: el.width / ratioPx2Inch,
  565. h: el.height / ratioPx2Inch,
  566. fontSize: DEFAULT_FONT_SIZE / ratioPx2Pt,
  567. fontFace: DEFAULT_FONT_FAMILY,
  568. color: '#000000',
  569. paraSpaceBefore: 5 / ratioPx2Pt,
  570. valign: el.text.align,
  571. };
  572. if (el.rotate) textOptions.rotate = el.rotate;
  573. if (el.text.defaultColor) textOptions.color = formatColor(el.text.defaultColor).color;
  574. if (el.text.defaultFontName) textOptions.fontFace = el.text.defaultFontName;
  575. pptxSlide.addText(textProps, textOptions);
  576. }
  577. // Pattern overlay
  578. if (el.pattern) {
  579. const patternOptions: pptxgen.ImageProps = {
  580. x: el.left / ratioPx2Inch,
  581. y: el.top / ratioPx2Inch,
  582. w: el.width / ratioPx2Inch,
  583. h: el.height / ratioPx2Inch,
  584. };
  585. if (isBase64Image(el.pattern)) patternOptions.data = el.pattern;
  586. else patternOptions.path = el.pattern;
  587. if (el.flipH) patternOptions.flipH = el.flipH;
  588. if (el.flipV) patternOptions.flipV = el.flipV;
  589. if (el.rotate) patternOptions.rotate = el.rotate;
  590. if (el.link) {
  591. const linkOption = getLinkOption(el.link, slides);
  592. if (linkOption) patternOptions.hyperlink = linkOption;
  593. }
  594. pptxSlide.addImage(patternOptions);
  595. }
  596. }
  597. // ── LINE ──
  598. else if (el.type === 'line') {
  599. const path = getLineElementPath(el);
  600. const points = formatPoints(toPoints(path), ratioPx2Inch);
  601. const { minX, maxX, minY, maxY } = getElementRange(el);
  602. const c = formatColor(el.color);
  603. const lineOptions: pptxgen.ShapeProps = {
  604. x: el.left / ratioPx2Inch,
  605. y: el.top / ratioPx2Inch,
  606. w: (maxX - minX) / ratioPx2Inch,
  607. h: (maxY - minY) / ratioPx2Inch,
  608. line: {
  609. color: c.color,
  610. transparency: (1 - c.alpha) * 100,
  611. width: el.width / ratioPx2Pt,
  612. dashType: dashTypeMap[el.style] as 'solid' | 'dash' | 'sysDot',
  613. beginArrowType: el.points[0] ? 'arrow' : 'none',
  614. endArrowType: el.points[1] ? 'arrow' : 'none',
  615. },
  616. points,
  617. };
  618. if (el.shadow) lineOptions.shadow = getShadowOption(el.shadow, ratioPx2Pt);
  619. pptxSlide.addShape('custGeom' as pptxgen.ShapeType, lineOptions);
  620. }
  621. // ── CHART ──
  622. else if (el.type === 'chart') {
  623. const chartData = [];
  624. for (let i = 0; i < el.data.series.length; i++) {
  625. const item = el.data.series[i];
  626. chartData.push({
  627. name: `Series ${i + 1}`,
  628. labels: el.data.labels,
  629. values: item,
  630. });
  631. }
  632. let chartColors: string[] = [];
  633. if (el.themeColors.length === 10) {
  634. chartColors = el.themeColors.map((c) => formatColor(c).color);
  635. } else if (el.themeColors.length === 1) {
  636. chartColors = tinycolor(el.themeColors[0])
  637. .analogous(10)
  638. .map((c) => formatColor(c.toHexString()).color);
  639. } else {
  640. const len = el.themeColors.length;
  641. const supplement = tinycolor(el.themeColors[len - 1])
  642. .analogous(10 + 1 - len)
  643. .map((c) => c.toHexString());
  644. chartColors = [...el.themeColors.slice(0, len - 1), ...supplement].map(
  645. (c) => formatColor(c).color,
  646. );
  647. }
  648. const chartOptions: pptxgen.IChartOpts = {
  649. x: el.left / ratioPx2Inch,
  650. y: el.top / ratioPx2Inch,
  651. w: el.width / ratioPx2Inch,
  652. h: el.height / ratioPx2Inch,
  653. chartColors:
  654. el.chartType === 'pie' || el.chartType === 'ring'
  655. ? chartColors
  656. : chartColors.slice(0, el.data.series.length),
  657. };
  658. const textColor = formatColor(el.textColor || '#000000').color;
  659. chartOptions.catAxisLabelColor = textColor;
  660. chartOptions.valAxisLabelColor = textColor;
  661. const fontSize = 14 / ratioPx2Pt;
  662. chartOptions.catAxisLabelFontSize = fontSize;
  663. chartOptions.valAxisLabelFontSize = fontSize;
  664. if (el.fill || el.outline) {
  665. const plotArea: pptxgen.IChartPropsFillLine = {};
  666. if (el.fill) plotArea.fill = { color: formatColor(el.fill).color };
  667. if (el.outline) {
  668. plotArea.border = {
  669. pt: el.outline.width! / ratioPx2Pt,
  670. color: formatColor(el.outline.color!).color,
  671. };
  672. }
  673. chartOptions.plotArea = plotArea;
  674. }
  675. if (
  676. (el.data.series.length > 1 && el.chartType !== 'scatter') ||
  677. el.chartType === 'pie' ||
  678. el.chartType === 'ring'
  679. ) {
  680. chartOptions.showLegend = true;
  681. chartOptions.legendPos = 'b';
  682. chartOptions.legendColor = textColor;
  683. chartOptions.legendFontSize = fontSize;
  684. }
  685. let type = pptx.ChartType.bar;
  686. if (el.chartType === 'bar') {
  687. type = pptx.ChartType.bar;
  688. chartOptions.barDir = 'col';
  689. if (el.options?.stack) chartOptions.barGrouping = 'stacked';
  690. } else if (el.chartType === 'column') {
  691. type = pptx.ChartType.bar;
  692. chartOptions.barDir = 'bar';
  693. if (el.options?.stack) chartOptions.barGrouping = 'stacked';
  694. } else if (el.chartType === 'line') {
  695. type = pptx.ChartType.line;
  696. if (el.options?.lineSmooth) chartOptions.lineSmooth = true;
  697. } else if (el.chartType === 'area') {
  698. type = pptx.ChartType.area;
  699. } else if (el.chartType === 'radar') {
  700. type = pptx.ChartType.radar;
  701. } else if (el.chartType === 'scatter') {
  702. type = pptx.ChartType.scatter;
  703. chartOptions.lineSize = 0;
  704. } else if (el.chartType === 'pie') {
  705. type = pptx.ChartType.pie;
  706. } else if (el.chartType === 'ring') {
  707. type = pptx.ChartType.doughnut;
  708. chartOptions.holeSize = 60;
  709. }
  710. pptxSlide.addChart(type, chartData, chartOptions);
  711. }
  712. // ── TABLE ──
  713. else if (el.type === 'table') {
  714. const hiddenCells: string[] = [];
  715. for (let i = 0; i < el.data.length; i++) {
  716. const rowData = el.data[i];
  717. for (let j = 0; j < rowData.length; j++) {
  718. const cell = rowData[j];
  719. if (cell.colspan > 1 || cell.rowspan > 1) {
  720. for (let row = i; row < i + cell.rowspan; row++) {
  721. for (let col = row === i ? j + 1 : j; col < j + cell.colspan; col++) {
  722. hiddenCells.push(`${row}_${col}`);
  723. }
  724. }
  725. }
  726. }
  727. }
  728. const tableData: pptxgen.TableRow[] = [];
  729. const theme = el.theme;
  730. let themeColor: FormatColor | null = null;
  731. let subThemeColors: FormatColor[] = [];
  732. if (theme) {
  733. themeColor = formatColor(theme.color);
  734. subThemeColors = getTableSubThemeColor(theme.color).map((item) => formatColor(item));
  735. }
  736. for (let i = 0; i < el.data.length; i++) {
  737. const row = el.data[i];
  738. const _row: pptxgen.TableCell[] = [];
  739. for (let j = 0; j < row.length; j++) {
  740. const cell = row[j];
  741. const cellOptions: pptxgen.TableCellProps = {
  742. colspan: cell.colspan,
  743. rowspan: cell.rowspan,
  744. bold: cell.style?.bold || false,
  745. italic: cell.style?.em || false,
  746. underline: { style: cell.style?.underline ? 'sng' : 'none' },
  747. align: cell.style?.align || 'left',
  748. valign: 'middle',
  749. fontFace: cell.style?.fontname || DEFAULT_FONT_FAMILY,
  750. fontSize: (cell.style?.fontsize ? parseInt(cell.style.fontsize) : 14) / ratioPx2Pt,
  751. };
  752. if (theme && themeColor) {
  753. let c: FormatColor;
  754. if (i % 2 === 0) c = subThemeColors[1];
  755. else c = subThemeColors[0];
  756. if (theme.rowHeader && i === 0) c = themeColor;
  757. else if (theme.rowFooter && i === el.data.length - 1) c = themeColor;
  758. else if (theme.colHeader && j === 0) c = themeColor;
  759. else if (theme.colFooter && j === row.length - 1) c = themeColor;
  760. cellOptions.fill = {
  761. color: c.color,
  762. transparency: (1 - c.alpha) * 100,
  763. };
  764. }
  765. if (cell.style?.backcolor) {
  766. const c = formatColor(cell.style.backcolor);
  767. cellOptions.fill = {
  768. color: c.color,
  769. transparency: (1 - c.alpha) * 100,
  770. };
  771. }
  772. if (cell.style?.color) cellOptions.color = formatColor(cell.style.color).color;
  773. if (!hiddenCells.includes(`${i}_${j}`)) {
  774. _row.push({ text: cell.text, options: cellOptions });
  775. }
  776. }
  777. if (_row.length) tableData.push(_row);
  778. }
  779. const tableOptions: pptxgen.TableProps = {
  780. x: el.left / ratioPx2Inch,
  781. y: el.top / ratioPx2Inch,
  782. w: el.width / ratioPx2Inch,
  783. h: el.height / ratioPx2Inch,
  784. colW: el.colWidths.map((item) => (el.width * item) / ratioPx2Inch),
  785. };
  786. if (el.theme) tableOptions.fill = { color: '#ffffff' };
  787. if (el.outline.width && el.outline.color) {
  788. tableOptions.border = {
  789. type: el.outline.style === 'solid' ? 'solid' : 'dash',
  790. pt: el.outline.width / ratioPx2Pt,
  791. color: formatColor(el.outline.color).color,
  792. };
  793. }
  794. pptxSlide.addTable(tableData, tableOptions);
  795. }
  796. // ── LATEX ──
  797. else if (el.type === 'latex') {
  798. // Try native OMML formula first (editable in PowerPoint)
  799. // Estimate line count from \\ line breaks to compute a fitting font size.
  800. // Formula rendered height ≈ lines * 1.5 * fontSize, so fontSize ≈ boxHeight / (lines * 1.5)
  801. const lineBreaks = (el.latex?.match(/\\\\/g) || []).length;
  802. const lines = lineBreaks + 1;
  803. const boxHeightPt = el.height / ratioPx2Pt;
  804. const fontSize = Math.round(boxHeightPt / (lines * 3));
  805. const omml = el.latex ? latexToOmml(el.latex, fontSize) : null;
  806. if (omml) {
  807. pptxSlide.addFormula({
  808. omml,
  809. x: el.left / ratioPx2Inch,
  810. y: el.top / ratioPx2Inch,
  811. w: el.width / ratioPx2Inch,
  812. h: el.height / ratioPx2Inch,
  813. fontSize,
  814. align: el.align,
  815. });
  816. } else if (el.path) {
  817. // Fallback: render as SVG image (non-editable)
  818. const range = getSvgPathRange(el.path);
  819. const sw = el.strokeWidth || 0;
  820. const vbX = range.minX - sw;
  821. const vbY = range.minY - sw;
  822. const vbW = range.maxX - range.minX + sw * 2;
  823. const vbH = range.maxY - range.minY + sw * 2;
  824. const svgNS = 'http://www.w3.org/2000/svg';
  825. const svg = document.createElementNS(svgNS, 'svg');
  826. svg.setAttribute('xmlns', svgNS);
  827. svg.setAttribute('width', String(el.width));
  828. svg.setAttribute('height', String(el.height));
  829. svg.setAttribute('viewBox', `${vbX} ${vbY} ${vbW} ${vbH}`);
  830. svg.setAttribute('stroke', el.color || '#000000');
  831. svg.setAttribute('stroke-width', String(sw));
  832. svg.setAttribute('fill', 'none');
  833. svg.setAttribute('stroke-linecap', 'round');
  834. svg.setAttribute('stroke-linejoin', 'round');
  835. const path = document.createElementNS(svgNS, 'path');
  836. path.setAttribute('d', el.path);
  837. svg.appendChild(path);
  838. const base64SVG = svg2Base64(svg);
  839. if (!base64SVG) continue;
  840. const latexOptions: pptxgen.ImageProps = {
  841. data: base64SVG,
  842. x: el.left / ratioPx2Inch,
  843. y: el.top / ratioPx2Inch,
  844. w: el.width / ratioPx2Inch,
  845. h: el.height / ratioPx2Inch,
  846. };
  847. if (el.link) {
  848. const linkOption = getLinkOption(el.link, slides);
  849. if (linkOption) latexOptions.hyperlink = linkOption;
  850. }
  851. pptxSlide.addImage(latexOptions);
  852. }
  853. }
  854. // ── VIDEO / AUDIO ──
  855. else if (el.type === 'video' || el.type === 'audio') {
  856. // Resolve placeholder src → blob URL from media generation store
  857. let resolvedSrc = el.src;
  858. if (isMediaPlaceholder(el.src)) {
  859. const task = useMediaGenerationStore.getState().tasks[el.src];
  860. if (task?.status === 'done' && task.objectUrl) {
  861. resolvedSrc = task.objectUrl;
  862. } else {
  863. continue; // Media not ready, skip
  864. }
  865. }
  866. // Fetch blob and convert to base64 for embedding in PPTX
  867. // (blob: URLs and remote URLs won't work in offline PPTX)
  868. try {
  869. const resp = await fetch(resolvedSrc);
  870. const blob = await resp.blob();
  871. const base64 = await new Promise<string>((resolve, reject) => {
  872. const reader = new FileReader();
  873. reader.onloadend = () => resolve(reader.result as string);
  874. reader.onerror = reject;
  875. reader.readAsDataURL(blob);
  876. });
  877. const mediaOptions: pptxgen.MediaProps = {
  878. x: el.left / ratioPx2Inch,
  879. y: el.top / ratioPx2Inch,
  880. w: el.width / ratioPx2Inch,
  881. h: el.height / ratioPx2Inch,
  882. data: base64,
  883. type: el.type,
  884. };
  885. // Determine file extension
  886. const extMatch = resolvedSrc.match(/\.([a-zA-Z0-9]+)(?:[?#]|$)/);
  887. if (extMatch && extMatch[1]) mediaOptions.extn = extMatch[1];
  888. else if (el.ext) mediaOptions.extn = el.ext;
  889. else mediaOptions.extn = el.type === 'video' ? 'mp4' : 'mp3';
  890. // Generate cover image for video
  891. if (el.type === 'video') {
  892. let coverBase64: string | undefined;
  893. // 1. Try poster from element or media generation store
  894. let posterUrl = 'poster' in el && el.poster ? el.poster : undefined;
  895. if (!posterUrl && isMediaPlaceholder(el.src)) {
  896. const task = useMediaGenerationStore.getState().tasks[el.src];
  897. if (task?.poster) posterUrl = task.poster;
  898. }
  899. if (posterUrl) {
  900. try {
  901. const posterResp = await fetch(posterUrl);
  902. const posterBlob = await posterResp.blob();
  903. coverBase64 = await new Promise<string>((resolve, reject) => {
  904. const reader = new FileReader();
  905. reader.onloadend = () => resolve(reader.result as string);
  906. reader.onerror = reject;
  907. reader.readAsDataURL(posterBlob);
  908. });
  909. } catch {
  910. // Poster fetch failed, fall through to video frame capture
  911. }
  912. }
  913. // 2. Fallback: capture first frame from video via canvas
  914. if (!coverBase64) {
  915. try {
  916. coverBase64 = await new Promise<string>((resolve, reject) => {
  917. const video = document.createElement('video');
  918. video.crossOrigin = 'anonymous';
  919. video.muted = true;
  920. video.preload = 'auto';
  921. video.onloadeddata = () => {
  922. video.currentTime = 0;
  923. };
  924. video.onseeked = () => {
  925. try {
  926. const canvas = document.createElement('canvas');
  927. canvas.width = video.videoWidth || el.width;
  928. canvas.height = video.videoHeight || el.height;
  929. const ctx = canvas.getContext('2d');
  930. if (ctx) {
  931. ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
  932. resolve(canvas.toDataURL('image/png'));
  933. } else {
  934. reject(new Error('No canvas context'));
  935. }
  936. video.src = ''; // Release
  937. } catch (e) {
  938. reject(e);
  939. }
  940. };
  941. video.onerror = () => reject(new Error('Video load failed'));
  942. // Timeout to avoid hanging
  943. setTimeout(() => reject(new Error('Video frame capture timeout')), 10000);
  944. video.src = resolvedSrc;
  945. });
  946. } catch {
  947. // Frame capture also failed, video will use default play button
  948. }
  949. }
  950. if (coverBase64) mediaOptions.cover = coverBase64;
  951. }
  952. pptxSlide.addMedia(mediaOptions);
  953. } catch (err) {
  954. log.warn(`Failed to embed ${el.type} element:`, err);
  955. }
  956. }
  957. }
  958. }
  959. return (await pptx.write({ outputType: 'blob' })) as Blob;
  960. }
  961. // ── Hook ──
  962. export function useExportPPTX() {
  963. const [exporting, setExporting] = useState(false);
  964. const exportingRef = useRef(false);
  965. const { t } = useI18n();
  966. const scenes = useStageStore((s) => s.scenes);
  967. const stage = useStageStore((s) => s.stage);
  968. const viewportSize = useCanvasStore.use.viewportSize();
  969. const viewportRatio = useCanvasStore.use.viewportRatio();
  970. const ratioPx2Inch = 96 * (viewportSize / 960);
  971. const ratioPx2Pt = (96 / 72) * (viewportSize / 960);
  972. const slideScenes = scenes.filter((s) => s.content.type === 'slide');
  973. const slides = slideScenes.map((s) => (s.content as SlideContent).canvas);
  974. // Shared guard + state wrapper for export actions
  975. const withExportGuard = useCallback(
  976. (action: () => Promise<void>) => {
  977. if (exportingRef.current || slides.length === 0) return;
  978. exportingRef.current = true;
  979. setExporting(true);
  980. setTimeout(async () => {
  981. try {
  982. await action();
  983. } catch (err) {
  984. log.error('Export failed:', err);
  985. toast.error(t('export.exportFailed'));
  986. } finally {
  987. exportingRef.current = false;
  988. setExporting(false);
  989. }
  990. }, 100);
  991. },
  992. [slides.length, t],
  993. );
  994. // ── Export PPTX only ──
  995. const exportPPTX = useCallback(() => {
  996. withExportGuard(async () => {
  997. const fileName = stage?.name || 'slides';
  998. const blob = await buildPptxBlob(
  999. slides,
  1000. slideScenes,
  1001. viewportRatio,
  1002. viewportSize,
  1003. ratioPx2Inch,
  1004. ratioPx2Pt,
  1005. );
  1006. saveAs(blob, `${fileName}.pptx`);
  1007. toast.success(t('export.exportSuccess'));
  1008. });
  1009. }, [
  1010. withExportGuard,
  1011. slides,
  1012. slideScenes,
  1013. stage,
  1014. viewportSize,
  1015. viewportRatio,
  1016. ratioPx2Inch,
  1017. ratioPx2Pt,
  1018. t,
  1019. ]);
  1020. // ── Export Resource Pack (PPTX + interactive HTML pages as ZIP) ──
  1021. const exportResourcePack = useCallback(() => {
  1022. withExportGuard(async () => {
  1023. const JSZip = (await import('jszip')).default;
  1024. const zip = new JSZip();
  1025. const fileName = stage?.name || 'slides';
  1026. // 1. Generate PPTX
  1027. const pptxBlob = await buildPptxBlob(
  1028. slides,
  1029. slideScenes,
  1030. viewportRatio,
  1031. viewportSize,
  1032. ratioPx2Inch,
  1033. ratioPx2Pt,
  1034. );
  1035. zip.file(`${fileName}.pptx`, pptxBlob);
  1036. // 2. Add interactive HTML pages
  1037. let interactiveIndex = 0;
  1038. for (const scene of scenes) {
  1039. if (scene.content.type === 'interactive' && scene.content.html) {
  1040. interactiveIndex++;
  1041. const safeName = scene.title.replace(/[\\/:*?"<>|]/g, '_');
  1042. const htmlFileName = `interactive/${String(interactiveIndex).padStart(2, '0')}_${safeName}.html`;
  1043. zip.file(htmlFileName, scene.content.html);
  1044. }
  1045. }
  1046. // 3. Download ZIP
  1047. const zipBlob = await zip.generateAsync({ type: 'blob' });
  1048. saveAs(zipBlob, `${fileName}.zip`);
  1049. toast.success(t('export.exportSuccess'));
  1050. });
  1051. }, [
  1052. withExportGuard,
  1053. slides,
  1054. slideScenes,
  1055. scenes,
  1056. stage,
  1057. viewportSize,
  1058. viewportRatio,
  1059. ratioPx2Inch,
  1060. ratioPx2Pt,
  1061. t,
  1062. ]);
  1063. return { exporting, exportPPTX, exportResourcePack };
  1064. }