dom-to-pptx 1.1.0 → 1.1.1

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 CHANGED
@@ -1,905 +1,971 @@
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
- } from './utils.js';
28
- import { getProcessedImage } from './image-processor.js';
29
-
30
- const PPI = 96;
31
- const PX_TO_INCH = 1 / PPI;
32
-
33
- /**
34
- * Main export function.
35
- * @param {HTMLElement | string | Array<HTMLElement | string>} target
36
- * @param {Object} options
37
- * @param {string} [options.fileName]
38
- * @param {Array<{name: string, url: string}>} [options.fonts] - Explicit fonts
39
- * @param {boolean} [options.autoEmbedFonts=true] - Attempt to auto-detect and embed used fonts
40
- */
41
- export async function exportToPptx(target, options = {}) {
42
- const resolvePptxConstructor = (pkg) => {
43
- if (!pkg) return null;
44
- if (typeof pkg === 'function') return pkg;
45
- if (pkg && typeof pkg.default === 'function') return pkg.default;
46
- if (pkg && typeof pkg.PptxGenJS === 'function') return pkg.PptxGenJS;
47
- if (pkg && pkg.PptxGenJS && typeof pkg.PptxGenJS.default === 'function')
48
- return pkg.PptxGenJS.default;
49
- return null;
50
- };
51
-
52
- const PptxConstructor = resolvePptxConstructor(PptxGenJS);
53
- if (!PptxConstructor) throw new Error('PptxGenJS constructor not found.');
54
- const pptx = new PptxConstructor();
55
- pptx.layout = 'LAYOUT_16x9';
56
-
57
- const elements = Array.isArray(target) ? target : [target];
58
-
59
- for (const el of elements) {
60
- const root = typeof el === 'string' ? document.querySelector(el) : el;
61
- if (!root) {
62
- console.warn('Element not found, skipping slide:', el);
63
- continue;
64
- }
65
- const slide = pptx.addSlide();
66
- await processSlide(root, slide, pptx);
67
- }
68
-
69
- // 3. Font Embedding Logic
70
- let finalBlob;
71
- let fontsToEmbed = options.fonts || [];
72
-
73
- if (options.autoEmbedFonts) {
74
- // A. Scan DOM for used font families
75
- const usedFamilies = getUsedFontFamilies(elements);
76
-
77
- // B. Scan CSS for URLs matches
78
- const detectedFonts = await getAutoDetectedFonts(usedFamilies);
79
-
80
- // C. Merge (Avoid duplicates)
81
- const explicitNames = new Set(fontsToEmbed.map(f => f.name));
82
- for (const autoFont of detectedFonts) {
83
- if (!explicitNames.has(autoFont.name)) {
84
- fontsToEmbed.push(autoFont);
85
- }
86
- }
87
-
88
- if (detectedFonts.length > 0) {
89
- console.log('Auto-detected fonts:', detectedFonts.map(f => f.name));
90
- }
91
- }
92
-
93
- if (fontsToEmbed.length > 0) {
94
- // Generate initial PPTX
95
- const initialBlob = await pptx.write({ outputType: 'blob' });
96
-
97
- // Load into Embedder
98
- const zip = await JSZip.loadAsync(initialBlob);
99
- const embedder = new PPTXEmbedFonts();
100
- await embedder.loadZip(zip);
101
-
102
- // Fetch and Embed
103
- for (const fontCfg of fontsToEmbed) {
104
- try {
105
- const response = await fetch(fontCfg.url);
106
- if (!response.ok) throw new Error(`Failed to fetch ${fontCfg.url}`);
107
- const buffer = await response.arrayBuffer();
108
-
109
- // Infer type
110
- const ext = fontCfg.url.split('.').pop().split(/[?#]/)[0].toLowerCase();
111
- let type = 'ttf';
112
- if (['woff', 'otf'].includes(ext)) type = ext;
113
-
114
- await embedder.addFont(fontCfg.name, buffer, type);
115
- } catch (e) {
116
- console.warn(`Failed to embed font: ${fontCfg.name} (${fontCfg.url})`, e);
117
- }
118
- }
119
-
120
- await embedder.updateFiles();
121
- finalBlob = await embedder.generateBlob();
122
- } else {
123
- // No fonts to embed
124
- finalBlob = await pptx.write({ outputType: 'blob' });
125
- }
126
-
127
- // 4. Download
128
- const fileName = options.fileName || 'export.pptx';
129
- const url = URL.createObjectURL(finalBlob);
130
- const a = document.createElement('a');
131
- a.href = url;
132
- a.download = fileName;
133
- document.body.appendChild(a);
134
- a.click();
135
- document.body.removeChild(a);
136
- URL.revokeObjectURL(url);
137
- }
138
-
139
- /**
140
- * Worker function to process a single DOM element into a single PPTX slide.
141
- * @param {HTMLElement} root - The root element for this slide.
142
- * @param {PptxGenJS.Slide} slide - The PPTX slide object to add content to.
143
- * @param {PptxGenJS} pptx - The main PPTX instance.
144
- */
145
- async function processSlide(root, slide, pptx) {
146
- const rootRect = root.getBoundingClientRect();
147
- const PPTX_WIDTH_IN = 10;
148
- const PPTX_HEIGHT_IN = 5.625;
149
-
150
- const contentWidthIn = rootRect.width * PX_TO_INCH;
151
- const contentHeightIn = rootRect.height * PX_TO_INCH;
152
- const scale = Math.min(PPTX_WIDTH_IN / contentWidthIn, PPTX_HEIGHT_IN / contentHeightIn);
153
-
154
- const layoutConfig = {
155
- rootX: rootRect.x,
156
- rootY: rootRect.y,
157
- scale: scale,
158
- offX: (PPTX_WIDTH_IN - contentWidthIn * scale) / 2,
159
- offY: (PPTX_HEIGHT_IN - contentHeightIn * scale) / 2,
160
- };
161
-
162
- const renderQueue = [];
163
- const asyncTasks = []; // Queue for heavy operations (Images, Canvas)
164
- let domOrderCounter = 0;
165
-
166
- // Sync Traversal Function
167
- function collect(node, parentZIndex) {
168
- const order = domOrderCounter++;
169
-
170
- let currentZ = parentZIndex;
171
- let nodeStyle = null;
172
- const nodeType = node.nodeType;
173
-
174
- if (nodeType === 1) {
175
- nodeStyle = window.getComputedStyle(node);
176
- // Optimization: Skip completely hidden elements immediately
177
- if (
178
- nodeStyle.display === 'none' ||
179
- nodeStyle.visibility === 'hidden' ||
180
- nodeStyle.opacity === '0'
181
- ) {
182
- return;
183
- }
184
- if (nodeStyle.zIndex !== 'auto') {
185
- currentZ = parseInt(nodeStyle.zIndex);
186
- }
187
- }
188
-
189
- // Prepare the item. If it needs async work, it returns a 'job'
190
- const result = prepareRenderItem(
191
- node,
192
- { ...layoutConfig, root },
193
- order,
194
- pptx,
195
- currentZ,
196
- nodeStyle
197
- );
198
-
199
- if (result) {
200
- if (result.items) {
201
- // Push items immediately to queue (data might be missing but filled later)
202
- renderQueue.push(...result.items);
203
- }
204
- if (result.job) {
205
- // Push the promise-returning function to the task list
206
- asyncTasks.push(result.job);
207
- }
208
- if (result.stopRecursion) return;
209
- }
210
-
211
- // Recurse children synchronously
212
- const childNodes = node.childNodes;
213
- for (let i = 0; i < childNodes.length; i++) {
214
- collect(childNodes[i], currentZ);
215
- }
216
- }
217
-
218
- // 1. Traverse and build the structure (Fast)
219
- collect(root, 0);
220
-
221
- // 2. Execute all heavy tasks in parallel (Fast)
222
- if (asyncTasks.length > 0) {
223
- await Promise.all(asyncTasks.map((task) => task()));
224
- }
225
-
226
- // 3. Cleanup and Sort
227
- // Remove items that failed to generate data (marked with skip)
228
- const finalQueue = renderQueue.filter(
229
- (item) => !item.skip && (item.type !== 'image' || item.options.data)
230
- );
231
-
232
- finalQueue.sort((a, b) => {
233
- if (a.zIndex !== b.zIndex) return a.zIndex - b.zIndex;
234
- return a.domOrder - b.domOrder;
235
- });
236
-
237
- // 4. Add to Slide
238
- for (const item of finalQueue) {
239
- if (item.type === 'shape') slide.addShape(item.shapeType, item.options);
240
- if (item.type === 'image') slide.addImage(item.options);
241
- if (item.type === 'text') slide.addText(item.textParts, item.options);
242
- }
243
- }
244
-
245
- /**
246
- * Optimized html2canvas wrapper
247
- * Now strictly captures the node itself, not the root.
248
- */
249
- /**
250
- * Optimized html2canvas wrapper
251
- * Includes fix for cropped icons by adjusting styles in the cloned document.
252
- */
253
- async function elementToCanvasImage(node, widthPx, heightPx) {
254
- return new Promise((resolve) => {
255
- // 1. Assign a temp ID to locate the node inside the cloned document
256
- const originalId = node.id;
257
- const tempId = 'pptx-capture-' + Math.random().toString(36).substr(2, 9);
258
- node.id = tempId;
259
-
260
- const width = Math.max(Math.ceil(widthPx), 1);
261
- const height = Math.max(Math.ceil(heightPx), 1);
262
- const style = window.getComputedStyle(node);
263
-
264
- html2canvas(node, {
265
- backgroundColor: null,
266
- logging: false,
267
- scale: 3, // Higher scale for sharper icons
268
- useCORS: true, // critical for external fonts/images
269
- onclone: (clonedDoc) => {
270
- const clonedNode = clonedDoc.getElementById(tempId);
271
- if (clonedNode) {
272
- // --- FIX: PREVENT ICON CLIPPING ---
273
- // 1. Force overflow visible so glyphs bleeding out aren't cut
274
- clonedNode.style.overflow = 'visible';
275
-
276
- // 2. Adjust alignment for Icons to prevent baseline clipping
277
- // (Applies to <i>, <span>, or standard icon classes)
278
- const tag = clonedNode.tagName;
279
- if (tag === 'I' || tag === 'SPAN' || clonedNode.className.includes('fa-')) {
280
- // Flex center helps align the glyph exactly in the middle of the box
281
- // preventing top/bottom cropping due to line-height mismatches.
282
- clonedNode.style.display = 'inline-flex';
283
- clonedNode.style.justifyContent = 'center';
284
- clonedNode.style.alignItems = 'center';
285
-
286
- // Remove margins that might offset the capture
287
- clonedNode.style.margin = '0';
288
-
289
- // Ensure the font fits
290
- clonedNode.style.lineHeight = '1';
291
- clonedNode.style.verticalAlign = 'middle';
292
- }
293
- }
294
- },
295
- })
296
- .then((canvas) => {
297
- // Restore the original ID
298
- if (originalId) node.id = originalId;
299
- else node.removeAttribute('id');
300
-
301
- const destCanvas = document.createElement('canvas');
302
- destCanvas.width = width;
303
- destCanvas.height = height;
304
- const ctx = destCanvas.getContext('2d');
305
-
306
- // Draw captured canvas.
307
- // We simply draw it to fill the box. Since we centered it in 'onclone',
308
- // the glyph should now be visible within the bounds.
309
- ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, 0, width, height);
310
-
311
- // --- Border Radius Clipping (Existing Logic) ---
312
- let tl = parseFloat(style.borderTopLeftRadius) || 0;
313
- let tr = parseFloat(style.borderTopRightRadius) || 0;
314
- let br = parseFloat(style.borderBottomRightRadius) || 0;
315
- let bl = parseFloat(style.borderBottomLeftRadius) || 0;
316
-
317
- const f = Math.min(
318
- width / (tl + tr) || Infinity,
319
- height / (tr + br) || Infinity,
320
- width / (br + bl) || Infinity,
321
- height / (bl + tl) || Infinity
322
- );
323
-
324
- if (f < 1) {
325
- tl *= f;
326
- tr *= f;
327
- br *= f;
328
- bl *= f;
329
- }
330
-
331
- if (tl + tr + br + bl > 0) {
332
- ctx.globalCompositeOperation = 'destination-in';
333
- ctx.beginPath();
334
- ctx.moveTo(tl, 0);
335
- ctx.lineTo(width - tr, 0);
336
- ctx.arcTo(width, 0, width, tr, tr);
337
- ctx.lineTo(width, height - br);
338
- ctx.arcTo(width, height, width - br, height, br);
339
- ctx.lineTo(bl, height);
340
- ctx.arcTo(0, height, 0, height - bl, bl);
341
- ctx.lineTo(0, tl);
342
- ctx.arcTo(0, 0, tl, 0, tl);
343
- ctx.closePath();
344
- ctx.fill();
345
- }
346
-
347
- resolve(destCanvas.toDataURL('image/png'));
348
- })
349
- .catch((e) => {
350
- if (originalId) node.id = originalId;
351
- else node.removeAttribute('id');
352
- console.warn('Canvas capture failed for node', node, e);
353
- resolve(null);
354
- });
355
- });
356
- }
357
-
358
- /**
359
- * Helper to identify elements that should be rendered as icons (Images).
360
- * Detects Custom Elements AND generic tags (<i>, <span>) with icon classes/pseudo-elements.
361
- */
362
- function isIconElement(node) {
363
- // 1. Custom Elements (hyphenated tags) or Explicit Library Tags
364
- const tag = node.tagName.toUpperCase();
365
- if (
366
- tag.includes('-') ||
367
- [
368
- 'MATERIAL-ICON',
369
- 'ICONIFY-ICON',
370
- 'REMIX-ICON',
371
- 'ION-ICON',
372
- 'EVA-ICON',
373
- 'BOX-ICON',
374
- 'FA-ICON',
375
- ].includes(tag)
376
- ) {
377
- return true;
378
- }
379
-
380
- // 2. Class-based Icons (FontAwesome, Bootstrap, Material symbols) on <i> or <span>
381
- if (tag === 'I' || tag === 'SPAN') {
382
- const cls = node.getAttribute('class') || '';
383
- if (
384
- typeof cls === 'string' &&
385
- (cls.includes('fa-') ||
386
- cls.includes('fas') ||
387
- cls.includes('far') ||
388
- cls.includes('fab') ||
389
- cls.includes('bi-') ||
390
- cls.includes('material-icons') ||
391
- cls.includes('icon'))
392
- ) {
393
- // Double-check: Must have pseudo-element content to be a CSS icon
394
- const before = window.getComputedStyle(node, '::before').content;
395
- const after = window.getComputedStyle(node, '::after').content;
396
- const hasContent = (c) => c && c !== 'none' && c !== 'normal' && c !== '""';
397
-
398
- if (hasContent(before) || hasContent(after)) return true;
399
- }
400
- }
401
-
402
- return false;
403
- }
404
-
405
- /**
406
- * Replaces createRenderItem.
407
- * Returns { items: [], job: () => Promise, stopRecursion: boolean }
408
- */
409
- function prepareRenderItem(node, config, domOrder, pptx, effectiveZIndex, computedStyle) {
410
- // 1. Text Node Handling
411
- if (node.nodeType === 3) {
412
- const textContent = node.nodeValue.trim();
413
- if (!textContent) return null;
414
-
415
- const parent = node.parentElement;
416
- if (!parent) return null;
417
-
418
- if (isTextContainer(parent)) return null; // Parent handles it
419
-
420
- const range = document.createRange();
421
- range.selectNode(node);
422
- const rect = range.getBoundingClientRect();
423
- range.detach();
424
-
425
- const style = window.getComputedStyle(parent);
426
- const widthPx = rect.width;
427
- const heightPx = rect.height;
428
- const unrotatedW = widthPx * PX_TO_INCH * config.scale;
429
- const unrotatedH = heightPx * PX_TO_INCH * config.scale;
430
-
431
- const x = config.offX + (rect.left - config.rootX) * PX_TO_INCH * config.scale;
432
- const y = config.offY + (rect.top - config.rootY) * PX_TO_INCH * config.scale;
433
-
434
- return {
435
- items: [
436
- {
437
- type: 'text',
438
- zIndex: effectiveZIndex,
439
- domOrder,
440
- textParts: [
441
- {
442
- text: textContent,
443
- options: getTextStyle(style, config.scale),
444
- },
445
- ],
446
- options: { x, y, w: unrotatedW, h: unrotatedH, margin: 0, autoFit: false },
447
- },
448
- ],
449
- stopRecursion: false,
450
- };
451
- }
452
-
453
- if (node.nodeType !== 1) return null;
454
- const style = computedStyle; // Use pre-computed style
455
-
456
- const rect = node.getBoundingClientRect();
457
- if (rect.width < 0.5 || rect.height < 0.5) return null;
458
-
459
- const zIndex = effectiveZIndex;
460
- const rotation = getRotation(style.transform);
461
- const elementOpacity = parseFloat(style.opacity);
462
- const safeOpacity = isNaN(elementOpacity) ? 1 : elementOpacity;
463
-
464
- const widthPx = node.offsetWidth || rect.width;
465
- const heightPx = node.offsetHeight || rect.height;
466
- const unrotatedW = widthPx * PX_TO_INCH * config.scale;
467
- const unrotatedH = heightPx * PX_TO_INCH * config.scale;
468
- const centerX = rect.left + rect.width / 2;
469
- const centerY = rect.top + rect.height / 2;
470
-
471
- let x = config.offX + (centerX - config.rootX) * PX_TO_INCH * config.scale - unrotatedW / 2;
472
- let y = config.offY + (centerY - config.rootY) * PX_TO_INCH * config.scale - unrotatedH / 2;
473
- let w = unrotatedW;
474
- let h = unrotatedH;
475
-
476
- const items = [];
477
-
478
- // --- ASYNC JOB: SVG Tags ---
479
- if (node.nodeName.toUpperCase() === 'SVG') {
480
- const item = {
481
- type: 'image',
482
- zIndex,
483
- domOrder,
484
- options: { data: null, x, y, w, h, rotate: rotation },
485
- };
486
-
487
- const job = async () => {
488
- const processed = await svgToPng(node);
489
- if (processed) item.options.data = processed;
490
- else item.skip = true;
491
- };
492
-
493
- return { items: [item], job, stopRecursion: true };
494
- }
495
-
496
- // --- ASYNC JOB: IMG Tags ---
497
- if (node.tagName === 'IMG') {
498
- let radii = {
499
- tl: parseFloat(style.borderTopLeftRadius) || 0,
500
- tr: parseFloat(style.borderTopRightRadius) || 0,
501
- br: parseFloat(style.borderBottomRightRadius) || 0,
502
- bl: parseFloat(style.borderBottomLeftRadius) || 0,
503
- };
504
-
505
- const hasAnyRadius = radii.tl > 0 || radii.tr > 0 || radii.br > 0 || radii.bl > 0;
506
- if (!hasAnyRadius) {
507
- const parent = node.parentElement;
508
- const parentStyle = window.getComputedStyle(parent);
509
- if (parentStyle.overflow !== 'visible') {
510
- const pRadii = {
511
- tl: parseFloat(parentStyle.borderTopLeftRadius) || 0,
512
- tr: parseFloat(parentStyle.borderTopRightRadius) || 0,
513
- br: parseFloat(parentStyle.borderBottomRightRadius) || 0,
514
- bl: parseFloat(parentStyle.borderBottomLeftRadius) || 0,
515
- };
516
- const pRect = parent.getBoundingClientRect();
517
- if (Math.abs(pRect.width - rect.width) < 5 && Math.abs(pRect.height - rect.height) < 5) {
518
- radii = pRadii;
519
- }
520
- }
521
- }
522
-
523
- const item = {
524
- type: 'image',
525
- zIndex,
526
- domOrder,
527
- options: { x, y, w, h, rotate: rotation, data: null },
528
- };
529
-
530
- const job = async () => {
531
- const processed = await getProcessedImage(node.src, widthPx, heightPx, radii);
532
- if (processed) item.options.data = processed;
533
- else item.skip = true;
534
- };
535
-
536
- return { items: [item], job, stopRecursion: true };
537
- }
538
-
539
- // --- ASYNC JOB: Icons and Other Elements ---
540
- if (isIconElement(node)) {
541
- const item = {
542
- type: 'image',
543
- zIndex,
544
- domOrder,
545
- options: { x, y, w, h, rotate: rotation, data: null },
546
- };
547
- const job = async () => {
548
- const pngData = await elementToCanvasImage(node, widthPx, heightPx);
549
- if (pngData) item.options.data = pngData;
550
- else item.skip = true;
551
- };
552
- return { items: [item], job, stopRecursion: true };
553
- }
554
-
555
- // Radii logic
556
- const borderRadiusValue = parseFloat(style.borderRadius) || 0;
557
- const borderBottomLeftRadius = parseFloat(style.borderBottomLeftRadius) || 0;
558
- const borderBottomRightRadius = parseFloat(style.borderBottomRightRadius) || 0;
559
- const borderTopLeftRadius = parseFloat(style.borderTopLeftRadius) || 0;
560
- const borderTopRightRadius = parseFloat(style.borderTopRightRadius) || 0;
561
-
562
- const hasPartialBorderRadius =
563
- (borderBottomLeftRadius > 0 && borderBottomLeftRadius !== borderRadiusValue) ||
564
- (borderBottomRightRadius > 0 && borderBottomRightRadius !== borderRadiusValue) ||
565
- (borderTopLeftRadius > 0 && borderTopLeftRadius !== borderRadiusValue) ||
566
- (borderTopRightRadius > 0 && borderTopRightRadius !== borderRadiusValue) ||
567
- (borderRadiusValue === 0 &&
568
- (borderBottomLeftRadius ||
569
- borderBottomRightRadius ||
570
- borderTopLeftRadius ||
571
- borderTopRightRadius));
572
-
573
- // --- ASYNC JOB: Clipped Divs via Canvas ---
574
- if (hasPartialBorderRadius && isClippedByParent(node)) {
575
- const marginLeft = parseFloat(style.marginLeft) || 0;
576
- const marginTop = parseFloat(style.marginTop) || 0;
577
- x += marginLeft * PX_TO_INCH * config.scale;
578
- y += marginTop * PX_TO_INCH * config.scale;
579
-
580
- const item = {
581
- type: 'image',
582
- zIndex,
583
- domOrder,
584
- options: { x, y, w, h, rotate: rotation, data: null },
585
- };
586
-
587
- const job = async () => {
588
- const canvasImageData = await elementToCanvasImage(node, widthPx, heightPx);
589
- if (canvasImageData) item.options.data = canvasImageData;
590
- else item.skip = true;
591
- };
592
-
593
- return { items: [item], job, stopRecursion: true };
594
- }
595
-
596
- // --- SYNC: Standard CSS Extraction ---
597
- const bgColorObj = parseColor(style.backgroundColor);
598
- const bgClip = style.webkitBackgroundClip || style.backgroundClip;
599
- const isBgClipText = bgClip === 'text';
600
- const hasGradient =
601
- !isBgClipText && style.backgroundImage && style.backgroundImage.includes('linear-gradient');
602
-
603
- const borderColorObj = parseColor(style.borderColor);
604
- const borderWidth = parseFloat(style.borderWidth);
605
- const hasBorder = borderWidth > 0 && borderColorObj.hex;
606
-
607
- const borderInfo = getBorderInfo(style, config.scale);
608
- const hasUniformBorder = borderInfo.type === 'uniform';
609
- const hasCompositeBorder = borderInfo.type === 'composite';
610
-
611
- const shadowStr = style.boxShadow;
612
- const hasShadow = shadowStr && shadowStr !== 'none';
613
- const softEdge = getSoftEdges(style.filter, config.scale);
614
-
615
- let isImageWrapper = false;
616
- const imgChild = Array.from(node.children).find((c) => c.tagName === 'IMG');
617
- if (imgChild) {
618
- const childW = imgChild.offsetWidth || imgChild.getBoundingClientRect().width;
619
- const childH = imgChild.offsetHeight || imgChild.getBoundingClientRect().height;
620
- if (childW >= widthPx - 2 && childH >= heightPx - 2) isImageWrapper = true;
621
- }
622
-
623
- let textPayload = null;
624
- const isText = isTextContainer(node);
625
-
626
- if (isText) {
627
- const textParts = [];
628
- const isList = style.display === 'list-item';
629
- if (isList) {
630
- const fontSizePt = parseFloat(style.fontSize) * 0.75 * config.scale;
631
- const bulletShift = (parseFloat(style.fontSize) || 16) * PX_TO_INCH * config.scale * 1.5;
632
- x -= bulletShift;
633
- w += bulletShift;
634
- textParts.push({
635
- text: ' ',
636
- options: {
637
- color: parseColor(style.color).hex || '000000',
638
- fontSize: fontSizePt,
639
- },
640
- });
641
- }
642
-
643
- node.childNodes.forEach((child, index) => {
644
- let textVal = child.nodeType === 3 ? child.nodeValue : child.textContent;
645
- let nodeStyle = child.nodeType === 1 ? window.getComputedStyle(child) : style;
646
- textVal = textVal.replace(/[\n\r\t]+/g, ' ').replace(/\s{2,}/g, ' ');
647
- if (index === 0 && !isList) textVal = textVal.trimStart();
648
- else if (index === 0) textVal = textVal.trimStart();
649
- if (index === node.childNodes.length - 1) textVal = textVal.trimEnd();
650
- if (nodeStyle.textTransform === 'uppercase') textVal = textVal.toUpperCase();
651
- if (nodeStyle.textTransform === 'lowercase') textVal = textVal.toLowerCase();
652
-
653
- if (textVal.length > 0) {
654
- textParts.push({
655
- text: textVal,
656
- options: getTextStyle(nodeStyle, config.scale),
657
- });
658
- }
659
- });
660
-
661
- if (textParts.length > 0) {
662
- let align = style.textAlign || 'left';
663
- if (align === 'start') align = 'left';
664
- if (align === 'end') align = 'right';
665
- let valign = 'top';
666
- if (style.alignItems === 'center') valign = 'middle';
667
- if (style.justifyContent === 'center' && style.display.includes('flex')) align = 'center';
668
-
669
- const pt = parseFloat(style.paddingTop) || 0;
670
- const pb = parseFloat(style.paddingBottom) || 0;
671
- if (Math.abs(pt - pb) < 2 && bgColorObj.hex) valign = 'middle';
672
-
673
- let padding = getPadding(style, config.scale);
674
- if (align === 'center' && valign === 'middle') padding = [0, 0, 0, 0];
675
-
676
- textPayload = { text: textParts, align, valign, inset: padding };
677
- }
678
- }
679
-
680
- if (hasGradient || (softEdge && bgColorObj.hex && !isImageWrapper)) {
681
- let bgData = null;
682
- let padIn = 0;
683
- if (softEdge) {
684
- const svgInfo = generateBlurredSVG(
685
- widthPx,
686
- heightPx,
687
- bgColorObj.hex,
688
- borderRadiusValue,
689
- softEdge
690
- );
691
- bgData = svgInfo.data;
692
- padIn = svgInfo.padding * PX_TO_INCH * config.scale;
693
- } else {
694
- bgData = generateGradientSVG(
695
- widthPx,
696
- heightPx,
697
- style.backgroundImage,
698
- borderRadiusValue,
699
- hasBorder ? { color: borderColorObj.hex, width: borderWidth } : null
700
- );
701
- }
702
-
703
- if (bgData) {
704
- items.push({
705
- type: 'image',
706
- zIndex,
707
- domOrder,
708
- options: {
709
- data: bgData,
710
- x: x - padIn,
711
- y: y - padIn,
712
- w: w + padIn * 2,
713
- h: h + padIn * 2,
714
- rotate: rotation,
715
- },
716
- });
717
- }
718
-
719
- if (textPayload) {
720
- items.push({
721
- type: 'text',
722
- zIndex: zIndex + 1,
723
- domOrder,
724
- textParts: textPayload.text,
725
- options: {
726
- x,
727
- y,
728
- w,
729
- h,
730
- align: textPayload.align,
731
- valign: textPayload.valign,
732
- inset: textPayload.inset,
733
- rotate: rotation,
734
- margin: 0,
735
- wrap: true,
736
- autoFit: false,
737
- },
738
- });
739
- }
740
- if (hasCompositeBorder) {
741
- const borderItems = createCompositeBorderItems(
742
- borderInfo.sides,
743
- x,
744
- y,
745
- w,
746
- h,
747
- config.scale,
748
- zIndex,
749
- domOrder
750
- );
751
- items.push(...borderItems);
752
- }
753
- } else if (
754
- (bgColorObj.hex && !isImageWrapper) ||
755
- hasUniformBorder ||
756
- hasCompositeBorder ||
757
- hasShadow ||
758
- textPayload
759
- ) {
760
- const finalAlpha = safeOpacity * bgColorObj.opacity;
761
- const transparency = (1 - finalAlpha) * 100;
762
- const useSolidFill = bgColorObj.hex && !isImageWrapper;
763
-
764
- if (hasPartialBorderRadius && useSolidFill && !textPayload) {
765
- const shapeSvg = generateCustomShapeSVG(
766
- widthPx,
767
- heightPx,
768
- bgColorObj.hex,
769
- bgColorObj.opacity,
770
- {
771
- tl: parseFloat(style.borderTopLeftRadius) || 0,
772
- tr: parseFloat(style.borderTopRightRadius) || 0,
773
- br: parseFloat(style.borderBottomRightRadius) || 0,
774
- bl: parseFloat(style.borderBottomLeftRadius) || 0,
775
- }
776
- );
777
-
778
- items.push({
779
- type: 'image',
780
- zIndex,
781
- domOrder,
782
- options: { data: shapeSvg, x, y, w, h, rotate: rotation },
783
- });
784
- } else {
785
- const shapeOpts = {
786
- x,
787
- y,
788
- w,
789
- h,
790
- rotate: rotation,
791
- fill: useSolidFill
792
- ? { color: bgColorObj.hex, transparency: transparency }
793
- : { type: 'none' },
794
- line: hasUniformBorder ? borderInfo.options : null,
795
- };
796
-
797
- if (hasShadow) shapeOpts.shadow = getVisibleShadow(shadowStr, config.scale);
798
-
799
- const borderRadius = parseFloat(style.borderRadius) || 0;
800
- const aspectRatio = Math.max(widthPx, heightPx) / Math.min(widthPx, heightPx);
801
- const isCircle = aspectRatio < 1.1 && borderRadius >= Math.min(widthPx, heightPx) / 2 - 1;
802
-
803
- let shapeType = pptx.ShapeType.rect;
804
- if (isCircle) shapeType = pptx.ShapeType.ellipse;
805
- else if (borderRadius > 0) {
806
- shapeType = pptx.ShapeType.roundRect;
807
- shapeOpts.rectRadius = Math.min(0.5, borderRadius / Math.min(widthPx, heightPx));
808
- }
809
-
810
- if (textPayload) {
811
- const textOptions = {
812
- shape: shapeType,
813
- ...shapeOpts,
814
- align: textPayload.align,
815
- valign: textPayload.valign,
816
- inset: textPayload.inset,
817
- margin: 0,
818
- wrap: true,
819
- autoFit: false,
820
- };
821
- items.push({
822
- type: 'text',
823
- zIndex,
824
- domOrder,
825
- textParts: textPayload.text,
826
- options: textOptions,
827
- });
828
- } else if (!hasPartialBorderRadius) {
829
- items.push({
830
- type: 'shape',
831
- zIndex,
832
- domOrder,
833
- shapeType,
834
- options: shapeOpts,
835
- });
836
- }
837
- }
838
-
839
- if (hasCompositeBorder) {
840
- const borderSvgData = generateCompositeBorderSVG(
841
- widthPx,
842
- heightPx,
843
- borderRadiusValue,
844
- borderInfo.sides
845
- );
846
- if (borderSvgData) {
847
- items.push({
848
- type: 'image',
849
- zIndex: zIndex + 1,
850
- domOrder,
851
- options: { data: borderSvgData, x, y, w, h, rotate: rotation },
852
- });
853
- }
854
- }
855
- }
856
-
857
- return { items, stopRecursion: !!textPayload };
858
- }
859
-
860
- function createCompositeBorderItems(sides, x, y, w, h, scale, zIndex, domOrder) {
861
- const items = [];
862
- const pxToInch = 1 / 96;
863
- const common = { zIndex: zIndex + 1, domOrder, shapeType: 'rect' };
864
-
865
- if (sides.top.width > 0)
866
- items.push({
867
- ...common,
868
- options: { x, y, w, h: sides.top.width * pxToInch * scale, fill: { color: sides.top.color } },
869
- });
870
- if (sides.right.width > 0)
871
- items.push({
872
- ...common,
873
- options: {
874
- x: x + w - sides.right.width * pxToInch * scale,
875
- y,
876
- w: sides.right.width * pxToInch * scale,
877
- h,
878
- fill: { color: sides.right.color },
879
- },
880
- });
881
- if (sides.bottom.width > 0)
882
- items.push({
883
- ...common,
884
- options: {
885
- x,
886
- y: y + h - sides.bottom.width * pxToInch * scale,
887
- w,
888
- h: sides.bottom.width * pxToInch * scale,
889
- fill: { color: sides.bottom.color },
890
- },
891
- });
892
- if (sides.left.width > 0)
893
- items.push({
894
- ...common,
895
- options: {
896
- x,
897
- y,
898
- w: sides.left.width * pxToInch * scale,
899
- h,
900
- fill: { color: sides.left.color },
901
- },
902
- });
903
-
904
- return items;
905
- }
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
+ } from './utils.js';
28
+ import { getProcessedImage } from './image-processor.js';
29
+
30
+ const PPI = 96;
31
+ const PX_TO_INCH = 1 / PPI;
32
+
33
+ /**
34
+ * Main export function.
35
+ * @param {HTMLElement | string | Array<HTMLElement | string>} target
36
+ * @param {Object} options
37
+ * @param {string} [options.fileName]
38
+ * @param {Array<{name: string, url: string}>} [options.fonts] - Explicit fonts
39
+ * @param {boolean} [options.autoEmbedFonts=true] - Attempt to auto-detect and embed used fonts
40
+ */
41
+ export async function exportToPptx(target, options = {}) {
42
+ const resolvePptxConstructor = (pkg) => {
43
+ if (!pkg) return null;
44
+ if (typeof pkg === 'function') return pkg;
45
+ if (pkg && typeof pkg.default === 'function') return pkg.default;
46
+ if (pkg && typeof pkg.PptxGenJS === 'function') return pkg.PptxGenJS;
47
+ if (pkg && pkg.PptxGenJS && typeof pkg.PptxGenJS.default === 'function')
48
+ return pkg.PptxGenJS.default;
49
+ return null;
50
+ };
51
+
52
+ const PptxConstructor = resolvePptxConstructor(PptxGenJS);
53
+ if (!PptxConstructor) throw new Error('PptxGenJS constructor not found.');
54
+ const pptx = new PptxConstructor();
55
+ pptx.layout = 'LAYOUT_16x9';
56
+
57
+ const elements = Array.isArray(target) ? target : [target];
58
+
59
+ for (const el of elements) {
60
+ const root = typeof el === 'string' ? document.querySelector(el) : el;
61
+ if (!root) {
62
+ console.warn('Element not found, skipping slide:', el);
63
+ continue;
64
+ }
65
+ const slide = pptx.addSlide();
66
+ await processSlide(root, slide, pptx);
67
+ }
68
+
69
+ // 3. Font Embedding Logic
70
+ let finalBlob;
71
+ let fontsToEmbed = options.fonts || [];
72
+
73
+ if (options.autoEmbedFonts) {
74
+ // A. Scan DOM for used font families
75
+ const usedFamilies = getUsedFontFamilies(elements);
76
+
77
+ // B. Scan CSS for URLs matches
78
+ const detectedFonts = await getAutoDetectedFonts(usedFamilies);
79
+
80
+ // C. Merge (Avoid duplicates)
81
+ const explicitNames = new Set(fontsToEmbed.map((f) => f.name));
82
+ for (const autoFont of detectedFonts) {
83
+ if (!explicitNames.has(autoFont.name)) {
84
+ fontsToEmbed.push(autoFont);
85
+ }
86
+ }
87
+
88
+ if (detectedFonts.length > 0) {
89
+ console.log(
90
+ 'Auto-detected fonts:',
91
+ detectedFonts.map((f) => f.name)
92
+ );
93
+ }
94
+ }
95
+
96
+ if (fontsToEmbed.length > 0) {
97
+ // Generate initial PPTX
98
+ const initialBlob = await pptx.write({ outputType: 'blob' });
99
+
100
+ // Load into Embedder
101
+ const zip = await JSZip.loadAsync(initialBlob);
102
+ const embedder = new PPTXEmbedFonts();
103
+ await embedder.loadZip(zip);
104
+
105
+ // Fetch and Embed
106
+ for (const fontCfg of fontsToEmbed) {
107
+ try {
108
+ const response = await fetch(fontCfg.url);
109
+ if (!response.ok) throw new Error(`Failed to fetch ${fontCfg.url}`);
110
+ const buffer = await response.arrayBuffer();
111
+
112
+ // Infer type
113
+ const ext = fontCfg.url.split('.').pop().split(/[?#]/)[0].toLowerCase();
114
+ let type = 'ttf';
115
+ if (['woff', 'otf'].includes(ext)) type = ext;
116
+
117
+ await embedder.addFont(fontCfg.name, buffer, type);
118
+ } catch (e) {
119
+ console.warn(`Failed to embed font: ${fontCfg.name} (${fontCfg.url})`, e);
120
+ }
121
+ }
122
+
123
+ await embedder.updateFiles();
124
+ finalBlob = await embedder.generateBlob();
125
+ } else {
126
+ // No fonts to embed
127
+ finalBlob = await pptx.write({ outputType: 'blob' });
128
+ }
129
+
130
+ // 4. Download
131
+ const fileName = options.fileName || 'export.pptx';
132
+ const url = URL.createObjectURL(finalBlob);
133
+ const a = document.createElement('a');
134
+ a.href = url;
135
+ a.download = fileName;
136
+ document.body.appendChild(a);
137
+ a.click();
138
+ document.body.removeChild(a);
139
+ URL.revokeObjectURL(url);
140
+ }
141
+
142
+ /**
143
+ * Worker function to process a single DOM element into a single PPTX slide.
144
+ * @param {HTMLElement} root - The root element for this slide.
145
+ * @param {PptxGenJS.Slide} slide - The PPTX slide object to add content to.
146
+ * @param {PptxGenJS} pptx - The main PPTX instance.
147
+ */
148
+ async function processSlide(root, slide, pptx) {
149
+ const rootRect = root.getBoundingClientRect();
150
+ const PPTX_WIDTH_IN = 10;
151
+ const PPTX_HEIGHT_IN = 5.625;
152
+
153
+ const contentWidthIn = rootRect.width * PX_TO_INCH;
154
+ const contentHeightIn = rootRect.height * PX_TO_INCH;
155
+ const scale = Math.min(PPTX_WIDTH_IN / contentWidthIn, PPTX_HEIGHT_IN / contentHeightIn);
156
+
157
+ const layoutConfig = {
158
+ rootX: rootRect.x,
159
+ rootY: rootRect.y,
160
+ scale: scale,
161
+ offX: (PPTX_WIDTH_IN - contentWidthIn * scale) / 2,
162
+ offY: (PPTX_HEIGHT_IN - contentHeightIn * scale) / 2,
163
+ };
164
+
165
+ const renderQueue = [];
166
+ const asyncTasks = []; // Queue for heavy operations (Images, Canvas)
167
+ let domOrderCounter = 0;
168
+
169
+ // Sync Traversal Function
170
+ function collect(node, parentZIndex) {
171
+ const order = domOrderCounter++;
172
+
173
+ let currentZ = parentZIndex;
174
+ let nodeStyle = null;
175
+ const nodeType = node.nodeType;
176
+
177
+ if (nodeType === 1) {
178
+ nodeStyle = window.getComputedStyle(node);
179
+ // Optimization: Skip completely hidden elements immediately
180
+ if (
181
+ nodeStyle.display === 'none' ||
182
+ nodeStyle.visibility === 'hidden' ||
183
+ nodeStyle.opacity === '0'
184
+ ) {
185
+ return;
186
+ }
187
+ if (nodeStyle.zIndex !== 'auto') {
188
+ currentZ = parseInt(nodeStyle.zIndex);
189
+ }
190
+ }
191
+
192
+ // Prepare the item. If it needs async work, it returns a 'job'
193
+ const result = prepareRenderItem(
194
+ node,
195
+ { ...layoutConfig, root },
196
+ order,
197
+ pptx,
198
+ currentZ,
199
+ nodeStyle
200
+ );
201
+
202
+ if (result) {
203
+ if (result.items) {
204
+ // Push items immediately to queue (data might be missing but filled later)
205
+ renderQueue.push(...result.items);
206
+ }
207
+ if (result.job) {
208
+ // Push the promise-returning function to the task list
209
+ asyncTasks.push(result.job);
210
+ }
211
+ if (result.stopRecursion) return;
212
+ }
213
+
214
+ // Recurse children synchronously
215
+ const childNodes = node.childNodes;
216
+ for (let i = 0; i < childNodes.length; i++) {
217
+ collect(childNodes[i], currentZ);
218
+ }
219
+ }
220
+
221
+ // 1. Traverse and build the structure (Fast)
222
+ collect(root, 0);
223
+
224
+ // 2. Execute all heavy tasks in parallel (Fast)
225
+ if (asyncTasks.length > 0) {
226
+ await Promise.all(asyncTasks.map((task) => task()));
227
+ }
228
+
229
+ // 3. Cleanup and Sort
230
+ // Remove items that failed to generate data (marked with skip)
231
+ const finalQueue = renderQueue.filter(
232
+ (item) => !item.skip && (item.type !== 'image' || item.options.data)
233
+ );
234
+
235
+ finalQueue.sort((a, b) => {
236
+ if (a.zIndex !== b.zIndex) return a.zIndex - b.zIndex;
237
+ return a.domOrder - b.domOrder;
238
+ });
239
+
240
+ // 4. Add to Slide
241
+ for (const item of finalQueue) {
242
+ if (item.type === 'shape') slide.addShape(item.shapeType, item.options);
243
+ if (item.type === 'image') slide.addImage(item.options);
244
+ if (item.type === 'text') slide.addText(item.textParts, item.options);
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Optimized html2canvas wrapper
250
+ * Includes fix for cropped icons by adjusting styles in the cloned document.
251
+ */
252
+ async function elementToCanvasImage(node, widthPx, heightPx) {
253
+ return new Promise((resolve) => {
254
+ // 1. Assign a temp ID to locate the node inside the cloned document
255
+ const originalId = node.id;
256
+ const tempId = 'pptx-capture-' + Math.random().toString(36).substr(2, 9);
257
+ node.id = tempId;
258
+
259
+ const width = Math.max(Math.ceil(widthPx), 1);
260
+ const height = Math.max(Math.ceil(heightPx), 1);
261
+ const style = window.getComputedStyle(node);
262
+
263
+ html2canvas(node, {
264
+ backgroundColor: null,
265
+ logging: false,
266
+ scale: 3, // Higher scale for sharper icons
267
+ useCORS: true, // critical for external fonts/images
268
+ onclone: (clonedDoc) => {
269
+ const clonedNode = clonedDoc.getElementById(tempId);
270
+ if (clonedNode) {
271
+ // --- FIX: PREVENT ICON CLIPPING ---
272
+ // 1. Force overflow visible so glyphs bleeding out aren't cut
273
+ clonedNode.style.overflow = 'visible';
274
+
275
+ // 2. Adjust alignment for Icons to prevent baseline clipping
276
+ // (Applies to <i>, <span>, or standard icon classes)
277
+ const tag = clonedNode.tagName;
278
+ if (tag === 'I' || tag === 'SPAN' || clonedNode.className.includes('fa-')) {
279
+ // Flex center helps align the glyph exactly in the middle of the box
280
+ // preventing top/bottom cropping due to line-height mismatches.
281
+ clonedNode.style.display = 'inline-flex';
282
+ clonedNode.style.justifyContent = 'center';
283
+ clonedNode.style.alignItems = 'center';
284
+
285
+ // Remove margins that might offset the capture
286
+ clonedNode.style.margin = '0';
287
+
288
+ // Ensure the font fits
289
+ clonedNode.style.lineHeight = '1';
290
+ clonedNode.style.verticalAlign = 'middle';
291
+ }
292
+ }
293
+ },
294
+ })
295
+ .then((canvas) => {
296
+ // Restore the original ID
297
+ if (originalId) node.id = originalId;
298
+ else node.removeAttribute('id');
299
+
300
+ const destCanvas = document.createElement('canvas');
301
+ destCanvas.width = width;
302
+ destCanvas.height = height;
303
+ const ctx = destCanvas.getContext('2d');
304
+
305
+ // Draw captured canvas.
306
+ // We simply draw it to fill the box. Since we centered it in 'onclone',
307
+ // the glyph should now be visible within the bounds.
308
+ ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, 0, width, height);
309
+
310
+ // --- Border Radius Clipping (Existing Logic) ---
311
+ let tl = parseFloat(style.borderTopLeftRadius) || 0;
312
+ let tr = parseFloat(style.borderTopRightRadius) || 0;
313
+ let br = parseFloat(style.borderBottomRightRadius) || 0;
314
+ let bl = parseFloat(style.borderBottomLeftRadius) || 0;
315
+
316
+ const f = Math.min(
317
+ width / (tl + tr) || Infinity,
318
+ height / (tr + br) || Infinity,
319
+ width / (br + bl) || Infinity,
320
+ height / (bl + tl) || Infinity
321
+ );
322
+
323
+ if (f < 1) {
324
+ tl *= f;
325
+ tr *= f;
326
+ br *= f;
327
+ bl *= f;
328
+ }
329
+
330
+ if (tl + tr + br + bl > 0) {
331
+ ctx.globalCompositeOperation = 'destination-in';
332
+ ctx.beginPath();
333
+ ctx.moveTo(tl, 0);
334
+ ctx.lineTo(width - tr, 0);
335
+ ctx.arcTo(width, 0, width, tr, tr);
336
+ ctx.lineTo(width, height - br);
337
+ ctx.arcTo(width, height, width - br, height, br);
338
+ ctx.lineTo(bl, height);
339
+ ctx.arcTo(0, height, 0, height - bl, bl);
340
+ ctx.lineTo(0, tl);
341
+ ctx.arcTo(0, 0, tl, 0, tl);
342
+ ctx.closePath();
343
+ ctx.fill();
344
+ }
345
+
346
+ resolve(destCanvas.toDataURL('image/png'));
347
+ })
348
+ .catch((e) => {
349
+ if (originalId) node.id = originalId;
350
+ else node.removeAttribute('id');
351
+ console.warn('Canvas capture failed for node', node, e);
352
+ resolve(null);
353
+ });
354
+ });
355
+ }
356
+
357
+ /**
358
+ * Helper to identify elements that should be rendered as icons (Images).
359
+ * Detects Custom Elements AND generic tags (<i>, <span>) with icon classes/pseudo-elements.
360
+ */
361
+ function isIconElement(node) {
362
+ // 1. Custom Elements (hyphenated tags) or Explicit Library Tags
363
+ const tag = node.tagName.toUpperCase();
364
+ if (
365
+ tag.includes('-') ||
366
+ [
367
+ 'MATERIAL-ICON',
368
+ 'ICONIFY-ICON',
369
+ 'REMIX-ICON',
370
+ 'ION-ICON',
371
+ 'EVA-ICON',
372
+ 'BOX-ICON',
373
+ 'FA-ICON',
374
+ ].includes(tag)
375
+ ) {
376
+ return true;
377
+ }
378
+
379
+ // 2. Class-based Icons (FontAwesome, Bootstrap, Material symbols) on <i> or <span>
380
+ if (tag === 'I' || tag === 'SPAN') {
381
+ const cls = node.getAttribute('class') || '';
382
+ if (
383
+ typeof cls === 'string' &&
384
+ (cls.includes('fa-') ||
385
+ cls.includes('fas') ||
386
+ cls.includes('far') ||
387
+ cls.includes('fab') ||
388
+ cls.includes('bi-') ||
389
+ cls.includes('material-icons') ||
390
+ cls.includes('icon'))
391
+ ) {
392
+ // Double-check: Must have pseudo-element content to be a CSS icon
393
+ const before = window.getComputedStyle(node, '::before').content;
394
+ const after = window.getComputedStyle(node, '::after').content;
395
+ const hasContent = (c) => c && c !== 'none' && c !== 'normal' && c !== '""';
396
+
397
+ if (hasContent(before) || hasContent(after)) return true;
398
+ }
399
+ }
400
+
401
+ return false;
402
+ }
403
+
404
+ /**
405
+ * Replaces createRenderItem.
406
+ * Returns { items: [], job: () => Promise, stopRecursion: boolean }
407
+ */
408
+ function prepareRenderItem(node, config, domOrder, pptx, effectiveZIndex, computedStyle) {
409
+ // 1. Text Node Handling
410
+ if (node.nodeType === 3) {
411
+ const textContent = node.nodeValue.trim();
412
+ if (!textContent) return null;
413
+
414
+ const parent = node.parentElement;
415
+ if (!parent) return null;
416
+
417
+ if (isTextContainer(parent)) return null; // Parent handles it
418
+
419
+ const range = document.createRange();
420
+ range.selectNode(node);
421
+ const rect = range.getBoundingClientRect();
422
+ range.detach();
423
+
424
+ const style = window.getComputedStyle(parent);
425
+ const widthPx = rect.width;
426
+ const heightPx = rect.height;
427
+ const unrotatedW = widthPx * PX_TO_INCH * config.scale;
428
+ const unrotatedH = heightPx * PX_TO_INCH * config.scale;
429
+
430
+ const x = config.offX + (rect.left - config.rootX) * PX_TO_INCH * config.scale;
431
+ const y = config.offY + (rect.top - config.rootY) * PX_TO_INCH * config.scale;
432
+
433
+ return {
434
+ items: [
435
+ {
436
+ type: 'text',
437
+ zIndex: effectiveZIndex,
438
+ domOrder,
439
+ textParts: [
440
+ {
441
+ text: textContent,
442
+ options: getTextStyle(style, config.scale),
443
+ },
444
+ ],
445
+ options: { x, y, w: unrotatedW, h: unrotatedH, margin: 0, autoFit: false },
446
+ },
447
+ ],
448
+ stopRecursion: false,
449
+ };
450
+ }
451
+
452
+ if (node.nodeType !== 1) return null;
453
+ const style = computedStyle; // Use pre-computed style
454
+
455
+ const rect = node.getBoundingClientRect();
456
+ if (rect.width < 0.5 || rect.height < 0.5) return null;
457
+
458
+ const zIndex = effectiveZIndex;
459
+ const rotation = getRotation(style.transform);
460
+ const elementOpacity = parseFloat(style.opacity);
461
+ const safeOpacity = isNaN(elementOpacity) ? 1 : elementOpacity;
462
+
463
+ const widthPx = node.offsetWidth || rect.width;
464
+ const heightPx = node.offsetHeight || rect.height;
465
+ const unrotatedW = widthPx * PX_TO_INCH * config.scale;
466
+ const unrotatedH = heightPx * PX_TO_INCH * config.scale;
467
+ const centerX = rect.left + rect.width / 2;
468
+ const centerY = rect.top + rect.height / 2;
469
+
470
+ let x = config.offX + (centerX - config.rootX) * PX_TO_INCH * config.scale - unrotatedW / 2;
471
+ let y = config.offY + (centerY - config.rootY) * PX_TO_INCH * config.scale - unrotatedH / 2;
472
+ let w = unrotatedW;
473
+ let h = unrotatedH;
474
+
475
+ const items = [];
476
+
477
+ // --- ASYNC JOB: SVG Tags ---
478
+ if (node.nodeName.toUpperCase() === 'SVG') {
479
+ const item = {
480
+ type: 'image',
481
+ zIndex,
482
+ domOrder,
483
+ options: { data: null, x, y, w, h, rotate: rotation },
484
+ };
485
+
486
+ const job = async () => {
487
+ const processed = await svgToPng(node);
488
+ if (processed) item.options.data = processed;
489
+ else item.skip = true;
490
+ };
491
+
492
+ return { items: [item], job, stopRecursion: true };
493
+ }
494
+
495
+ // --- ASYNC JOB: IMG Tags ---
496
+ if (node.tagName === 'IMG') {
497
+ let radii = {
498
+ tl: parseFloat(style.borderTopLeftRadius) || 0,
499
+ tr: parseFloat(style.borderTopRightRadius) || 0,
500
+ br: parseFloat(style.borderBottomRightRadius) || 0,
501
+ bl: parseFloat(style.borderBottomLeftRadius) || 0,
502
+ };
503
+
504
+ const hasAnyRadius = radii.tl > 0 || radii.tr > 0 || radii.br > 0 || radii.bl > 0;
505
+ if (!hasAnyRadius) {
506
+ const parent = node.parentElement;
507
+ const parentStyle = window.getComputedStyle(parent);
508
+ if (parentStyle.overflow !== 'visible') {
509
+ const pRadii = {
510
+ tl: parseFloat(parentStyle.borderTopLeftRadius) || 0,
511
+ tr: parseFloat(parentStyle.borderTopRightRadius) || 0,
512
+ br: parseFloat(parentStyle.borderBottomRightRadius) || 0,
513
+ bl: parseFloat(parentStyle.borderBottomLeftRadius) || 0,
514
+ };
515
+ const pRect = parent.getBoundingClientRect();
516
+ if (Math.abs(pRect.width - rect.width) < 5 && Math.abs(pRect.height - rect.height) < 5) {
517
+ radii = pRadii;
518
+ }
519
+ }
520
+ }
521
+
522
+ const objectFit = style.objectFit || 'fill'; // default CSS behavior is fill
523
+ const objectPosition = style.objectPosition || '50% 50%';
524
+
525
+ const item = {
526
+ type: 'image',
527
+ zIndex,
528
+ domOrder,
529
+ options: { x, y, w, h, rotate: rotation, data: null },
530
+ };
531
+
532
+ const job = async () => {
533
+ const processed = await getProcessedImage(
534
+ node.src,
535
+ widthPx,
536
+ heightPx,
537
+ radii,
538
+ objectFit,
539
+ objectPosition
540
+ );
541
+ if (processed) item.options.data = processed;
542
+ else item.skip = true;
543
+ };
544
+
545
+ return { items: [item], job, stopRecursion: true };
546
+ }
547
+
548
+ // --- ASYNC JOB: Icons and Other Elements ---
549
+ if (isIconElement(node)) {
550
+ const item = {
551
+ type: 'image',
552
+ zIndex,
553
+ domOrder,
554
+ options: { x, y, w, h, rotate: rotation, data: null },
555
+ };
556
+ const job = async () => {
557
+ const pngData = await elementToCanvasImage(node, widthPx, heightPx);
558
+ if (pngData) item.options.data = pngData;
559
+ else item.skip = true;
560
+ };
561
+ return { items: [item], job, stopRecursion: true };
562
+ }
563
+
564
+ // Radii logic
565
+ const borderRadiusValue = parseFloat(style.borderRadius) || 0;
566
+ const borderBottomLeftRadius = parseFloat(style.borderBottomLeftRadius) || 0;
567
+ const borderBottomRightRadius = parseFloat(style.borderBottomRightRadius) || 0;
568
+ const borderTopLeftRadius = parseFloat(style.borderTopLeftRadius) || 0;
569
+ const borderTopRightRadius = parseFloat(style.borderTopRightRadius) || 0;
570
+
571
+ const hasPartialBorderRadius =
572
+ (borderBottomLeftRadius > 0 && borderBottomLeftRadius !== borderRadiusValue) ||
573
+ (borderBottomRightRadius > 0 && borderBottomRightRadius !== borderRadiusValue) ||
574
+ (borderTopLeftRadius > 0 && borderTopLeftRadius !== borderRadiusValue) ||
575
+ (borderTopRightRadius > 0 && borderTopRightRadius !== borderRadiusValue) ||
576
+ (borderRadiusValue === 0 &&
577
+ (borderBottomLeftRadius ||
578
+ borderBottomRightRadius ||
579
+ borderTopLeftRadius ||
580
+ borderTopRightRadius));
581
+
582
+ // --- ASYNC JOB: Clipped Divs via Canvas ---
583
+ if (hasPartialBorderRadius && isClippedByParent(node)) {
584
+ const marginLeft = parseFloat(style.marginLeft) || 0;
585
+ const marginTop = parseFloat(style.marginTop) || 0;
586
+ x += marginLeft * PX_TO_INCH * config.scale;
587
+ y += marginTop * PX_TO_INCH * config.scale;
588
+
589
+ const item = {
590
+ type: 'image',
591
+ zIndex,
592
+ domOrder,
593
+ options: { x, y, w, h, rotate: rotation, data: null },
594
+ };
595
+
596
+ const job = async () => {
597
+ const canvasImageData = await elementToCanvasImage(node, widthPx, heightPx);
598
+ if (canvasImageData) item.options.data = canvasImageData;
599
+ else item.skip = true;
600
+ };
601
+
602
+ return { items: [item], job, stopRecursion: true };
603
+ }
604
+
605
+ // --- SYNC: Standard CSS Extraction ---
606
+ const bgColorObj = parseColor(style.backgroundColor);
607
+ const bgClip = style.webkitBackgroundClip || style.backgroundClip;
608
+ const isBgClipText = bgClip === 'text';
609
+ const hasGradient =
610
+ !isBgClipText && style.backgroundImage && style.backgroundImage.includes('linear-gradient');
611
+
612
+ const borderColorObj = parseColor(style.borderColor);
613
+ const borderWidth = parseFloat(style.borderWidth);
614
+ const hasBorder = borderWidth > 0 && borderColorObj.hex;
615
+
616
+ const borderInfo = getBorderInfo(style, config.scale);
617
+ const hasUniformBorder = borderInfo.type === 'uniform';
618
+ const hasCompositeBorder = borderInfo.type === 'composite';
619
+
620
+ const shadowStr = style.boxShadow;
621
+ const hasShadow = shadowStr && shadowStr !== 'none';
622
+ const softEdge = getSoftEdges(style.filter, config.scale);
623
+
624
+ let isImageWrapper = false;
625
+ const imgChild = Array.from(node.children).find((c) => c.tagName === 'IMG');
626
+ if (imgChild) {
627
+ const childW = imgChild.offsetWidth || imgChild.getBoundingClientRect().width;
628
+ const childH = imgChild.offsetHeight || imgChild.getBoundingClientRect().height;
629
+ if (childW >= widthPx - 2 && childH >= heightPx - 2) isImageWrapper = true;
630
+ }
631
+
632
+ let textPayload = null;
633
+ const isText = isTextContainer(node);
634
+
635
+ if (isText) {
636
+ const textParts = [];
637
+ const isList = style.display === 'list-item';
638
+ if (isList) {
639
+ const fontSizePt = parseFloat(style.fontSize) * 0.75 * config.scale;
640
+ const listStyleType = style.listStyleType || 'disc';
641
+ const listStylePos = style.listStylePosition || 'outside';
642
+
643
+ let marker = null;
644
+
645
+ // 1. Determine the marker character based on list-style-type
646
+ if (listStyleType !== 'none') {
647
+ if (listStyleType === 'decimal') {
648
+ // Calculate index for ordered lists (1., 2., etc.)
649
+ const index = Array.prototype.indexOf.call(node.parentNode.children, node) + 1;
650
+ marker = `${index}.`;
651
+ } else if (listStyleType === 'circle') {
652
+ marker = '○';
653
+ } else if (listStyleType === 'square') {
654
+ marker = '■';
655
+ } else {
656
+ marker = '•'; // Default to disc
657
+ }
658
+ }
659
+
660
+ // 2. Apply alignment and add marker
661
+ if (marker) {
662
+ // Only shift the text box to the left if the bullet is OUTSIDE the content box.
663
+ // Tailwind 'list-inside' puts the bullet inside the box, so we must NOT shift X.
664
+ if (listStylePos === 'outside') {
665
+ const bulletShift = (parseFloat(style.fontSize) || 16) * PX_TO_INCH * config.scale * 1.5;
666
+ x -= bulletShift;
667
+ w += bulletShift;
668
+ }
669
+
670
+ // Add the bullet + 3 spaces for visual separation
671
+ textParts.push({
672
+ text: marker + ' ',
673
+ options: {
674
+ color: parseColor(style.color).hex || '000000',
675
+ fontSize: fontSizePt,
676
+ },
677
+ });
678
+ }
679
+ }
680
+
681
+ node.childNodes.forEach((child, index) => {
682
+ let textVal = child.nodeType === 3 ? child.nodeValue : child.textContent;
683
+ let nodeStyle = child.nodeType === 1 ? window.getComputedStyle(child) : style;
684
+ textVal = textVal.replace(/[\n\r\t]+/g, ' ').replace(/\s{2,}/g, ' ');
685
+ if (index === 0 && !isList) textVal = textVal.trimStart();
686
+ else if (index === 0) textVal = textVal.trimStart();
687
+ if (index === node.childNodes.length - 1) textVal = textVal.trimEnd();
688
+ if (nodeStyle.textTransform === 'uppercase') textVal = textVal.toUpperCase();
689
+ if (nodeStyle.textTransform === 'lowercase') textVal = textVal.toLowerCase();
690
+
691
+ if (textVal.length > 0) {
692
+ textParts.push({
693
+ text: textVal,
694
+ options: getTextStyle(nodeStyle, config.scale),
695
+ });
696
+ }
697
+ });
698
+
699
+ if (textParts.length > 0) {
700
+ let align = style.textAlign || 'left';
701
+ if (align === 'start') align = 'left';
702
+ if (align === 'end') align = 'right';
703
+ let valign = 'top';
704
+ if (style.alignItems === 'center') valign = 'middle';
705
+ if (style.justifyContent === 'center' && style.display.includes('flex')) align = 'center';
706
+
707
+ const pt = parseFloat(style.paddingTop) || 0;
708
+ const pb = parseFloat(style.paddingBottom) || 0;
709
+ if (Math.abs(pt - pb) < 2 && bgColorObj.hex) valign = 'middle';
710
+
711
+ let padding = getPadding(style, config.scale);
712
+ if (align === 'center' && valign === 'middle') padding = [0, 0, 0, 0];
713
+
714
+ textPayload = { text: textParts, align, valign, inset: padding };
715
+ }
716
+ }
717
+
718
+ if (hasGradient || (softEdge && bgColorObj.hex && !isImageWrapper)) {
719
+ let bgData = null;
720
+ let padIn = 0;
721
+ if (softEdge) {
722
+ const svgInfo = generateBlurredSVG(
723
+ widthPx,
724
+ heightPx,
725
+ bgColorObj.hex,
726
+ borderRadiusValue,
727
+ softEdge
728
+ );
729
+ bgData = svgInfo.data;
730
+ padIn = svgInfo.padding * PX_TO_INCH * config.scale;
731
+ } else {
732
+ bgData = generateGradientSVG(
733
+ widthPx,
734
+ heightPx,
735
+ style.backgroundImage,
736
+ borderRadiusValue,
737
+ hasBorder ? { color: borderColorObj.hex, width: borderWidth } : null
738
+ );
739
+ }
740
+
741
+ if (bgData) {
742
+ items.push({
743
+ type: 'image',
744
+ zIndex,
745
+ domOrder,
746
+ options: {
747
+ data: bgData,
748
+ x: x - padIn,
749
+ y: y - padIn,
750
+ w: w + padIn * 2,
751
+ h: h + padIn * 2,
752
+ rotate: rotation,
753
+ },
754
+ });
755
+ }
756
+
757
+ if (textPayload) {
758
+ textPayload.text[0].options.fontSize =
759
+ Math.floor(textPayload.text[0]?.options?.fontSize) || 12;
760
+ items.push({
761
+ type: 'text',
762
+ zIndex: zIndex + 1,
763
+ domOrder,
764
+ textParts: textPayload.text,
765
+ options: {
766
+ x,
767
+ y,
768
+ w,
769
+ h,
770
+ align: textPayload.align,
771
+ valign: textPayload.valign,
772
+ inset: textPayload.inset,
773
+ rotate: rotation,
774
+ margin: 0,
775
+ wrap: true,
776
+ autoFit: false,
777
+ },
778
+ });
779
+ }
780
+ if (hasCompositeBorder) {
781
+ const borderItems = createCompositeBorderItems(
782
+ borderInfo.sides,
783
+ x,
784
+ y,
785
+ w,
786
+ h,
787
+ config.scale,
788
+ zIndex,
789
+ domOrder
790
+ );
791
+ items.push(...borderItems);
792
+ }
793
+ } else if (
794
+ (bgColorObj.hex && !isImageWrapper) ||
795
+ hasUniformBorder ||
796
+ hasCompositeBorder ||
797
+ hasShadow ||
798
+ textPayload
799
+ ) {
800
+ const finalAlpha = safeOpacity * bgColorObj.opacity;
801
+ const transparency = (1 - finalAlpha) * 100;
802
+ const useSolidFill = bgColorObj.hex && !isImageWrapper;
803
+
804
+ if (hasPartialBorderRadius && useSolidFill && !textPayload) {
805
+ const shapeSvg = generateCustomShapeSVG(
806
+ widthPx,
807
+ heightPx,
808
+ bgColorObj.hex,
809
+ bgColorObj.opacity,
810
+ {
811
+ tl: parseFloat(style.borderTopLeftRadius) || 0,
812
+ tr: parseFloat(style.borderTopRightRadius) || 0,
813
+ br: parseFloat(style.borderBottomRightRadius) || 0,
814
+ bl: parseFloat(style.borderBottomLeftRadius) || 0,
815
+ }
816
+ );
817
+
818
+ items.push({
819
+ type: 'image',
820
+ zIndex,
821
+ domOrder,
822
+ options: { data: shapeSvg, x, y, w, h, rotate: rotation },
823
+ });
824
+ } else {
825
+ const shapeOpts = {
826
+ x,
827
+ y,
828
+ w,
829
+ h,
830
+ rotate: rotation,
831
+ fill: useSolidFill
832
+ ? { color: bgColorObj.hex, transparency: transparency }
833
+ : { type: 'none' },
834
+ line: hasUniformBorder ? borderInfo.options : null,
835
+ };
836
+
837
+ if (hasShadow) shapeOpts.shadow = getVisibleShadow(shadowStr, config.scale);
838
+
839
+ // 1. Calculate dimensions first
840
+ const minDimension = Math.min(widthPx, heightPx);
841
+
842
+ let rawRadius = parseFloat(style.borderRadius) || 0;
843
+ const isPercentage = style.borderRadius && style.borderRadius.toString().includes('%');
844
+
845
+ // 2. Normalize radius to pixels
846
+ let radiusPx = rawRadius;
847
+ if (isPercentage) {
848
+ radiusPx = (rawRadius / 100) * minDimension;
849
+ }
850
+
851
+ let shapeType = pptx.ShapeType.rect;
852
+
853
+ // 3. Determine Shape Logic
854
+ const isSquare = Math.abs(widthPx - heightPx) < 1;
855
+ const isFullyRound = radiusPx >= minDimension / 2;
856
+
857
+ // CASE A: It is an Ellipse if:
858
+ // 1. It is explicitly "50%" (standard CSS way to make ovals/circles)
859
+ // 2. OR it is a perfect square and fully rounded (a circle)
860
+ if (isFullyRound && (isPercentage || isSquare)) {
861
+ shapeType = pptx.ShapeType.ellipse;
862
+ }
863
+ // CASE B: It is a Rounded Rectangle (including "Pill" shapes)
864
+ else if (radiusPx > 0) {
865
+ shapeType = pptx.ShapeType.roundRect;
866
+ let r = radiusPx / minDimension;
867
+ if (r > 0.5) r = 0.5;
868
+ if (minDimension < 100) r = r * 0.25; // Small size adjustment for small shapes
869
+
870
+ shapeOpts.rectRadius = r;
871
+ }
872
+
873
+ if (textPayload) {
874
+ textPayload.text[0].options.fontSize =
875
+ Math.floor(textPayload.text[0]?.options?.fontSize) || 12;
876
+ const textOptions = {
877
+ shape: shapeType,
878
+ ...shapeOpts,
879
+ rotate: rotation,
880
+ align: textPayload.align,
881
+ valign: textPayload.valign,
882
+ inset: textPayload.inset,
883
+ margin: 0,
884
+ wrap: true,
885
+ autoFit: false,
886
+ };
887
+ items.push({
888
+ type: 'text',
889
+ zIndex,
890
+ domOrder,
891
+ textParts: textPayload.text,
892
+ options: textOptions,
893
+ });
894
+ } else if (!hasPartialBorderRadius) {
895
+ items.push({
896
+ type: 'shape',
897
+ zIndex,
898
+ domOrder,
899
+ shapeType,
900
+ options: shapeOpts,
901
+ });
902
+ }
903
+ }
904
+
905
+ if (hasCompositeBorder) {
906
+ const borderSvgData = generateCompositeBorderSVG(
907
+ widthPx,
908
+ heightPx,
909
+ borderRadiusValue,
910
+ borderInfo.sides
911
+ );
912
+ if (borderSvgData) {
913
+ items.push({
914
+ type: 'image',
915
+ zIndex: zIndex + 1,
916
+ domOrder,
917
+ options: { data: borderSvgData, x, y, w, h, rotate: rotation },
918
+ });
919
+ }
920
+ }
921
+ }
922
+
923
+ return { items, stopRecursion: !!textPayload };
924
+ }
925
+
926
+ function createCompositeBorderItems(sides, x, y, w, h, scale, zIndex, domOrder) {
927
+ const items = [];
928
+ const pxToInch = 1 / 96;
929
+ const common = { zIndex: zIndex + 1, domOrder, shapeType: 'rect' };
930
+
931
+ if (sides.top.width > 0)
932
+ items.push({
933
+ ...common,
934
+ options: { x, y, w, h: sides.top.width * pxToInch * scale, fill: { color: sides.top.color } },
935
+ });
936
+ if (sides.right.width > 0)
937
+ items.push({
938
+ ...common,
939
+ options: {
940
+ x: x + w - sides.right.width * pxToInch * scale,
941
+ y,
942
+ w: sides.right.width * pxToInch * scale,
943
+ h,
944
+ fill: { color: sides.right.color },
945
+ },
946
+ });
947
+ if (sides.bottom.width > 0)
948
+ items.push({
949
+ ...common,
950
+ options: {
951
+ x,
952
+ y: y + h - sides.bottom.width * pxToInch * scale,
953
+ w,
954
+ h: sides.bottom.width * pxToInch * scale,
955
+ fill: { color: sides.bottom.color },
956
+ },
957
+ });
958
+ if (sides.left.width > 0)
959
+ items.push({
960
+ ...common,
961
+ options: {
962
+ x,
963
+ y,
964
+ w: sides.left.width * pxToInch * scale,
965
+ h,
966
+ fill: { color: sides.left.color },
967
+ },
968
+ });
969
+
970
+ return items;
971
+ }