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/dist/dom-to-pptx.mjs
CHANGED
|
@@ -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 /
|
|
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
|
-
//
|
|
8076
|
-
const isInlineTag = ['SPAN', 'B', 'STRONG', 'EM', 'I', 'A', 'SMALL', 'MARK'].includes(
|
|
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
|
-
//
|
|
8082
|
-
//
|
|
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 =
|
|
8105
|
+
const hasBorder =
|
|
8106
|
+
parseFloat(style.borderWidth) > 0 && parseColor(style.borderColor).opacity > 0;
|
|
8088
8107
|
|
|
8089
8108
|
if (hasVisibleBg || hasBorder) {
|
|
8090
8109
|
return false;
|
|
@@ -8205,57 +8224,119 @@ function getVisibleShadow(shadowStr, scale) {
|
|
|
8205
8224
|
return null;
|
|
8206
8225
|
}
|
|
8207
8226
|
|
|
8227
|
+
/**
|
|
8228
|
+
* Generates an SVG image for gradients, supporting degrees and keywords.
|
|
8229
|
+
*/
|
|
8208
8230
|
function generateGradientSVG(w, h, bgString, radius, border) {
|
|
8209
8231
|
try {
|
|
8210
8232
|
const match = bgString.match(/linear-gradient\((.*)\)/);
|
|
8211
8233
|
if (!match) return null;
|
|
8212
8234
|
const content = match[1];
|
|
8235
|
+
|
|
8236
|
+
// Split by comma, ignoring commas inside parentheses (e.g. rgba())
|
|
8213
8237
|
const parts = content.split(/,(?![^()]*\))/).map((p) => p.trim());
|
|
8238
|
+
if (parts.length < 2) return null;
|
|
8214
8239
|
|
|
8215
8240
|
let x1 = '0%',
|
|
8216
8241
|
y1 = '0%',
|
|
8217
8242
|
x2 = '0%',
|
|
8218
8243
|
y2 = '100%';
|
|
8219
|
-
let
|
|
8220
|
-
|
|
8221
|
-
|
|
8222
|
-
|
|
8223
|
-
|
|
8224
|
-
|
|
8225
|
-
|
|
8226
|
-
|
|
8227
|
-
|
|
8228
|
-
|
|
8229
|
-
|
|
8230
|
-
|
|
8231
|
-
|
|
8232
|
-
|
|
8233
|
-
|
|
8234
|
-
|
|
8235
|
-
|
|
8236
|
-
|
|
8237
|
-
|
|
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
|
+
}
|
|
8238
8311
|
}
|
|
8239
8312
|
|
|
8313
|
+
// 3. Process Color Stops
|
|
8240
8314
|
let stopsXML = '';
|
|
8241
|
-
const stopParts = parts.slice(
|
|
8315
|
+
const stopParts = parts.slice(stopsStartIndex);
|
|
8316
|
+
|
|
8242
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
|
|
8243
8320
|
let color = part;
|
|
8244
8321
|
let offset = Math.round((idx / (stopParts.length - 1)) * 100) + '%';
|
|
8245
|
-
|
|
8322
|
+
|
|
8323
|
+
const posMatch = part.match(/^(.*?)\s+(-?[\d.]+(?:%|px)?)$/);
|
|
8246
8324
|
if (posMatch) {
|
|
8247
8325
|
color = posMatch[1];
|
|
8248
8326
|
offset = posMatch[2];
|
|
8249
8327
|
}
|
|
8328
|
+
|
|
8329
|
+
// Handle RGBA/RGB for SVG compatibility
|
|
8250
8330
|
let opacity = 1;
|
|
8251
8331
|
if (color.includes('rgba')) {
|
|
8252
|
-
const
|
|
8253
|
-
if (
|
|
8254
|
-
opacity =
|
|
8255
|
-
color = `rgb(${
|
|
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]})`;
|
|
8256
8336
|
}
|
|
8257
8337
|
}
|
|
8258
|
-
|
|
8338
|
+
|
|
8339
|
+
stopsXML += `<stop offset="${offset}" stop-color="${color.trim()}" stop-opacity="${opacity}"/>`;
|
|
8259
8340
|
});
|
|
8260
8341
|
|
|
8261
8342
|
let strokeAttr = '';
|
|
@@ -8264,12 +8345,18 @@ function generateGradientSVG(w, h, bgString, radius, border) {
|
|
|
8264
8345
|
}
|
|
8265
8346
|
|
|
8266
8347
|
const svg = `
|
|
8267
|
-
|
|
8268
|
-
|
|
8269
|
-
|
|
8270
|
-
|
|
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
|
+
|
|
8271
8357
|
return 'data:image/svg+xml;base64,' + btoa(svg);
|
|
8272
|
-
} catch {
|
|
8358
|
+
} catch (e) {
|
|
8359
|
+
console.warn('Gradient generation failed:', e);
|
|
8273
8360
|
return null;
|
|
8274
8361
|
}
|
|
8275
8362
|
}
|
|
@@ -8544,29 +8631,69 @@ async function processSlide(root, slide, pptx) {
|
|
|
8544
8631
|
* Optimized html2canvas wrapper
|
|
8545
8632
|
* Now strictly captures the node itself, not the root.
|
|
8546
8633
|
*/
|
|
8634
|
+
/**
|
|
8635
|
+
* Optimized html2canvas wrapper
|
|
8636
|
+
* Includes fix for cropped icons by adjusting styles in the cloned document.
|
|
8637
|
+
*/
|
|
8547
8638
|
async function elementToCanvasImage(node, widthPx, heightPx) {
|
|
8548
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
|
+
|
|
8549
8645
|
const width = Math.max(Math.ceil(widthPx), 1);
|
|
8550
8646
|
const height = Math.max(Math.ceil(heightPx), 1);
|
|
8551
8647
|
const style = window.getComputedStyle(node);
|
|
8552
8648
|
|
|
8553
|
-
// Optimized: Capture ONLY the specific node
|
|
8554
8649
|
html2canvas(node, {
|
|
8555
8650
|
backgroundColor: null,
|
|
8556
8651
|
logging: false,
|
|
8557
|
-
scale:
|
|
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
|
+
},
|
|
8558
8680
|
})
|
|
8559
8681
|
.then((canvas) => {
|
|
8682
|
+
// Restore the original ID
|
|
8683
|
+
if (originalId) node.id = originalId;
|
|
8684
|
+
else node.removeAttribute('id');
|
|
8685
|
+
|
|
8560
8686
|
const destCanvas = document.createElement('canvas');
|
|
8561
8687
|
destCanvas.width = width;
|
|
8562
8688
|
destCanvas.height = height;
|
|
8563
8689
|
const ctx = destCanvas.getContext('2d');
|
|
8564
8690
|
|
|
8565
|
-
// Draw
|
|
8566
|
-
//
|
|
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.
|
|
8567
8694
|
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, 0, width, height);
|
|
8568
8695
|
|
|
8569
|
-
//
|
|
8696
|
+
// --- Border Radius Clipping (Existing Logic) ---
|
|
8570
8697
|
let tl = parseFloat(style.borderTopLeftRadius) || 0;
|
|
8571
8698
|
let tr = parseFloat(style.borderTopRightRadius) || 0;
|
|
8572
8699
|
let br = parseFloat(style.borderBottomRightRadius) || 0;
|
|
@@ -8605,12 +8732,62 @@ async function elementToCanvasImage(node, widthPx, heightPx) {
|
|
|
8605
8732
|
resolve(destCanvas.toDataURL('image/png'));
|
|
8606
8733
|
})
|
|
8607
8734
|
.catch((e) => {
|
|
8735
|
+
if (originalId) node.id = originalId;
|
|
8736
|
+
else node.removeAttribute('id');
|
|
8608
8737
|
console.warn('Canvas capture failed for node', node, e);
|
|
8609
8738
|
resolve(null);
|
|
8610
8739
|
});
|
|
8611
8740
|
});
|
|
8612
8741
|
}
|
|
8613
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
|
+
|
|
8614
8791
|
/**
|
|
8615
8792
|
* Replaces createRenderItem.
|
|
8616
8793
|
* Returns { items: [], job: () => Promise, stopRecursion: boolean }
|
|
@@ -8746,30 +8923,18 @@ function prepareRenderItem(node, config, domOrder, pptx, effectiveZIndex, comput
|
|
|
8746
8923
|
}
|
|
8747
8924
|
|
|
8748
8925
|
// --- ASYNC JOB: Icons and Other Elements ---
|
|
8749
|
-
if (
|
|
8750
|
-
node.tagName.toUpperCase() === 'MATERIAL-ICON' ||
|
|
8751
|
-
node.tagName.toUpperCase() === 'ICONIFY-ICON' ||
|
|
8752
|
-
node.tagName.toUpperCase() === 'REMIX-ICON' ||
|
|
8753
|
-
node.tagName.toUpperCase() === 'ION-ICON' ||
|
|
8754
|
-
node.tagName.toUpperCase() === 'EVA-ICON' ||
|
|
8755
|
-
node.tagName.toUpperCase() === 'BOX-ICON' ||
|
|
8756
|
-
node.tagName.toUpperCase() === 'FA-ICON' ||
|
|
8757
|
-
node.tagName.includes('-')
|
|
8758
|
-
) {
|
|
8926
|
+
if (isIconElement(node)) {
|
|
8759
8927
|
const item = {
|
|
8760
8928
|
type: 'image',
|
|
8761
8929
|
zIndex,
|
|
8762
8930
|
domOrder,
|
|
8763
|
-
options: { x, y, w, h, rotate: rotation, data: null },
|
|
8931
|
+
options: { x, y, w, h, rotate: rotation, data: null },
|
|
8764
8932
|
};
|
|
8765
|
-
|
|
8766
|
-
// Create Job
|
|
8767
8933
|
const job = async () => {
|
|
8768
8934
|
const pngData = await elementToCanvasImage(node, widthPx, heightPx);
|
|
8769
8935
|
if (pngData) item.options.data = pngData;
|
|
8770
8936
|
else item.skip = true;
|
|
8771
8937
|
};
|
|
8772
|
-
|
|
8773
8938
|
return { items: [item], job, stopRecursion: true };
|
|
8774
8939
|
}
|
|
8775
8940
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dom-to-pptx",
|
|
3
|
-
"version": "1.0.
|
|
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",
|
package/src/index.js
CHANGED
|
@@ -173,29 +173,69 @@ async function processSlide(root, slide, pptx) {
|
|
|
173
173
|
* Optimized html2canvas wrapper
|
|
174
174
|
* Now strictly captures the node itself, not the root.
|
|
175
175
|
*/
|
|
176
|
+
/**
|
|
177
|
+
* Optimized html2canvas wrapper
|
|
178
|
+
* Includes fix for cropped icons by adjusting styles in the cloned document.
|
|
179
|
+
*/
|
|
176
180
|
async function elementToCanvasImage(node, widthPx, heightPx) {
|
|
177
181
|
return new Promise((resolve) => {
|
|
182
|
+
// 1. Assign a temp ID to locate the node inside the cloned document
|
|
183
|
+
const originalId = node.id;
|
|
184
|
+
const tempId = 'pptx-capture-' + Math.random().toString(36).substr(2, 9);
|
|
185
|
+
node.id = tempId;
|
|
186
|
+
|
|
178
187
|
const width = Math.max(Math.ceil(widthPx), 1);
|
|
179
188
|
const height = Math.max(Math.ceil(heightPx), 1);
|
|
180
189
|
const style = window.getComputedStyle(node);
|
|
181
190
|
|
|
182
|
-
// Optimized: Capture ONLY the specific node
|
|
183
191
|
html2canvas(node, {
|
|
184
192
|
backgroundColor: null,
|
|
185
193
|
logging: false,
|
|
186
|
-
scale:
|
|
194
|
+
scale: 3, // Higher scale for sharper icons
|
|
195
|
+
useCORS: true, // critical for external fonts/images
|
|
196
|
+
onclone: (clonedDoc) => {
|
|
197
|
+
const clonedNode = clonedDoc.getElementById(tempId);
|
|
198
|
+
if (clonedNode) {
|
|
199
|
+
// --- FIX: PREVENT ICON CLIPPING ---
|
|
200
|
+
// 1. Force overflow visible so glyphs bleeding out aren't cut
|
|
201
|
+
clonedNode.style.overflow = 'visible';
|
|
202
|
+
|
|
203
|
+
// 2. Adjust alignment for Icons to prevent baseline clipping
|
|
204
|
+
// (Applies to <i>, <span>, or standard icon classes)
|
|
205
|
+
const tag = clonedNode.tagName;
|
|
206
|
+
if (tag === 'I' || tag === 'SPAN' || clonedNode.className.includes('fa-')) {
|
|
207
|
+
// Flex center helps align the glyph exactly in the middle of the box
|
|
208
|
+
// preventing top/bottom cropping due to line-height mismatches.
|
|
209
|
+
clonedNode.style.display = 'inline-flex';
|
|
210
|
+
clonedNode.style.justifyContent = 'center';
|
|
211
|
+
clonedNode.style.alignItems = 'center';
|
|
212
|
+
|
|
213
|
+
// Remove margins that might offset the capture
|
|
214
|
+
clonedNode.style.margin = '0';
|
|
215
|
+
|
|
216
|
+
// Ensure the font fits
|
|
217
|
+
clonedNode.style.lineHeight = '1';
|
|
218
|
+
clonedNode.style.verticalAlign = 'middle';
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
},
|
|
187
222
|
})
|
|
188
223
|
.then((canvas) => {
|
|
224
|
+
// Restore the original ID
|
|
225
|
+
if (originalId) node.id = originalId;
|
|
226
|
+
else node.removeAttribute('id');
|
|
227
|
+
|
|
189
228
|
const destCanvas = document.createElement('canvas');
|
|
190
229
|
destCanvas.width = width;
|
|
191
230
|
destCanvas.height = height;
|
|
192
231
|
const ctx = destCanvas.getContext('2d');
|
|
193
232
|
|
|
194
|
-
// Draw
|
|
195
|
-
//
|
|
233
|
+
// Draw captured canvas.
|
|
234
|
+
// We simply draw it to fill the box. Since we centered it in 'onclone',
|
|
235
|
+
// the glyph should now be visible within the bounds.
|
|
196
236
|
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, 0, width, height);
|
|
197
237
|
|
|
198
|
-
//
|
|
238
|
+
// --- Border Radius Clipping (Existing Logic) ---
|
|
199
239
|
let tl = parseFloat(style.borderTopLeftRadius) || 0;
|
|
200
240
|
let tr = parseFloat(style.borderTopRightRadius) || 0;
|
|
201
241
|
let br = parseFloat(style.borderBottomRightRadius) || 0;
|
|
@@ -234,12 +274,62 @@ async function elementToCanvasImage(node, widthPx, heightPx) {
|
|
|
234
274
|
resolve(destCanvas.toDataURL('image/png'));
|
|
235
275
|
})
|
|
236
276
|
.catch((e) => {
|
|
277
|
+
if (originalId) node.id = originalId;
|
|
278
|
+
else node.removeAttribute('id');
|
|
237
279
|
console.warn('Canvas capture failed for node', node, e);
|
|
238
280
|
resolve(null);
|
|
239
281
|
});
|
|
240
282
|
});
|
|
241
283
|
}
|
|
242
284
|
|
|
285
|
+
/**
|
|
286
|
+
* Helper to identify elements that should be rendered as icons (Images).
|
|
287
|
+
* Detects Custom Elements AND generic tags (<i>, <span>) with icon classes/pseudo-elements.
|
|
288
|
+
*/
|
|
289
|
+
function isIconElement(node) {
|
|
290
|
+
// 1. Custom Elements (hyphenated tags) or Explicit Library Tags
|
|
291
|
+
const tag = node.tagName.toUpperCase();
|
|
292
|
+
if (
|
|
293
|
+
tag.includes('-') ||
|
|
294
|
+
[
|
|
295
|
+
'MATERIAL-ICON',
|
|
296
|
+
'ICONIFY-ICON',
|
|
297
|
+
'REMIX-ICON',
|
|
298
|
+
'ION-ICON',
|
|
299
|
+
'EVA-ICON',
|
|
300
|
+
'BOX-ICON',
|
|
301
|
+
'FA-ICON',
|
|
302
|
+
].includes(tag)
|
|
303
|
+
) {
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 2. Class-based Icons (FontAwesome, Bootstrap, Material symbols) on <i> or <span>
|
|
308
|
+
if (tag === 'I' || tag === 'SPAN') {
|
|
309
|
+
const cls = node.getAttribute('class') || '';
|
|
310
|
+
if (
|
|
311
|
+
typeof cls === 'string' &&
|
|
312
|
+
(cls.includes('fa-') ||
|
|
313
|
+
cls.includes('fas') ||
|
|
314
|
+
cls.includes('far') ||
|
|
315
|
+
cls.includes('fab') ||
|
|
316
|
+
cls.includes('bi-') ||
|
|
317
|
+
cls.includes('material-icons') ||
|
|
318
|
+
cls.includes('icon'))
|
|
319
|
+
) {
|
|
320
|
+
// Double-check: Must have pseudo-element content to be a CSS icon
|
|
321
|
+
const before = window.getComputedStyle(node, '::before').content;
|
|
322
|
+
const after = window.getComputedStyle(node, '::after').content;
|
|
323
|
+
const hasContent = (c) => c && c !== 'none' && c !== 'normal' && c !== '""';
|
|
324
|
+
|
|
325
|
+
if (hasContent(before) || hasContent(after)) return true;
|
|
326
|
+
}
|
|
327
|
+
console.log('Icon element:', node, cls);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
|
|
243
333
|
/**
|
|
244
334
|
* Replaces createRenderItem.
|
|
245
335
|
* Returns { items: [], job: () => Promise, stopRecursion: boolean }
|
|
@@ -375,30 +465,18 @@ function prepareRenderItem(node, config, domOrder, pptx, effectiveZIndex, comput
|
|
|
375
465
|
}
|
|
376
466
|
|
|
377
467
|
// --- ASYNC JOB: Icons and Other Elements ---
|
|
378
|
-
if (
|
|
379
|
-
node.tagName.toUpperCase() === 'MATERIAL-ICON' ||
|
|
380
|
-
node.tagName.toUpperCase() === 'ICONIFY-ICON' ||
|
|
381
|
-
node.tagName.toUpperCase() === 'REMIX-ICON' ||
|
|
382
|
-
node.tagName.toUpperCase() === 'ION-ICON' ||
|
|
383
|
-
node.tagName.toUpperCase() === 'EVA-ICON' ||
|
|
384
|
-
node.tagName.toUpperCase() === 'BOX-ICON' ||
|
|
385
|
-
node.tagName.toUpperCase() === 'FA-ICON' ||
|
|
386
|
-
node.tagName.includes('-')
|
|
387
|
-
) {
|
|
468
|
+
if (isIconElement(node)) {
|
|
388
469
|
const item = {
|
|
389
470
|
type: 'image',
|
|
390
471
|
zIndex,
|
|
391
472
|
domOrder,
|
|
392
|
-
options: { x, y, w, h, rotate: rotation, data: null },
|
|
473
|
+
options: { x, y, w, h, rotate: rotation, data: null },
|
|
393
474
|
};
|
|
394
|
-
|
|
395
|
-
// Create Job
|
|
396
475
|
const job = async () => {
|
|
397
476
|
const pngData = await elementToCanvasImage(node, widthPx, heightPx);
|
|
398
477
|
if (pngData) item.options.data = pngData;
|
|
399
478
|
else item.skip = true;
|
|
400
479
|
};
|
|
401
|
-
|
|
402
480
|
return { items: [item], job, stopRecursion: true };
|
|
403
481
|
}
|
|
404
482
|
|