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 +14 -0
- package/README.md +1 -1
- package/dist/dom-to-pptx.bundle.js +306 -55
- package/dist/dom-to-pptx.cjs +306 -55
- package/dist/dom-to-pptx.min.js +306 -55
- package/dist/dom-to-pptx.mjs +306 -55
- package/package.json +1 -1
- package/src/index.js +117 -15
- package/src/utils.js +128 -41
package/src/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
getVisibleShadow,
|
|
13
13
|
generateGradientSVG,
|
|
14
14
|
getRotation,
|
|
15
|
+
svgToPng,
|
|
15
16
|
getPadding,
|
|
16
17
|
getSoftEdges,
|
|
17
18
|
generateBlurredSVG,
|
|
@@ -172,29 +173,69 @@ async function processSlide(root, slide, pptx) {
|
|
|
172
173
|
* Optimized html2canvas wrapper
|
|
173
174
|
* Now strictly captures the node itself, not the root.
|
|
174
175
|
*/
|
|
176
|
+
/**
|
|
177
|
+
* Optimized html2canvas wrapper
|
|
178
|
+
* Includes fix for cropped icons by adjusting styles in the cloned document.
|
|
179
|
+
*/
|
|
175
180
|
async function elementToCanvasImage(node, widthPx, heightPx) {
|
|
176
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
|
+
|
|
177
187
|
const width = Math.max(Math.ceil(widthPx), 1);
|
|
178
188
|
const height = Math.max(Math.ceil(heightPx), 1);
|
|
179
189
|
const style = window.getComputedStyle(node);
|
|
180
190
|
|
|
181
|
-
// Optimized: Capture ONLY the specific node
|
|
182
191
|
html2canvas(node, {
|
|
183
192
|
backgroundColor: null,
|
|
184
193
|
logging: false,
|
|
185
|
-
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
|
+
},
|
|
186
222
|
})
|
|
187
223
|
.then((canvas) => {
|
|
224
|
+
// Restore the original ID
|
|
225
|
+
if (originalId) node.id = originalId;
|
|
226
|
+
else node.removeAttribute('id');
|
|
227
|
+
|
|
188
228
|
const destCanvas = document.createElement('canvas');
|
|
189
229
|
destCanvas.width = width;
|
|
190
230
|
destCanvas.height = height;
|
|
191
231
|
const ctx = destCanvas.getContext('2d');
|
|
192
232
|
|
|
193
|
-
// Draw
|
|
194
|
-
//
|
|
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.
|
|
195
236
|
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, 0, width, height);
|
|
196
237
|
|
|
197
|
-
//
|
|
238
|
+
// --- Border Radius Clipping (Existing Logic) ---
|
|
198
239
|
let tl = parseFloat(style.borderTopLeftRadius) || 0;
|
|
199
240
|
let tr = parseFloat(style.borderTopRightRadius) || 0;
|
|
200
241
|
let br = parseFloat(style.borderBottomRightRadius) || 0;
|
|
@@ -233,12 +274,62 @@ async function elementToCanvasImage(node, widthPx, heightPx) {
|
|
|
233
274
|
resolve(destCanvas.toDataURL('image/png'));
|
|
234
275
|
})
|
|
235
276
|
.catch((e) => {
|
|
277
|
+
if (originalId) node.id = originalId;
|
|
278
|
+
else node.removeAttribute('id');
|
|
236
279
|
console.warn('Canvas capture failed for node', node, e);
|
|
237
280
|
resolve(null);
|
|
238
281
|
});
|
|
239
282
|
});
|
|
240
283
|
}
|
|
241
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
|
+
|
|
242
333
|
/**
|
|
243
334
|
* Replaces createRenderItem.
|
|
244
335
|
* Returns { items: [], job: () => Promise, stopRecursion: boolean }
|
|
@@ -312,23 +403,18 @@ function prepareRenderItem(node, config, domOrder, pptx, effectiveZIndex, comput
|
|
|
312
403
|
|
|
313
404
|
const items = [];
|
|
314
405
|
|
|
315
|
-
// --- ASYNC JOB:
|
|
316
|
-
if (
|
|
317
|
-
node.nodeName.toUpperCase() === 'SVG' ||
|
|
318
|
-
node.tagName.includes('-') ||
|
|
319
|
-
node.tagName === 'ION-ICON'
|
|
320
|
-
) {
|
|
406
|
+
// --- ASYNC JOB: SVG Tags ---
|
|
407
|
+
if (node.nodeName.toUpperCase() === 'SVG') {
|
|
321
408
|
const item = {
|
|
322
409
|
type: 'image',
|
|
323
410
|
zIndex,
|
|
324
411
|
domOrder,
|
|
325
|
-
options: { x, y, w, h, rotate: rotation
|
|
412
|
+
options: { data: null, x, y, w, h, rotate: rotation },
|
|
326
413
|
};
|
|
327
414
|
|
|
328
|
-
// Create Job
|
|
329
415
|
const job = async () => {
|
|
330
|
-
const
|
|
331
|
-
if (
|
|
416
|
+
const processed = await svgToPng(node);
|
|
417
|
+
if (processed) item.options.data = processed;
|
|
332
418
|
else item.skip = true;
|
|
333
419
|
};
|
|
334
420
|
|
|
@@ -378,6 +464,22 @@ function prepareRenderItem(node, config, domOrder, pptx, effectiveZIndex, comput
|
|
|
378
464
|
return { items: [item], job, stopRecursion: true };
|
|
379
465
|
}
|
|
380
466
|
|
|
467
|
+
// --- ASYNC JOB: Icons and Other Elements ---
|
|
468
|
+
if (isIconElement(node)) {
|
|
469
|
+
const item = {
|
|
470
|
+
type: 'image',
|
|
471
|
+
zIndex,
|
|
472
|
+
domOrder,
|
|
473
|
+
options: { x, y, w, h, rotate: rotation, data: null },
|
|
474
|
+
};
|
|
475
|
+
const job = async () => {
|
|
476
|
+
const pngData = await elementToCanvasImage(node, widthPx, heightPx);
|
|
477
|
+
if (pngData) item.options.data = pngData;
|
|
478
|
+
else item.skip = true;
|
|
479
|
+
};
|
|
480
|
+
return { items: [item], job, stopRecursion: true };
|
|
481
|
+
}
|
|
482
|
+
|
|
381
483
|
// Radii logic
|
|
382
484
|
const borderRadiusValue = parseFloat(style.borderRadius) || 0;
|
|
383
485
|
const borderBottomLeftRadius = parseFloat(style.borderBottomLeftRadius) || 0;
|
package/src/utils.js
CHANGED
|
@@ -233,6 +233,7 @@ export function getTextStyle(style, scale) {
|
|
|
233
233
|
|
|
234
234
|
/**
|
|
235
235
|
* Determines if a given DOM node is primarily a text container.
|
|
236
|
+
* Updated to correctly reject Icon elements so they are rendered as images.
|
|
236
237
|
*/
|
|
237
238
|
export function isTextContainer(node) {
|
|
238
239
|
const hasText = node.textContent.trim().length > 0;
|
|
@@ -241,28 +242,46 @@ export function isTextContainer(node) {
|
|
|
241
242
|
const children = Array.from(node.children);
|
|
242
243
|
if (children.length === 0) return true;
|
|
243
244
|
|
|
244
|
-
// Check if children are purely inline text formatting or visual shapes
|
|
245
245
|
const isSafeInline = (el) => {
|
|
246
|
-
// 1. Reject Web Components /
|
|
246
|
+
// 1. Reject Web Components / Custom Elements
|
|
247
247
|
if (el.tagName.includes('-')) return false;
|
|
248
|
+
// 2. Reject Explicit Images/SVGs
|
|
248
249
|
if (el.tagName === 'IMG' || el.tagName === 'SVG') return false;
|
|
249
250
|
|
|
251
|
+
// 3. Reject Class-based Icons (FontAwesome, Material, Bootstrap, etc.)
|
|
252
|
+
// If an <i> or <span> has icon classes, it is a visual object, not text.
|
|
253
|
+
if (el.tagName === 'I' || el.tagName === 'SPAN') {
|
|
254
|
+
const cls = el.getAttribute('class') || '';
|
|
255
|
+
if (
|
|
256
|
+
cls.includes('fa-') ||
|
|
257
|
+
cls.includes('fas') ||
|
|
258
|
+
cls.includes('far') ||
|
|
259
|
+
cls.includes('fab') ||
|
|
260
|
+
cls.includes('material-icons') ||
|
|
261
|
+
cls.includes('bi-') ||
|
|
262
|
+
cls.includes('icon')
|
|
263
|
+
) {
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
250
268
|
const style = window.getComputedStyle(el);
|
|
251
269
|
const display = style.display;
|
|
252
270
|
|
|
253
|
-
//
|
|
254
|
-
const isInlineTag = ['SPAN', 'B', 'STRONG', 'EM', 'I', 'A', 'SMALL', 'MARK'].includes(
|
|
271
|
+
// 4. Standard Inline Tag Check
|
|
272
|
+
const isInlineTag = ['SPAN', 'B', 'STRONG', 'EM', 'I', 'A', 'SMALL', 'MARK'].includes(
|
|
273
|
+
el.tagName
|
|
274
|
+
);
|
|
255
275
|
const isInlineDisplay = display.includes('inline');
|
|
256
276
|
|
|
257
277
|
if (!isInlineTag && !isInlineDisplay) return false;
|
|
258
278
|
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
// If a child element has these, the parent is NOT a simple text container;
|
|
262
|
-
// it is a layout container composed of styled blocks.
|
|
279
|
+
// 5. Structural Styling Check
|
|
280
|
+
// If a child has a background or border, it's a layout block, not a simple text span.
|
|
263
281
|
const bgColor = parseColor(style.backgroundColor);
|
|
264
282
|
const hasVisibleBg = bgColor.hex && bgColor.opacity > 0;
|
|
265
|
-
const hasBorder =
|
|
283
|
+
const hasBorder =
|
|
284
|
+
parseFloat(style.borderWidth) > 0 && parseColor(style.borderColor).opacity > 0;
|
|
266
285
|
|
|
267
286
|
if (hasVisibleBg || hasBorder) {
|
|
268
287
|
return false;
|
|
@@ -383,57 +402,119 @@ export function getVisibleShadow(shadowStr, scale) {
|
|
|
383
402
|
return null;
|
|
384
403
|
}
|
|
385
404
|
|
|
405
|
+
/**
|
|
406
|
+
* Generates an SVG image for gradients, supporting degrees and keywords.
|
|
407
|
+
*/
|
|
386
408
|
export function generateGradientSVG(w, h, bgString, radius, border) {
|
|
387
409
|
try {
|
|
388
410
|
const match = bgString.match(/linear-gradient\((.*)\)/);
|
|
389
411
|
if (!match) return null;
|
|
390
412
|
const content = match[1];
|
|
413
|
+
|
|
414
|
+
// Split by comma, ignoring commas inside parentheses (e.g. rgba())
|
|
391
415
|
const parts = content.split(/,(?![^()]*\))/).map((p) => p.trim());
|
|
416
|
+
if (parts.length < 2) return null;
|
|
392
417
|
|
|
393
418
|
let x1 = '0%',
|
|
394
419
|
y1 = '0%',
|
|
395
420
|
x2 = '0%',
|
|
396
421
|
y2 = '100%';
|
|
397
|
-
let
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
422
|
+
let stopsStartIndex = 0;
|
|
423
|
+
const firstPart = parts[0].toLowerCase();
|
|
424
|
+
|
|
425
|
+
// 1. Check for Keywords (to right, etc.)
|
|
426
|
+
if (firstPart.startsWith('to ')) {
|
|
427
|
+
stopsStartIndex = 1;
|
|
428
|
+
const direction = firstPart.replace('to ', '').trim();
|
|
429
|
+
switch (direction) {
|
|
430
|
+
case 'top':
|
|
431
|
+
y1 = '100%';
|
|
432
|
+
y2 = '0%';
|
|
433
|
+
break;
|
|
434
|
+
case 'bottom':
|
|
435
|
+
y1 = '0%';
|
|
436
|
+
y2 = '100%';
|
|
437
|
+
break;
|
|
438
|
+
case 'left':
|
|
439
|
+
x1 = '100%';
|
|
440
|
+
x2 = '0%';
|
|
441
|
+
break;
|
|
442
|
+
case 'right':
|
|
443
|
+
x2 = '100%';
|
|
444
|
+
break;
|
|
445
|
+
case 'top right':
|
|
446
|
+
x1 = '0%';
|
|
447
|
+
y1 = '100%';
|
|
448
|
+
x2 = '100%';
|
|
449
|
+
y2 = '0%';
|
|
450
|
+
break;
|
|
451
|
+
case 'top left':
|
|
452
|
+
x1 = '100%';
|
|
453
|
+
y1 = '100%';
|
|
454
|
+
x2 = '0%';
|
|
455
|
+
y2 = '0%';
|
|
456
|
+
break;
|
|
457
|
+
case 'bottom right':
|
|
458
|
+
x2 = '100%';
|
|
459
|
+
y2 = '100%';
|
|
460
|
+
break;
|
|
461
|
+
case 'bottom left':
|
|
462
|
+
x1 = '100%';
|
|
463
|
+
y2 = '100%';
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
// 2. Check for Degrees (45deg, 90deg, etc.)
|
|
468
|
+
else if (firstPart.match(/^-?[\d.]+(deg|rad|turn|grad)$/)) {
|
|
469
|
+
stopsStartIndex = 1;
|
|
470
|
+
const val = parseFloat(firstPart);
|
|
471
|
+
// CSS 0deg is Top (North), 90deg is Right (East), 180deg is Bottom (South)
|
|
472
|
+
// We convert this to SVG coordinates on a unit square (0-100%).
|
|
473
|
+
// Formula: Map angle to perimeter coordinates.
|
|
474
|
+
if (!isNaN(val)) {
|
|
475
|
+
const deg = firstPart.includes('rad') ? val * (180 / Math.PI) : val;
|
|
476
|
+
const cssRad = ((deg - 90) * Math.PI) / 180; // Correct CSS angle offset
|
|
477
|
+
|
|
478
|
+
// Calculate standard vector for rectangle center (50, 50)
|
|
479
|
+
const scale = 50; // Distance from center to edge (approx)
|
|
480
|
+
const cos = Math.cos(cssRad); // Y component (reversed in SVG)
|
|
481
|
+
const sin = Math.sin(cssRad); // X component
|
|
482
|
+
|
|
483
|
+
// Invert Y for SVG coordinate system
|
|
484
|
+
x1 = (50 - sin * scale).toFixed(1) + '%';
|
|
485
|
+
y1 = (50 + cos * scale).toFixed(1) + '%';
|
|
486
|
+
x2 = (50 + sin * scale).toFixed(1) + '%';
|
|
487
|
+
y2 = (50 - cos * scale).toFixed(1) + '%';
|
|
488
|
+
}
|
|
416
489
|
}
|
|
417
490
|
|
|
491
|
+
// 3. Process Color Stops
|
|
418
492
|
let stopsXML = '';
|
|
419
|
-
const stopParts = parts.slice(
|
|
493
|
+
const stopParts = parts.slice(stopsStartIndex);
|
|
494
|
+
|
|
420
495
|
stopParts.forEach((part, idx) => {
|
|
496
|
+
// Parse "Color Position" (e.g., "red 50%")
|
|
497
|
+
// Regex looks for optional space + number + unit at the end of the string
|
|
421
498
|
let color = part;
|
|
422
499
|
let offset = Math.round((idx / (stopParts.length - 1)) * 100) + '%';
|
|
423
|
-
|
|
500
|
+
|
|
501
|
+
const posMatch = part.match(/^(.*?)\s+(-?[\d.]+(?:%|px)?)$/);
|
|
424
502
|
if (posMatch) {
|
|
425
503
|
color = posMatch[1];
|
|
426
504
|
offset = posMatch[2];
|
|
427
505
|
}
|
|
506
|
+
|
|
507
|
+
// Handle RGBA/RGB for SVG compatibility
|
|
428
508
|
let opacity = 1;
|
|
429
509
|
if (color.includes('rgba')) {
|
|
430
|
-
const
|
|
431
|
-
if (
|
|
432
|
-
opacity =
|
|
433
|
-
color = `rgb(${
|
|
510
|
+
const rgbaMatch = color.match(/[\d.]+/g);
|
|
511
|
+
if (rgbaMatch && rgbaMatch.length >= 4) {
|
|
512
|
+
opacity = rgbaMatch[3];
|
|
513
|
+
color = `rgb(${rgbaMatch[0]},${rgbaMatch[1]},${rgbaMatch[2]})`;
|
|
434
514
|
}
|
|
435
515
|
}
|
|
436
|
-
|
|
516
|
+
|
|
517
|
+
stopsXML += `<stop offset="${offset}" stop-color="${color.trim()}" stop-opacity="${opacity}"/>`;
|
|
437
518
|
});
|
|
438
519
|
|
|
439
520
|
let strokeAttr = '';
|
|
@@ -442,12 +523,18 @@ export function generateGradientSVG(w, h, bgString, radius, border) {
|
|
|
442
523
|
}
|
|
443
524
|
|
|
444
525
|
const svg = `
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
526
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
|
|
527
|
+
<defs>
|
|
528
|
+
<linearGradient id="grad" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">
|
|
529
|
+
${stopsXML}
|
|
530
|
+
</linearGradient>
|
|
531
|
+
</defs>
|
|
532
|
+
<rect x="0" y="0" width="${w}" height="${h}" rx="${radius}" ry="${radius}" fill="url(#grad)" ${strokeAttr} />
|
|
533
|
+
</svg>`;
|
|
534
|
+
|
|
449
535
|
return 'data:image/svg+xml;base64,' + btoa(svg);
|
|
450
|
-
} catch {
|
|
536
|
+
} catch (e) {
|
|
537
|
+
console.warn('Gradient generation failed:', e);
|
|
451
538
|
return null;
|
|
452
539
|
}
|
|
453
540
|
}
|
|
@@ -485,4 +572,4 @@ export function generateBlurredSVG(w, h, color, radius, blurPx) {
|
|
|
485
572
|
data: 'data:image/svg+xml;base64,' + btoa(svg),
|
|
486
573
|
padding: padding,
|
|
487
574
|
};
|
|
488
|
-
}
|
|
575
|
+
}
|