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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
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
+
5
12
  ## [1.0.8] - 2025-12-12 (Hot-Patch)
6
13
 
7
14
  ### Fixed
@@ -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;
@@ -17902,57 +17921,119 @@
17902
17921
  return null;
17903
17922
  }
17904
17923
 
17924
+ /**
17925
+ * Generates an SVG image for gradients, supporting degrees and keywords.
17926
+ */
17905
17927
  function generateGradientSVG(w, h, bgString, radius, border) {
17906
17928
  try {
17907
17929
  const match = bgString.match(/linear-gradient\((.*)\)/);
17908
17930
  if (!match) return null;
17909
17931
  const content = match[1];
17932
+
17933
+ // Split by comma, ignoring commas inside parentheses (e.g. rgba())
17910
17934
  const parts = content.split(/,(?![^()]*\))/).map((p) => p.trim());
17935
+ if (parts.length < 2) return null;
17911
17936
 
17912
17937
  let x1 = '0%',
17913
17938
  y1 = '0%',
17914
17939
  x2 = '0%',
17915
17940
  y2 = '100%';
17916
- let stopsStartIdx = 0;
17917
- if (parts[0].includes('to right')) {
17918
- x1 = '0%';
17919
- x2 = '100%';
17920
- y2 = '0%';
17921
- stopsStartIdx = 1;
17922
- } else if (parts[0].includes('to left')) {
17923
- x1 = '100%';
17924
- x2 = '0%';
17925
- y2 = '0%';
17926
- stopsStartIdx = 1;
17927
- } else if (parts[0].includes('to top')) {
17928
- y1 = '100%';
17929
- y2 = '0%';
17930
- stopsStartIdx = 1;
17931
- } else if (parts[0].includes('to bottom')) {
17932
- y1 = '0%';
17933
- y2 = '100%';
17934
- 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
+ }
17935
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
17936
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
+ }
18008
+ }
18009
+
18010
+ // 3. Process Color Stops
17937
18011
  let stopsXML = '';
17938
- const stopParts = parts.slice(stopsStartIdx);
18012
+ const stopParts = parts.slice(stopsStartIndex);
18013
+
17939
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
17940
18017
  let color = part;
17941
18018
  let offset = Math.round((idx / (stopParts.length - 1)) * 100) + '%';
17942
- const posMatch = part.match(/(.*?)\s+(\d+(\.\d+)?%?)$/);
18019
+
18020
+ const posMatch = part.match(/^(.*?)\s+(-?[\d.]+(?:%|px)?)$/);
17943
18021
  if (posMatch) {
17944
18022
  color = posMatch[1];
17945
18023
  offset = posMatch[2];
17946
18024
  }
18025
+
18026
+ // Handle RGBA/RGB for SVG compatibility
17947
18027
  let opacity = 1;
17948
18028
  if (color.includes('rgba')) {
17949
- const rgba = color.match(/[\d.]+/g);
17950
- if (rgba && rgba.length > 3) {
17951
- opacity = rgba[3];
17952
- 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]})`;
17953
18033
  }
17954
18034
  }
17955
- stopsXML += `<stop offset="${offset}" stop-color="${color}" stop-opacity="${opacity}"/>`;
18035
+
18036
+ stopsXML += `<stop offset="${offset}" stop-color="${color.trim()}" stop-opacity="${opacity}"/>`;
17956
18037
  });
17957
18038
 
17958
18039
  let strokeAttr = '';
@@ -17961,12 +18042,18 @@
17961
18042
  }
17962
18043
 
17963
18044
  const svg = `
17964
- <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
17965
- <defs><linearGradient id="grad" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">${stopsXML}</linearGradient></defs>
17966
- <rect x="0" y="0" width="${w}" height="${h}" rx="${radius}" ry="${radius}" fill="url(#grad)" ${strokeAttr} />
17967
- </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
+
17968
18054
  return 'data:image/svg+xml;base64,' + btoa(svg);
17969
- } catch {
18055
+ } catch (e) {
18056
+ console.warn('Gradient generation failed:', e);
17970
18057
  return null;
17971
18058
  }
17972
18059
  }
@@ -18241,29 +18328,69 @@
18241
18328
  * Optimized html2canvas wrapper
18242
18329
  * Now strictly captures the node itself, not the root.
18243
18330
  */
18331
+ /**
18332
+ * Optimized html2canvas wrapper
18333
+ * Includes fix for cropped icons by adjusting styles in the cloned document.
18334
+ */
18244
18335
  async function elementToCanvasImage(node, widthPx, heightPx) {
18245
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
+
18246
18342
  const width = Math.max(Math.ceil(widthPx), 1);
18247
18343
  const height = Math.max(Math.ceil(heightPx), 1);
18248
18344
  const style = window.getComputedStyle(node);
18249
18345
 
18250
- // Optimized: Capture ONLY the specific node
18251
18346
  html2canvas(node, {
18252
18347
  backgroundColor: null,
18253
18348
  logging: false,
18254
- 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
+ },
18255
18377
  })
18256
18378
  .then((canvas) => {
18379
+ // Restore the original ID
18380
+ if (originalId) node.id = originalId;
18381
+ else node.removeAttribute('id');
18382
+
18257
18383
  const destCanvas = document.createElement('canvas');
18258
18384
  destCanvas.width = width;
18259
18385
  destCanvas.height = height;
18260
18386
  const ctx = destCanvas.getContext('2d');
18261
18387
 
18262
- // Draw the captured canvas into our sized canvas
18263
- // 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.
18264
18391
  ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, 0, width, height);
18265
18392
 
18266
- // Apply border radius clipping
18393
+ // --- Border Radius Clipping (Existing Logic) ---
18267
18394
  let tl = parseFloat(style.borderTopLeftRadius) || 0;
18268
18395
  let tr = parseFloat(style.borderTopRightRadius) || 0;
18269
18396
  let br = parseFloat(style.borderBottomRightRadius) || 0;
@@ -18302,12 +18429,62 @@
18302
18429
  resolve(destCanvas.toDataURL('image/png'));
18303
18430
  })
18304
18431
  .catch((e) => {
18432
+ if (originalId) node.id = originalId;
18433
+ else node.removeAttribute('id');
18305
18434
  console.warn('Canvas capture failed for node', node, e);
18306
18435
  resolve(null);
18307
18436
  });
18308
18437
  });
18309
18438
  }
18310
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
+
18311
18488
  /**
18312
18489
  * Replaces createRenderItem.
18313
18490
  * Returns { items: [], job: () => Promise, stopRecursion: boolean }
@@ -18443,30 +18620,18 @@
18443
18620
  }
18444
18621
 
18445
18622
  // --- ASYNC JOB: Icons and Other Elements ---
18446
- if (
18447
- node.tagName.toUpperCase() === 'MATERIAL-ICON' ||
18448
- node.tagName.toUpperCase() === 'ICONIFY-ICON' ||
18449
- node.tagName.toUpperCase() === 'REMIX-ICON' ||
18450
- node.tagName.toUpperCase() === 'ION-ICON' ||
18451
- node.tagName.toUpperCase() === 'EVA-ICON' ||
18452
- node.tagName.toUpperCase() === 'BOX-ICON' ||
18453
- node.tagName.toUpperCase() === 'FA-ICON' ||
18454
- node.tagName.includes('-')
18455
- ) {
18623
+ if (isIconElement(node)) {
18456
18624
  const item = {
18457
18625
  type: 'image',
18458
18626
  zIndex,
18459
18627
  domOrder,
18460
- options: { x, y, w, h, rotate: rotation, data: null }, // Data null initially
18628
+ options: { x, y, w, h, rotate: rotation, data: null },
18461
18629
  };
18462
-
18463
- // Create Job
18464
18630
  const job = async () => {
18465
18631
  const pngData = await elementToCanvasImage(node, widthPx, heightPx);
18466
18632
  if (pngData) item.options.data = pngData;
18467
18633
  else item.skip = true;
18468
18634
  };
18469
-
18470
18635
  return { items: [item], job, stopRecursion: true };
18471
18636
  }
18472
18637