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.
@@ -8079,6 +8079,7 @@
8079
8079
 
8080
8080
  /**
8081
8081
  * Determines if a given DOM node is primarily a text container.
8082
+ * Updated to correctly reject Icon elements so they are rendered as images.
8082
8083
  */
8083
8084
  function isTextContainer(node) {
8084
8085
  const hasText = node.textContent.trim().length > 0;
@@ -8087,28 +8088,46 @@
8087
8088
  const children = Array.from(node.children);
8088
8089
  if (children.length === 0) return true;
8089
8090
 
8090
- // Check if children are purely inline text formatting or visual shapes
8091
8091
  const isSafeInline = (el) => {
8092
- // 1. Reject Web Components / Icons / Images
8092
+ // 1. Reject Web Components / Custom Elements
8093
8093
  if (el.tagName.includes('-')) return false;
8094
+ // 2. Reject Explicit Images/SVGs
8094
8095
  if (el.tagName === 'IMG' || el.tagName === 'SVG') return false;
8095
8096
 
8097
+ // 3. Reject Class-based Icons (FontAwesome, Material, Bootstrap, etc.)
8098
+ // If an <i> or <span> has icon classes, it is a visual object, not text.
8099
+ if (el.tagName === 'I' || el.tagName === 'SPAN') {
8100
+ const cls = el.getAttribute('class') || '';
8101
+ if (
8102
+ cls.includes('fa-') ||
8103
+ cls.includes('fas') ||
8104
+ cls.includes('far') ||
8105
+ cls.includes('fab') ||
8106
+ cls.includes('material-icons') ||
8107
+ cls.includes('bi-') ||
8108
+ cls.includes('icon')
8109
+ ) {
8110
+ return false;
8111
+ }
8112
+ }
8113
+
8096
8114
  const style = window.getComputedStyle(el);
8097
8115
  const display = style.display;
8098
8116
 
8099
- // 2. Initial check: Must be a standard inline tag OR display:inline
8100
- const isInlineTag = ['SPAN', 'B', 'STRONG', 'EM', 'I', 'A', 'SMALL', 'MARK'].includes(el.tagName);
8117
+ // 4. Standard Inline Tag Check
8118
+ const isInlineTag = ['SPAN', 'B', 'STRONG', 'EM', 'I', 'A', 'SMALL', 'MARK'].includes(
8119
+ el.tagName
8120
+ );
8101
8121
  const isInlineDisplay = display.includes('inline');
8102
8122
 
8103
8123
  if (!isInlineTag && !isInlineDisplay) return false;
8104
8124
 
8105
- // 3. CRITICAL FIX: Check for Structural Styling
8106
- // PPTX Text Runs (parts of a text line) CANNOT have backgrounds, borders, or padding.
8107
- // If a child element has these, the parent is NOT a simple text container;
8108
- // it is a layout container composed of styled blocks.
8125
+ // 5. Structural Styling Check
8126
+ // If a child has a background or border, it's a layout block, not a simple text span.
8109
8127
  const bgColor = parseColor(style.backgroundColor);
8110
8128
  const hasVisibleBg = bgColor.hex && bgColor.opacity > 0;
8111
- const hasBorder = parseFloat(style.borderWidth) > 0 && parseColor(style.borderColor).opacity > 0;
8129
+ const hasBorder =
8130
+ parseFloat(style.borderWidth) > 0 && parseColor(style.borderColor).opacity > 0;
8112
8131
 
8113
8132
  if (hasVisibleBg || hasBorder) {
8114
8133
  return false;
@@ -8229,57 +8248,119 @@
8229
8248
  return null;
8230
8249
  }
8231
8250
 
8251
+ /**
8252
+ * Generates an SVG image for gradients, supporting degrees and keywords.
8253
+ */
8232
8254
  function generateGradientSVG(w, h, bgString, radius, border) {
8233
8255
  try {
8234
8256
  const match = bgString.match(/linear-gradient\((.*)\)/);
8235
8257
  if (!match) return null;
8236
8258
  const content = match[1];
8259
+
8260
+ // Split by comma, ignoring commas inside parentheses (e.g. rgba())
8237
8261
  const parts = content.split(/,(?![^()]*\))/).map((p) => p.trim());
8262
+ if (parts.length < 2) return null;
8238
8263
 
8239
8264
  let x1 = '0%',
8240
8265
  y1 = '0%',
8241
8266
  x2 = '0%',
8242
8267
  y2 = '100%';
8243
- let stopsStartIdx = 0;
8244
- if (parts[0].includes('to right')) {
8245
- x1 = '0%';
8246
- x2 = '100%';
8247
- y2 = '0%';
8248
- stopsStartIdx = 1;
8249
- } else if (parts[0].includes('to left')) {
8250
- x1 = '100%';
8251
- x2 = '0%';
8252
- y2 = '0%';
8253
- stopsStartIdx = 1;
8254
- } else if (parts[0].includes('to top')) {
8255
- y1 = '100%';
8256
- y2 = '0%';
8257
- stopsStartIdx = 1;
8258
- } else if (parts[0].includes('to bottom')) {
8259
- y1 = '0%';
8260
- y2 = '100%';
8261
- stopsStartIdx = 1;
8268
+ let stopsStartIndex = 0;
8269
+ const firstPart = parts[0].toLowerCase();
8270
+
8271
+ // 1. Check for Keywords (to right, etc.)
8272
+ if (firstPart.startsWith('to ')) {
8273
+ stopsStartIndex = 1;
8274
+ const direction = firstPart.replace('to ', '').trim();
8275
+ switch (direction) {
8276
+ case 'top':
8277
+ y1 = '100%';
8278
+ y2 = '0%';
8279
+ break;
8280
+ case 'bottom':
8281
+ y1 = '0%';
8282
+ y2 = '100%';
8283
+ break;
8284
+ case 'left':
8285
+ x1 = '100%';
8286
+ x2 = '0%';
8287
+ break;
8288
+ case 'right':
8289
+ x2 = '100%';
8290
+ break;
8291
+ case 'top right':
8292
+ x1 = '0%';
8293
+ y1 = '100%';
8294
+ x2 = '100%';
8295
+ y2 = '0%';
8296
+ break;
8297
+ case 'top left':
8298
+ x1 = '100%';
8299
+ y1 = '100%';
8300
+ x2 = '0%';
8301
+ y2 = '0%';
8302
+ break;
8303
+ case 'bottom right':
8304
+ x2 = '100%';
8305
+ y2 = '100%';
8306
+ break;
8307
+ case 'bottom left':
8308
+ x1 = '100%';
8309
+ y2 = '100%';
8310
+ break;
8311
+ }
8312
+ }
8313
+ // 2. Check for Degrees (45deg, 90deg, etc.)
8314
+ else if (firstPart.match(/^-?[\d.]+(deg|rad|turn|grad)$/)) {
8315
+ stopsStartIndex = 1;
8316
+ const val = parseFloat(firstPart);
8317
+ // CSS 0deg is Top (North), 90deg is Right (East), 180deg is Bottom (South)
8318
+ // We convert this to SVG coordinates on a unit square (0-100%).
8319
+ // Formula: Map angle to perimeter coordinates.
8320
+ if (!isNaN(val)) {
8321
+ const deg = firstPart.includes('rad') ? val * (180 / Math.PI) : val;
8322
+ const cssRad = ((deg - 90) * Math.PI) / 180; // Correct CSS angle offset
8323
+
8324
+ // Calculate standard vector for rectangle center (50, 50)
8325
+ const scale = 50; // Distance from center to edge (approx)
8326
+ const cos = Math.cos(cssRad); // Y component (reversed in SVG)
8327
+ const sin = Math.sin(cssRad); // X component
8328
+
8329
+ // Invert Y for SVG coordinate system
8330
+ x1 = (50 - sin * scale).toFixed(1) + '%';
8331
+ y1 = (50 + cos * scale).toFixed(1) + '%';
8332
+ x2 = (50 + sin * scale).toFixed(1) + '%';
8333
+ y2 = (50 - cos * scale).toFixed(1) + '%';
8334
+ }
8262
8335
  }
8263
8336
 
8337
+ // 3. Process Color Stops
8264
8338
  let stopsXML = '';
8265
- const stopParts = parts.slice(stopsStartIdx);
8339
+ const stopParts = parts.slice(stopsStartIndex);
8340
+
8266
8341
  stopParts.forEach((part, idx) => {
8342
+ // Parse "Color Position" (e.g., "red 50%")
8343
+ // Regex looks for optional space + number + unit at the end of the string
8267
8344
  let color = part;
8268
8345
  let offset = Math.round((idx / (stopParts.length - 1)) * 100) + '%';
8269
- const posMatch = part.match(/(.*?)\s+(\d+(\.\d+)?%?)$/);
8346
+
8347
+ const posMatch = part.match(/^(.*?)\s+(-?[\d.]+(?:%|px)?)$/);
8270
8348
  if (posMatch) {
8271
8349
  color = posMatch[1];
8272
8350
  offset = posMatch[2];
8273
8351
  }
8352
+
8353
+ // Handle RGBA/RGB for SVG compatibility
8274
8354
  let opacity = 1;
8275
8355
  if (color.includes('rgba')) {
8276
- const rgba = color.match(/[\d.]+/g);
8277
- if (rgba && rgba.length > 3) {
8278
- opacity = rgba[3];
8279
- color = `rgb(${rgba[0]},${rgba[1]},${rgba[2]})`;
8356
+ const rgbaMatch = color.match(/[\d.]+/g);
8357
+ if (rgbaMatch && rgbaMatch.length >= 4) {
8358
+ opacity = rgbaMatch[3];
8359
+ color = `rgb(${rgbaMatch[0]},${rgbaMatch[1]},${rgbaMatch[2]})`;
8280
8360
  }
8281
8361
  }
8282
- stopsXML += `<stop offset="${offset}" stop-color="${color}" stop-opacity="${opacity}"/>`;
8362
+
8363
+ stopsXML += `<stop offset="${offset}" stop-color="${color.trim()}" stop-opacity="${opacity}"/>`;
8283
8364
  });
8284
8365
 
8285
8366
  let strokeAttr = '';
@@ -8288,12 +8369,18 @@
8288
8369
  }
8289
8370
 
8290
8371
  const svg = `
8291
- <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
8292
- <defs><linearGradient id="grad" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">${stopsXML}</linearGradient></defs>
8293
- <rect x="0" y="0" width="${w}" height="${h}" rx="${radius}" ry="${radius}" fill="url(#grad)" ${strokeAttr} />
8294
- </svg>`;
8372
+ <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
8373
+ <defs>
8374
+ <linearGradient id="grad" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">
8375
+ ${stopsXML}
8376
+ </linearGradient>
8377
+ </defs>
8378
+ <rect x="0" y="0" width="${w}" height="${h}" rx="${radius}" ry="${radius}" fill="url(#grad)" ${strokeAttr} />
8379
+ </svg>`;
8380
+
8295
8381
  return 'data:image/svg+xml;base64,' + btoa(svg);
8296
- } catch {
8382
+ } catch (e) {
8383
+ console.warn('Gradient generation failed:', e);
8297
8384
  return null;
8298
8385
  }
8299
8386
  }
@@ -8568,29 +8655,69 @@
8568
8655
  * Optimized html2canvas wrapper
8569
8656
  * Now strictly captures the node itself, not the root.
8570
8657
  */
8658
+ /**
8659
+ * Optimized html2canvas wrapper
8660
+ * Includes fix for cropped icons by adjusting styles in the cloned document.
8661
+ */
8571
8662
  async function elementToCanvasImage(node, widthPx, heightPx) {
8572
8663
  return new Promise((resolve) => {
8664
+ // 1. Assign a temp ID to locate the node inside the cloned document
8665
+ const originalId = node.id;
8666
+ const tempId = 'pptx-capture-' + Math.random().toString(36).substr(2, 9);
8667
+ node.id = tempId;
8668
+
8573
8669
  const width = Math.max(Math.ceil(widthPx), 1);
8574
8670
  const height = Math.max(Math.ceil(heightPx), 1);
8575
8671
  const style = window.getComputedStyle(node);
8576
8672
 
8577
- // Optimized: Capture ONLY the specific node
8578
8673
  html2canvas(node, {
8579
8674
  backgroundColor: null,
8580
8675
  logging: false,
8581
- scale: 2, // Slight quality boost
8676
+ scale: 3, // Higher scale for sharper icons
8677
+ useCORS: true, // critical for external fonts/images
8678
+ onclone: (clonedDoc) => {
8679
+ const clonedNode = clonedDoc.getElementById(tempId);
8680
+ if (clonedNode) {
8681
+ // --- FIX: PREVENT ICON CLIPPING ---
8682
+ // 1. Force overflow visible so glyphs bleeding out aren't cut
8683
+ clonedNode.style.overflow = 'visible';
8684
+
8685
+ // 2. Adjust alignment for Icons to prevent baseline clipping
8686
+ // (Applies to <i>, <span>, or standard icon classes)
8687
+ const tag = clonedNode.tagName;
8688
+ if (tag === 'I' || tag === 'SPAN' || clonedNode.className.includes('fa-')) {
8689
+ // Flex center helps align the glyph exactly in the middle of the box
8690
+ // preventing top/bottom cropping due to line-height mismatches.
8691
+ clonedNode.style.display = 'inline-flex';
8692
+ clonedNode.style.justifyContent = 'center';
8693
+ clonedNode.style.alignItems = 'center';
8694
+
8695
+ // Remove margins that might offset the capture
8696
+ clonedNode.style.margin = '0';
8697
+
8698
+ // Ensure the font fits
8699
+ clonedNode.style.lineHeight = '1';
8700
+ clonedNode.style.verticalAlign = 'middle';
8701
+ }
8702
+ }
8703
+ },
8582
8704
  })
8583
8705
  .then((canvas) => {
8706
+ // Restore the original ID
8707
+ if (originalId) node.id = originalId;
8708
+ else node.removeAttribute('id');
8709
+
8584
8710
  const destCanvas = document.createElement('canvas');
8585
8711
  destCanvas.width = width;
8586
8712
  destCanvas.height = height;
8587
8713
  const ctx = destCanvas.getContext('2d');
8588
8714
 
8589
- // Draw the captured canvas into our sized canvas
8590
- // html2canvas might return a larger canvas if scale > 1, so we fit it
8715
+ // Draw captured canvas.
8716
+ // We simply draw it to fill the box. Since we centered it in 'onclone',
8717
+ // the glyph should now be visible within the bounds.
8591
8718
  ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, 0, width, height);
8592
8719
 
8593
- // Apply border radius clipping
8720
+ // --- Border Radius Clipping (Existing Logic) ---
8594
8721
  let tl = parseFloat(style.borderTopLeftRadius) || 0;
8595
8722
  let tr = parseFloat(style.borderTopRightRadius) || 0;
8596
8723
  let br = parseFloat(style.borderBottomRightRadius) || 0;
@@ -8629,12 +8756,62 @@
8629
8756
  resolve(destCanvas.toDataURL('image/png'));
8630
8757
  })
8631
8758
  .catch((e) => {
8759
+ if (originalId) node.id = originalId;
8760
+ else node.removeAttribute('id');
8632
8761
  console.warn('Canvas capture failed for node', node, e);
8633
8762
  resolve(null);
8634
8763
  });
8635
8764
  });
8636
8765
  }
8637
8766
 
8767
+ /**
8768
+ * Helper to identify elements that should be rendered as icons (Images).
8769
+ * Detects Custom Elements AND generic tags (<i>, <span>) with icon classes/pseudo-elements.
8770
+ */
8771
+ function isIconElement(node) {
8772
+ // 1. Custom Elements (hyphenated tags) or Explicit Library Tags
8773
+ const tag = node.tagName.toUpperCase();
8774
+ if (
8775
+ tag.includes('-') ||
8776
+ [
8777
+ 'MATERIAL-ICON',
8778
+ 'ICONIFY-ICON',
8779
+ 'REMIX-ICON',
8780
+ 'ION-ICON',
8781
+ 'EVA-ICON',
8782
+ 'BOX-ICON',
8783
+ 'FA-ICON',
8784
+ ].includes(tag)
8785
+ ) {
8786
+ return true;
8787
+ }
8788
+
8789
+ // 2. Class-based Icons (FontAwesome, Bootstrap, Material symbols) on <i> or <span>
8790
+ if (tag === 'I' || tag === 'SPAN') {
8791
+ const cls = node.getAttribute('class') || '';
8792
+ if (
8793
+ typeof cls === 'string' &&
8794
+ (cls.includes('fa-') ||
8795
+ cls.includes('fas') ||
8796
+ cls.includes('far') ||
8797
+ cls.includes('fab') ||
8798
+ cls.includes('bi-') ||
8799
+ cls.includes('material-icons') ||
8800
+ cls.includes('icon'))
8801
+ ) {
8802
+ // Double-check: Must have pseudo-element content to be a CSS icon
8803
+ const before = window.getComputedStyle(node, '::before').content;
8804
+ const after = window.getComputedStyle(node, '::after').content;
8805
+ const hasContent = (c) => c && c !== 'none' && c !== 'normal' && c !== '""';
8806
+
8807
+ if (hasContent(before) || hasContent(after)) return true;
8808
+ }
8809
+ console.log('Icon element:', node, cls);
8810
+ }
8811
+
8812
+ return false;
8813
+ }
8814
+
8638
8815
  /**
8639
8816
  * Replaces createRenderItem.
8640
8817
  * Returns { items: [], job: () => Promise, stopRecursion: boolean }
@@ -8770,30 +8947,18 @@
8770
8947
  }
8771
8948
 
8772
8949
  // --- ASYNC JOB: Icons and Other Elements ---
8773
- if (
8774
- node.tagName.toUpperCase() === 'MATERIAL-ICON' ||
8775
- node.tagName.toUpperCase() === 'ICONIFY-ICON' ||
8776
- node.tagName.toUpperCase() === 'REMIX-ICON' ||
8777
- node.tagName.toUpperCase() === 'ION-ICON' ||
8778
- node.tagName.toUpperCase() === 'EVA-ICON' ||
8779
- node.tagName.toUpperCase() === 'BOX-ICON' ||
8780
- node.tagName.toUpperCase() === 'FA-ICON' ||
8781
- node.tagName.includes('-')
8782
- ) {
8950
+ if (isIconElement(node)) {
8783
8951
  const item = {
8784
8952
  type: 'image',
8785
8953
  zIndex,
8786
8954
  domOrder,
8787
- options: { x, y, w, h, rotate: rotation, data: null }, // Data null initially
8955
+ options: { x, y, w, h, rotate: rotation, data: null },
8788
8956
  };
8789
-
8790
- // Create Job
8791
8957
  const job = async () => {
8792
8958
  const pngData = await elementToCanvasImage(node, widthPx, heightPx);
8793
8959
  if (pngData) item.options.data = pngData;
8794
8960
  else item.skip = true;
8795
8961
  };
8796
-
8797
8962
  return { items: [item], job, stopRecursion: true };
8798
8963
  }
8799
8964