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.
@@ -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;
@@ -8111,6 +8130,69 @@ function getRotation(transformStr) {
8111
8130
  return Math.round(Math.atan2(b, a) * (180 / Math.PI));
8112
8131
  }
8113
8132
 
8133
+ function svgToPng(node) {
8134
+ return new Promise((resolve) => {
8135
+ const clone = node.cloneNode(true);
8136
+ const rect = node.getBoundingClientRect();
8137
+ const width = rect.width || 300;
8138
+ const height = rect.height || 150;
8139
+
8140
+ function inlineStyles(source, target) {
8141
+ const computed = window.getComputedStyle(source);
8142
+ const properties = [
8143
+ 'fill',
8144
+ 'stroke',
8145
+ 'stroke-width',
8146
+ 'stroke-linecap',
8147
+ 'stroke-linejoin',
8148
+ 'opacity',
8149
+ 'font-family',
8150
+ 'font-size',
8151
+ 'font-weight',
8152
+ ];
8153
+
8154
+ if (computed.fill === 'none') target.setAttribute('fill', 'none');
8155
+ else if (computed.fill) target.style.fill = computed.fill;
8156
+
8157
+ if (computed.stroke === 'none') target.setAttribute('stroke', 'none');
8158
+ else if (computed.stroke) target.style.stroke = computed.stroke;
8159
+
8160
+ properties.forEach((prop) => {
8161
+ if (prop !== 'fill' && prop !== 'stroke') {
8162
+ const val = computed[prop];
8163
+ if (val && val !== 'auto') target.style[prop] = val;
8164
+ }
8165
+ });
8166
+
8167
+ for (let i = 0; i < source.children.length; i++) {
8168
+ if (target.children[i]) inlineStyles(source.children[i], target.children[i]);
8169
+ }
8170
+ }
8171
+
8172
+ inlineStyles(node, clone);
8173
+ clone.setAttribute('width', width);
8174
+ clone.setAttribute('height', height);
8175
+ clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
8176
+
8177
+ const xml = new XMLSerializer().serializeToString(clone);
8178
+ const svgUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(xml)}`;
8179
+ const img = new Image();
8180
+ img.crossOrigin = 'Anonymous';
8181
+ img.onload = () => {
8182
+ const canvas = document.createElement('canvas');
8183
+ const scale = 3;
8184
+ canvas.width = width * scale;
8185
+ canvas.height = height * scale;
8186
+ const ctx = canvas.getContext('2d');
8187
+ ctx.scale(scale, scale);
8188
+ ctx.drawImage(img, 0, 0, width, height);
8189
+ resolve(canvas.toDataURL('image/png'));
8190
+ };
8191
+ img.onerror = () => resolve(null);
8192
+ img.src = svgUrl;
8193
+ });
8194
+ }
8195
+
8114
8196
  function getVisibleShadow(shadowStr, scale) {
8115
8197
  if (!shadowStr || shadowStr === 'none') return null;
8116
8198
  const shadows = shadowStr.split(/,(?![^()]*\))/);
@@ -8142,57 +8224,119 @@ function getVisibleShadow(shadowStr, scale) {
8142
8224
  return null;
8143
8225
  }
8144
8226
 
8227
+ /**
8228
+ * Generates an SVG image for gradients, supporting degrees and keywords.
8229
+ */
8145
8230
  function generateGradientSVG(w, h, bgString, radius, border) {
8146
8231
  try {
8147
8232
  const match = bgString.match(/linear-gradient\((.*)\)/);
8148
8233
  if (!match) return null;
8149
8234
  const content = match[1];
8235
+
8236
+ // Split by comma, ignoring commas inside parentheses (e.g. rgba())
8150
8237
  const parts = content.split(/,(?![^()]*\))/).map((p) => p.trim());
8238
+ if (parts.length < 2) return null;
8151
8239
 
8152
8240
  let x1 = '0%',
8153
8241
  y1 = '0%',
8154
8242
  x2 = '0%',
8155
8243
  y2 = '100%';
8156
- let stopsStartIdx = 0;
8157
- if (parts[0].includes('to right')) {
8158
- x1 = '0%';
8159
- x2 = '100%';
8160
- y2 = '0%';
8161
- stopsStartIdx = 1;
8162
- } else if (parts[0].includes('to left')) {
8163
- x1 = '100%';
8164
- x2 = '0%';
8165
- y2 = '0%';
8166
- stopsStartIdx = 1;
8167
- } else if (parts[0].includes('to top')) {
8168
- y1 = '100%';
8169
- y2 = '0%';
8170
- stopsStartIdx = 1;
8171
- } else if (parts[0].includes('to bottom')) {
8172
- y1 = '0%';
8173
- y2 = '100%';
8174
- 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
+ }
8175
8311
  }
8176
8312
 
8313
+ // 3. Process Color Stops
8177
8314
  let stopsXML = '';
8178
- const stopParts = parts.slice(stopsStartIdx);
8315
+ const stopParts = parts.slice(stopsStartIndex);
8316
+
8179
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
8180
8320
  let color = part;
8181
8321
  let offset = Math.round((idx / (stopParts.length - 1)) * 100) + '%';
8182
- const posMatch = part.match(/(.*?)\s+(\d+(\.\d+)?%?)$/);
8322
+
8323
+ const posMatch = part.match(/^(.*?)\s+(-?[\d.]+(?:%|px)?)$/);
8183
8324
  if (posMatch) {
8184
8325
  color = posMatch[1];
8185
8326
  offset = posMatch[2];
8186
8327
  }
8328
+
8329
+ // Handle RGBA/RGB for SVG compatibility
8187
8330
  let opacity = 1;
8188
8331
  if (color.includes('rgba')) {
8189
- const rgba = color.match(/[\d.]+/g);
8190
- if (rgba && rgba.length > 3) {
8191
- opacity = rgba[3];
8192
- 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]})`;
8193
8336
  }
8194
8337
  }
8195
- stopsXML += `<stop offset="${offset}" stop-color="${color}" stop-opacity="${opacity}"/>`;
8338
+
8339
+ stopsXML += `<stop offset="${offset}" stop-color="${color.trim()}" stop-opacity="${opacity}"/>`;
8196
8340
  });
8197
8341
 
8198
8342
  let strokeAttr = '';
@@ -8201,12 +8345,18 @@ function generateGradientSVG(w, h, bgString, radius, border) {
8201
8345
  }
8202
8346
 
8203
8347
  const svg = `
8204
- <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
8205
- <defs><linearGradient id="grad" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">${stopsXML}</linearGradient></defs>
8206
- <rect x="0" y="0" width="${w}" height="${h}" rx="${radius}" ry="${radius}" fill="url(#grad)" ${strokeAttr} />
8207
- </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
+
8208
8357
  return 'data:image/svg+xml;base64,' + btoa(svg);
8209
- } catch {
8358
+ } catch (e) {
8359
+ console.warn('Gradient generation failed:', e);
8210
8360
  return null;
8211
8361
  }
8212
8362
  }
@@ -8481,29 +8631,69 @@ async function processSlide(root, slide, pptx) {
8481
8631
  * Optimized html2canvas wrapper
8482
8632
  * Now strictly captures the node itself, not the root.
8483
8633
  */
8634
+ /**
8635
+ * Optimized html2canvas wrapper
8636
+ * Includes fix for cropped icons by adjusting styles in the cloned document.
8637
+ */
8484
8638
  async function elementToCanvasImage(node, widthPx, heightPx) {
8485
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
+
8486
8645
  const width = Math.max(Math.ceil(widthPx), 1);
8487
8646
  const height = Math.max(Math.ceil(heightPx), 1);
8488
8647
  const style = window.getComputedStyle(node);
8489
8648
 
8490
- // Optimized: Capture ONLY the specific node
8491
8649
  html2canvas(node, {
8492
8650
  backgroundColor: null,
8493
8651
  logging: false,
8494
- 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
+ },
8495
8680
  })
8496
8681
  .then((canvas) => {
8682
+ // Restore the original ID
8683
+ if (originalId) node.id = originalId;
8684
+ else node.removeAttribute('id');
8685
+
8497
8686
  const destCanvas = document.createElement('canvas');
8498
8687
  destCanvas.width = width;
8499
8688
  destCanvas.height = height;
8500
8689
  const ctx = destCanvas.getContext('2d');
8501
8690
 
8502
- // Draw the captured canvas into our sized canvas
8503
- // 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.
8504
8694
  ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, 0, width, height);
8505
8695
 
8506
- // Apply border radius clipping
8696
+ // --- Border Radius Clipping (Existing Logic) ---
8507
8697
  let tl = parseFloat(style.borderTopLeftRadius) || 0;
8508
8698
  let tr = parseFloat(style.borderTopRightRadius) || 0;
8509
8699
  let br = parseFloat(style.borderBottomRightRadius) || 0;
@@ -8542,12 +8732,62 @@ async function elementToCanvasImage(node, widthPx, heightPx) {
8542
8732
  resolve(destCanvas.toDataURL('image/png'));
8543
8733
  })
8544
8734
  .catch((e) => {
8735
+ if (originalId) node.id = originalId;
8736
+ else node.removeAttribute('id');
8545
8737
  console.warn('Canvas capture failed for node', node, e);
8546
8738
  resolve(null);
8547
8739
  });
8548
8740
  });
8549
8741
  }
8550
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
+
8551
8791
  /**
8552
8792
  * Replaces createRenderItem.
8553
8793
  * Returns { items: [], job: () => Promise, stopRecursion: boolean }
@@ -8621,23 +8861,18 @@ function prepareRenderItem(node, config, domOrder, pptx, effectiveZIndex, comput
8621
8861
 
8622
8862
  const items = [];
8623
8863
 
8624
- // --- ASYNC JOB: SVGs / Icons ---
8625
- if (
8626
- node.nodeName.toUpperCase() === 'SVG' ||
8627
- node.tagName.includes('-') ||
8628
- node.tagName === 'ION-ICON'
8629
- ) {
8864
+ // --- ASYNC JOB: SVG Tags ---
8865
+ if (node.nodeName.toUpperCase() === 'SVG') {
8630
8866
  const item = {
8631
8867
  type: 'image',
8632
8868
  zIndex,
8633
8869
  domOrder,
8634
- options: { x, y, w, h, rotate: rotation, data: null }, // Data null initially
8870
+ options: { data: null, x, y, w, h, rotate: rotation },
8635
8871
  };
8636
8872
 
8637
- // Create Job
8638
8873
  const job = async () => {
8639
- const pngData = await elementToCanvasImage(node, widthPx, heightPx);
8640
- if (pngData) item.options.data = pngData;
8874
+ const processed = await svgToPng(node);
8875
+ if (processed) item.options.data = processed;
8641
8876
  else item.skip = true;
8642
8877
  };
8643
8878
 
@@ -8687,6 +8922,22 @@ function prepareRenderItem(node, config, domOrder, pptx, effectiveZIndex, comput
8687
8922
  return { items: [item], job, stopRecursion: true };
8688
8923
  }
8689
8924
 
8925
+ // --- ASYNC JOB: Icons and Other Elements ---
8926
+ if (isIconElement(node)) {
8927
+ const item = {
8928
+ type: 'image',
8929
+ zIndex,
8930
+ domOrder,
8931
+ options: { x, y, w, h, rotate: rotation, data: null },
8932
+ };
8933
+ const job = async () => {
8934
+ const pngData = await elementToCanvasImage(node, widthPx, heightPx);
8935
+ if (pngData) item.options.data = pngData;
8936
+ else item.skip = true;
8937
+ };
8938
+ return { items: [item], job, stopRecursion: true };
8939
+ }
8940
+
8690
8941
  // Radii logic
8691
8942
  const borderRadiusValue = parseFloat(style.borderRadius) || 0;
8692
8943
  const borderBottomLeftRadius = parseFloat(style.borderBottomLeftRadius) || 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dom-to-pptx",
3
- "version": "1.0.7",
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",