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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.0.9] - 2025-12-28
6
+
7
+ ### Fixed
8
+
9
+ - **Linear Gradients not applied properly**: Seperated the logic to handle tailwind class and normal css.
10
+ - **Font Awesome icon library cannot be converted**: Updated logic to handle Icons inside lists and span elements. fixes [#3].
11
+
12
+ ## [1.0.8] - 2025-12-12 (Hot-Patch)
13
+
14
+ ### Fixed
15
+
16
+ - **Fixed SVGs not getting converted**: Seperated the logic to handle SVGs and Web Components/Icons.
17
+
18
+
5
19
  ## [1.0.7] - 2025-12-12
6
20
 
7
21
  ### Fixed
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dom-to-pptx
2
2
 
3
- **The High-Fidelity HTML to PowerPoint Converter (v1.0.7).**
3
+ **The High-Fidelity HTML to PowerPoint Converter (v1.0.8).**
4
4
 
5
5
  Most HTML-to-PPTX libraries fail when faced with modern web design. They break on gradients, misalign text, ignore rounded corners, or simply take a screenshot (which isn't editable).
6
6
 
@@ -17752,6 +17752,7 @@
17752
17752
 
17753
17753
  /**
17754
17754
  * Determines if a given DOM node is primarily a text container.
17755
+ * Updated to correctly reject Icon elements so they are rendered as images.
17755
17756
  */
17756
17757
  function isTextContainer(node) {
17757
17758
  const hasText = node.textContent.trim().length > 0;
@@ -17760,28 +17761,46 @@
17760
17761
  const children = Array.from(node.children);
17761
17762
  if (children.length === 0) return true;
17762
17763
 
17763
- // Check if children are purely inline text formatting or visual shapes
17764
17764
  const isSafeInline = (el) => {
17765
- // 1. Reject Web Components / Icons / Images
17765
+ // 1. Reject Web Components / Custom Elements
17766
17766
  if (el.tagName.includes('-')) return false;
17767
+ // 2. Reject Explicit Images/SVGs
17767
17768
  if (el.tagName === 'IMG' || el.tagName === 'SVG') return false;
17768
17769
 
17770
+ // 3. Reject Class-based Icons (FontAwesome, Material, Bootstrap, etc.)
17771
+ // If an <i> or <span> has icon classes, it is a visual object, not text.
17772
+ if (el.tagName === 'I' || el.tagName === 'SPAN') {
17773
+ const cls = el.getAttribute('class') || '';
17774
+ if (
17775
+ cls.includes('fa-') ||
17776
+ cls.includes('fas') ||
17777
+ cls.includes('far') ||
17778
+ cls.includes('fab') ||
17779
+ cls.includes('material-icons') ||
17780
+ cls.includes('bi-') ||
17781
+ cls.includes('icon')
17782
+ ) {
17783
+ return false;
17784
+ }
17785
+ }
17786
+
17769
17787
  const style = window.getComputedStyle(el);
17770
17788
  const display = style.display;
17771
17789
 
17772
- // 2. Initial check: Must be a standard inline tag OR display:inline
17773
- const isInlineTag = ['SPAN', 'B', 'STRONG', 'EM', 'I', 'A', 'SMALL', 'MARK'].includes(el.tagName);
17790
+ // 4. Standard Inline Tag Check
17791
+ const isInlineTag = ['SPAN', 'B', 'STRONG', 'EM', 'I', 'A', 'SMALL', 'MARK'].includes(
17792
+ el.tagName
17793
+ );
17774
17794
  const isInlineDisplay = display.includes('inline');
17775
17795
 
17776
17796
  if (!isInlineTag && !isInlineDisplay) return false;
17777
17797
 
17778
- // 3. CRITICAL FIX: Check for Structural Styling
17779
- // PPTX Text Runs (parts of a text line) CANNOT have backgrounds, borders, or padding.
17780
- // If a child element has these, the parent is NOT a simple text container;
17781
- // it is a layout container composed of styled blocks.
17798
+ // 5. Structural Styling Check
17799
+ // If a child has a background or border, it's a layout block, not a simple text span.
17782
17800
  const bgColor = parseColor(style.backgroundColor);
17783
17801
  const hasVisibleBg = bgColor.hex && bgColor.opacity > 0;
17784
- const hasBorder = parseFloat(style.borderWidth) > 0 && parseColor(style.borderColor).opacity > 0;
17802
+ const hasBorder =
17803
+ parseFloat(style.borderWidth) > 0 && parseColor(style.borderColor).opacity > 0;
17785
17804
 
17786
17805
  if (hasVisibleBg || hasBorder) {
17787
17806
  return false;
@@ -17808,6 +17827,69 @@
17808
17827
  return Math.round(Math.atan2(b, a) * (180 / Math.PI));
17809
17828
  }
17810
17829
 
17830
+ function svgToPng(node) {
17831
+ return new Promise((resolve) => {
17832
+ const clone = node.cloneNode(true);
17833
+ const rect = node.getBoundingClientRect();
17834
+ const width = rect.width || 300;
17835
+ const height = rect.height || 150;
17836
+
17837
+ function inlineStyles(source, target) {
17838
+ const computed = window.getComputedStyle(source);
17839
+ const properties = [
17840
+ 'fill',
17841
+ 'stroke',
17842
+ 'stroke-width',
17843
+ 'stroke-linecap',
17844
+ 'stroke-linejoin',
17845
+ 'opacity',
17846
+ 'font-family',
17847
+ 'font-size',
17848
+ 'font-weight',
17849
+ ];
17850
+
17851
+ if (computed.fill === 'none') target.setAttribute('fill', 'none');
17852
+ else if (computed.fill) target.style.fill = computed.fill;
17853
+
17854
+ if (computed.stroke === 'none') target.setAttribute('stroke', 'none');
17855
+ else if (computed.stroke) target.style.stroke = computed.stroke;
17856
+
17857
+ properties.forEach((prop) => {
17858
+ if (prop !== 'fill' && prop !== 'stroke') {
17859
+ const val = computed[prop];
17860
+ if (val && val !== 'auto') target.style[prop] = val;
17861
+ }
17862
+ });
17863
+
17864
+ for (let i = 0; i < source.children.length; i++) {
17865
+ if (target.children[i]) inlineStyles(source.children[i], target.children[i]);
17866
+ }
17867
+ }
17868
+
17869
+ inlineStyles(node, clone);
17870
+ clone.setAttribute('width', width);
17871
+ clone.setAttribute('height', height);
17872
+ clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
17873
+
17874
+ const xml = new XMLSerializer().serializeToString(clone);
17875
+ const svgUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(xml)}`;
17876
+ const img = new Image();
17877
+ img.crossOrigin = 'Anonymous';
17878
+ img.onload = () => {
17879
+ const canvas = document.createElement('canvas');
17880
+ const scale = 3;
17881
+ canvas.width = width * scale;
17882
+ canvas.height = height * scale;
17883
+ const ctx = canvas.getContext('2d');
17884
+ ctx.scale(scale, scale);
17885
+ ctx.drawImage(img, 0, 0, width, height);
17886
+ resolve(canvas.toDataURL('image/png'));
17887
+ };
17888
+ img.onerror = () => resolve(null);
17889
+ img.src = svgUrl;
17890
+ });
17891
+ }
17892
+
17811
17893
  function getVisibleShadow(shadowStr, scale) {
17812
17894
  if (!shadowStr || shadowStr === 'none') return null;
17813
17895
  const shadows = shadowStr.split(/,(?![^()]*\))/);
@@ -17839,57 +17921,119 @@
17839
17921
  return null;
17840
17922
  }
17841
17923
 
17924
+ /**
17925
+ * Generates an SVG image for gradients, supporting degrees and keywords.
17926
+ */
17842
17927
  function generateGradientSVG(w, h, bgString, radius, border) {
17843
17928
  try {
17844
17929
  const match = bgString.match(/linear-gradient\((.*)\)/);
17845
17930
  if (!match) return null;
17846
17931
  const content = match[1];
17932
+
17933
+ // Split by comma, ignoring commas inside parentheses (e.g. rgba())
17847
17934
  const parts = content.split(/,(?![^()]*\))/).map((p) => p.trim());
17935
+ if (parts.length < 2) return null;
17848
17936
 
17849
17937
  let x1 = '0%',
17850
17938
  y1 = '0%',
17851
17939
  x2 = '0%',
17852
17940
  y2 = '100%';
17853
- let stopsStartIdx = 0;
17854
- if (parts[0].includes('to right')) {
17855
- x1 = '0%';
17856
- x2 = '100%';
17857
- y2 = '0%';
17858
- stopsStartIdx = 1;
17859
- } else if (parts[0].includes('to left')) {
17860
- x1 = '100%';
17861
- x2 = '0%';
17862
- y2 = '0%';
17863
- stopsStartIdx = 1;
17864
- } else if (parts[0].includes('to top')) {
17865
- y1 = '100%';
17866
- y2 = '0%';
17867
- stopsStartIdx = 1;
17868
- } else if (parts[0].includes('to bottom')) {
17869
- y1 = '0%';
17870
- y2 = '100%';
17871
- stopsStartIdx = 1;
17941
+ let stopsStartIndex = 0;
17942
+ const firstPart = parts[0].toLowerCase();
17943
+
17944
+ // 1. Check for Keywords (to right, etc.)
17945
+ if (firstPart.startsWith('to ')) {
17946
+ stopsStartIndex = 1;
17947
+ const direction = firstPart.replace('to ', '').trim();
17948
+ switch (direction) {
17949
+ case 'top':
17950
+ y1 = '100%';
17951
+ y2 = '0%';
17952
+ break;
17953
+ case 'bottom':
17954
+ y1 = '0%';
17955
+ y2 = '100%';
17956
+ break;
17957
+ case 'left':
17958
+ x1 = '100%';
17959
+ x2 = '0%';
17960
+ break;
17961
+ case 'right':
17962
+ x2 = '100%';
17963
+ break;
17964
+ case 'top right':
17965
+ x1 = '0%';
17966
+ y1 = '100%';
17967
+ x2 = '100%';
17968
+ y2 = '0%';
17969
+ break;
17970
+ case 'top left':
17971
+ x1 = '100%';
17972
+ y1 = '100%';
17973
+ x2 = '0%';
17974
+ y2 = '0%';
17975
+ break;
17976
+ case 'bottom right':
17977
+ x2 = '100%';
17978
+ y2 = '100%';
17979
+ break;
17980
+ case 'bottom left':
17981
+ x1 = '100%';
17982
+ y2 = '100%';
17983
+ break;
17984
+ }
17985
+ }
17986
+ // 2. Check for Degrees (45deg, 90deg, etc.)
17987
+ else if (firstPart.match(/^-?[\d.]+(deg|rad|turn|grad)$/)) {
17988
+ stopsStartIndex = 1;
17989
+ const val = parseFloat(firstPart);
17990
+ // CSS 0deg is Top (North), 90deg is Right (East), 180deg is Bottom (South)
17991
+ // We convert this to SVG coordinates on a unit square (0-100%).
17992
+ // Formula: Map angle to perimeter coordinates.
17993
+ if (!isNaN(val)) {
17994
+ const deg = firstPart.includes('rad') ? val * (180 / Math.PI) : val;
17995
+ const cssRad = ((deg - 90) * Math.PI) / 180; // Correct CSS angle offset
17996
+
17997
+ // Calculate standard vector for rectangle center (50, 50)
17998
+ const scale = 50; // Distance from center to edge (approx)
17999
+ const cos = Math.cos(cssRad); // Y component (reversed in SVG)
18000
+ const sin = Math.sin(cssRad); // X component
18001
+
18002
+ // Invert Y for SVG coordinate system
18003
+ x1 = (50 - sin * scale).toFixed(1) + '%';
18004
+ y1 = (50 + cos * scale).toFixed(1) + '%';
18005
+ x2 = (50 + sin * scale).toFixed(1) + '%';
18006
+ y2 = (50 - cos * scale).toFixed(1) + '%';
18007
+ }
17872
18008
  }
17873
18009
 
18010
+ // 3. Process Color Stops
17874
18011
  let stopsXML = '';
17875
- const stopParts = parts.slice(stopsStartIdx);
18012
+ const stopParts = parts.slice(stopsStartIndex);
18013
+
17876
18014
  stopParts.forEach((part, idx) => {
18015
+ // Parse "Color Position" (e.g., "red 50%")
18016
+ // Regex looks for optional space + number + unit at the end of the string
17877
18017
  let color = part;
17878
18018
  let offset = Math.round((idx / (stopParts.length - 1)) * 100) + '%';
17879
- const posMatch = part.match(/(.*?)\s+(\d+(\.\d+)?%?)$/);
18019
+
18020
+ const posMatch = part.match(/^(.*?)\s+(-?[\d.]+(?:%|px)?)$/);
17880
18021
  if (posMatch) {
17881
18022
  color = posMatch[1];
17882
18023
  offset = posMatch[2];
17883
18024
  }
18025
+
18026
+ // Handle RGBA/RGB for SVG compatibility
17884
18027
  let opacity = 1;
17885
18028
  if (color.includes('rgba')) {
17886
- const rgba = color.match(/[\d.]+/g);
17887
- if (rgba && rgba.length > 3) {
17888
- opacity = rgba[3];
17889
- color = `rgb(${rgba[0]},${rgba[1]},${rgba[2]})`;
18029
+ const rgbaMatch = color.match(/[\d.]+/g);
18030
+ if (rgbaMatch && rgbaMatch.length >= 4) {
18031
+ opacity = rgbaMatch[3];
18032
+ color = `rgb(${rgbaMatch[0]},${rgbaMatch[1]},${rgbaMatch[2]})`;
17890
18033
  }
17891
18034
  }
17892
- stopsXML += `<stop offset="${offset}" stop-color="${color}" stop-opacity="${opacity}"/>`;
18035
+
18036
+ stopsXML += `<stop offset="${offset}" stop-color="${color.trim()}" stop-opacity="${opacity}"/>`;
17893
18037
  });
17894
18038
 
17895
18039
  let strokeAttr = '';
@@ -17898,12 +18042,18 @@
17898
18042
  }
17899
18043
 
17900
18044
  const svg = `
17901
- <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
17902
- <defs><linearGradient id="grad" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">${stopsXML}</linearGradient></defs>
17903
- <rect x="0" y="0" width="${w}" height="${h}" rx="${radius}" ry="${radius}" fill="url(#grad)" ${strokeAttr} />
17904
- </svg>`;
18045
+ <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
18046
+ <defs>
18047
+ <linearGradient id="grad" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">
18048
+ ${stopsXML}
18049
+ </linearGradient>
18050
+ </defs>
18051
+ <rect x="0" y="0" width="${w}" height="${h}" rx="${radius}" ry="${radius}" fill="url(#grad)" ${strokeAttr} />
18052
+ </svg>`;
18053
+
17905
18054
  return 'data:image/svg+xml;base64,' + btoa(svg);
17906
- } catch {
18055
+ } catch (e) {
18056
+ console.warn('Gradient generation failed:', e);
17907
18057
  return null;
17908
18058
  }
17909
18059
  }
@@ -18178,29 +18328,69 @@
18178
18328
  * Optimized html2canvas wrapper
18179
18329
  * Now strictly captures the node itself, not the root.
18180
18330
  */
18331
+ /**
18332
+ * Optimized html2canvas wrapper
18333
+ * Includes fix for cropped icons by adjusting styles in the cloned document.
18334
+ */
18181
18335
  async function elementToCanvasImage(node, widthPx, heightPx) {
18182
18336
  return new Promise((resolve) => {
18337
+ // 1. Assign a temp ID to locate the node inside the cloned document
18338
+ const originalId = node.id;
18339
+ const tempId = 'pptx-capture-' + Math.random().toString(36).substr(2, 9);
18340
+ node.id = tempId;
18341
+
18183
18342
  const width = Math.max(Math.ceil(widthPx), 1);
18184
18343
  const height = Math.max(Math.ceil(heightPx), 1);
18185
18344
  const style = window.getComputedStyle(node);
18186
18345
 
18187
- // Optimized: Capture ONLY the specific node
18188
18346
  html2canvas(node, {
18189
18347
  backgroundColor: null,
18190
18348
  logging: false,
18191
- scale: 2, // Slight quality boost
18349
+ scale: 3, // Higher scale for sharper icons
18350
+ useCORS: true, // critical for external fonts/images
18351
+ onclone: (clonedDoc) => {
18352
+ const clonedNode = clonedDoc.getElementById(tempId);
18353
+ if (clonedNode) {
18354
+ // --- FIX: PREVENT ICON CLIPPING ---
18355
+ // 1. Force overflow visible so glyphs bleeding out aren't cut
18356
+ clonedNode.style.overflow = 'visible';
18357
+
18358
+ // 2. Adjust alignment for Icons to prevent baseline clipping
18359
+ // (Applies to <i>, <span>, or standard icon classes)
18360
+ const tag = clonedNode.tagName;
18361
+ if (tag === 'I' || tag === 'SPAN' || clonedNode.className.includes('fa-')) {
18362
+ // Flex center helps align the glyph exactly in the middle of the box
18363
+ // preventing top/bottom cropping due to line-height mismatches.
18364
+ clonedNode.style.display = 'inline-flex';
18365
+ clonedNode.style.justifyContent = 'center';
18366
+ clonedNode.style.alignItems = 'center';
18367
+
18368
+ // Remove margins that might offset the capture
18369
+ clonedNode.style.margin = '0';
18370
+
18371
+ // Ensure the font fits
18372
+ clonedNode.style.lineHeight = '1';
18373
+ clonedNode.style.verticalAlign = 'middle';
18374
+ }
18375
+ }
18376
+ },
18192
18377
  })
18193
18378
  .then((canvas) => {
18379
+ // Restore the original ID
18380
+ if (originalId) node.id = originalId;
18381
+ else node.removeAttribute('id');
18382
+
18194
18383
  const destCanvas = document.createElement('canvas');
18195
18384
  destCanvas.width = width;
18196
18385
  destCanvas.height = height;
18197
18386
  const ctx = destCanvas.getContext('2d');
18198
18387
 
18199
- // Draw the captured canvas into our sized canvas
18200
- // html2canvas might return a larger canvas if scale > 1, so we fit it
18388
+ // Draw captured canvas.
18389
+ // We simply draw it to fill the box. Since we centered it in 'onclone',
18390
+ // the glyph should now be visible within the bounds.
18201
18391
  ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, 0, width, height);
18202
18392
 
18203
- // Apply border radius clipping
18393
+ // --- Border Radius Clipping (Existing Logic) ---
18204
18394
  let tl = parseFloat(style.borderTopLeftRadius) || 0;
18205
18395
  let tr = parseFloat(style.borderTopRightRadius) || 0;
18206
18396
  let br = parseFloat(style.borderBottomRightRadius) || 0;
@@ -18239,12 +18429,62 @@
18239
18429
  resolve(destCanvas.toDataURL('image/png'));
18240
18430
  })
18241
18431
  .catch((e) => {
18432
+ if (originalId) node.id = originalId;
18433
+ else node.removeAttribute('id');
18242
18434
  console.warn('Canvas capture failed for node', node, e);
18243
18435
  resolve(null);
18244
18436
  });
18245
18437
  });
18246
18438
  }
18247
18439
 
18440
+ /**
18441
+ * Helper to identify elements that should be rendered as icons (Images).
18442
+ * Detects Custom Elements AND generic tags (<i>, <span>) with icon classes/pseudo-elements.
18443
+ */
18444
+ function isIconElement(node) {
18445
+ // 1. Custom Elements (hyphenated tags) or Explicit Library Tags
18446
+ const tag = node.tagName.toUpperCase();
18447
+ if (
18448
+ tag.includes('-') ||
18449
+ [
18450
+ 'MATERIAL-ICON',
18451
+ 'ICONIFY-ICON',
18452
+ 'REMIX-ICON',
18453
+ 'ION-ICON',
18454
+ 'EVA-ICON',
18455
+ 'BOX-ICON',
18456
+ 'FA-ICON',
18457
+ ].includes(tag)
18458
+ ) {
18459
+ return true;
18460
+ }
18461
+
18462
+ // 2. Class-based Icons (FontAwesome, Bootstrap, Material symbols) on <i> or <span>
18463
+ if (tag === 'I' || tag === 'SPAN') {
18464
+ const cls = node.getAttribute('class') || '';
18465
+ if (
18466
+ typeof cls === 'string' &&
18467
+ (cls.includes('fa-') ||
18468
+ cls.includes('fas') ||
18469
+ cls.includes('far') ||
18470
+ cls.includes('fab') ||
18471
+ cls.includes('bi-') ||
18472
+ cls.includes('material-icons') ||
18473
+ cls.includes('icon'))
18474
+ ) {
18475
+ // Double-check: Must have pseudo-element content to be a CSS icon
18476
+ const before = window.getComputedStyle(node, '::before').content;
18477
+ const after = window.getComputedStyle(node, '::after').content;
18478
+ const hasContent = (c) => c && c !== 'none' && c !== 'normal' && c !== '""';
18479
+
18480
+ if (hasContent(before) || hasContent(after)) return true;
18481
+ }
18482
+ console.log('Icon element:', node, cls);
18483
+ }
18484
+
18485
+ return false;
18486
+ }
18487
+
18248
18488
  /**
18249
18489
  * Replaces createRenderItem.
18250
18490
  * Returns { items: [], job: () => Promise, stopRecursion: boolean }
@@ -18318,23 +18558,18 @@
18318
18558
 
18319
18559
  const items = [];
18320
18560
 
18321
- // --- ASYNC JOB: SVGs / Icons ---
18322
- if (
18323
- node.nodeName.toUpperCase() === 'SVG' ||
18324
- node.tagName.includes('-') ||
18325
- node.tagName === 'ION-ICON'
18326
- ) {
18561
+ // --- ASYNC JOB: SVG Tags ---
18562
+ if (node.nodeName.toUpperCase() === 'SVG') {
18327
18563
  const item = {
18328
18564
  type: 'image',
18329
18565
  zIndex,
18330
18566
  domOrder,
18331
- options: { x, y, w, h, rotate: rotation, data: null }, // Data null initially
18567
+ options: { data: null, x, y, w, h, rotate: rotation },
18332
18568
  };
18333
18569
 
18334
- // Create Job
18335
18570
  const job = async () => {
18336
- const pngData = await elementToCanvasImage(node, widthPx, heightPx);
18337
- if (pngData) item.options.data = pngData;
18571
+ const processed = await svgToPng(node);
18572
+ if (processed) item.options.data = processed;
18338
18573
  else item.skip = true;
18339
18574
  };
18340
18575
 
@@ -18384,6 +18619,22 @@
18384
18619
  return { items: [item], job, stopRecursion: true };
18385
18620
  }
18386
18621
 
18622
+ // --- ASYNC JOB: Icons and Other Elements ---
18623
+ if (isIconElement(node)) {
18624
+ const item = {
18625
+ type: 'image',
18626
+ zIndex,
18627
+ domOrder,
18628
+ options: { x, y, w, h, rotate: rotation, data: null },
18629
+ };
18630
+ const job = async () => {
18631
+ const pngData = await elementToCanvasImage(node, widthPx, heightPx);
18632
+ if (pngData) item.options.data = pngData;
18633
+ else item.skip = true;
18634
+ };
18635
+ return { items: [item], job, stopRecursion: true };
18636
+ }
18637
+
18387
18638
  // Radii logic
18388
18639
  const borderRadiusValue = parseFloat(style.borderRadius) || 0;
18389
18640
  const borderBottomLeftRadius = parseFloat(style.borderBottomLeftRadius) || 0;