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/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: 2, // Slight quality boost
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 the captured canvas into our sized canvas
194
- // html2canvas might return a larger canvas if scale > 1, so we fit it
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
- // Apply border radius clipping
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: SVGs / Icons ---
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, data: null }, // Data null initially
412
+ options: { data: null, x, y, w, h, rotate: rotation },
326
413
  };
327
414
 
328
- // Create Job
329
415
  const job = async () => {
330
- const pngData = await elementToCanvasImage(node, widthPx, heightPx);
331
- if (pngData) item.options.data = pngData;
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 / Icons / Images
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
- // 2. Initial check: Must be a standard inline tag OR display:inline
254
- const isInlineTag = ['SPAN', 'B', 'STRONG', 'EM', 'I', 'A', 'SMALL', 'MARK'].includes(el.tagName);
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
- // 3. CRITICAL FIX: Check for Structural Styling
260
- // PPTX Text Runs (parts of a text line) CANNOT have backgrounds, borders, or padding.
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 = parseFloat(style.borderWidth) > 0 && parseColor(style.borderColor).opacity > 0;
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 stopsStartIdx = 0;
398
- if (parts[0].includes('to right')) {
399
- x1 = '0%';
400
- x2 = '100%';
401
- y2 = '0%';
402
- stopsStartIdx = 1;
403
- } else if (parts[0].includes('to left')) {
404
- x1 = '100%';
405
- x2 = '0%';
406
- y2 = '0%';
407
- stopsStartIdx = 1;
408
- } else if (parts[0].includes('to top')) {
409
- y1 = '100%';
410
- y2 = '0%';
411
- stopsStartIdx = 1;
412
- } else if (parts[0].includes('to bottom')) {
413
- y1 = '0%';
414
- y2 = '100%';
415
- stopsStartIdx = 1;
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(stopsStartIdx);
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
- const posMatch = part.match(/(.*?)\s+(\d+(\.\d+)?%?)$/);
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 rgba = color.match(/[\d.]+/g);
431
- if (rgba && rgba.length > 3) {
432
- opacity = rgba[3];
433
- color = `rgb(${rgba[0]},${rgba[1]},${rgba[2]})`;
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
- stopsXML += `<stop offset="${offset}" stop-color="${color}" stop-opacity="${opacity}"/>`;
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
- <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
446
- <defs><linearGradient id="grad" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">${stopsXML}</linearGradient></defs>
447
- <rect x="0" y="0" width="${w}" height="${h}" rx="${radius}" ry="${radius}" fill="url(#grad)" ${strokeAttr} />
448
- </svg>`;
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
+ }