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 +7 -0
- package/dist/dom-to-pptx.bundle.js +224 -59
- package/dist/dom-to-pptx.cjs +224 -59
- package/dist/dom-to-pptx.min.js +224 -59
- package/dist/dom-to-pptx.mjs +224 -59
- package/package.json +1 -1
- package/src/index.js +97 -19
- package/src/utils.js +128 -41
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 /
|
|
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
|
-
//
|
|
17773
|
-
const isInlineTag = ['SPAN', 'B', 'STRONG', 'EM', 'I', 'A', 'SMALL', 'MARK'].includes(
|
|
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
|
-
//
|
|
17779
|
-
//
|
|
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 =
|
|
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
|
|
17917
|
-
|
|
17918
|
-
|
|
17919
|
-
|
|
17920
|
-
|
|
17921
|
-
|
|
17922
|
-
|
|
17923
|
-
|
|
17924
|
-
|
|
17925
|
-
|
|
17926
|
-
|
|
17927
|
-
|
|
17928
|
-
|
|
17929
|
-
|
|
17930
|
-
|
|
17931
|
-
|
|
17932
|
-
|
|
17933
|
-
|
|
17934
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
17950
|
-
if (
|
|
17951
|
-
opacity =
|
|
17952
|
-
color = `rgb(${
|
|
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
|
-
|
|
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
|
-
|
|
17965
|
-
|
|
17966
|
-
|
|
17967
|
-
|
|
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:
|
|
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
|
|
18263
|
-
//
|
|
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
|
-
//
|
|
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 },
|
|
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
|
|