| 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 |