返回 presentation-ai
domToPptxConverter.ts
根目录 / src / components / presentation / export / domToPptxConverter.ts
1 /**
2 * DOM-based PPTX Converter
3 * Converts scanned slide DOM data to PPTX using pptxgenjs
4 */
5
6 import PptxGenJS from "pptxgenjs";
7
8 import { type PlateSlide } from "@/components/notebook/presentation/utils/parser";
9 import {
10 resolveExportImageSource,
11 type ExportImageSource,
12 } from "@/lib/image-proxy";
13 import {
14 type BackgroundRectExportElement,
15 type DecorExportElement,
16 type ElementPosition,
17 type ExportElement,
18 type ImageExportElement,
19 type PresentationStyles,
20 type RootImageData,
21 type ScanResult,
22 type ShapeExportElement,
23 type TableExportElement,
24 type TextExportElement,
25 } from "./types";
26
27 const SLIDE_WIDTH_INCHES = 10;
28 const SLIDE_HEIGHT_INCHES = 5.625;
29
30 /**
31 * Fixed reference width for font-size conversion, matching the Fabric
32 * converter. Presentation slides use em-based typography (e.g.
33 * h1 = 3em = 48px at base 16px), so the computed CSS px values are identical
34 * regardless of the slide's configured base width (896/1024/1152 for S/M/L).
35 * Using a fixed reference avoids inflating font sizes for narrower slides.
36 */
37 const FONT_REFERENCE_WIDTH_PX = 1280;
38
39 /**
40 * Pixels-per-inch at the reference width.
41 * 1280px / 10in = 128 px/in.
42 */
43 const PX_PER_INCH = FONT_REFERENCE_WIDTH_PX / SLIDE_WIDTH_INCHES;
44
45 /**
46 * Fixed conversion factor: CSS px → PPTX points.
47 * 72 pt/in ÷ 128 px/in = 0.5625 pt/px.
48 * Shared conversion constant for presentation export.
49 */
50 const POINTS_PER_PIXEL = 72 / PX_PER_INCH;
51
52 type ImageDimensions = {
53 width: number;
54 height: number;
55 };
56
57 /**
58 * Convert scanned slides to PPTX
59 */
60 async function convertToPptx(
61 scanResults: ScanResult[],
62 slides: PlateSlide[],
63 ): Promise<ArrayBuffer> {
64 const pptx = new PptxGenJS();
65 pptx.layout = "LAYOUT_16x9";
66
67 // Process each slide
68 for (let i = 0; i < scanResults.length; i++) {
69 const scanResult = scanResults[i];
70 const slideData = slides.find((slide) => slide.id === scanResult?.slideId);
71
72 if (!scanResult || !slideData) continue;
73
74 await addSlide(pptx, scanResult, slideData);
75 }
76
77 // Generate the file
78 const data = await pptx.write({ outputType: "arraybuffer" });
79 return data as ArrayBuffer;
80 }
81
82 /**
83 * Add a single slide to the presentation
84 */
85 async function addSlide(
86 pptx: PptxGenJS,
87 scanResult: ScanResult,
88 slideData: PlateSlide,
89 ): Promise<void> {
90 const slide = pptx.addSlide();
91
92 // SPECIAL HANDLING: Image Slide
93 // If this is an image slide, we just want the image to fill the slide completely
94 // and ignore all other content
95 if (slideData.isImageSlide && slideData.rootImage?.url) {
96 const slideWidthInches = 10;
97 const slideHeightInches = 5.625;
98 const imageSource = await resolveExportImageSource(
99 slideData.rootImage.url,
100 slideData.rootImage,
101 );
102 const imageDimensions = await loadImageDimensionsFromSource(imageSource);
103 const sourceSize = getPptSourceSizeForCover(imageDimensions);
104
105 // Use 'data' for base64 images, 'path' for URLs
106 const imageProps: PptxGenJS.ImageProps = {
107 x: 0,
108 y: 0,
109 w: sourceSize.w,
110 h: sourceSize.h,
111 sizing: {
112 type: "cover",
113 w: slideWidthInches,
114 h: slideHeightInches,
115 },
116 };
117
118 if (imageSource.type === "data") {
119 imageProps.data = imageSource.value;
120 } else {
121 imageProps.path = imageSource.value;
122 }
123
124 try {
125 slide.addImage(imageProps);
126 } catch (error) {
127 console.warn("Failed to add image slide image:", error);
128 }
129
130 // Stop processing this slide
131 return;
132 }
133
134 const {
135 styles,
136 elements,
137 rootImage: scannedRootImage,
138 backgroundImageUrl,
139 } = scanResult;
140
141 // Calculate conversion factors
142 const slideWidthInches = SLIDE_WIDTH_INCHES; // Standard 16:9 width
143 const slideHeightInches = SLIDE_HEIGHT_INCHES; // Standard 16:9 height
144
145 const scaleX = slideWidthInches / scanResult.width;
146 const scaleY = slideHeightInches / scanResult.height;
147
148 // Set slide background color ONLY if it's a non-white, non-black color
149 // (black "000000" is the default fallback which we don't want)
150 // (white "FFFFFF" is the standard slide background)
151 const bgColor = styles.backgroundColor;
152 const skipColors = ["000000", "FFFFFF", ""];
153 if (bgColor && !skipColors.includes(bgColor.toUpperCase())) {
154 slide.background = { color: bgColor };
155 }
156
157 // Handle background image (layout type "background")
158 if (backgroundImageUrl) {
159 try {
160 const backgroundSource =
161 await resolveExportImageSource(backgroundImageUrl);
162 if (backgroundSource.type === "data") {
163 slide.addImage({
164 data: backgroundSource.value,
165 x: 0,
166 y: 0,
167 w: slideWidthInches,
168 h: slideHeightInches,
169 sizing: {
170 type: "crop",
171 w: slideWidthInches,
172 h: slideHeightInches,
173 },
174 });
175 } else {
176 slide.background = {
177 path: backgroundSource.value,
178 };
179 }
180 } catch (error) {
181 console.warn("Failed to set background image:", error);
182 }
183 }
184
185 if (scannedRootImage?.url) {
186 // Use DOM-scanned position for accurate placement
187 const position = scalePosition(scannedRootImage.position, scaleX, scaleY);
188 await addRootImage(slide, scannedRootImage, position);
189 }
190
191 // Add all scanned elements (includes in-editor images from contentWalker)
192 for (const element of elements) {
193 await addElement(slide, element, scaleX, scaleY, styles);
194 }
195 }
196
197 /**
198 * Add root image to slide.
199 * Prefer originalUrl (the actual source URL) over the captured base64 snapshot.
200 * The captures can be identical across slides when toPng hits CORS/timing
201 * issues, causing pptxgenjs to deduplicate all slides onto the first image.
202 */
203 async function addRootImage(
204 slide: PptxGenJS.Slide,
205 rootImage: RootImageData,
206 position: { x: number; y: number; w: number; h: number },
207 ): Promise<void> {
208 const urlToUse = rootImage.originalUrl ?? rootImage.url;
209 const imageSource = urlToUse.startsWith("data:")
210 ? ({ type: "data", value: urlToUse } satisfies ExportImageSource)
211 : await resolveExportImageSource(urlToUse, rootImage);
212 const imageDimensions = await loadImageDimensionsFromSource(imageSource);
213 const sourceSize = getPptSourceSizeForCover(imageDimensions);
214
215 const imageOptions: PptxGenJS.ImageProps = {
216 x: position.x,
217 y: position.y,
218 w: sourceSize.w,
219 h: sourceSize.h,
220 sizing: {
221 type: "cover",
222 w: position.w,
223 h: position.h,
224 },
225 };
226
227 if (imageSource.type === "data") {
228 imageOptions.data = imageSource.value;
229 } else {
230 imageOptions.path = imageSource.value;
231 }
232
233 try {
234 slide.addImage(imageOptions);
235 } catch (error) {
236 console.warn("Failed to add root image:", error);
237 }
238 }
239
240 function getPptSourceSizeForCover(dimensions: ImageDimensions | null): {
241 w: number;
242 h: number;
243 } {
244 if (!dimensions || dimensions.width <= 0 || dimensions.height <= 0) {
245 return { w: 1, h: 1 };
246 }
247
248 return {
249 w: dimensions.width / dimensions.height,
250 h: 1,
251 };
252 }
253
254 function loadImageDimensionsFromSource(
255 source: ExportImageSource,
256 ): Promise<ImageDimensions | null> {
257 return loadImageDimensions(source.value).catch((error: unknown) => {
258 console.warn("Failed to load export image dimensions:", error);
259 return null;
260 });
261 }
262
263 function loadImageDimensions(url: string): Promise<ImageDimensions> {
264 return new Promise((resolve, reject) => {
265 const img = new Image();
266 img.onload = () => {
267 if (img.naturalWidth > 0 && img.naturalHeight > 0) {
268 resolve({ width: img.naturalWidth, height: img.naturalHeight });
269 return;
270 }
271
272 reject(new Error("Image loaded without natural dimensions."));
273 };
274 img.onerror = () => reject(new Error(`Unable to load image: ${url}`));
275 img.src = url;
276 });
277 }
278
279 /**
280 * Add a scanned element to the slide
281 */
282 async function addElement(
283 slide: PptxGenJS.Slide,
284 element: ExportElement,
285 scaleX: number,
286 scaleY: number,
287 styles: PresentationStyles,
288 ): Promise<void> {
289 const position = scalePosition(element.position, scaleX, scaleY);
290
291 switch (element.type) {
292 case "text":
293 addTextElement(slide, element, position, styles);
294 break;
295 case "table":
296 addTable(slide, element, position, styles);
297 break;
298 case "image":
299 await addImageElement(slide, element, position);
300 break;
301 case "decor":
302 addDecorElement(slide, element, position);
303 break;
304 case "shape":
305 addShapeElement(slide, element, position);
306 break;
307 case "backgroundRect":
308 addBackgroundRectElement(slide, element, position);
309 break;
310 }
311 }
312
313 /**
314 * Convert position from percentage (0-100) to inches
315 * ContentWalker returns positions as percentages relative to slide dimensions
316 * If aspectRatio is present, preserve proportions while fitting inside
317 * the original measured box so export never grows beyond DOM bounds.
318 */
319 function scalePosition(
320 position: ElementPosition,
321 _scaleX: number,
322 _scaleY: number,
323 ): { x: number; y: number; w: number; h: number } {
324 // Standard 16:9 slide dimensions in inches
325 const slideWidthInches = SLIDE_WIDTH_INCHES;
326 const slideHeightInches = SLIDE_HEIGHT_INCHES;
327
328 let x = (position.x / 100) * slideWidthInches;
329 let y = (position.y / 100) * slideHeightInches;
330 let w = (position.width / 100) * slideWidthInches;
331 let h = (position.height / 100) * slideHeightInches;
332
333 const originalW = w;
334 const originalH = h;
335
336 // Preserve aspect ratio while constraining to original measured bounds.
337 // This prevents wide charts from expanding outside the slide.
338 if (position.aspectRatio !== undefined && position.aspectRatio > 0) {
339 if (position.aspectRatioBase === "height") {
340 // Start from height, then clamp into the original box.
341 w = h * position.aspectRatio;
342 if (w > originalW) {
343 w = originalW;
344 h = w / position.aspectRatio;
345 }
346 } else {
347 // Start from width, then clamp into the original box.
348 h = w / position.aspectRatio;
349 if (h > originalH) {
350 h = originalH;
351 w = h * position.aspectRatio;
352 }
353 }
354 }
355
356 if (position.centerAspectRatio) {
357 x += (originalW - w) / 2;
358 y += (originalH - h) / 2;
359 }
360
361 return { x, y, w, h };
362 }
363
364 /**
365 * Fallback CSS font sizes (px) for Plate text nodes.
366 * Used only when the browser returns an invalid computed font size.
367 */
368 const FALLBACK_FONT_SIZES_PX: Record<string, number> = {
369 h1: 48,
370 h2: 30,
371 h3: 24,
372 h4: 20,
373 h5: 18,
374 h6: 16,
375 p: 16,
376 blockquote: 16,
377 code_block: 16,
378 li: 16,
379 ul: 16,
380 ol: 16,
381 };
382
383 /**
384 * Get fallback CSS font size based on node type.
385 */
386 function getFallbackFontSizePx(nodeType?: string): number {
387 if (!nodeType) return FALLBACK_FONT_SIZES_PX.p!;
388 return FALLBACK_FONT_SIZES_PX[nodeType] ?? FALLBACK_FONT_SIZES_PX.p!;
389 }
390
391 /**
392 * Convert a CSS px font size to PPTX points using the fixed conversion factor.
393 * Converts with `fontSize = fontSizePx * POINTS_PER_PIXEL`.
394 */
395 function fontSizePxToPptPoints(fontSizePx: number): number {
396 return Math.max(1, fontSizePx * POINTS_PER_PIXEL);
397 }
398
399 function getMeasuredFontSizePx(
400 fontSizePx: number | undefined,
401 nodeType?: string,
402 ): number {
403 if (fontSizePx && Number.isFinite(fontSizePx) && fontSizePx > 0) {
404 return fontSizePx;
405 }
406
407 return getFallbackFontSizePx(nodeType);
408 }
409
410 /**
411 * Add text element
412 */
413 function addTextElement(
414 slide: PptxGenJS.Slide,
415 element: TextExportElement,
416 position: { x: number; y: number; w: number; h: number },
417 styles: PresentationStyles,
418 ): void {
419 const { textContent, textStyles, nodeType } = element;
420
421 if (!textContent.trim()) return;
422
423 const fontSizePx = getMeasuredFontSizePx(textStyles.fontSize, nodeType);
424 const fontSizePt = fontSizePxToPptPoints(fontSizePx);
425 const lineHeightPx = textStyles.lineHeight;
426 const lineSpacingMultiple =
427 lineHeightPx && Number.isFinite(lineHeightPx) && fontSizePx > 0
428 ? Math.max(0.1, Math.min(9.99, lineHeightPx / fontSizePx))
429 : undefined;
430
431 // Check if this is a heading type
432 const isHeading = nodeType && /^h[1-6]$/.test(nodeType);
433
434 const textOptions: PptxGenJS.TextPropsOptions = {
435 x: position.x,
436 y: position.y,
437 w: position.w,
438 h: position.h,
439 fontSize: fontSizePt,
440 fontFace: isHeading
441 ? styles.headingFont || textStyles.fontFamily
442 : textStyles.fontFamily || styles.bodyFont,
443 color: isHeading
444 ? styles.headingColor || textStyles.color
445 : textStyles.color || styles.textColor,
446 align: textStyles.textAlign || "left",
447 valign: "top",
448 wrap: true,
449 // Auto-shrink text to fit within the bounding box if it overflows
450 // This ensures text that fits in HTML also fits in the exported PPT
451 fit: "shrink",
452 lineSpacingMultiple,
453 };
454
455 // Add bold for headings or if specified
456 if (
457 isHeading ||
458 (textStyles.fontWeight &&
459 (textStyles.fontWeight === "bold" ||
460 Number(textStyles.fontWeight) >= 700))
461 ) {
462 textOptions.bold = true;
463 }
464 if (textStyles.fontStyle === "italic") {
465 textOptions.italic = true;
466 }
467 if (textStyles.textDecoration?.includes("underline")) {
468 textOptions.underline = { style: "sng" };
469 }
470
471 // Add background if present
472 if (element.backgroundColor) {
473 textOptions.fill = { color: element.backgroundColor.replace("#", "") };
474 }
475
476 slide.addText(textContent, textOptions);
477 }
478
479 /**
480 * Add SVG element as image
481 */
482
483 /**
484 * Add chart element as image
485 */
486
487 /**
488 * Add decorative element
489 */
490 function addDecorElement(
491 slide: PptxGenJS.Slide,
492 element: DecorExportElement,
493 position: { x: number; y: number; w: number; h: number },
494 ): void {
495 if (!element.base64Data) return;
496
497 try {
498 slide.addImage({
499 data: element.base64Data,
500 x: position.x,
501 y: position.y,
502 w: position.w,
503 h: position.h,
504 sizing: {
505 type: "contain",
506 w: position.w,
507 h: position.h,
508 },
509 });
510 } catch (error) {
511 console.warn("Failed to add decor element:", error);
512 }
513 }
514
515 /**
516 * Add shape element
517 */
518
519 /**
520 * Add native shape element (arrow, pill, parallelogram)
521 */
522 function addShapeElement(
523 slide: PptxGenJS.Slide,
524 element: ShapeExportElement,
525 position: { x: number; y: number; w: number; h: number },
526 ): void {
527 try {
528 // Use string literals cast to the correct type to ensure runtime compatibility
529 // while satisfying the type checker.
530 let shapeType: PptxGenJS.SHAPE_NAME = "rect";
531 let rotate = 0;
532 let rectRadius = 0;
533
534 const isHorizontal = element.orientation === "horizontal";
535
536 switch (element.shapeType) {
537 case "arrow":
538 // Use rightArrow for horizontal, downArrow (or rotated) for vertical
539 if (isHorizontal) {
540 shapeType = "rightArrow";
541 } else {
542 shapeType = "downArrow";
543 }
544 break;
545 case "pill":
546 shapeType = "roundRect";
547 // Fully rounded for pill effect (pptxgenjs maps 1.0 to fully rounded)
548 rectRadius = 1.0;
549 break;
550 case "parallelogram":
551 shapeType = "parallelogram";
552 // Vertical parallelogram needs rotation to match look
553 if (!isHorizontal) {
554 rotate = 90;
555 }
556 break;
557 case "ellipse":
558 shapeType = "ellipse";
559 break;
560 case "rect":
561 shapeType = "rect";
562 break;
563 }
564
565 slide.addShape(shapeType, {
566 x: position.x,
567 y: position.y,
568 w: position.w,
569 h: position.h,
570 fill: { color: element.fillColor.replace("#", "") },
571 rotate: rotate,
572 rectRadius: rectRadius > 0 ? rectRadius : undefined,
573 line: { type: "none" }, // Minimal/no border for these shapes
574 });
575
576 if (element.textContent?.trim()) {
577 slide.addText(element.textContent, {
578 x: position.x,
579 y: position.y + position.h * 0.27,
580 w: position.w,
581 h: position.h * 0.5,
582 fontSize: Math.min(Math.max(position.h * 72 * 0.42, 8), 14),
583 bold: true,
584 color: (element.textColor ?? "FFFFFF").replace("#", ""),
585 align: "center",
586 margin: 0,
587 fit: "shrink",
588 });
589 }
590 } catch (error) {
591 console.warn("Failed to add shape element:", error);
592 }
593 }
594
595 /**
596 * Add background rectangle element (renders as PPT shape with fill color)
597 */
598 function addBackgroundRectElement(
599 slide: PptxGenJS.Slide,
600 element: BackgroundRectExportElement,
601 position: { x: number; y: number; w: number; h: number },
602 ): void {
603 try {
604 // Calculate rectRadius (pptxgenjs uses 0-1 scale, where 1 = fully rounded)
605 // cornerRadius from DOM is in pixels, position.h is in inches
606 // Convert: pixels / (inches * 96 dpi) gives us ratio, then cap at 0.5
607 const heightInPx = position.h * 96;
608 const rectRadius = element.cornerRadius
609 ? Math.min(element.cornerRadius / heightInPx, 0.5)
610 : 0;
611
612 // Parse gradient if present
613 const gradientFill = parseGradientToFill(element.background);
614
615 // Use "roundRect" when there's a corner radius, "rect" otherwise
616 // IMPORTANT: rectRadius property ONLY works with "roundRect" shape type!
617 const shapeType = rectRadius > 0 ? "roundRect" : "rect";
618
619 slide.addShape(shapeType, {
620 x: position.x,
621 y: position.y,
622 w: position.w,
623 h: position.h,
624 fill: gradientFill || { color: element.fillColor.replace("#", "") },
625 rectRadius: rectRadius > 0 ? rectRadius : undefined,
626 line: element.borderWidth
627 ? {
628 color: element.borderColor?.replace("#", "") || "000000",
629 width: element.borderWidth,
630 }
631 : { type: "none" }, // Completely remove border - { width: 0 } still renders a faint line
632 });
633 } catch (error) {
634 console.warn("Failed to add background rect element:", error);
635 }
636 }
637
638 /**
639 * Parse CSS gradient string to pptxgenjs fill object
640 * Since pptxgenjs shapes don't support gradient fills, we extract the first
641 * color from the gradient and use it as a solid fill.
642 *
643 * Handles full computed CSS background property like:
644 * "rgba(0, 0, 0, 0) linear-gradient(135deg, rgb(231, 76, 60) 0%, rgb(192, 57, 43) 100%) repeat scroll 0% 0% / auto padding-box border-box"
645 */
646 function parseGradientToFill(
647 background?: string,
648 ): PptxGenJS.ShapeFillProps | null {
649 if (!background || !background.includes("linear-gradient")) {
650 return null;
651 }
652
653 // Extract just the linear-gradient(...) portion from the full background string
654 // Match: linear-gradient( ... balanced parentheses ... )
655 const gradientExtractRegex =
656 /linear-gradient\(([^()]*(?:\([^()]*\)[^()]*)*)\)/;
657 const extractMatch = background.match(gradientExtractRegex);
658
659 if (!extractMatch || !extractMatch[1]) {
660 return null;
661 }
662
663 // Extract the first color from the gradient content
664 // Matches hex colors and rgb/rgba formats
665 const colorRegex =
666 /#[0-9a-fA-F]{3,8}|rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+(?:\s*,\s*[\d.]+)?\s*\)/;
667 const colorMatch = extractMatch[1].match(colorRegex);
668
669 if (!colorMatch || !colorMatch[0]) {
670 return null;
671 }
672
673 // Use the first color as a solid fill (pptxgenjs shapes don't support gradients)
674 const firstColor = colorToHexSimple(colorMatch[0]);
675
676 return {
677 type: "solid",
678 color: firstColor,
679 };
680 }
681
682 /**
683 * Simple hex color converter helper
684 * Converts CSS color formats to hex (without #)
685 */
686 function colorToHexSimple(color: string): string {
687 if (!color) return "FFFFFF";
688
689 // Already hex
690 if (color.startsWith("#")) {
691 const hex = color.slice(1);
692 // Handle shorthand hex (#abc -> AABBCC)
693 if (hex.length === 3) {
694 return (
695 hex[0]! +
696 hex[0]! +
697 hex[1]! +
698 hex[1]! +
699 hex[2]! +
700 hex[2]!
701 ).toUpperCase();
702 }
703 return hex.toUpperCase();
704 }
705
706 // Handle rgb/rgba
707 const rgbMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
708 if (rgbMatch) {
709 const r = parseInt(rgbMatch[1]!, 10);
710 const g = parseInt(rgbMatch[2]!, 10);
711 const b = parseInt(rgbMatch[3]!, 10);
712 const toHex = (n: number) => n.toString(16).padStart(2, "0");
713 return `${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
714 }
715
716 return "FFFFFF";
717 }
718
719 /**
720 * Add bullet list element
721 */
722
723 /**
724 * Add table element
725 */
726 function addTable(
727 slide: PptxGenJS.Slide,
728 element: TableExportElement,
729 position: { x: number; y: number; w: number; h: number },
730 styles: PresentationStyles,
731 ): void {
732 const rows = element.rows.map((row) =>
733 row.cells.map((cell) => ({
734 text: cell.text,
735 options: {
736 fill: cell.isHeader
737 ? { color: styles.cardBackground.replace("#", "") }
738 : cell.backgroundColor
739 ? { color: cell.backgroundColor.replace("#", "") }
740 : undefined,
741 bold: cell.isHeader,
742 color: cell.textStyles?.color || styles.textColor,
743 fontFace: cell.textStyles?.fontFamily || styles.bodyFont,
744 fontSize: fontSizePxToPptPoints(
745 getMeasuredFontSizePx(cell.textStyles?.fontSize, "p"),
746 ),
747 colspan: cell.colSpan,
748 rowspan: cell.rowSpan,
749 },
750 })),
751 );
752
753 slide.addTable(rows, {
754 x: position.x,
755 y: position.y,
756 w: position.w,
757 // Auto-calculate row height or let PPTX handle it
758 });
759 }
760
761 /**
762 * Add image element (in-editor)
763 */
764 async function addImageElement(
765 slide: PptxGenJS.Slide,
766 element: ImageExportElement,
767 position: { x: number; y: number; w: number; h: number },
768 ): Promise<void> {
769 try {
770 const imageSizing = {
771 type: (element.sizing === "fill" ? "crop" : element.sizing) || "contain",
772 w: position.w,
773 h: position.h,
774 } as const;
775
776 // html-to-image returns chart snapshots as data URLs; these must use `data`
777 // in pptxgenjs, not `path`, otherwise media metadata/targets can be invalid.
778 const isBase64Image =
779 element.url.startsWith("data:image/") ||
780 (element.url.startsWith("image/") && element.url.includes("base64,"));
781
782 if (isBase64Image) {
783 slide.addImage({
784 data: element.url,
785 x: position.x,
786 y: position.y,
787 w: position.w,
788 h: position.h,
789 sizing: imageSizing,
790 });
791 return;
792 }
793
794 const imageSource = await resolveExportImageSource(element.url, element);
795 const imageOptions: PptxGenJS.ImageProps = {
796 x: position.x,
797 y: position.y,
798 w: position.w,
799 h: position.h,
800 sizing: imageSizing,
801 };
802
803 if (imageSource.type === "data") {
804 imageOptions.data = imageSource.value;
805 } else {
806 imageOptions.path = imageSource.value;
807 }
808
809 slide.addImage(imageOptions);
810 } catch (error) {
811 console.warn("Failed to add image element:", error);
812 }
813 }
814
815 /**
816 * Export function for client-side use
817 * Returns the blob and fileName for manual download handling
818 */
819 export async function exportPresentationToPptx(
820 scanResults: ScanResult[],
821 slides: PlateSlide[],
822 fileName: string = "presentation",
823 ): Promise<{ blob: Blob; fileName: string }> {
824 const arrayBuffer = await convertToPptx(scanResults, slides);
825
826 const blob = new Blob([arrayBuffer], {
827 type: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
828 });
829
830 return { blob, fileName: `${fileName}.pptx` };
831 }
832
832 lines TYPESCRIPT