| 1 | /** |
| 2 | * PlateJS Content Walker |
| 3 | * Traverses PlateJS slide content to extract structured elements |
| 4 | */ |
| 5 | import { toPng } from "html-to-image"; |
| 6 | import { KEYS } from "platejs"; |
| 7 | |
| 8 | import { type PlateNode } from "@/components/notebook/presentation/utils/parser"; |
| 9 | import { extractTextStyles } from "./cssVariableResolver"; |
| 10 | import { getEChartSvgDataUrl } from "./echartSvgExport"; |
| 11 | import { |
| 12 | type BackgroundRectExportElement, |
| 13 | type DecorExportElement, |
| 14 | type ElementPosition, |
| 15 | type ExportElement, |
| 16 | type ImageExportElement, |
| 17 | type NativeShapeType, |
| 18 | type ShapeExportElement, |
| 19 | type TableCell, |
| 20 | type TableExportElement, |
| 21 | type TableRow, |
| 22 | } from "./types"; |
| 23 | import { getOptimalPixelRatio } from "./utils"; |
| 24 | |
| 25 | /** |
| 26 | * |
| 27 | * Scan a slide's content and extract all exportable elements |
| 28 | */ |
| 29 | export async function walkSlideContent( |
| 30 | content: PlateNode[], |
| 31 | slideElement: Element, |
| 32 | ): Promise<ExportElement[]> { |
| 33 | const elements: ExportElement[] = []; |
| 34 | |
| 35 | // 1. First, scan for background rectangle elements (elements with data-bg-export) |
| 36 | // These are backgrounds of components like box-item, cycle-item, etc. |
| 37 | const backgroundRects = await scanBackgroundRects(slideElement); |
| 38 | elements.push(...backgroundRects); |
| 39 | |
| 40 | // Scan for shape elements (native PPT shapes for arrows, etc.) |
| 41 | const shapeElements = await scanShapeElements(slideElement); |
| 42 | elements.push(...shapeElements); |
| 43 | |
| 44 | // 2. Scan for ALL decorative elements at the slide level |
| 45 | // This catches decor elements that are siblings of PlateElements (like bullet markers) |
| 46 | const decorElements = await scanDecorElements(slideElement); |
| 47 | elements.push(...decorElements); |
| 48 | |
| 49 | // 2. Then process content nodes (text, tables, images, etc.) |
| 50 | for (const node of content) { |
| 51 | const processed = await processNode(node, slideElement); |
| 52 | if (processed) { |
| 53 | if (Array.isArray(processed)) { |
| 54 | elements.push(...processed); |
| 55 | } else { |
| 56 | elements.push(processed); |
| 57 | } |
| 58 | } |
| 59 | } |
| 60 | return elements; |
| 61 | } |
| 62 | |
| 63 | /** |
| 64 | * Scan for all decorative elements (data-decor, standalone SVGs) in the slide |
| 65 | * Uses parallel processing for better performance |
| 66 | */ |
| 67 | async function scanDecorElements( |
| 68 | slideElement: Element, |
| 69 | ): Promise<ExportElement[]> { |
| 70 | // Find all elements with data-decor="true" and standalone SVGs |
| 71 | const allDecorElements = Array.from( |
| 72 | slideElement.querySelectorAll( |
| 73 | '[data-decor="true"]:not([data-shape]), svg:not([data-ppt-ignore="true"]):not([data-shape])', |
| 74 | ), |
| 75 | ); |
| 76 | |
| 77 | // Filter to only top-level decor elements (not nested inside other decor elements) |
| 78 | // Also filter out SVGs that are inside chart/antv elements (already captured as screenshots) |
| 79 | const distinctDecorElements = allDecorElements.filter((el) => { |
| 80 | // Skip if nested inside another decor element |
| 81 | if ( |
| 82 | allDecorElements.some((parent) => parent !== el && parent.contains(el)) |
| 83 | ) { |
| 84 | return false; |
| 85 | } |
| 86 | |
| 87 | // For SVG elements, check if they're inside an element with data-ppt-ignore |
| 88 | // These are already captured as screenshots (e.g., charts, infographics) |
| 89 | if (el.tagName.toLowerCase() === "svg") { |
| 90 | const ignoredContainer = el.closest('[data-ppt-ignore="true"]'); |
| 91 | if (ignoredContainer) { |
| 92 | return false; // Skip SVGs inside data-ppt-ignore containers |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | return true; |
| 97 | }); |
| 98 | |
| 99 | // Process all decor elements in parallel for better performance |
| 100 | const results = await Promise.all( |
| 101 | distinctDecorElements.map(async (decorEl) => { |
| 102 | // preserve aspect ratio for decor elements (width-based) to prevent distortion in PPT |
| 103 | const position = getElementPositionFromDOM( |
| 104 | decorEl, |
| 105 | slideElement, |
| 106 | true, |
| 107 | "width", |
| 108 | ); |
| 109 | const decorType = decorEl.getAttribute("data-decor") || "svg-content"; |
| 110 | return createDecorImage(decorEl, position, decorType); |
| 111 | }), |
| 112 | ); |
| 113 | |
| 114 | return results.filter((el): el is DecorExportElement => el !== null); |
| 115 | } |
| 116 | |
| 117 | /** |
| 118 | * Scan for background rectangle elements (elements with data-bg-export attribute) |
| 119 | * These are backgrounds of components like box-item, cycle-item, pros/cons items, etc. |
| 120 | * We clone the element, remove children, and capture just the empty background. |
| 121 | */ |
| 122 | async function scanBackgroundRects( |
| 123 | slideElement: Element, |
| 124 | ): Promise<ExportElement[]> { |
| 125 | const elements: ExportElement[] = []; |
| 126 | |
| 127 | // Find all elements with data-bg-export="true" |
| 128 | const bgExportElements = Array.from( |
| 129 | slideElement.querySelectorAll('[data-bg-export="true"]'), |
| 130 | ); |
| 131 | |
| 132 | for (const bgEl of bgExportElements) { |
| 133 | const backgroundRect = createBackgroundRect( |
| 134 | bgEl as HTMLElement, |
| 135 | slideElement, |
| 136 | ); |
| 137 | if (backgroundRect) { |
| 138 | elements.push(backgroundRect); |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | return elements; |
| 143 | } |
| 144 | |
| 145 | /** |
| 146 | * Create a background rectangle element by extracting computed styles. |
| 147 | * No cloning or image capture needed - just get the styles for PPT shape rendering. |
| 148 | */ |
| 149 | function createBackgroundRect( |
| 150 | element: HTMLElement, |
| 151 | slideElement: Element, |
| 152 | ): BackgroundRectExportElement | null { |
| 153 | try { |
| 154 | // Get position from DOM |
| 155 | const position = getElementPositionFromDOM(element, slideElement); |
| 156 | |
| 157 | // Get computed styles |
| 158 | const computedStyle = window.getComputedStyle(element); |
| 159 | |
| 160 | // Get background color (resolved from CSS variables) |
| 161 | const bgColor = computedStyle.backgroundColor; |
| 162 | |
| 163 | // Get background (for gradients like "linear-gradient(...)") |
| 164 | const background = computedStyle.background; |
| 165 | |
| 166 | // Extract corner radius as number (in pixels) |
| 167 | const borderRadiusStr = computedStyle.borderRadius; |
| 168 | const cornerRadius = parseFloat(borderRadiusStr) || 0; |
| 169 | |
| 170 | // Extract border info |
| 171 | const borderWidth = parseFloat(computedStyle.borderWidth) || 0; |
| 172 | const borderColor = computedStyle.borderColor; |
| 173 | |
| 174 | // Skip if no visible background |
| 175 | if ( |
| 176 | (!bgColor || |
| 177 | bgColor === "rgba(0, 0, 0, 0)" || |
| 178 | bgColor === "transparent") && |
| 179 | (!background || background === "none") |
| 180 | ) { |
| 181 | return null; |
| 182 | } |
| 183 | |
| 184 | return { |
| 185 | type: "backgroundRect", |
| 186 | position, |
| 187 | fillColor: colorToHex(bgColor), |
| 188 | cornerRadius, |
| 189 | borderColor: borderWidth > 0 ? colorToHex(borderColor) : undefined, |
| 190 | borderWidth: borderWidth > 0 ? borderWidth : undefined, |
| 191 | // Store the full background string for gradient support |
| 192 | background: background !== "none" ? background : undefined, |
| 193 | }; |
| 194 | } catch (error) { |
| 195 | console.error("Failed to create background rect:", error); |
| 196 | return null; |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | /** |
| 201 | * Convert color to hex (simple helper) |
| 202 | */ |
| 203 | function colorToHex(color: string): string { |
| 204 | if (!color || color === "transparent" || color === "rgba(0, 0, 0, 0)") { |
| 205 | return "FFFFFF"; |
| 206 | } |
| 207 | |
| 208 | // Already hex |
| 209 | if (color.startsWith("#")) { |
| 210 | return color.slice(1).toUpperCase(); |
| 211 | } |
| 212 | |
| 213 | // Use canvas to convert any color format |
| 214 | const canvas = document.createElement("canvas"); |
| 215 | canvas.width = 1; |
| 216 | canvas.height = 1; |
| 217 | const ctx = canvas.getContext("2d"); |
| 218 | if (!ctx) return "FFFFFF"; |
| 219 | |
| 220 | ctx.fillStyle = color; |
| 221 | ctx.fillRect(0, 0, 1, 1); |
| 222 | const data = ctx.getImageData(0, 0, 1, 1).data; |
| 223 | |
| 224 | const toHex = (n: number) => n.toString(16).padStart(2, "0"); |
| 225 | return `${toHex(data[0]!)}${toHex(data[1]!)}${toHex(data[2]!)}`.toUpperCase(); |
| 226 | } |
| 227 | |
| 228 | /** |
| 229 | * Process a single PlateJS node |
| 230 | */ |
| 231 | async function processNode( |
| 232 | node: PlateNode, |
| 233 | slideElement: Element, |
| 234 | ): Promise<ExportElement | ExportElement[] | null> { |
| 235 | const type = node.type || "p"; |
| 236 | |
| 237 | // 1. Process Table |
| 238 | if (type === "table") { |
| 239 | return processTable(node, slideElement); |
| 240 | } |
| 241 | |
| 242 | // 2. Process Image |
| 243 | // Checks for standard PlateJS image types |
| 244 | if (type === KEYS.img || type === "image") { |
| 245 | return processImage(node, slideElement); |
| 246 | } |
| 247 | |
| 248 | // 3. Process MediaEmbed with image provider |
| 249 | // MediaEmbed elements with provider === 'image' should be exported as images |
| 250 | if (type === KEYS.mediaEmbed && node.provider === "image") { |
| 251 | return processMediaEmbedImage(node, slideElement); |
| 252 | } |
| 253 | |
| 254 | // 4. Process Chart/AntV elements |
| 255 | // Elements with type starting with 'chart' or 'antv' should be converted to images |
| 256 | if (type.startsWith("chart") || type.startsWith("antv")) { |
| 257 | return processChartElement(node, slideElement); |
| 258 | } |
| 259 | |
| 260 | // 5. Process generic Element (Text, Container, etc.) |
| 261 | // This handles everything else: paragraphs, headings, blockquotes, lists, etc. |
| 262 | return processElement(node, slideElement); |
| 263 | } |
| 264 | |
| 265 | // ============================================================================ |
| 266 | // 1. PROCESS TABLE |
| 267 | // ============================================================================ |
| 268 | |
| 269 | function processTable( |
| 270 | node: PlateNode, |
| 271 | slideElement: Element, |
| 272 | ): TableExportElement | null { |
| 273 | const children = node.children || []; |
| 274 | const rows: TableRow[] = []; |
| 275 | |
| 276 | // Find the DOM element for the table to measure columns |
| 277 | const tableDOM = findDOMElement(node, slideElement) as HTMLTableElement; |
| 278 | // Get table width for ratio calculation |
| 279 | const tableWidth = tableDOM ? tableDOM.getBoundingClientRect().width : 0; |
| 280 | |
| 281 | for (let r = 0; r < children.length; r++) { |
| 282 | const rowNode = children[r]; |
| 283 | if (!rowNode || rowNode.type !== KEYS.tr) continue; |
| 284 | |
| 285 | const cells: TableCell[] = []; |
| 286 | const rowChildren = (rowNode.children as PlateNode[]) || []; |
| 287 | |
| 288 | // Get DOM row if possible |
| 289 | // const rowDOM = tableDOM |
| 290 | // ? (tableDOM.querySelectorAll("tr")[r] as HTMLTableRowElement) |
| 291 | // : null; |
| 292 | |
| 293 | for (let c = 0; c < rowChildren.length; c++) { |
| 294 | const cellNode = rowChildren[c]; |
| 295 | if (!cellNode) continue; |
| 296 | |
| 297 | const isHeader = cellNode.type === KEYS.th; |
| 298 | // Extract text from cell children |
| 299 | |
| 300 | const text = extractTextFromNodes(cellNode.children as PlateNode[]); |
| 301 | |
| 302 | const cell: TableCell = { |
| 303 | text: text.trim(), |
| 304 | isHeader, |
| 305 | colSpan: (cellNode.colSpan as number) || 1, |
| 306 | rowSpan: (cellNode.rowSpan as number) || 1, |
| 307 | backgroundColor: isHeader ? "#f3f4f6" : undefined, |
| 308 | }; |
| 309 | |
| 310 | // Precise Measurement Strategy: |
| 311 | // Find the specific DOM element for this cell using data-block-id |
| 312 | const cellDOM = findDOMElement(cellNode, slideElement); |
| 313 | |
| 314 | if (cellDOM && tableWidth > 0) { |
| 315 | const tableRect = tableDOM.getBoundingClientRect(); |
| 316 | const cellRect = cellDOM.getBoundingClientRect(); |
| 317 | const textStyleElement = |
| 318 | cellDOM.querySelector("h1,h2,h3,h4,h5,h6,p,span") ?? cellDOM; |
| 319 | |
| 320 | // Calculate relative position and size in pixels (source dimensions) |
| 321 | // We will scale these in the converter |
| 322 | cell.box = { |
| 323 | x: cellRect.left - tableRect.left, |
| 324 | y: cellRect.top - tableRect.top, |
| 325 | width: cellRect.width, |
| 326 | height: cellRect.height, |
| 327 | }; |
| 328 | cell.textStyles = extractTextStyles(textStyleElement); |
| 329 | } |
| 330 | |
| 331 | cells.push(cell); |
| 332 | } |
| 333 | |
| 334 | if (cells.length > 0) { |
| 335 | rows.push({ cells }); |
| 336 | } |
| 337 | } |
| 338 | |
| 339 | if (rows.length === 0) return null; |
| 340 | |
| 341 | // IMPORTANT: Get position using data-block-id |
| 342 | const position = findElementPosition(node, slideElement); |
| 343 | |
| 344 | return { |
| 345 | type: "table", |
| 346 | rows, |
| 347 | position, |
| 348 | headerRowCount: 1, |
| 349 | }; |
| 350 | } |
| 351 | |
| 352 | // ============================================================================ |
| 353 | // 2. PROCESS IMAGE |
| 354 | // ============================================================================ |
| 355 | |
| 356 | async function processImage( |
| 357 | node: PlateNode, |
| 358 | slideElement: Element, |
| 359 | ): Promise<ImageExportElement | null> { |
| 360 | const url = node.url as string; |
| 361 | if (!url) return null; |
| 362 | |
| 363 | // IMPORTANT: Get position using data-block-id |
| 364 | const position = findElementPosition(node, slideElement); |
| 365 | |
| 366 | return { |
| 367 | type: "image", |
| 368 | url: url, |
| 369 | alt: (node.caption as string) || (node.alt as string) || "", |
| 370 | imageSource: node.imageSource as ImageExportElement["imageSource"], |
| 371 | position, |
| 372 | sizing: "contain", // Default to contain for clarity |
| 373 | stockImageProvider: node.stockImageProvider as string | undefined, |
| 374 | }; |
| 375 | } |
| 376 | |
| 377 | // ============================================================================ |
| 378 | // 3. PROCESS MEDIA EMBED IMAGE |
| 379 | // ============================================================================ |
| 380 | |
| 381 | /** |
| 382 | * Process MediaEmbed elements with provider === 'image' |
| 383 | * These are image embeds created via the MediaEmbed component |
| 384 | */ |
| 385 | async function processMediaEmbedImage( |
| 386 | node: PlateNode, |
| 387 | slideElement: Element, |
| 388 | ): Promise<ImageExportElement | null> { |
| 389 | // MediaEmbed stores the URL directly in the url property |
| 390 | const url = node.url as string; |
| 391 | if (!url) return null; |
| 392 | |
| 393 | // IMPORTANT: Get position using data-block-id |
| 394 | const position = findElementPosition(node, slideElement); |
| 395 | |
| 396 | return { |
| 397 | type: "image", |
| 398 | url: url, |
| 399 | alt: "Embedded image", |
| 400 | imageSource: node.imageSource as ImageExportElement["imageSource"], |
| 401 | position, |
| 402 | sizing: "contain", |
| 403 | stockImageProvider: node.stockImageProvider as string | undefined, |
| 404 | }; |
| 405 | } |
| 406 | |
| 407 | // ============================================================================ |
| 408 | // 4. PROCESS CHART/ANTV ELEMENTS |
| 409 | // ============================================================================ |
| 410 | |
| 411 | /** |
| 412 | * Process Chart/AntV elements for export. |
| 413 | * ECharts render in SVG mode, so prefer their SVG instead of a screenshot. |
| 414 | */ |
| 415 | async function processChartElement( |
| 416 | node: PlateNode, |
| 417 | slideElement: Element, |
| 418 | ): Promise<ImageExportElement | null> { |
| 419 | // Find the DOM element for this chart |
| 420 | const domElement = findDOMElement(node, slideElement); |
| 421 | if (!domElement) { |
| 422 | console.warn(`Chart element not found in DOM: ${node.id}`); |
| 423 | return null; |
| 424 | } |
| 425 | |
| 426 | // Get the position with dimensions intact, preserving aspect ratio by height for charts |
| 427 | const position = getElementPositionFromDOM( |
| 428 | domElement, |
| 429 | slideElement, |
| 430 | true, |
| 431 | "height", |
| 432 | ); |
| 433 | |
| 434 | try { |
| 435 | const svgDataUrl = getEChartSvgDataUrl(domElement); |
| 436 | if (svgDataUrl) { |
| 437 | return { |
| 438 | type: "image", |
| 439 | url: svgDataUrl, |
| 440 | alt: `Chart: ${node.type}`, |
| 441 | position, |
| 442 | sizing: "contain", |
| 443 | }; |
| 444 | } |
| 445 | |
| 446 | // Convert the chart element to a PNG data URL |
| 447 | const dataUrl = await toPng(domElement as HTMLElement, { |
| 448 | backgroundColor: "transparent", |
| 449 | // Skip font embedding to avoid CORS errors with Google Fonts |
| 450 | skipFonts: true, |
| 451 | quality: 1, |
| 452 | // Bust cache for reliable rendering |
| 453 | cacheBust: true, |
| 454 | // Adaptive quality based on device capability |
| 455 | pixelRatio: getOptimalPixelRatio(), |
| 456 | }); |
| 457 | |
| 458 | return { |
| 459 | type: "image", |
| 460 | url: dataUrl, |
| 461 | alt: `Chart: ${node.type}`, |
| 462 | position, |
| 463 | sizing: "contain", |
| 464 | }; |
| 465 | } catch (error) { |
| 466 | console.error(`Failed to convert chart element to image:`, error); |
| 467 | // Return null on failure - the chart won't be included |
| 468 | return null; |
| 469 | } |
| 470 | } |
| 471 | |
| 472 | async function processElement( |
| 473 | node: PlateNode, |
| 474 | slideElement: Element, |
| 475 | ): Promise<ExportElement | ExportElement[] | null> { |
| 476 | const elements: ExportElement[] = []; |
| 477 | |
| 478 | // 1. Find the DOM element for this node |
| 479 | const domElement = findDOMElement(node, slideElement); |
| 480 | if (!domElement) { |
| 481 | // If no DOM element found for this node, still try to process children recursively |
| 482 | if (node.children && Array.isArray(node.children)) { |
| 483 | for (const child of node.children as PlateNode[]) { |
| 484 | if (child.type) { |
| 485 | const childResult = await processNode(child, slideElement); |
| 486 | if (childResult) { |
| 487 | if (Array.isArray(childResult)) { |
| 488 | elements.push(...childResult); |
| 489 | } else { |
| 490 | elements.push(childResult); |
| 491 | } |
| 492 | } |
| 493 | } |
| 494 | } |
| 495 | } |
| 496 | return elements.length > 0 ? elements : null; |
| 497 | } |
| 498 | |
| 499 | // 2. Process text content from this node's leaf nodes |
| 500 | // NOTE: Decor elements (SVGs, data-decor) are now scanned at the slide level |
| 501 | // in scanDecorElements() to ensure all are captured regardless of DOM structure |
| 502 | const textContent = extractTextFromLeafNodes(node); |
| 503 | |
| 504 | // Only add text element if there is actual text at this level |
| 505 | if (textContent.trim()) { |
| 506 | const textStyles = extractTextStyles(domElement); |
| 507 | const textPosition = getElementPositionFromDOM(domElement, slideElement); |
| 508 | |
| 509 | // Increase width by 5% for heading elements to prevent text overflow in PPT |
| 510 | const type = node.type || ""; |
| 511 | const isHeading = /^h[1-6]$/.test(type); |
| 512 | if (isHeading) { |
| 513 | textPosition.width = textPosition.width * 1.1; |
| 514 | } |
| 515 | |
| 516 | elements.push({ |
| 517 | type: "text", |
| 518 | textContent: textContent, |
| 519 | textStyles, |
| 520 | position: textPosition, |
| 521 | nodeType: type || "p", |
| 522 | }); |
| 523 | } |
| 524 | |
| 525 | // 4. Recursively process child nodes that have their own type (block elements) |
| 526 | if (node.children && Array.isArray(node.children)) { |
| 527 | for (const child of node.children as PlateNode[]) { |
| 528 | // Only process children that are block-level elements (have a type) |
| 529 | // Skip leaf text nodes as they're already handled above |
| 530 | if (child.type && !child.text) { |
| 531 | const childResult = await processNode(child, slideElement); |
| 532 | if (childResult) { |
| 533 | if (Array.isArray(childResult)) { |
| 534 | elements.push(...childResult); |
| 535 | } else { |
| 536 | elements.push(childResult); |
| 537 | } |
| 538 | } |
| 539 | } |
| 540 | } |
| 541 | } |
| 542 | |
| 543 | return elements.length > 0 ? elements : null; |
| 544 | } |
| 545 | |
| 546 | /** |
| 547 | * Helper to capture element as image (for decor/svg) |
| 548 | */ |
| 549 | async function createDecorImage( |
| 550 | element: Element, |
| 551 | position: ElementPosition, |
| 552 | decorType: string, |
| 553 | ): Promise<DecorExportElement | null> { |
| 554 | try { |
| 555 | const htmlElement = element as HTMLElement; |
| 556 | const computedStyle = window.getComputedStyle(htmlElement); |
| 557 | const width = htmlElement.offsetWidth; |
| 558 | const height = htmlElement.offsetHeight; |
| 559 | const dataUrl = await toPng(htmlElement, { |
| 560 | backgroundColor: "transparent", |
| 561 | // Skip font embedding to avoid CORS errors with Google Fonts |
| 562 | skipFonts: true, |
| 563 | // Bust cache for reliable rendering |
| 564 | cacheBust: true, |
| 565 | // Force dimensions to match rendered size |
| 566 | width: width, |
| 567 | height: height, |
| 568 | // Apply computed styles explicitly to ensure CSS variables are resolved |
| 569 | style: { |
| 570 | backgroundColor: computedStyle.backgroundColor, |
| 571 | color: computedStyle.color, |
| 572 | borderColor: computedStyle.borderColor, |
| 573 | borderWidth: computedStyle.borderWidth, |
| 574 | borderStyle: computedStyle.borderStyle, |
| 575 | borderRadius: computedStyle.borderRadius, |
| 576 | // Ensure flex layouts inside (like centered numbers) are preserved |
| 577 | display: computedStyle.display, |
| 578 | alignItems: computedStyle.alignItems, |
| 579 | justifyContent: computedStyle.justifyContent, |
| 580 | }, |
| 581 | // Adaptive quality based on device capability |
| 582 | pixelRatio: getOptimalPixelRatio(), |
| 583 | quality: 1, |
| 584 | }); |
| 585 | |
| 586 | return { |
| 587 | type: "decor", |
| 588 | decorType: decorType, |
| 589 | base64Data: dataUrl, |
| 590 | position, |
| 591 | sizing: "contain", |
| 592 | }; |
| 593 | } catch (e) { |
| 594 | console.error("Failed to convert element to image", e); |
| 595 | // Return null on failure - the element won't be included |
| 596 | return null; |
| 597 | } |
| 598 | } |
| 599 | |
| 600 | // ============================================================================ |
| 601 | // HELPERS |
| 602 | // ============================================================================ |
| 603 | |
| 604 | /** |
| 605 | * Extract plain text from nested nodes (recursive) |
| 606 | */ |
| 607 | function extractTextFromNodes(nodes: PlateNode[]): string { |
| 608 | let text = ""; |
| 609 | for (const node of nodes) { |
| 610 | if (node.text) { |
| 611 | text += node.text; |
| 612 | } |
| 613 | if (node.children) { |
| 614 | text += extractTextFromNodes(node.children as PlateNode[]); |
| 615 | } |
| 616 | } |
| 617 | return text; |
| 618 | } |
| 619 | |
| 620 | /** |
| 621 | * Extract text only from direct leaf children of a node (not recursive into block children) |
| 622 | * This extracts text nodes at the current block level only |
| 623 | */ |
| 624 | function extractTextFromLeafNodes(node: PlateNode): string { |
| 625 | let text = ""; |
| 626 | |
| 627 | // If the node itself has text, return it |
| 628 | if (node.text) { |
| 629 | return node.text as string; |
| 630 | } |
| 631 | |
| 632 | // Only process immediate children that are text leaves |
| 633 | if (node.children && Array.isArray(node.children)) { |
| 634 | for (const child of node.children as PlateNode[]) { |
| 635 | // Only process if it's a text node (has .text property) or inline element |
| 636 | // Skip block-level children (they have their own type and will be processed recursively) |
| 637 | if (child.text) { |
| 638 | text += child.text; |
| 639 | } else if (!child.type || isInlineType(child.type as string)) { |
| 640 | // Inline elements - extract text from them |
| 641 | text += extractTextFromLeafNodes(child); |
| 642 | } |
| 643 | // Block-level children with their own type are skipped here |
| 644 | // They will be processed recursively by processElement |
| 645 | } |
| 646 | } |
| 647 | |
| 648 | return text; |
| 649 | } |
| 650 | |
| 651 | /** |
| 652 | * Check if a PlateJS type is inline (not a block element) |
| 653 | */ |
| 654 | function isInlineType(type: string): boolean { |
| 655 | const inlineTypes = ["a", "link", "mention", "inline-code", "code_line"]; |
| 656 | return inlineTypes.includes(type); |
| 657 | } |
| 658 | |
| 659 | /** |
| 660 | * Find DOM element for a PlateNode |
| 661 | */ |
| 662 | function findDOMElement( |
| 663 | node: PlateNode, |
| 664 | slideElement: Element, |
| 665 | ): Element | null { |
| 666 | if (!node.id) return null; |
| 667 | return slideElement.querySelector(`[data-block-id="${node.id}"]`); |
| 668 | } |
| 669 | |
| 670 | /** |
| 671 | * Find element position and normalize it (0-100%) |
| 672 | */ |
| 673 | function findElementPosition( |
| 674 | node: PlateNode, |
| 675 | slideElement: Element, |
| 676 | ): ElementPosition { |
| 677 | const domElement = findDOMElement(node, slideElement); |
| 678 | if (domElement) { |
| 679 | return getElementPositionFromDOM(domElement, slideElement); |
| 680 | } |
| 681 | return getDefaultPosition(); |
| 682 | } |
| 683 | |
| 684 | /** |
| 685 | * Get position of a DOM element relative to slide (Percentage) |
| 686 | * @param preserveAspectRatio - If true, includes aspect ratio for PPT shape preservation |
| 687 | * @param aspectRatioBase - Which dimension to preserve: 'width' recalculates height, 'height' recalculates width |
| 688 | */ |
| 689 | function getElementPositionFromDOM( |
| 690 | element: Element, |
| 691 | slideElement: Element, |
| 692 | preserveAspectRatio = false, |
| 693 | aspectRatioBase: "width" | "height" = "width", |
| 694 | ): ElementPosition { |
| 695 | const slideRect = slideElement.getBoundingClientRect(); |
| 696 | const elementRect = element.getBoundingClientRect(); |
| 697 | |
| 698 | return getRelativePercentRect({ |
| 699 | child: elementRect, |
| 700 | parent: slideRect, |
| 701 | preserveAspectRatio, |
| 702 | aspectRatioBase, |
| 703 | }); |
| 704 | } |
| 705 | |
| 706 | /** |
| 707 | * Calculates normalized percentage positions |
| 708 | * @param preserveAspectRatio - If true, includes the original pixel aspect ratio for shape preservation |
| 709 | * @param aspectRatioBase - Which dimension to preserve: 'width' recalculates height, 'height' recalculates width |
| 710 | */ |
| 711 | function getRelativePercentRect({ |
| 712 | child, |
| 713 | parent, |
| 714 | preserveAspectRatio = false, |
| 715 | aspectRatioBase = "width", |
| 716 | }: { |
| 717 | child: DOMRect; |
| 718 | parent: DOMRect; |
| 719 | preserveAspectRatio?: boolean; |
| 720 | aspectRatioBase?: "width" | "height"; |
| 721 | }): ElementPosition { |
| 722 | return { |
| 723 | x: ((child.left - parent.left) / parent.width) * 100, |
| 724 | y: ((child.top - parent.top) / parent.height) * 100, |
| 725 | width: (child.width / parent.width) * 100, |
| 726 | height: (child.height / parent.height) * 100, |
| 727 | // Original pixel aspect ratio for elements that need it preserved in PPT |
| 728 | aspectRatio: |
| 729 | preserveAspectRatio && child.height > 0 |
| 730 | ? child.width / child.height |
| 731 | : undefined, |
| 732 | aspectRatioBase: preserveAspectRatio ? aspectRatioBase : undefined, |
| 733 | }; |
| 734 | } |
| 735 | |
| 736 | function getDefaultPosition(): ElementPosition { |
| 737 | return { x: 5, y: 5, width: 90, height: 90 }; |
| 738 | } |
| 739 | |
| 740 | /** |
| 741 | * Scan for native shape elements (data-shape attribute) |
| 742 | * These are elements like arrows that should be exported as native PPT shapes |
| 743 | * instead of images to prevent quality/overflow issues |
| 744 | */ |
| 745 | async function scanShapeElements( |
| 746 | slideElement: Element, |
| 747 | ): Promise<ExportElement[]> { |
| 748 | const elements: ExportElement[] = []; |
| 749 | |
| 750 | // Find all elements with data-shape |
| 751 | const shapeElements = Array.from( |
| 752 | slideElement.querySelectorAll("[data-shape]"), |
| 753 | ).sort((a, b) => { |
| 754 | const aOrder = getTimelineShapeOrder(a); |
| 755 | const bOrder = getTimelineShapeOrder(b); |
| 756 | |
| 757 | if (aOrder === null || bOrder === null) { |
| 758 | return 0; |
| 759 | } |
| 760 | |
| 761 | return aOrder - bOrder; |
| 762 | }); |
| 763 | |
| 764 | for (const shapeEl of shapeElements) { |
| 765 | const shapeElement = createShapeElement( |
| 766 | shapeEl as HTMLElement, |
| 767 | slideElement, |
| 768 | ); |
| 769 | if (shapeElement) { |
| 770 | elements.push(shapeElement); |
| 771 | } |
| 772 | } |
| 773 | |
| 774 | return elements; |
| 775 | } |
| 776 | |
| 777 | function createShapeElement( |
| 778 | element: HTMLElement, |
| 779 | slideElement: Element, |
| 780 | ): ShapeExportElement | null { |
| 781 | try { |
| 782 | const shapeType = getNativeShapeType(element.getAttribute("data-shape")); |
| 783 | if (!shapeType) { |
| 784 | return null; |
| 785 | } |
| 786 | |
| 787 | const orientation = |
| 788 | (element.getAttribute("data-orientation") as "horizontal" | "vertical") || |
| 789 | "horizontal"; |
| 790 | |
| 791 | let fillColor = element.getAttribute("data-fill-color") ?? "#ffffff"; |
| 792 | let textColor = element.getAttribute("data-text-color") ?? ""; |
| 793 | |
| 794 | // Resolve CSS variables if present |
| 795 | if (fillColor.includes("var(")) { |
| 796 | const prevColor = element.style.color; |
| 797 | element.style.color = fillColor; |
| 798 | fillColor = window.getComputedStyle(element).color; |
| 799 | element.style.color = prevColor; |
| 800 | } |
| 801 | |
| 802 | if (textColor.includes("var(")) { |
| 803 | const prevColor = element.style.color; |
| 804 | element.style.color = textColor; |
| 805 | textColor = window.getComputedStyle(element).color; |
| 806 | element.style.color = prevColor; |
| 807 | } |
| 808 | |
| 809 | const position = getElementPositionFromDOM( |
| 810 | element, |
| 811 | slideElement, |
| 812 | shapeType === "ellipse", |
| 813 | "height", |
| 814 | ); |
| 815 | position.centerAspectRatio = shapeType === "ellipse" ? true : undefined; |
| 816 | |
| 817 | const parentBlock = element.closest(".slate-blockWrapper"); |
| 818 | if ( |
| 819 | parentBlock && |
| 820 | (shapeType === "arrow" || |
| 821 | shapeType === "pill" || |
| 822 | shapeType === "parallelogram") |
| 823 | ) { |
| 824 | const parentRect = parentBlock.getBoundingClientRect(); |
| 825 | const slideRect = slideElement.getBoundingClientRect(); |
| 826 | |
| 827 | // Take 95% of the parent height to leave some breathing room |
| 828 | const targetSize = parentRect.height * 0.95; |
| 829 | |
| 830 | if (orientation === "vertical") { |
| 831 | position.height = (targetSize / slideRect.height) * 100; |
| 832 | } else { |
| 833 | position.width = (targetSize / slideRect.width) * 100; |
| 834 | } |
| 835 | } |
| 836 | |
| 837 | return { |
| 838 | type: "shape", |
| 839 | shapeType, |
| 840 | orientation, |
| 841 | fillColor: colorToHex(fillColor), |
| 842 | textContent: element.getAttribute("data-shape-text") ?? undefined, |
| 843 | textColor: textColor ? colorToHex(textColor) : undefined, |
| 844 | position, |
| 845 | }; |
| 846 | } catch (error) { |
| 847 | console.error("Failed to create shape element:", error); |
| 848 | return null; |
| 849 | } |
| 850 | } |
| 851 | |
| 852 | function getNativeShapeType(shapeType: string | null): NativeShapeType | null { |
| 853 | if ( |
| 854 | shapeType === "arrow" || |
| 855 | shapeType === "pill" || |
| 856 | shapeType === "parallelogram" || |
| 857 | shapeType === "rect" || |
| 858 | shapeType === "ellipse" |
| 859 | ) { |
| 860 | return shapeType; |
| 861 | } |
| 862 | |
| 863 | return null; |
| 864 | } |
| 865 | |
| 866 | function getTimelineShapeOrder(element: Element): number | null { |
| 867 | const role = element.getAttribute("data-shape-role"); |
| 868 | |
| 869 | if (role === "timeline-rail") { |
| 870 | return 0; |
| 871 | } |
| 872 | |
| 873 | if (role === "timeline-connector") { |
| 874 | return 1; |
| 875 | } |
| 876 | |
| 877 | if (role === "timeline-marker") { |
| 878 | return 2; |
| 879 | } |
| 880 | |
| 881 | return null; |
| 882 | } |
| 883 |