dom-to-pptx 1.0.7 → 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;
@@ -8135,6 +8154,69 @@
8135
8154
  return Math.round(Math.atan2(b, a) * (180 / Math.PI));
8136
8155
  }
8137
8156
 
8157
+ function svgToPng(node) {
8158
+ return new Promise((resolve) => {
8159
+ const clone = node.cloneNode(true);
8160
+ const rect = node.getBoundingClientRect();
8161
+ const width = rect.width || 300;
8162
+ const height = rect.height || 150;
8163
+
8164
+ function inlineStyles(source, target) {
8165
+ const computed = window.getComputedStyle(source);
8166
+ const properties = [
8167
+ 'fill',
8168
+ 'stroke',
8169
+ 'stroke-width',
8170
+ 'stroke-linecap',
8171
+ 'stroke-linejoin',
8172
+ 'opacity',
8173
+ 'font-family',
8174
+ 'font-size',
8175
+ 'font-weight',
8176
+ ];
8177
+
8178
+ if (computed.fill === 'none') target.setAttribute('fill', 'none');
8179
+ else if (computed.fill) target.style.fill = computed.fill;
8180
+
8181
+ if (computed.stroke === 'none') target.setAttribute('stroke', 'none');
8182
+ else if (computed.stroke) target.style.stroke = computed.stroke;
8183
+
8184
+ properties.forEach((prop) => {
8185
+ if (prop !== 'fill' && prop !== 'stroke') {
8186
+ const val = computed[prop];
8187
+ if (val && val !== 'auto') target.style[prop] = val;
8188
+ }
8189
+ });
8190
+
8191
+ for (let i = 0; i < source.children.length; i++) {
8192
+ if (target.children[i]) inlineStyles(source.children[i], target.children[i]);
8193
+ }
8194
+ }
8195
+
8196
+ inlineStyles(node, clone);
8197
+ clone.setAttribute('width', width);
8198
+ clone.setAttribute('height', height);
8199
+ clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
8200
+
8201
+ const xml = new XMLSerializer().serializeToString(clone);
8202
+ const svgUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(xml)}`;
8203
+ const img = new Image();
8204
+ img.crossOrigin = 'Anonymous';
8205
+ img.onload = () => {
8206
+ const canvas = document.createElement('canvas');
8207
+ const scale = 3;
8208
+ canvas.width = width * scale;
8209
+ canvas.height = height * scale;
8210
+ const ctx = canvas.getContext('2d');
8211
+ ctx.scale(scale, scale);
8212
+ ctx.drawImage(img, 0, 0, width, height);
8213
+ resolve(canvas.toDataURL('image/png'));
8214
+ };
8215
+ img.onerror = () => resolve(null);
8216
+ img.src = svgUrl;
8217
+ });
8218
+ }
8219
+
8138
8220
  function getVisibleShadow(shadowStr, scale) {
8139
8221
  if (!shadowStr || shadowStr === 'none') return null;
8140
8222
  const shadows = shadowStr.split(/,(?![^()]*\))/);
@@ -8166,57 +8248,119 @@
8166
8248
  return null;
8167
8249
  }
8168
8250
 
8251
+ /**
8252
+ * Generates an SVG image for gradients, supporting degrees and keywords.
8253
+ */
8169
8254
  function generateGradientSVG(w, h, bgString, radius, border) {
8170
8255
  try {
8171
8256
  const match = bgString.match(/linear-gradient\((.*)\)/);
8172
8257
  if (!match) return null;
8173
8258
  const content = match[1];
8259
+
8260
+ // Split by comma, ignoring commas inside parentheses (e.g. rgba())
8174
8261
  const parts = content.split(/,(?![^()]*\))/).map((p) => p.trim());
8262
+ if (parts.length < 2) return null;
8175
8263
 
8176
8264
  let x1 = '0%',
8177
8265
  y1 = '0%',
8178
8266
  x2 = '0%',
8179
8267
  y2 = '100%';
8180
- let stopsStartIdx = 0;
8181
- if (parts[0].includes('to right')) {
8182
- x1 = '0%';
8183
- x2 = '100%';
8184
- y2 = '0%';
8185
- stopsStartIdx = 1;
8186
- } else if (parts[0].includes('to left')) {
8187
- x1 = '100%';
8188
- x2 = '0%';
8189
- y2 = '0%';
8190
- stopsStartIdx = 1;
8191
- } else if (parts[0].includes('to top')) {
8192
- y1 = '100%';
8193
- y2 = '0%';
8194
- stopsStartIdx = 1;
8195
- } else if (parts[0].includes('to bottom')) {
8196
- y1 = '0%';
8197
- y2 = '100%';
8198
- 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
+ }
8199
8335
  }
8200
8336
 
8337
+ // 3. Process Color Stops
8201
8338
  let stopsXML = '';
8202
- const stopParts = parts.slice(stopsStartIdx);
8339
+ const stopParts = parts.slice(stopsStartIndex);
8340
+
8203
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
8204
8344
  let color = part;
8205
8345
  let offset = Math.round((idx / (stopParts.length - 1)) * 100) + '%';
8206
- const posMatch = part.match(/(.*?)\s+(\d+(\.\d+)?%?)$/);
8346
+
8347
+ const posMatch = part.match(/^(.*?)\s+(-?[\d.]+(?:%|px)?)$/);
8207
8348
  if (posMatch) {
8208
8349
  color = posMatch[1];
8209
8350
  offset = posMatch[2];
8210
8351
  }
8352
+
8353
+ // Handle RGBA/RGB for SVG compatibility
8211
8354
  let opacity = 1;
8212
8355
  if (color.includes('rgba')) {
8213
- const rgba = color.match(/[\d.]+/g);
8214
- if (rgba && rgba.length > 3) {
8215
- opacity = rgba[3];
8216
- 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]})`;
8217
8360
  }
8218
8361
  }
8219
- stopsXML += `<stop offset="${offset}" stop-color="${color}" stop-opacity="${opacity}"/>`;
8362
+
8363
+ stopsXML += `<stop offset="${offset}" stop-color="${color.trim()}" stop-opacity="${opacity}"/>`;
8220
8364
  });
8221
8365
 
8222
8366
  let strokeAttr = '';
@@ -8225,12 +8369,18 @@
8225
8369
  }
8226
8370
 
8227
8371
  const svg = `
8228
- <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
8229
- <defs><linearGradient id="grad" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">${stopsXML}</linearGradient></defs>
8230
- <rect x="0" y="0" width="${w}" height="${h}" rx="${radius}" ry="${radius}" fill="url(#grad)" ${strokeAttr} />
8231
- </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
+
8232
8381
  return 'data:image/svg+xml;base64,' + btoa(svg);
8233
- } catch {
8382
+ } catch (e) {
8383
+ console.warn('Gradient generation failed:', e);
8234
8384
  return null;
8235
8385
  }
8236
8386
  }
@@ -8505,29 +8655,69 @@
8505
8655
  * Optimized html2canvas wrapper
8506
8656
  * Now strictly captures the node itself, not the root.
8507
8657
  */
8658
+ /**
8659
+ * Optimized html2canvas wrapper
8660
+ * Includes fix for cropped icons by adjusting styles in the cloned document.
8661
+ */
8508
8662
  async function elementToCanvasImage(node, widthPx, heightPx) {
8509
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
+
8510
8669
  const width = Math.max(Math.ceil(widthPx), 1);
8511
8670
  const height = Math.max(Math.ceil(heightPx), 1);
8512
8671
  const style = window.getComputedStyle(node);
8513
8672
 
8514
- // Optimized: Capture ONLY the specific node
8515
8673
  html2canvas(node, {
8516
8674
  backgroundColor: null,
8517
8675
  logging: false,
8518
- 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
+ },
8519
8704
  })
8520
8705
  .then((canvas) => {
8706
+ // Restore the original ID
8707
+ if (originalId) node.id = originalId;
8708
+ else node.removeAttribute('id');
8709
+
8521
8710
  const destCanvas = document.createElement('canvas');
8522
8711
  destCanvas.width = width;
8523
8712
  destCanvas.height = height;
8524
8713
  const ctx = destCanvas.getContext('2d');
8525
8714
 
8526
- // Draw the captured canvas into our sized canvas
8527
- // 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.
8528
8718
  ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, 0, width, height);
8529
8719
 
8530
- // Apply border radius clipping
8720
+ // --- Border Radius Clipping (Existing Logic) ---
8531
8721
  let tl = parseFloat(style.borderTopLeftRadius) || 0;
8532
8722
  let tr = parseFloat(style.borderTopRightRadius) || 0;
8533
8723
  let br = parseFloat(style.borderBottomRightRadius) || 0;
@@ -8566,12 +8756,62 @@
8566
8756
  resolve(destCanvas.toDataURL('image/png'));
8567
8757
  })
8568
8758
  .catch((e) => {
8759
+ if (originalId) node.id = originalId;
8760
+ else node.removeAttribute('id');
8569
8761
  console.warn('Canvas capture failed for node', node, e);
8570
8762
  resolve(null);
8571
8763
  });
8572
8764
  });
8573
8765
  }
8574
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
+
8575
8815
  /**
8576
8816
  * Replaces createRenderItem.
8577
8817
  * Returns { items: [], job: () => Promise, stopRecursion: boolean }
@@ -8645,23 +8885,18 @@
8645
8885
 
8646
8886
  const items = [];
8647
8887
 
8648
- // --- ASYNC JOB: SVGs / Icons ---
8649
- if (
8650
- node.nodeName.toUpperCase() === 'SVG' ||
8651
- node.tagName.includes('-') ||
8652
- node.tagName === 'ION-ICON'
8653
- ) {
8888
+ // --- ASYNC JOB: SVG Tags ---
8889
+ if (node.nodeName.toUpperCase() === 'SVG') {
8654
8890
  const item = {
8655
8891
  type: 'image',
8656
8892
  zIndex,
8657
8893
  domOrder,
8658
- options: { x, y, w, h, rotate: rotation, data: null }, // Data null initially
8894
+ options: { data: null, x, y, w, h, rotate: rotation },
8659
8895
  };
8660
8896
 
8661
- // Create Job
8662
8897
  const job = async () => {
8663
- const pngData = await elementToCanvasImage(node, widthPx, heightPx);
8664
- if (pngData) item.options.data = pngData;
8898
+ const processed = await svgToPng(node);
8899
+ if (processed) item.options.data = processed;
8665
8900
  else item.skip = true;
8666
8901
  };
8667
8902
 
@@ -8711,6 +8946,22 @@
8711
8946
  return { items: [item], job, stopRecursion: true };
8712
8947
  }
8713
8948
 
8949
+ // --- ASYNC JOB: Icons and Other Elements ---
8950
+ if (isIconElement(node)) {
8951
+ const item = {
8952
+ type: 'image',
8953
+ zIndex,
8954
+ domOrder,
8955
+ options: { x, y, w, h, rotate: rotation, data: null },
8956
+ };
8957
+ const job = async () => {
8958
+ const pngData = await elementToCanvasImage(node, widthPx, heightPx);
8959
+ if (pngData) item.options.data = pngData;
8960
+ else item.skip = true;
8961
+ };
8962
+ return { items: [item], job, stopRecursion: true };
8963
+ }
8964
+
8714
8965
  // Radii logic
8715
8966
  const borderRadiusValue = parseFloat(style.borderRadius) || 0;
8716
8967
  const borderBottomLeftRadius = parseFloat(style.borderBottomLeftRadius) || 0;