dom-to-pptx 1.0.8 → 1.0.9

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.
@@ -8055,6 +8055,7 @@ function getTextStyle(style, scale) {
8055
8055
 
8056
8056
  /**
8057
8057
  * Determines if a given DOM node is primarily a text container.
8058
+ * Updated to correctly reject Icon elements so they are rendered as images.
8058
8059
  */
8059
8060
  function isTextContainer(node) {
8060
8061
  const hasText = node.textContent.trim().length > 0;
@@ -8063,28 +8064,46 @@ function isTextContainer(node) {
8063
8064
  const children = Array.from(node.children);
8064
8065
  if (children.length === 0) return true;
8065
8066
 
8066
- // Check if children are purely inline text formatting or visual shapes
8067
8067
  const isSafeInline = (el) => {
8068
- // 1. Reject Web Components / Icons / Images
8068
+ // 1. Reject Web Components / Custom Elements
8069
8069
  if (el.tagName.includes('-')) return false;
8070
+ // 2. Reject Explicit Images/SVGs
8070
8071
  if (el.tagName === 'IMG' || el.tagName === 'SVG') return false;
8071
8072
 
8073
+ // 3. Reject Class-based Icons (FontAwesome, Material, Bootstrap, etc.)
8074
+ // If an <i> or <span> has icon classes, it is a visual object, not text.
8075
+ if (el.tagName === 'I' || el.tagName === 'SPAN') {
8076
+ const cls = el.getAttribute('class') || '';
8077
+ if (
8078
+ cls.includes('fa-') ||
8079
+ cls.includes('fas') ||
8080
+ cls.includes('far') ||
8081
+ cls.includes('fab') ||
8082
+ cls.includes('material-icons') ||
8083
+ cls.includes('bi-') ||
8084
+ cls.includes('icon')
8085
+ ) {
8086
+ return false;
8087
+ }
8088
+ }
8089
+
8072
8090
  const style = window.getComputedStyle(el);
8073
8091
  const display = style.display;
8074
8092
 
8075
- // 2. Initial check: Must be a standard inline tag OR display:inline
8076
- const isInlineTag = ['SPAN', 'B', 'STRONG', 'EM', 'I', 'A', 'SMALL', 'MARK'].includes(el.tagName);
8093
+ // 4. Standard Inline Tag Check
8094
+ const isInlineTag = ['SPAN', 'B', 'STRONG', 'EM', 'I', 'A', 'SMALL', 'MARK'].includes(
8095
+ el.tagName
8096
+ );
8077
8097
  const isInlineDisplay = display.includes('inline');
8078
8098
 
8079
8099
  if (!isInlineTag && !isInlineDisplay) return false;
8080
8100
 
8081
- // 3. CRITICAL FIX: Check for Structural Styling
8082
- // PPTX Text Runs (parts of a text line) CANNOT have backgrounds, borders, or padding.
8083
- // If a child element has these, the parent is NOT a simple text container;
8084
- // it is a layout container composed of styled blocks.
8101
+ // 5. Structural Styling Check
8102
+ // If a child has a background or border, it's a layout block, not a simple text span.
8085
8103
  const bgColor = parseColor(style.backgroundColor);
8086
8104
  const hasVisibleBg = bgColor.hex && bgColor.opacity > 0;
8087
- const hasBorder = parseFloat(style.borderWidth) > 0 && parseColor(style.borderColor).opacity > 0;
8105
+ const hasBorder =
8106
+ parseFloat(style.borderWidth) > 0 && parseColor(style.borderColor).opacity > 0;
8088
8107
 
8089
8108
  if (hasVisibleBg || hasBorder) {
8090
8109
  return false;
@@ -8205,57 +8224,119 @@ function getVisibleShadow(shadowStr, scale) {
8205
8224
  return null;
8206
8225
  }
8207
8226
 
8227
+ /**
8228
+ * Generates an SVG image for gradients, supporting degrees and keywords.
8229
+ */
8208
8230
  function generateGradientSVG(w, h, bgString, radius, border) {
8209
8231
  try {
8210
8232
  const match = bgString.match(/linear-gradient\((.*)\)/);
8211
8233
  if (!match) return null;
8212
8234
  const content = match[1];
8235
+
8236
+ // Split by comma, ignoring commas inside parentheses (e.g. rgba())
8213
8237
  const parts = content.split(/,(?![^()]*\))/).map((p) => p.trim());
8238
+ if (parts.length < 2) return null;
8214
8239
 
8215
8240
  let x1 = '0%',
8216
8241
  y1 = '0%',
8217
8242
  x2 = '0%',
8218
8243
  y2 = '100%';
8219
- let stopsStartIdx = 0;
8220
- if (parts[0].includes('to right')) {
8221
- x1 = '0%';
8222
- x2 = '100%';
8223
- y2 = '0%';
8224
- stopsStartIdx = 1;
8225
- } else if (parts[0].includes('to left')) {
8226
- x1 = '100%';
8227
- x2 = '0%';
8228
- y2 = '0%';
8229
- stopsStartIdx = 1;
8230
- } else if (parts[0].includes('to top')) {
8231
- y1 = '100%';
8232
- y2 = '0%';
8233
- stopsStartIdx = 1;
8234
- } else if (parts[0].includes('to bottom')) {
8235
- y1 = '0%';
8236
- y2 = '100%';
8237
- stopsStartIdx = 1;
8244
+ let stopsStartIndex = 0;
8245
+ const firstPart = parts[0].toLowerCase();
8246
+
8247
+ // 1. Check for Keywords (to right, etc.)
8248
+ if (firstPart.startsWith('to ')) {
8249
+ stopsStartIndex = 1;
8250
+ const direction = firstPart.replace('to ', '').trim();
8251
+ switch (direction) {
8252
+ case 'top':
8253
+ y1 = '100%';
8254
+ y2 = '0%';
8255
+ break;
8256
+ case 'bottom':
8257
+ y1 = '0%';
8258
+ y2 = '100%';
8259
+ break;
8260
+ case 'left':
8261
+ x1 = '100%';
8262
+ x2 = '0%';
8263
+ break;
8264
+ case 'right':
8265
+ x2 = '100%';
8266
+ break;
8267
+ case 'top right':
8268
+ x1 = '0%';
8269
+ y1 = '100%';
8270
+ x2 = '100%';
8271
+ y2 = '0%';
8272
+ break;
8273
+ case 'top left':
8274
+ x1 = '100%';
8275
+ y1 = '100%';
8276
+ x2 = '0%';
8277
+ y2 = '0%';
8278
+ break;
8279
+ case 'bottom right':
8280
+ x2 = '100%';
8281
+ y2 = '100%';
8282
+ break;
8283
+ case 'bottom left':
8284
+ x1 = '100%';
8285
+ y2 = '100%';
8286
+ break;
8287
+ }
8288
+ }
8289
+ // 2. Check for Degrees (45deg, 90deg, etc.)
8290
+ else if (firstPart.match(/^-?[\d.]+(deg|rad|turn|grad)$/)) {
8291
+ stopsStartIndex = 1;
8292
+ const val = parseFloat(firstPart);
8293
+ // CSS 0deg is Top (North), 90deg is Right (East), 180deg is Bottom (South)
8294
+ // We convert this to SVG coordinates on a unit square (0-100%).
8295
+ // Formula: Map angle to perimeter coordinates.
8296
+ if (!isNaN(val)) {
8297
+ const deg = firstPart.includes('rad') ? val * (180 / Math.PI) : val;
8298
+ const cssRad = ((deg - 90) * Math.PI) / 180; // Correct CSS angle offset
8299
+
8300
+ // Calculate standard vector for rectangle center (50, 50)
8301
+ const scale = 50; // Distance from center to edge (approx)
8302
+ const cos = Math.cos(cssRad); // Y component (reversed in SVG)
8303
+ const sin = Math.sin(cssRad); // X component
8304
+
8305
+ // Invert Y for SVG coordinate system
8306
+ x1 = (50 - sin * scale).toFixed(1) + '%';
8307
+ y1 = (50 + cos * scale).toFixed(1) + '%';
8308
+ x2 = (50 + sin * scale).toFixed(1) + '%';
8309
+ y2 = (50 - cos * scale).toFixed(1) + '%';
8310
+ }
8238
8311
  }
8239
8312
 
8313
+ // 3. Process Color Stops
8240
8314
  let stopsXML = '';
8241
- const stopParts = parts.slice(stopsStartIdx);
8315
+ const stopParts = parts.slice(stopsStartIndex);
8316
+
8242
8317
  stopParts.forEach((part, idx) => {
8318
+ // Parse "Color Position" (e.g., "red 50%")
8319
+ // Regex looks for optional space + number + unit at the end of the string
8243
8320
  let color = part;
8244
8321
  let offset = Math.round((idx / (stopParts.length - 1)) * 100) + '%';
8245
- const posMatch = part.match(/(.*?)\s+(\d+(\.\d+)?%?)$/);
8322
+
8323
+ const posMatch = part.match(/^(.*?)\s+(-?[\d.]+(?:%|px)?)$/);
8246
8324
  if (posMatch) {
8247
8325
  color = posMatch[1];
8248
8326
  offset = posMatch[2];
8249
8327
  }
8328
+
8329
+ // Handle RGBA/RGB for SVG compatibility
8250
8330
  let opacity = 1;
8251
8331
  if (color.includes('rgba')) {
8252
- const rgba = color.match(/[\d.]+/g);
8253
- if (rgba && rgba.length > 3) {
8254
- opacity = rgba[3];
8255
- color = `rgb(${rgba[0]},${rgba[1]},${rgba[2]})`;
8332
+ const rgbaMatch = color.match(/[\d.]+/g);
8333
+ if (rgbaMatch && rgbaMatch.length >= 4) {
8334
+ opacity = rgbaMatch[3];
8335
+ color = `rgb(${rgbaMatch[0]},${rgbaMatch[1]},${rgbaMatch[2]})`;
8256
8336
  }
8257
8337
  }
8258
- stopsXML += `<stop offset="${offset}" stop-color="${color}" stop-opacity="${opacity}"/>`;
8338
+
8339
+ stopsXML += `<stop offset="${offset}" stop-color="${color.trim()}" stop-opacity="${opacity}"/>`;
8259
8340
  });
8260
8341
 
8261
8342
  let strokeAttr = '';
@@ -8264,12 +8345,18 @@ function generateGradientSVG(w, h, bgString, radius, border) {
8264
8345
  }
8265
8346
 
8266
8347
  const svg = `
8267
- <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
8268
- <defs><linearGradient id="grad" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">${stopsXML}</linearGradient></defs>
8269
- <rect x="0" y="0" width="${w}" height="${h}" rx="${radius}" ry="${radius}" fill="url(#grad)" ${strokeAttr} />
8270
- </svg>`;
8348
+ <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
8349
+ <defs>
8350
+ <linearGradient id="grad" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">
8351
+ ${stopsXML}
8352
+ </linearGradient>
8353
+ </defs>
8354
+ <rect x="0" y="0" width="${w}" height="${h}" rx="${radius}" ry="${radius}" fill="url(#grad)" ${strokeAttr} />
8355
+ </svg>`;
8356
+
8271
8357
  return 'data:image/svg+xml;base64,' + btoa(svg);
8272
- } catch {
8358
+ } catch (e) {
8359
+ console.warn('Gradient generation failed:', e);
8273
8360
  return null;
8274
8361
  }
8275
8362
  }
@@ -8544,29 +8631,69 @@ async function processSlide(root, slide, pptx) {
8544
8631
  * Optimized html2canvas wrapper
8545
8632
  * Now strictly captures the node itself, not the root.
8546
8633
  */
8634
+ /**
8635
+ * Optimized html2canvas wrapper
8636
+ * Includes fix for cropped icons by adjusting styles in the cloned document.
8637
+ */
8547
8638
  async function elementToCanvasImage(node, widthPx, heightPx) {
8548
8639
  return new Promise((resolve) => {
8640
+ // 1. Assign a temp ID to locate the node inside the cloned document
8641
+ const originalId = node.id;
8642
+ const tempId = 'pptx-capture-' + Math.random().toString(36).substr(2, 9);
8643
+ node.id = tempId;
8644
+
8549
8645
  const width = Math.max(Math.ceil(widthPx), 1);
8550
8646
  const height = Math.max(Math.ceil(heightPx), 1);
8551
8647
  const style = window.getComputedStyle(node);
8552
8648
 
8553
- // Optimized: Capture ONLY the specific node
8554
8649
  html2canvas(node, {
8555
8650
  backgroundColor: null,
8556
8651
  logging: false,
8557
- scale: 2, // Slight quality boost
8652
+ scale: 3, // Higher scale for sharper icons
8653
+ useCORS: true, // critical for external fonts/images
8654
+ onclone: (clonedDoc) => {
8655
+ const clonedNode = clonedDoc.getElementById(tempId);
8656
+ if (clonedNode) {
8657
+ // --- FIX: PREVENT ICON CLIPPING ---
8658
+ // 1. Force overflow visible so glyphs bleeding out aren't cut
8659
+ clonedNode.style.overflow = 'visible';
8660
+
8661
+ // 2. Adjust alignment for Icons to prevent baseline clipping
8662
+ // (Applies to <i>, <span>, or standard icon classes)
8663
+ const tag = clonedNode.tagName;
8664
+ if (tag === 'I' || tag === 'SPAN' || clonedNode.className.includes('fa-')) {
8665
+ // Flex center helps align the glyph exactly in the middle of the box
8666
+ // preventing top/bottom cropping due to line-height mismatches.
8667
+ clonedNode.style.display = 'inline-flex';
8668
+ clonedNode.style.justifyContent = 'center';
8669
+ clonedNode.style.alignItems = 'center';
8670
+
8671
+ // Remove margins that might offset the capture
8672
+ clonedNode.style.margin = '0';
8673
+
8674
+ // Ensure the font fits
8675
+ clonedNode.style.lineHeight = '1';
8676
+ clonedNode.style.verticalAlign = 'middle';
8677
+ }
8678
+ }
8679
+ },
8558
8680
  })
8559
8681
  .then((canvas) => {
8682
+ // Restore the original ID
8683
+ if (originalId) node.id = originalId;
8684
+ else node.removeAttribute('id');
8685
+
8560
8686
  const destCanvas = document.createElement('canvas');
8561
8687
  destCanvas.width = width;
8562
8688
  destCanvas.height = height;
8563
8689
  const ctx = destCanvas.getContext('2d');
8564
8690
 
8565
- // Draw the captured canvas into our sized canvas
8566
- // html2canvas might return a larger canvas if scale > 1, so we fit it
8691
+ // Draw captured canvas.
8692
+ // We simply draw it to fill the box. Since we centered it in 'onclone',
8693
+ // the glyph should now be visible within the bounds.
8567
8694
  ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, 0, width, height);
8568
8695
 
8569
- // Apply border radius clipping
8696
+ // --- Border Radius Clipping (Existing Logic) ---
8570
8697
  let tl = parseFloat(style.borderTopLeftRadius) || 0;
8571
8698
  let tr = parseFloat(style.borderTopRightRadius) || 0;
8572
8699
  let br = parseFloat(style.borderBottomRightRadius) || 0;
@@ -8605,12 +8732,62 @@ async function elementToCanvasImage(node, widthPx, heightPx) {
8605
8732
  resolve(destCanvas.toDataURL('image/png'));
8606
8733
  })
8607
8734
  .catch((e) => {
8735
+ if (originalId) node.id = originalId;
8736
+ else node.removeAttribute('id');
8608
8737
  console.warn('Canvas capture failed for node', node, e);
8609
8738
  resolve(null);
8610
8739
  });
8611
8740
  });
8612
8741
  }
8613
8742
 
8743
+ /**
8744
+ * Helper to identify elements that should be rendered as icons (Images).
8745
+ * Detects Custom Elements AND generic tags (<i>, <span>) with icon classes/pseudo-elements.
8746
+ */
8747
+ function isIconElement(node) {
8748
+ // 1. Custom Elements (hyphenated tags) or Explicit Library Tags
8749
+ const tag = node.tagName.toUpperCase();
8750
+ if (
8751
+ tag.includes('-') ||
8752
+ [
8753
+ 'MATERIAL-ICON',
8754
+ 'ICONIFY-ICON',
8755
+ 'REMIX-ICON',
8756
+ 'ION-ICON',
8757
+ 'EVA-ICON',
8758
+ 'BOX-ICON',
8759
+ 'FA-ICON',
8760
+ ].includes(tag)
8761
+ ) {
8762
+ return true;
8763
+ }
8764
+
8765
+ // 2. Class-based Icons (FontAwesome, Bootstrap, Material symbols) on <i> or <span>
8766
+ if (tag === 'I' || tag === 'SPAN') {
8767
+ const cls = node.getAttribute('class') || '';
8768
+ if (
8769
+ typeof cls === 'string' &&
8770
+ (cls.includes('fa-') ||
8771
+ cls.includes('fas') ||
8772
+ cls.includes('far') ||
8773
+ cls.includes('fab') ||
8774
+ cls.includes('bi-') ||
8775
+ cls.includes('material-icons') ||
8776
+ cls.includes('icon'))
8777
+ ) {
8778
+ // Double-check: Must have pseudo-element content to be a CSS icon
8779
+ const before = window.getComputedStyle(node, '::before').content;
8780
+ const after = window.getComputedStyle(node, '::after').content;
8781
+ const hasContent = (c) => c && c !== 'none' && c !== 'normal' && c !== '""';
8782
+
8783
+ if (hasContent(before) || hasContent(after)) return true;
8784
+ }
8785
+ console.log('Icon element:', node, cls);
8786
+ }
8787
+
8788
+ return false;
8789
+ }
8790
+
8614
8791
  /**
8615
8792
  * Replaces createRenderItem.
8616
8793
  * Returns { items: [], job: () => Promise, stopRecursion: boolean }
@@ -8746,30 +8923,18 @@ function prepareRenderItem(node, config, domOrder, pptx, effectiveZIndex, comput
8746
8923
  }
8747
8924
 
8748
8925
  // --- ASYNC JOB: Icons and Other Elements ---
8749
- if (
8750
- node.tagName.toUpperCase() === 'MATERIAL-ICON' ||
8751
- node.tagName.toUpperCase() === 'ICONIFY-ICON' ||
8752
- node.tagName.toUpperCase() === 'REMIX-ICON' ||
8753
- node.tagName.toUpperCase() === 'ION-ICON' ||
8754
- node.tagName.toUpperCase() === 'EVA-ICON' ||
8755
- node.tagName.toUpperCase() === 'BOX-ICON' ||
8756
- node.tagName.toUpperCase() === 'FA-ICON' ||
8757
- node.tagName.includes('-')
8758
- ) {
8926
+ if (isIconElement(node)) {
8759
8927
  const item = {
8760
8928
  type: 'image',
8761
8929
  zIndex,
8762
8930
  domOrder,
8763
- options: { x, y, w, h, rotate: rotation, data: null }, // Data null initially
8931
+ options: { x, y, w, h, rotate: rotation, data: null },
8764
8932
  };
8765
-
8766
- // Create Job
8767
8933
  const job = async () => {
8768
8934
  const pngData = await elementToCanvasImage(node, widthPx, heightPx);
8769
8935
  if (pngData) item.options.data = pngData;
8770
8936
  else item.skip = true;
8771
8937
  };
8772
-
8773
8938
  return { items: [item], job, stopRecursion: true };
8774
8939
  }
8775
8940
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dom-to-pptx",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "description": "A client-side library that converts any HTML element into a fully editable PowerPoint slide. **dom-to-pptx** transforms DOM structures into pixel-accurate `.pptx` content, preserving gradients, shadows, rounded images, and responsive layouts. It translates CSS Flexbox/Grid, linear-gradients, box-shadows, and typography into native PowerPoint shapes, enabling precise, design-faithful slide generation directly from the browser.",
5
5
  "main": "dist/dom-to-pptx.cjs",
6
6
  "module": "dist/dom-to-pptx.mjs",
package/src/index.js CHANGED
@@ -173,29 +173,69 @@ async function processSlide(root, slide, pptx) {
173
173
  * Optimized html2canvas wrapper
174
174
  * Now strictly captures the node itself, not the root.
175
175
  */
176
+ /**
177
+ * Optimized html2canvas wrapper
178
+ * Includes fix for cropped icons by adjusting styles in the cloned document.
179
+ */
176
180
  async function elementToCanvasImage(node, widthPx, heightPx) {
177
181
  return new Promise((resolve) => {
182
+ // 1. Assign a temp ID to locate the node inside the cloned document
183
+ const originalId = node.id;
184
+ const tempId = 'pptx-capture-' + Math.random().toString(36).substr(2, 9);
185
+ node.id = tempId;
186
+
178
187
  const width = Math.max(Math.ceil(widthPx), 1);
179
188
  const height = Math.max(Math.ceil(heightPx), 1);
180
189
  const style = window.getComputedStyle(node);
181
190
 
182
- // Optimized: Capture ONLY the specific node
183
191
  html2canvas(node, {
184
192
  backgroundColor: null,
185
193
  logging: false,
186
- scale: 2, // Slight quality boost
194
+ scale: 3, // Higher scale for sharper icons
195
+ useCORS: true, // critical for external fonts/images
196
+ onclone: (clonedDoc) => {
197
+ const clonedNode = clonedDoc.getElementById(tempId);
198
+ if (clonedNode) {
199
+ // --- FIX: PREVENT ICON CLIPPING ---
200
+ // 1. Force overflow visible so glyphs bleeding out aren't cut
201
+ clonedNode.style.overflow = 'visible';
202
+
203
+ // 2. Adjust alignment for Icons to prevent baseline clipping
204
+ // (Applies to <i>, <span>, or standard icon classes)
205
+ const tag = clonedNode.tagName;
206
+ if (tag === 'I' || tag === 'SPAN' || clonedNode.className.includes('fa-')) {
207
+ // Flex center helps align the glyph exactly in the middle of the box
208
+ // preventing top/bottom cropping due to line-height mismatches.
209
+ clonedNode.style.display = 'inline-flex';
210
+ clonedNode.style.justifyContent = 'center';
211
+ clonedNode.style.alignItems = 'center';
212
+
213
+ // Remove margins that might offset the capture
214
+ clonedNode.style.margin = '0';
215
+
216
+ // Ensure the font fits
217
+ clonedNode.style.lineHeight = '1';
218
+ clonedNode.style.verticalAlign = 'middle';
219
+ }
220
+ }
221
+ },
187
222
  })
188
223
  .then((canvas) => {
224
+ // Restore the original ID
225
+ if (originalId) node.id = originalId;
226
+ else node.removeAttribute('id');
227
+
189
228
  const destCanvas = document.createElement('canvas');
190
229
  destCanvas.width = width;
191
230
  destCanvas.height = height;
192
231
  const ctx = destCanvas.getContext('2d');
193
232
 
194
- // Draw the captured canvas into our sized canvas
195
- // html2canvas might return a larger canvas if scale > 1, so we fit it
233
+ // Draw captured canvas.
234
+ // We simply draw it to fill the box. Since we centered it in 'onclone',
235
+ // the glyph should now be visible within the bounds.
196
236
  ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, 0, width, height);
197
237
 
198
- // Apply border radius clipping
238
+ // --- Border Radius Clipping (Existing Logic) ---
199
239
  let tl = parseFloat(style.borderTopLeftRadius) || 0;
200
240
  let tr = parseFloat(style.borderTopRightRadius) || 0;
201
241
  let br = parseFloat(style.borderBottomRightRadius) || 0;
@@ -234,12 +274,62 @@ async function elementToCanvasImage(node, widthPx, heightPx) {
234
274
  resolve(destCanvas.toDataURL('image/png'));
235
275
  })
236
276
  .catch((e) => {
277
+ if (originalId) node.id = originalId;
278
+ else node.removeAttribute('id');
237
279
  console.warn('Canvas capture failed for node', node, e);
238
280
  resolve(null);
239
281
  });
240
282
  });
241
283
  }
242
284
 
285
+ /**
286
+ * Helper to identify elements that should be rendered as icons (Images).
287
+ * Detects Custom Elements AND generic tags (<i>, <span>) with icon classes/pseudo-elements.
288
+ */
289
+ function isIconElement(node) {
290
+ // 1. Custom Elements (hyphenated tags) or Explicit Library Tags
291
+ const tag = node.tagName.toUpperCase();
292
+ if (
293
+ tag.includes('-') ||
294
+ [
295
+ 'MATERIAL-ICON',
296
+ 'ICONIFY-ICON',
297
+ 'REMIX-ICON',
298
+ 'ION-ICON',
299
+ 'EVA-ICON',
300
+ 'BOX-ICON',
301
+ 'FA-ICON',
302
+ ].includes(tag)
303
+ ) {
304
+ return true;
305
+ }
306
+
307
+ // 2. Class-based Icons (FontAwesome, Bootstrap, Material symbols) on <i> or <span>
308
+ if (tag === 'I' || tag === 'SPAN') {
309
+ const cls = node.getAttribute('class') || '';
310
+ if (
311
+ typeof cls === 'string' &&
312
+ (cls.includes('fa-') ||
313
+ cls.includes('fas') ||
314
+ cls.includes('far') ||
315
+ cls.includes('fab') ||
316
+ cls.includes('bi-') ||
317
+ cls.includes('material-icons') ||
318
+ cls.includes('icon'))
319
+ ) {
320
+ // Double-check: Must have pseudo-element content to be a CSS icon
321
+ const before = window.getComputedStyle(node, '::before').content;
322
+ const after = window.getComputedStyle(node, '::after').content;
323
+ const hasContent = (c) => c && c !== 'none' && c !== 'normal' && c !== '""';
324
+
325
+ if (hasContent(before) || hasContent(after)) return true;
326
+ }
327
+ console.log('Icon element:', node, cls);
328
+ }
329
+
330
+ return false;
331
+ }
332
+
243
333
  /**
244
334
  * Replaces createRenderItem.
245
335
  * Returns { items: [], job: () => Promise, stopRecursion: boolean }
@@ -375,30 +465,18 @@ function prepareRenderItem(node, config, domOrder, pptx, effectiveZIndex, comput
375
465
  }
376
466
 
377
467
  // --- ASYNC JOB: Icons and Other Elements ---
378
- if (
379
- node.tagName.toUpperCase() === 'MATERIAL-ICON' ||
380
- node.tagName.toUpperCase() === 'ICONIFY-ICON' ||
381
- node.tagName.toUpperCase() === 'REMIX-ICON' ||
382
- node.tagName.toUpperCase() === 'ION-ICON' ||
383
- node.tagName.toUpperCase() === 'EVA-ICON' ||
384
- node.tagName.toUpperCase() === 'BOX-ICON' ||
385
- node.tagName.toUpperCase() === 'FA-ICON' ||
386
- node.tagName.includes('-')
387
- ) {
468
+ if (isIconElement(node)) {
388
469
  const item = {
389
470
  type: 'image',
390
471
  zIndex,
391
472
  domOrder,
392
- options: { x, y, w, h, rotate: rotation, data: null }, // Data null initially
473
+ options: { x, y, w, h, rotate: rotation, data: null },
393
474
  };
394
-
395
- // Create Job
396
475
  const job = async () => {
397
476
  const pngData = await elementToCanvasImage(node, widthPx, heightPx);
398
477
  if (pngData) item.options.data = pngData;
399
478
  else item.skip = true;
400
479
  };
401
-
402
480
  return { items: [item], job, stopRecursion: true };
403
481
  }
404
482