@microlee666/dom-to-pptx 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js ADDED
@@ -0,0 +1,1376 @@
1
+ // src/index.js
2
+ import * as PptxGenJSImport from 'pptxgenjs';
3
+ import html2canvas from 'html2canvas';
4
+ import { PPTXEmbedFonts } from './font-embedder.js';
5
+ import JSZip from 'jszip';
6
+
7
+ // Normalize import
8
+ const PptxGenJS = PptxGenJSImport?.default ?? PptxGenJSImport;
9
+
10
+ import {
11
+ parseColor,
12
+ getTextStyle,
13
+ isTextContainer,
14
+ getVisibleShadow,
15
+ generateGradientSVG,
16
+ getRotation,
17
+ svgToPng,
18
+ getPadding,
19
+ getSoftEdges,
20
+ generateBlurredSVG,
21
+ getBorderInfo,
22
+ generateCompositeBorderSVG,
23
+ isClippedByParent,
24
+ generateCustomShapeSVG,
25
+ getUsedFontFamilies,
26
+ getAutoDetectedFonts,
27
+ extractTableData,
28
+ computeTableCellFill
29
+ } from './utils.js';
30
+ import { getProcessedImage } from './image-processor.js';
31
+
32
+ const PPI = 96;
33
+ const PX_TO_INCH = 1 / PPI;
34
+
35
+ /**
36
+ * Main export function.
37
+ * @param {HTMLElement | string | Array<HTMLElement | string>} target
38
+ * @param {Object} options
39
+ * @param {string} [options.fileName]
40
+ * @param {boolean} [options.skipDownload=false] - If true, prevents automatic download
41
+ * @param {Object} [options.listConfig] - Config for bullets
42
+ * @returns {Promise<Blob>} - Returns the generated PPTX Blob
43
+ */
44
+ export async function exportToPptx(target, options = {}) {
45
+ const resolvePptxConstructor = (pkg) => {
46
+ if (!pkg) return null;
47
+ if (typeof pkg === 'function') return pkg;
48
+ if (pkg && typeof pkg.default === 'function') return pkg.default;
49
+ if (pkg && typeof pkg.PptxGenJS === 'function') return pkg.PptxGenJS;
50
+ if (pkg && pkg.PptxGenJS && typeof pkg.PptxGenJS.default === 'function')
51
+ return pkg.PptxGenJS.default;
52
+ return null;
53
+ };
54
+
55
+ const PptxConstructor = resolvePptxConstructor(PptxGenJS);
56
+ if (!PptxConstructor) throw new Error('PptxGenJS constructor not found.');
57
+ const pptx = new PptxConstructor();
58
+ pptx.layout = 'LAYOUT_16x9';
59
+
60
+ const elements = Array.isArray(target) ? target : [target];
61
+
62
+ for (const el of elements) {
63
+ const root = typeof el === 'string' ? document.querySelector(el) : el;
64
+ if (!root) {
65
+ console.warn('Element not found, skipping slide:', el);
66
+ continue;
67
+ }
68
+ const slide = pptx.addSlide();
69
+ await processSlide(root, slide, pptx, options);
70
+ }
71
+
72
+ // 3. Font Embedding Logic
73
+ let finalBlob;
74
+ let fontsToEmbed = options.fonts || [];
75
+
76
+ if (options.autoEmbedFonts) {
77
+ // A. Scan DOM for used font families
78
+ const usedFamilies = getUsedFontFamilies(elements);
79
+
80
+ // B. Scan CSS for URLs matches
81
+ const detectedFonts = await getAutoDetectedFonts(usedFamilies);
82
+
83
+ // C. Merge (Avoid duplicates)
84
+ const explicitNames = new Set(fontsToEmbed.map((f) => f.name));
85
+ for (const autoFont of detectedFonts) {
86
+ if (!explicitNames.has(autoFont.name)) {
87
+ fontsToEmbed.push(autoFont);
88
+ }
89
+ }
90
+
91
+ if (detectedFonts.length > 0) {
92
+ console.log(
93
+ 'Auto-detected fonts:',
94
+ detectedFonts.map((f) => f.name)
95
+ );
96
+ }
97
+ }
98
+
99
+ if (fontsToEmbed.length > 0) {
100
+ // Generate initial PPTX
101
+ const initialBlob = await pptx.write({ outputType: 'blob' });
102
+
103
+ // Load into Embedder
104
+ const zip = await JSZip.loadAsync(initialBlob);
105
+ const embedder = new PPTXEmbedFonts();
106
+ await embedder.loadZip(zip);
107
+
108
+ // Fetch and Embed
109
+ for (const fontCfg of fontsToEmbed) {
110
+ try {
111
+ const response = await fetch(fontCfg.url);
112
+ if (!response.ok) throw new Error(`Failed to fetch ${fontCfg.url}`);
113
+ const buffer = await response.arrayBuffer();
114
+
115
+ // Infer type
116
+ const ext = fontCfg.url.split('.').pop().split(/[?#]/)[0].toLowerCase();
117
+ let type = 'ttf';
118
+ if (['woff', 'otf'].includes(ext)) type = ext;
119
+
120
+ await embedder.addFont(fontCfg.name, buffer, type);
121
+ } catch (e) {
122
+ console.warn(`Failed to embed font: ${fontCfg.name} (${fontCfg.url})`, e);
123
+ }
124
+ }
125
+
126
+ await embedder.updateFiles();
127
+ finalBlob = await embedder.generateBlob();
128
+ } else {
129
+ // No fonts to embed
130
+ finalBlob = await pptx.write({ outputType: 'blob' });
131
+ }
132
+
133
+ // 4. Output Handling
134
+ // If skipDownload is NOT true, proceed with browser download
135
+ if (!options.skipDownload) {
136
+ const fileName = options.fileName || 'export.pptx';
137
+ const url = URL.createObjectURL(finalBlob);
138
+ const a = document.createElement('a');
139
+ a.href = url;
140
+ a.download = fileName;
141
+ document.body.appendChild(a);
142
+ a.click();
143
+ document.body.removeChild(a);
144
+ URL.revokeObjectURL(url);
145
+ }
146
+
147
+ // Always return the blob so the caller can use it (e.g. upload to server)
148
+ return finalBlob;
149
+ }
150
+
151
+ /**
152
+ * Worker function to process a single DOM element into a single PPTX slide.
153
+ * @param {HTMLElement} root - The root element for this slide.
154
+ * @param {PptxGenJS.Slide} slide - The PPTX slide object to add content to.
155
+ * @param {PptxGenJS} pptx - The main PPTX instance.
156
+ */
157
+ async function processSlide(root, slide, pptx, globalOptions = {}) {
158
+ const rootRect = root.getBoundingClientRect();
159
+ const PPTX_WIDTH_IN = 10;
160
+ const PPTX_HEIGHT_IN = 5.625;
161
+
162
+ const contentWidthIn = rootRect.width * PX_TO_INCH;
163
+ const contentHeightIn = rootRect.height * PX_TO_INCH;
164
+ const scale = Math.min(PPTX_WIDTH_IN / contentWidthIn, PPTX_HEIGHT_IN / contentHeightIn);
165
+
166
+ const layoutConfig = {
167
+ rootX: rootRect.x,
168
+ rootY: rootRect.y,
169
+ scale: scale,
170
+ offX: (PPTX_WIDTH_IN - contentWidthIn * scale) / 2,
171
+ offY: (PPTX_HEIGHT_IN - contentHeightIn * scale) / 2,
172
+ };
173
+
174
+ const renderQueue = [];
175
+ const asyncTasks = []; // Queue for heavy operations (Images, Canvas)
176
+ let domOrderCounter = 0;
177
+
178
+ // Sync Traversal Function
179
+ function collect(node, parentZIndex) {
180
+ const order = domOrderCounter++;
181
+
182
+ let currentZ = parentZIndex;
183
+ let nodeStyle = null;
184
+ const nodeType = node.nodeType;
185
+
186
+ if (nodeType === 1) {
187
+ nodeStyle = window.getComputedStyle(node);
188
+ // Optimization: Skip completely hidden elements immediately
189
+ if (
190
+ nodeStyle.display === 'none' ||
191
+ nodeStyle.visibility === 'hidden' ||
192
+ nodeStyle.opacity === '0'
193
+ ) {
194
+ return;
195
+ }
196
+ if (nodeStyle.zIndex !== 'auto') {
197
+ currentZ = parseInt(nodeStyle.zIndex);
198
+ }
199
+ }
200
+
201
+ // Prepare the item. If it needs async work, it returns a 'job'
202
+ const result = prepareRenderItem(
203
+ node,
204
+ { ...layoutConfig, root },
205
+ order,
206
+ pptx,
207
+ currentZ,
208
+ nodeStyle,
209
+ globalOptions
210
+ );
211
+
212
+ if (result) {
213
+ if (result.items) {
214
+ // Push items immediately to queue (data might be missing but filled later)
215
+ renderQueue.push(...result.items);
216
+ }
217
+ if (result.job) {
218
+ // Push the promise-returning function to the task list
219
+ asyncTasks.push(result.job);
220
+ }
221
+ if (result.stopRecursion) return;
222
+ }
223
+
224
+ // Recurse children synchronously
225
+ const childNodes = node.childNodes;
226
+ for (let i = 0; i < childNodes.length; i++) {
227
+ collect(childNodes[i], currentZ);
228
+ }
229
+ }
230
+
231
+ // 1. Traverse and build the structure (Fast)
232
+ collect(root, 0);
233
+
234
+ // 2. Execute all heavy tasks in parallel (Fast)
235
+ if (asyncTasks.length > 0) {
236
+ await Promise.all(asyncTasks.map((task) => task()));
237
+ }
238
+
239
+ // 3. Cleanup and Sort
240
+ // Remove items that failed to generate data (marked with skip)
241
+ const finalQueue = renderQueue.filter(
242
+ (item) => !item.skip && (item.type !== 'image' || item.options.data)
243
+ );
244
+
245
+ finalQueue.sort((a, b) => {
246
+ if (a.zIndex !== b.zIndex) return a.zIndex - b.zIndex;
247
+ return a.domOrder - b.domOrder;
248
+ });
249
+
250
+ // 4. Add to Slide
251
+ for (const item of finalQueue) {
252
+ if (item.type === 'shape') slide.addShape(item.shapeType, item.options);
253
+ if (item.type === 'image') slide.addImage(item.options);
254
+ if (item.type === 'text') slide.addText(item.textParts, item.options);
255
+ if (item.type === 'table') {
256
+ slide.addTable(item.tableData.rows, {
257
+ x: item.options.x,
258
+ y: item.options.y,
259
+ w: item.options.w,
260
+ colW: item.tableData.colWidths, // Essential for correct layout
261
+ autoPage: false,
262
+ // Remove default table styles so our extracted CSS applies cleanly
263
+ border: { type: "none" },
264
+ fill: { color: "FFFFFF", transparency: 100 }
265
+ });
266
+ }
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Optimized html2canvas wrapper
272
+ * Includes fix for cropped icons by adjusting styles in the cloned document.
273
+ */
274
+ async function elementToCanvasImage(node, widthPx, heightPx) {
275
+ return new Promise((resolve) => {
276
+ // 1. Assign a temp ID to locate the node inside the cloned document
277
+ const originalId = node.id;
278
+ const tempId = 'pptx-capture-' + Math.random().toString(36).substr(2, 9);
279
+ node.id = tempId;
280
+
281
+ const width = Math.max(Math.ceil(widthPx), 1);
282
+ const height = Math.max(Math.ceil(heightPx), 1);
283
+ const style = window.getComputedStyle(node);
284
+
285
+ html2canvas(node, {
286
+ backgroundColor: null,
287
+ logging: false,
288
+ scale: 3, // Higher scale for sharper icons
289
+ useCORS: true, // critical for external fonts/images
290
+ onclone: (clonedDoc) => {
291
+ const clonedNode = clonedDoc.getElementById(tempId);
292
+ if (clonedNode) {
293
+ // --- FIX: PREVENT ICON CLIPPING ---
294
+ // 1. Force overflow visible so glyphs bleeding out aren't cut
295
+ clonedNode.style.overflow = 'visible';
296
+
297
+ // 2. Adjust alignment for Icons to prevent baseline clipping
298
+ // (Applies to <i>, <span>, or standard icon classes)
299
+ const tag = clonedNode.tagName;
300
+ if (tag === 'I' || tag === 'SPAN' || clonedNode.className.includes('fa-')) {
301
+ // Flex center helps align the glyph exactly in the middle of the box
302
+ // preventing top/bottom cropping due to line-height mismatches.
303
+ clonedNode.style.display = 'inline-flex';
304
+ clonedNode.style.justifyContent = 'center';
305
+ clonedNode.style.alignItems = 'center';
306
+
307
+ // Remove margins that might offset the capture
308
+ clonedNode.style.margin = '0';
309
+
310
+ // Ensure the font fits
311
+ clonedNode.style.lineHeight = '1';
312
+ clonedNode.style.verticalAlign = 'middle';
313
+ }
314
+ }
315
+ },
316
+ })
317
+ .then((canvas) => {
318
+ // Restore the original ID
319
+ if (originalId) node.id = originalId;
320
+ else node.removeAttribute('id');
321
+
322
+ const destCanvas = document.createElement('canvas');
323
+ destCanvas.width = width;
324
+ destCanvas.height = height;
325
+ const ctx = destCanvas.getContext('2d');
326
+
327
+ // Draw captured canvas.
328
+ // We simply draw it to fill the box. Since we centered it in 'onclone',
329
+ // the glyph should now be visible within the bounds.
330
+ ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, 0, width, height);
331
+
332
+ // --- Border Radius Clipping (Existing Logic) ---
333
+ let tl = parseFloat(style.borderTopLeftRadius) || 0;
334
+ let tr = parseFloat(style.borderTopRightRadius) || 0;
335
+ let br = parseFloat(style.borderBottomRightRadius) || 0;
336
+ let bl = parseFloat(style.borderBottomLeftRadius) || 0;
337
+
338
+ const f = Math.min(
339
+ width / (tl + tr) || Infinity,
340
+ height / (tr + br) || Infinity,
341
+ width / (br + bl) || Infinity,
342
+ height / (bl + tl) || Infinity
343
+ );
344
+
345
+ if (f < 1) {
346
+ tl *= f;
347
+ tr *= f;
348
+ br *= f;
349
+ bl *= f;
350
+ }
351
+
352
+ if (tl + tr + br + bl > 0) {
353
+ ctx.globalCompositeOperation = 'destination-in';
354
+ ctx.beginPath();
355
+ ctx.moveTo(tl, 0);
356
+ ctx.lineTo(width - tr, 0);
357
+ ctx.arcTo(width, 0, width, tr, tr);
358
+ ctx.lineTo(width, height - br);
359
+ ctx.arcTo(width, height, width - br, height, br);
360
+ ctx.lineTo(bl, height);
361
+ ctx.arcTo(0, height, 0, height - bl, bl);
362
+ ctx.lineTo(0, tl);
363
+ ctx.arcTo(0, 0, tl, 0, tl);
364
+ ctx.closePath();
365
+ ctx.fill();
366
+ }
367
+
368
+ resolve(destCanvas.toDataURL('image/png'));
369
+ })
370
+ .catch((e) => {
371
+ if (originalId) node.id = originalId;
372
+ else node.removeAttribute('id');
373
+ console.warn('Canvas capture failed for node', node, e);
374
+ resolve(null);
375
+ });
376
+ });
377
+ }
378
+
379
+ /**
380
+ * Helper to identify elements that should be rendered as icons (Images).
381
+ * Detects Custom Elements AND generic tags (<i>, <span>) with icon classes/pseudo-elements.
382
+ */
383
+ function isIconElement(node) {
384
+ // 1. Custom Elements (hyphenated tags) or Explicit Library Tags
385
+ const tag = node.tagName.toUpperCase();
386
+ if (
387
+ tag.includes('-') ||
388
+ [
389
+ 'MATERIAL-ICON',
390
+ 'ICONIFY-ICON',
391
+ 'REMIX-ICON',
392
+ 'ION-ICON',
393
+ 'EVA-ICON',
394
+ 'BOX-ICON',
395
+ 'FA-ICON',
396
+ ].includes(tag)
397
+ ) {
398
+ return true;
399
+ }
400
+
401
+ // 2. Class-based Icons (FontAwesome, Bootstrap, Material symbols) on <i> or <span>
402
+ if (tag === 'I' || tag === 'SPAN') {
403
+ const cls = node.getAttribute('class') || '';
404
+ if (
405
+ typeof cls === 'string' &&
406
+ (cls.includes('fa-') ||
407
+ cls.includes('fas') ||
408
+ cls.includes('far') ||
409
+ cls.includes('fab') ||
410
+ cls.includes('bi-') ||
411
+ cls.includes('material-icons') ||
412
+ cls.includes('icon'))
413
+ ) {
414
+ // Double-check: Must have pseudo-element content to be a CSS icon
415
+ const before = window.getComputedStyle(node, '::before').content;
416
+ const after = window.getComputedStyle(node, '::after').content;
417
+ const hasContent = (c) => c && c !== 'none' && c !== 'normal' && c !== '""';
418
+
419
+ if (hasContent(before) || hasContent(after)) return true;
420
+ }
421
+ }
422
+
423
+ return false;
424
+ }
425
+
426
+ /**
427
+ * Replaces createRenderItem.
428
+ * Returns { items: [], job: () => Promise, stopRecursion: boolean }
429
+ */
430
+ function prepareRenderItem(
431
+ node,
432
+ config,
433
+ domOrder,
434
+ pptx,
435
+ effectiveZIndex,
436
+ computedStyle,
437
+ globalOptions = {}
438
+ ) {
439
+ // 1. Text Node Handling
440
+ if (node.nodeType === 3) {
441
+ const textContent = node.nodeValue.trim();
442
+ if (!textContent) return null;
443
+
444
+ const parent = node.parentElement;
445
+ if (!parent) return null;
446
+
447
+ if (isTextContainer(parent)) return null; // Parent handles it
448
+
449
+ const range = document.createRange();
450
+ range.selectNode(node);
451
+ const rect = range.getBoundingClientRect();
452
+ range.detach();
453
+
454
+ const style = window.getComputedStyle(parent);
455
+ const widthPx = rect.width;
456
+ const heightPx = rect.height;
457
+ const unrotatedW = widthPx * PX_TO_INCH * config.scale;
458
+ const unrotatedH = heightPx * PX_TO_INCH * config.scale;
459
+
460
+ const x = config.offX + (rect.left - config.rootX) * PX_TO_INCH * config.scale;
461
+ const y = config.offY + (rect.top - config.rootY) * PX_TO_INCH * config.scale;
462
+
463
+ return {
464
+ items: [
465
+ {
466
+ type: 'text',
467
+ zIndex: effectiveZIndex,
468
+ domOrder,
469
+ textParts: [
470
+ {
471
+ text: textContent,
472
+ options: (() => {
473
+ const opts = getTextStyle(style, config.scale, textContent, globalOptions);
474
+ const bg = parseColor(style.backgroundColor);
475
+ if (opts.highlight && bg.hex && bg.opacity > 0 && !isTextContainer(parent)) {
476
+ delete opts.highlight;
477
+ }
478
+ return opts;
479
+ })(),
480
+ },
481
+ ],
482
+ options: { x, y, w: unrotatedW, h: unrotatedH, margin: 0, autoFit: false },
483
+ },
484
+ ],
485
+ stopRecursion: false,
486
+ };
487
+ }
488
+
489
+ if (node.nodeType !== 1) return null;
490
+ const style = computedStyle; // Use pre-computed style
491
+
492
+ const rect = node.getBoundingClientRect();
493
+ if (rect.width < 0.5 || rect.height < 0.5) return null;
494
+
495
+ const zIndex = effectiveZIndex;
496
+ const rotation = getRotation(style.transform);
497
+ const elementOpacity = parseFloat(style.opacity);
498
+ const safeOpacity = isNaN(elementOpacity) ? 1 : elementOpacity;
499
+
500
+ const widthPx = node.offsetWidth || rect.width;
501
+ const heightPx = node.offsetHeight || rect.height;
502
+ const unrotatedW = widthPx * PX_TO_INCH * config.scale;
503
+ const unrotatedH = heightPx * PX_TO_INCH * config.scale;
504
+ const centerX = rect.left + rect.width / 2;
505
+ const centerY = rect.top + rect.height / 2;
506
+
507
+ let x = config.offX + (centerX - config.rootX) * PX_TO_INCH * config.scale - unrotatedW / 2;
508
+ let y = config.offY + (centerY - config.rootY) * PX_TO_INCH * config.scale - unrotatedH / 2;
509
+ let w = unrotatedW;
510
+ let h = unrotatedH;
511
+
512
+ const items = [];
513
+
514
+ if (node.tagName === 'TABLE') {
515
+ const tableData = extractTableData(node, config.scale, { ...globalOptions, root: config.root });
516
+ const cellBgItems = [];
517
+ const renderCellBg = globalOptions.tableConfig?.renderCellBackgrounds !== false;
518
+
519
+ if (renderCellBg) {
520
+ const trList = node.querySelectorAll('tr');
521
+ trList.forEach((tr) => {
522
+ const cellList = Array.from(tr.children).filter((c) => ['TD', 'TH'].includes(c.tagName));
523
+ cellList.forEach((cell) => {
524
+ const style = window.getComputedStyle(cell);
525
+ const fill = computeTableCellFill(style, cell, config.root, globalOptions);
526
+ if (!fill) return;
527
+
528
+ const rect = cell.getBoundingClientRect();
529
+ const wIn = rect.width * PX_TO_INCH * config.scale;
530
+ const hIn = rect.height * PX_TO_INCH * config.scale;
531
+ const xIn = config.offX + (rect.left - config.rootX) * PX_TO_INCH * config.scale;
532
+ const yIn = config.offY + (rect.top - config.rootY) * PX_TO_INCH * config.scale;
533
+
534
+ cellBgItems.push({
535
+ type: 'shape',
536
+ zIndex: effectiveZIndex - 0.5,
537
+ domOrder,
538
+ shapeType: 'rect',
539
+ options: { x: xIn, y: yIn, w: wIn, h: hIn, fill: fill, line: { color: 'FFFFFF', transparency: 100 } }
540
+ });
541
+ });
542
+ });
543
+ }
544
+
545
+ // Calculate total table width to ensure X position is correct
546
+ // (Though x calculation above usually handles it, tables can be finicky)
547
+ return {
548
+ items: [
549
+ ...cellBgItems,
550
+ {
551
+ type: 'table',
552
+ zIndex: effectiveZIndex,
553
+ domOrder,
554
+ tableData: tableData,
555
+ options: { x, y, w: unrotatedW, h: unrotatedH }
556
+ }
557
+ ],
558
+ stopRecursion: true // Important: Don't process TR/TD as separate shapes
559
+ };
560
+ }
561
+
562
+ if ((node.tagName === 'UL' || node.tagName === 'OL') && !isComplexHierarchy(node)) {
563
+ const listItems = [];
564
+ const liChildren = Array.from(node.children).filter((c) => c.tagName === 'LI');
565
+
566
+ liChildren.forEach((child, index) => {
567
+ const liStyle = window.getComputedStyle(child);
568
+ const liRect = child.getBoundingClientRect();
569
+ const parentRect = node.getBoundingClientRect(); // node is UL/OL
570
+
571
+ // 1. Determine Bullet Config
572
+ let bullet = { type: 'bullet' };
573
+ const listStyleType = liStyle.listStyleType || 'disc';
574
+
575
+ if (node.tagName === 'OL' || listStyleType === 'decimal') {
576
+ bullet = { type: 'number' };
577
+ } else if (listStyleType === 'none') {
578
+ bullet = false;
579
+ } else {
580
+ let code = '2022'; // disc
581
+ if (listStyleType === 'circle') code = '25CB';
582
+ if (listStyleType === 'square') code = '25A0';
583
+
584
+ // --- CHANGE: Color & Size Logic (Option > ::marker > CSS color) ---
585
+ let finalHex = '000000';
586
+ let markerFontSize = null;
587
+
588
+ // A. Check Global Option override
589
+ if (globalOptions?.listConfig?.color) {
590
+ finalHex = parseColor(globalOptions.listConfig.color).hex || '000000';
591
+ }
592
+ // B. Check ::marker pseudo element (supported in modern browsers)
593
+ else {
594
+ const markerStyle = window.getComputedStyle(child, '::marker');
595
+ const markerColor = parseColor(markerStyle.color);
596
+ if (markerColor.hex) {
597
+ finalHex = markerColor.hex;
598
+ } else {
599
+ // C. Fallback to LI text color
600
+ const colorObj = parseColor(liStyle.color);
601
+ if (colorObj.hex) finalHex = colorObj.hex;
602
+ }
603
+
604
+ // Check ::marker font-size
605
+ const markerFs = parseFloat(markerStyle.fontSize);
606
+ if (!isNaN(markerFs) && markerFs > 0) {
607
+ // Convert px->pt for PPTX
608
+ markerFontSize = markerFs * 0.75 * config.scale;
609
+ }
610
+ }
611
+
612
+ bullet = { code, color: finalHex };
613
+ if (markerFontSize) {
614
+ bullet.fontSize = markerFontSize;
615
+ }
616
+ }
617
+
618
+ // 2. Calculate Dynamic Indent (Respects padding-left)
619
+ // Visual Indent = Distance from UL left edge to LI Content left edge.
620
+ // PptxGenJS 'indent' = Space between bullet and text?
621
+ // Actually PptxGenJS 'indent' allows setting the hanging indent.
622
+ // We calculate the TOTAL visual offset from the parent container.
623
+ // 1 px = 0.75 pt (approx, standard DTP).
624
+ // We must scale it by config.scale.
625
+ const visualIndentPx = liRect.left - parentRect.left;
626
+ /*
627
+ Standard indent in PPT is ~27pt.
628
+ If visualIndentPx is small (e.g. 10px padding), we want small indent.
629
+ If visualIndentPx is large (40px padding), we want large indent.
630
+ We treat 'indent' as the value to pass to PptxGenJS.
631
+ */
632
+ const computedIndentPt = visualIndentPx * 0.75 * config.scale;
633
+
634
+ if (bullet && computedIndentPt > 0) {
635
+ bullet.indent = computedIndentPt;
636
+ // Also support custom margin between bullet and text if provided in listConfig?
637
+ // For now, computedIndentPt covers the visual placement.
638
+ }
639
+
640
+ // 3. Extract Text Parts
641
+ const parts = collectListParts(child, liStyle, config.scale, globalOptions);
642
+
643
+ if (parts.length > 0) {
644
+ parts.forEach((p) => {
645
+ if (!p.options) p.options = {};
646
+ });
647
+
648
+ // A. Apply Bullet
649
+ // Workaround: pptxgenjs bullets inherit the style of the text run they are attached to.
650
+ // To support ::marker styles (color, size) that differ from the text, we create
651
+ // a "dummy" text run at the start of the list item that carries the bullet configuration.
652
+ if (bullet) {
653
+ const firstPartInfo = parts[0].options;
654
+
655
+ // Create a dummy run. We use a Zero Width Space to ensure it's rendered but invisible.
656
+ // This "run" will hold the bullet and its specific color/size.
657
+ const bulletRun = {
658
+ text: '\u200B',
659
+ options: {
660
+ ...firstPartInfo, // Inherit base props (fontFace, etc.)
661
+ color: bullet.color || firstPartInfo.color,
662
+ fontSize: bullet.fontSize || firstPartInfo.fontSize,
663
+ bullet: bullet
664
+ }
665
+ };
666
+
667
+ // Don't duplicate transparent or empty color from firstPart if bullet has one
668
+ if (bullet.color) bulletRun.options.color = bullet.color;
669
+ if (bullet.fontSize) bulletRun.options.fontSize = bullet.fontSize;
670
+
671
+ // Prepend
672
+ parts.unshift(bulletRun);
673
+ }
674
+
675
+ // B. Apply Spacing
676
+ let ptBefore = 0;
677
+ let ptAfter = 0;
678
+
679
+ // A. Check Global Options (Expected in Points)
680
+ if (globalOptions.listConfig?.spacing) {
681
+ if (typeof globalOptions.listConfig.spacing.before === 'number') {
682
+ ptBefore = globalOptions.listConfig.spacing.before;
683
+ }
684
+ if (typeof globalOptions.listConfig.spacing.after === 'number') {
685
+ ptAfter = globalOptions.listConfig.spacing.after;
686
+ }
687
+ }
688
+ // B. Fallback to CSS Margins (Convert px -> pt)
689
+ else {
690
+ const mt = parseFloat(liStyle.marginTop) || 0;
691
+ const mb = parseFloat(liStyle.marginBottom) || 0;
692
+ if (mt > 0) ptBefore = mt * 0.75 * config.scale;
693
+ if (mb > 0) ptAfter = mb * 0.75 * config.scale;
694
+ }
695
+
696
+ if (ptBefore > 0) parts[0].options.paraSpaceBefore = ptBefore;
697
+ if (ptAfter > 0) parts[0].options.paraSpaceAfter = ptAfter;
698
+
699
+ if (index < liChildren.length - 1) {
700
+ parts[parts.length - 1].options.breakLine = true;
701
+ }
702
+
703
+ listItems.push(...parts);
704
+ }
705
+ });
706
+
707
+ if (listItems.length > 0) {
708
+ // Add background if exists
709
+ const bgColorObj = parseColor(style.backgroundColor);
710
+ if (bgColorObj.hex && bgColorObj.opacity > 0) {
711
+ items.push({
712
+ type: 'shape',
713
+ zIndex,
714
+ domOrder,
715
+ shapeType: 'rect',
716
+ options: { x, y, w, h, fill: { color: bgColorObj.hex } },
717
+ });
718
+ }
719
+
720
+ items.push({
721
+ type: 'text',
722
+ zIndex: zIndex + 1,
723
+ domOrder,
724
+ textParts: listItems,
725
+ options: {
726
+ x,
727
+ y,
728
+ w,
729
+ h,
730
+ align: 'left',
731
+ valign: 'top',
732
+ margin: 0,
733
+ autoFit: false,
734
+ wrap: true,
735
+ },
736
+ });
737
+
738
+ return { items, stopRecursion: true };
739
+ }
740
+ }
741
+
742
+ if (node.tagName === 'CANVAS') {
743
+ const item = {
744
+ type: 'image',
745
+ zIndex,
746
+ domOrder,
747
+ options: { x, y, w, h, rotate: rotation, data: null }
748
+ };
749
+
750
+ const job = async () => {
751
+ try {
752
+ // Direct data extraction from the canvas element
753
+ // This preserves the exact current state of the chart
754
+ const dataUrl = node.toDataURL('image/png');
755
+
756
+ // Basic validation
757
+ if (dataUrl && dataUrl.length > 10) {
758
+ item.options.data = dataUrl;
759
+ } else {
760
+ item.skip = true;
761
+ }
762
+ } catch (e) {
763
+ // Tainted canvas (CORS issues) will throw here
764
+ console.warn('Failed to capture canvas content:', e);
765
+ item.skip = true;
766
+ }
767
+ };
768
+
769
+ return { items: [item], job, stopRecursion: true };
770
+ }
771
+
772
+ // --- ASYNC JOB: SVG Tags ---
773
+ if (node.nodeName.toUpperCase() === 'SVG') {
774
+ const item = {
775
+ type: 'image',
776
+ zIndex,
777
+ domOrder,
778
+ options: { data: null, x, y, w, h, rotate: rotation },
779
+ };
780
+
781
+ const job = async () => {
782
+ const processed = await svgToPng(node);
783
+ if (processed) item.options.data = processed;
784
+ else item.skip = true;
785
+ };
786
+
787
+ return { items: [item], job, stopRecursion: true };
788
+ }
789
+
790
+ // --- ASYNC JOB: IMG Tags ---
791
+ if (node.tagName === 'IMG') {
792
+ let radii = {
793
+ tl: parseFloat(style.borderTopLeftRadius) || 0,
794
+ tr: parseFloat(style.borderTopRightRadius) || 0,
795
+ br: parseFloat(style.borderBottomRightRadius) || 0,
796
+ bl: parseFloat(style.borderBottomLeftRadius) || 0,
797
+ };
798
+
799
+ const hasAnyRadius = radii.tl > 0 || radii.tr > 0 || radii.br > 0 || radii.bl > 0;
800
+ if (!hasAnyRadius) {
801
+ const parent = node.parentElement;
802
+ const parentStyle = window.getComputedStyle(parent);
803
+ if (parentStyle.overflow !== 'visible') {
804
+ const pRadii = {
805
+ tl: parseFloat(parentStyle.borderTopLeftRadius) || 0,
806
+ tr: parseFloat(parentStyle.borderTopRightRadius) || 0,
807
+ br: parseFloat(parentStyle.borderBottomRightRadius) || 0,
808
+ bl: parseFloat(parentStyle.borderBottomLeftRadius) || 0,
809
+ };
810
+ const pRect = parent.getBoundingClientRect();
811
+ if (Math.abs(pRect.width - rect.width) < 5 && Math.abs(pRect.height - rect.height) < 5) {
812
+ radii = pRadii;
813
+ }
814
+ }
815
+ }
816
+
817
+ const objectFit = style.objectFit || 'fill'; // default CSS behavior is fill
818
+ const objectPosition = style.objectPosition || '50% 50%';
819
+
820
+ const item = {
821
+ type: 'image',
822
+ zIndex,
823
+ domOrder,
824
+ options: { x, y, w, h, rotate: rotation, data: null },
825
+ };
826
+
827
+ const job = async () => {
828
+ const processed = await getProcessedImage(
829
+ node.src,
830
+ widthPx,
831
+ heightPx,
832
+ radii,
833
+ objectFit,
834
+ objectPosition
835
+ );
836
+ if (processed) item.options.data = processed;
837
+ else item.skip = true;
838
+ };
839
+
840
+ return { items: [item], job, stopRecursion: true };
841
+ }
842
+
843
+ // --- ASYNC JOB: Icons and Other Elements ---
844
+ if (isIconElement(node)) {
845
+ const item = {
846
+ type: 'image',
847
+ zIndex,
848
+ domOrder,
849
+ options: { x, y, w, h, rotate: rotation, data: null },
850
+ };
851
+ const job = async () => {
852
+ const pngData = await elementToCanvasImage(node, widthPx, heightPx);
853
+ if (pngData) item.options.data = pngData;
854
+ else item.skip = true;
855
+ };
856
+ return { items: [item], job, stopRecursion: true };
857
+ }
858
+
859
+ // Radii logic
860
+ const borderRadiusValue = parseFloat(style.borderRadius) || 0;
861
+ const borderBottomLeftRadius = parseFloat(style.borderBottomLeftRadius) || 0;
862
+ const borderBottomRightRadius = parseFloat(style.borderBottomRightRadius) || 0;
863
+ const borderTopLeftRadius = parseFloat(style.borderTopLeftRadius) || 0;
864
+ const borderTopRightRadius = parseFloat(style.borderTopRightRadius) || 0;
865
+
866
+ const hasPartialBorderRadius =
867
+ (borderBottomLeftRadius > 0 && borderBottomLeftRadius !== borderRadiusValue) ||
868
+ (borderBottomRightRadius > 0 && borderBottomRightRadius !== borderRadiusValue) ||
869
+ (borderTopLeftRadius > 0 && borderTopLeftRadius !== borderRadiusValue) ||
870
+ (borderTopRightRadius > 0 && borderTopRightRadius !== borderRadiusValue) ||
871
+ (borderRadiusValue === 0 &&
872
+ (borderBottomLeftRadius ||
873
+ borderBottomRightRadius ||
874
+ borderTopLeftRadius ||
875
+ borderTopRightRadius));
876
+
877
+ // --- PRIORITY SVG: Solid Fill with Partial Border Radius (Vector Cone/Tab) ---
878
+ // Fix for "missing cone": Prioritize SVG vector generation over Raster Canvas for simple shapes with partial radii.
879
+ // This avoids html2canvas failures on empty divs.
880
+ const tempBg = parseColor(style.backgroundColor);
881
+ const isTxt = isTextContainer(node);
882
+
883
+ if (hasPartialBorderRadius && tempBg.hex && !isTxt) {
884
+ const shapeSvg = generateCustomShapeSVG(
885
+ widthPx,
886
+ heightPx,
887
+ tempBg.hex,
888
+ tempBg.opacity,
889
+ {
890
+ tl: parseFloat(style.borderTopLeftRadius) || 0,
891
+ tr: parseFloat(style.borderTopRightRadius) || 0,
892
+ br: parseFloat(style.borderBottomRightRadius) || 0,
893
+ bl: parseFloat(style.borderBottomLeftRadius) || 0,
894
+ }
895
+ );
896
+
897
+ return {
898
+ items: [{
899
+ type: 'image',
900
+ zIndex,
901
+ domOrder,
902
+ options: { data: shapeSvg, x, y, w, h, rotate: rotation },
903
+ }],
904
+ stopRecursion: true // Treat as leaf
905
+ };
906
+ }
907
+
908
+ // --- ASYNC JOB: Clipped Divs via Canvas ---
909
+ if (hasPartialBorderRadius && isClippedByParent(node)) {
910
+ const marginLeft = parseFloat(style.marginLeft) || 0;
911
+ const marginTop = parseFloat(style.marginTop) || 0;
912
+ x += marginLeft * PX_TO_INCH * config.scale;
913
+ y += marginTop * PX_TO_INCH * config.scale;
914
+
915
+ const item = {
916
+ type: 'image',
917
+ zIndex,
918
+ domOrder,
919
+ options: { x, y, w, h, rotate: rotation, data: null },
920
+ };
921
+
922
+ const job = async () => {
923
+ const canvasImageData = await elementToCanvasImage(node, widthPx, heightPx);
924
+ if (canvasImageData) item.options.data = canvasImageData;
925
+ else item.skip = true;
926
+ };
927
+
928
+ return { items: [item], job, stopRecursion: true };
929
+ }
930
+
931
+ // --- SYNC: Standard CSS Extraction ---
932
+ const bgColorObj = parseColor(style.backgroundColor);
933
+ const bgClip = style.webkitBackgroundClip || style.backgroundClip;
934
+ const isBgClipText = bgClip === 'text';
935
+ const hasGradient =
936
+ !isBgClipText && style.backgroundImage && style.backgroundImage.includes('linear-gradient');
937
+
938
+ const borderColorObj = parseColor(style.borderColor);
939
+ const borderWidth = parseFloat(style.borderWidth);
940
+ const hasBorder = borderWidth > 0 && borderColorObj.hex;
941
+
942
+ const borderInfo = getBorderInfo(style, config.scale);
943
+ const hasUniformBorder = borderInfo.type === 'uniform';
944
+ const hasCompositeBorder = borderInfo.type === 'composite';
945
+
946
+ const shadowStr = style.boxShadow;
947
+ const hasShadow = shadowStr && shadowStr !== 'none';
948
+ const softEdge = getSoftEdges(style.filter, config.scale);
949
+
950
+ let isImageWrapper = false;
951
+ const imgChild = Array.from(node.children).find((c) => c.tagName === 'IMG');
952
+ if (imgChild) {
953
+ const childW = imgChild.offsetWidth || imgChild.getBoundingClientRect().width;
954
+ const childH = imgChild.offsetHeight || imgChild.getBoundingClientRect().height;
955
+ if (childW >= widthPx - 2 && childH >= heightPx - 2) isImageWrapper = true;
956
+ }
957
+
958
+ let textPayload = null;
959
+ const isText = isTextContainer(node);
960
+
961
+ if (isText) {
962
+ const textParts = [];
963
+ let trimNextLeading = false;
964
+
965
+ node.childNodes.forEach((child, index) => {
966
+ // Handle <br> tags
967
+ if (child.tagName === 'BR') {
968
+ // 1. Trim trailing space from the *previous* text part to prevent double wrapping
969
+ if (textParts.length > 0) {
970
+ const lastPart = textParts[textParts.length - 1];
971
+ if (lastPart.text && typeof lastPart.text === 'string') {
972
+ lastPart.text = lastPart.text.trimEnd();
973
+ }
974
+ }
975
+
976
+ textParts.push({ text: '', options: { breakLine: true } });
977
+
978
+ // 2. Signal to trim leading space from the *next* text part
979
+ trimNextLeading = true;
980
+ return;
981
+ }
982
+
983
+ let textVal = child.nodeType === 3 ? child.nodeValue : child.textContent;
984
+ let nodeStyle = child.nodeType === 1 ? window.getComputedStyle(child) : style;
985
+ textVal = textVal.replace(/[\n\r\t]+/g, ' ').replace(/\s{2,}/g, ' ');
986
+
987
+ // Trimming logic
988
+ if (index === 0) textVal = textVal.trimStart();
989
+ if (trimNextLeading) {
990
+ textVal = textVal.trimStart();
991
+ trimNextLeading = false;
992
+ }
993
+
994
+ if (index === node.childNodes.length - 1) textVal = textVal.trimEnd();
995
+ if (nodeStyle.textTransform === 'uppercase') textVal = textVal.toUpperCase();
996
+ if (nodeStyle.textTransform === 'lowercase') textVal = textVal.toLowerCase();
997
+
998
+ if (textVal.length > 0) {
999
+ const textOpts = getTextStyle(nodeStyle, config.scale, textVal, globalOptions);
1000
+
1001
+ // BUG FIX: Numbers 1 and 2 having background.
1002
+ // If this is a naked Text Node (nodeType 3), it inherits style from the parent container.
1003
+ // The parent container's background is already rendered as the Shape Fill.
1004
+ // We must NOT render it again as a Text Highlight, otherwise it looks like a solid marker on top of the shape.
1005
+ if (child.nodeType === 3 && textOpts.highlight) {
1006
+ delete textOpts.highlight;
1007
+ }
1008
+
1009
+ textParts.push({ text: textVal, options: textOpts });
1010
+ }
1011
+ });
1012
+
1013
+ if (textParts.length > 0) {
1014
+ let align = style.textAlign || 'left';
1015
+ if (align === 'start') align = 'left';
1016
+ if (align === 'end') align = 'right';
1017
+ let valign = 'top';
1018
+ if (style.alignItems === 'center') valign = 'middle';
1019
+ if (style.justifyContent === 'center' && style.display.includes('flex')) align = 'center';
1020
+
1021
+ const pt = parseFloat(style.paddingTop) || 0;
1022
+ const pb = parseFloat(style.paddingBottom) || 0;
1023
+ if (Math.abs(pt - pb) < 2 && bgColorObj.hex) valign = 'middle';
1024
+
1025
+ let padding = getPadding(style, config.scale);
1026
+ if (align === 'center' && valign === 'middle') padding = [0, 0, 0, 0];
1027
+
1028
+ textPayload = { text: textParts, align, valign, inset: padding };
1029
+ }
1030
+ }
1031
+
1032
+ if (hasGradient || (softEdge && bgColorObj.hex && !isImageWrapper)) {
1033
+ let bgData = null;
1034
+ let padIn = 0;
1035
+ if (softEdge) {
1036
+ const svgInfo = generateBlurredSVG(
1037
+ widthPx,
1038
+ heightPx,
1039
+ bgColorObj.hex,
1040
+ borderRadiusValue,
1041
+ softEdge
1042
+ );
1043
+ bgData = svgInfo.data;
1044
+ padIn = svgInfo.padding * PX_TO_INCH * config.scale;
1045
+ } else {
1046
+ bgData = generateGradientSVG(
1047
+ widthPx,
1048
+ heightPx,
1049
+ style.backgroundImage,
1050
+ borderRadiusValue,
1051
+ hasBorder ? { color: borderColorObj.hex, width: borderWidth } : null
1052
+ );
1053
+ }
1054
+
1055
+ if (bgData) {
1056
+ items.push({
1057
+ type: 'image',
1058
+ zIndex,
1059
+ domOrder,
1060
+ options: {
1061
+ data: bgData,
1062
+ x: x - padIn,
1063
+ y: y - padIn,
1064
+ w: w + padIn * 2,
1065
+ h: h + padIn * 2,
1066
+ rotate: rotation,
1067
+ },
1068
+ });
1069
+ }
1070
+
1071
+ if (textPayload) {
1072
+ textPayload.text[0].options.fontSize =
1073
+ Math.floor(textPayload.text[0]?.options?.fontSize) || 12;
1074
+ items.push({
1075
+ type: 'text',
1076
+ zIndex: zIndex + 1,
1077
+ domOrder,
1078
+ textParts: textPayload.text,
1079
+ options: {
1080
+ x,
1081
+ y,
1082
+ w,
1083
+ h,
1084
+ align: textPayload.align,
1085
+ valign: textPayload.valign,
1086
+ inset: textPayload.inset,
1087
+ rotate: rotation,
1088
+ margin: 0,
1089
+ wrap: true,
1090
+ autoFit: false,
1091
+ },
1092
+ });
1093
+ }
1094
+ if (hasCompositeBorder) {
1095
+ const borderItems = createCompositeBorderItems(
1096
+ borderInfo.sides,
1097
+ x,
1098
+ y,
1099
+ w,
1100
+ h,
1101
+ config.scale,
1102
+ zIndex,
1103
+ domOrder
1104
+ );
1105
+ items.push(...borderItems);
1106
+ }
1107
+ } else if (
1108
+ (bgColorObj.hex && !isImageWrapper) ||
1109
+ hasUniformBorder ||
1110
+ hasCompositeBorder ||
1111
+ hasShadow ||
1112
+ textPayload
1113
+ ) {
1114
+ const finalAlpha = safeOpacity * bgColorObj.opacity;
1115
+ const transparency = (1 - finalAlpha) * 100;
1116
+ const useSolidFill = bgColorObj.hex && !isImageWrapper;
1117
+
1118
+ if (hasPartialBorderRadius && useSolidFill && !textPayload) {
1119
+ const shapeSvg = generateCustomShapeSVG(
1120
+ widthPx,
1121
+ heightPx,
1122
+ bgColorObj.hex,
1123
+ bgColorObj.opacity,
1124
+ {
1125
+ tl: parseFloat(style.borderTopLeftRadius) || 0,
1126
+ tr: parseFloat(style.borderTopRightRadius) || 0,
1127
+ br: parseFloat(style.borderBottomRightRadius) || 0,
1128
+ bl: parseFloat(style.borderBottomLeftRadius) || 0,
1129
+ }
1130
+ );
1131
+
1132
+ items.push({
1133
+ type: 'image',
1134
+ zIndex,
1135
+ domOrder,
1136
+ options: { data: shapeSvg, x, y, w, h, rotate: rotation },
1137
+ });
1138
+ } else {
1139
+ const shapeOpts = {
1140
+ x,
1141
+ y,
1142
+ w,
1143
+ h,
1144
+ rotate: rotation,
1145
+ fill: useSolidFill
1146
+ ? { color: bgColorObj.hex, transparency: transparency }
1147
+ : { type: 'none' },
1148
+ line: hasUniformBorder ? borderInfo.options : null,
1149
+ };
1150
+
1151
+ if (hasShadow) shapeOpts.shadow = getVisibleShadow(shadowStr, config.scale);
1152
+
1153
+ // 1. Calculate dimensions first
1154
+ const minDimension = Math.min(widthPx, heightPx);
1155
+
1156
+ let rawRadius = parseFloat(style.borderRadius) || 0;
1157
+ const isPercentage = style.borderRadius && style.borderRadius.toString().includes('%');
1158
+
1159
+ // 2. Normalize radius to pixels
1160
+ let radiusPx = rawRadius;
1161
+ if (isPercentage) {
1162
+ radiusPx = (rawRadius / 100) * minDimension;
1163
+ }
1164
+
1165
+ let shapeType = pptx.ShapeType.rect;
1166
+
1167
+ // 3. Determine Shape Logic
1168
+ const isSquare = Math.abs(widthPx - heightPx) < 1;
1169
+ const isFullyRound = radiusPx >= minDimension / 2;
1170
+
1171
+ // CASE A: It is an Ellipse if:
1172
+ // 1. It is explicitly "50%" (standard CSS way to make ovals/circles)
1173
+ // 2. OR it is a perfect square and fully rounded (a circle)
1174
+ if (isFullyRound && (isPercentage || isSquare)) {
1175
+ shapeType = pptx.ShapeType.ellipse;
1176
+ }
1177
+ // CASE B: It is a Rounded Rectangle (including "Pill" shapes)
1178
+ else if (radiusPx > 0) {
1179
+ shapeType = pptx.ShapeType.roundRect;
1180
+ let r = radiusPx / minDimension;
1181
+ if (r > 0.5) r = 0.5;
1182
+ if (minDimension < 100) r = r * 0.25; // Small size adjustment for small shapes
1183
+
1184
+ shapeOpts.rectRadius = r;
1185
+ }
1186
+
1187
+ if (textPayload) {
1188
+ textPayload.text[0].options.fontSize =
1189
+ Math.floor(textPayload.text[0]?.options?.fontSize) || 12;
1190
+ const textOptions = {
1191
+ shape: shapeType,
1192
+ ...shapeOpts,
1193
+ rotate: rotation,
1194
+ align: textPayload.align,
1195
+ valign: textPayload.valign,
1196
+ inset: textPayload.inset,
1197
+ margin: 0,
1198
+ wrap: true,
1199
+ autoFit: false,
1200
+ };
1201
+ items.push({
1202
+ type: 'text',
1203
+ zIndex,
1204
+ domOrder,
1205
+ textParts: textPayload.text,
1206
+ options: textOptions,
1207
+ });
1208
+ } else if (!hasPartialBorderRadius) {
1209
+ items.push({
1210
+ type: 'shape',
1211
+ zIndex,
1212
+ domOrder,
1213
+ shapeType,
1214
+ options: shapeOpts,
1215
+ });
1216
+ }
1217
+ }
1218
+
1219
+ if (hasCompositeBorder) {
1220
+ const borderSvgData = generateCompositeBorderSVG(
1221
+ widthPx,
1222
+ heightPx,
1223
+ borderRadiusValue,
1224
+ borderInfo.sides
1225
+ );
1226
+ if (borderSvgData) {
1227
+ items.push({
1228
+ type: 'image',
1229
+ zIndex: zIndex + 1,
1230
+ domOrder,
1231
+ options: { data: borderSvgData, x, y, w, h, rotate: rotation },
1232
+ });
1233
+ }
1234
+ }
1235
+ }
1236
+
1237
+ return { items, stopRecursion: !!textPayload };
1238
+ }
1239
+
1240
+ function isComplexHierarchy(root) {
1241
+ // Use a simple tree traversal to find forbidden elements in the list structure
1242
+ if (root?.getAttribute?.('data-pptx-list') === 'complex') return true;
1243
+
1244
+ const stack = [root];
1245
+ while (stack.length > 0) {
1246
+ const el = stack.pop();
1247
+
1248
+ // 1. Layouts: Flex/Grid on LIs
1249
+ if (el.tagName === 'LI') {
1250
+ const s = window.getComputedStyle(el);
1251
+ if (s.display === 'flex' || s.display === 'grid' || s.display === 'inline-flex') return true;
1252
+
1253
+ // Custom list items (e.g., list-style: none + structured children)
1254
+ const listStyleType = s.listStyleType || s.listStyle;
1255
+ if (listStyleType === 'none') {
1256
+ const hasStructuredChild = Array.from(el.children).some((child) => {
1257
+ const cs = window.getComputedStyle(child);
1258
+ const display = cs.display || '';
1259
+ if (!display.includes('inline')) return true;
1260
+
1261
+ const bg = parseColor(cs.backgroundColor);
1262
+ if (bg.hex && bg.opacity > 0) return true;
1263
+
1264
+ const bw = parseFloat(cs.borderWidth) || 0;
1265
+ const bc = parseColor(cs.borderColor);
1266
+ if (bw > 0 && bc.opacity > 0) return true;
1267
+
1268
+ return false;
1269
+ });
1270
+
1271
+ if (hasStructuredChild) return true;
1272
+ }
1273
+ }
1274
+
1275
+ // 2. Media / Icons
1276
+ if (['IMG', 'SVG', 'CANVAS', 'VIDEO', 'IFRAME'].includes(el.tagName)) return true;
1277
+ if (isIconElement(el)) return true;
1278
+
1279
+ // 3. Nested Lists (Flattening logic doesn't support nested bullets well yet)
1280
+ if (el !== root && (el.tagName === 'UL' || el.tagName === 'OL')) return true;
1281
+
1282
+ // Recurse, but don't go too deep if not needed
1283
+ for (let i = 0; i < el.children.length; i++) {
1284
+ stack.push(el.children[i]);
1285
+ }
1286
+ }
1287
+ return false;
1288
+ }
1289
+
1290
+ function collectListParts(node, parentStyle, scale, globalOptions) {
1291
+ const parts = [];
1292
+
1293
+ // Check for CSS Content (::before) - often used for icons
1294
+ if (node.nodeType === 1) {
1295
+ const beforeStyle = window.getComputedStyle(node, '::before');
1296
+ const content = beforeStyle.content;
1297
+ if (content && content !== 'none' && content !== 'normal' && content !== '""') {
1298
+ // Strip quotes
1299
+ const cleanContent = content.replace(/^['"]|['"]$/g, '');
1300
+ if (cleanContent.trim()) {
1301
+ parts.push({
1302
+ text: cleanContent + ' ', // Add space after icon
1303
+ options: getTextStyle(window.getComputedStyle(node), scale, cleanContent, globalOptions),
1304
+ });
1305
+ }
1306
+ }
1307
+ }
1308
+
1309
+ node.childNodes.forEach((child) => {
1310
+ if (child.nodeType === 3) {
1311
+ // Text
1312
+ const val = child.nodeValue.replace(/[\n\r\t]+/g, ' ').replace(/\s{2,}/g, ' ');
1313
+ if (val) {
1314
+ // Use parent style if child is text node, otherwise current style
1315
+ const styleToUse = node.nodeType === 1 ? window.getComputedStyle(node) : parentStyle;
1316
+ parts.push({
1317
+ text: val,
1318
+ options: getTextStyle(styleToUse, scale, val, globalOptions),
1319
+ });
1320
+ }
1321
+ } else if (child.nodeType === 1) {
1322
+ // Element (span, i, b)
1323
+ // Recurse
1324
+ parts.push(...collectListParts(child, parentStyle, scale, globalOptions));
1325
+ }
1326
+ });
1327
+
1328
+ return parts;
1329
+ }
1330
+
1331
+ function createCompositeBorderItems(sides, x, y, w, h, scale, zIndex, domOrder) {
1332
+ const items = [];
1333
+ const pxToInch = 1 / 96;
1334
+ const common = { zIndex: zIndex + 1, domOrder, shapeType: 'rect' };
1335
+
1336
+ if (sides.top.width > 0)
1337
+ items.push({
1338
+ ...common,
1339
+ options: { x, y, w, h: sides.top.width * pxToInch * scale, fill: { color: sides.top.color } },
1340
+ });
1341
+ if (sides.right.width > 0)
1342
+ items.push({
1343
+ ...common,
1344
+ options: {
1345
+ x: x + w - sides.right.width * pxToInch * scale,
1346
+ y,
1347
+ w: sides.right.width * pxToInch * scale,
1348
+ h,
1349
+ fill: { color: sides.right.color },
1350
+ },
1351
+ });
1352
+ if (sides.bottom.width > 0)
1353
+ items.push({
1354
+ ...common,
1355
+ options: {
1356
+ x,
1357
+ y: y + h - sides.bottom.width * pxToInch * scale,
1358
+ w,
1359
+ h: sides.bottom.width * pxToInch * scale,
1360
+ fill: { color: sides.bottom.color },
1361
+ },
1362
+ });
1363
+ if (sides.left.width > 0)
1364
+ items.push({
1365
+ ...common,
1366
+ options: {
1367
+ x,
1368
+ y,
1369
+ w: sides.left.width * pxToInch * scale,
1370
+ h,
1371
+ fill: { color: sides.left.color },
1372
+ },
1373
+ });
1374
+
1375
+ return items;
1376
+ }