dom-to-pptx 1.0.8 → 1.1.0

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/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,103 @@ export function generateBlurredSVG(w, h, color, radius, blurPx) {
485
572
  data: 'data:image/svg+xml;base64,' + btoa(svg),
486
573
  padding: padding,
487
574
  };
575
+ }
576
+
577
+ // src/utils.js
578
+
579
+ // ... (keep all existing exports) ...
580
+
581
+ /**
582
+ * Traverses the target DOM and collects all unique font-family names used.
583
+ */
584
+ export function getUsedFontFamilies(root) {
585
+ const families = new Set();
586
+
587
+ function scan(node) {
588
+ if (node.nodeType === 1) { // Element
589
+ const style = window.getComputedStyle(node);
590
+ const fontList = style.fontFamily.split(',');
591
+ // The first font in the stack is the primary one
592
+ const primary = fontList[0].trim().replace(/['"]/g, '');
593
+ if (primary) families.add(primary);
594
+ }
595
+ for (const child of node.childNodes) {
596
+ scan(child);
597
+ }
598
+ }
599
+
600
+ // Handle array of roots or single root
601
+ const elements = Array.isArray(root) ? root : [root];
602
+ elements.forEach(el => {
603
+ const node = typeof el === 'string' ? document.querySelector(el) : el;
604
+ if (node) scan(node);
605
+ });
606
+
607
+ return families;
608
+ }
609
+
610
+ /**
611
+ * Scans document.styleSheets to find @font-face URLs for the requested families.
612
+ * Returns an array of { name, url } objects.
613
+ */
614
+ export async function getAutoDetectedFonts(usedFamilies) {
615
+ const foundFonts = [];
616
+ const processedUrls = new Set();
617
+
618
+ // Helper to extract clean URL from CSS src string
619
+ const extractUrl = (srcStr) => {
620
+ // Look for url("...") or url('...') or url(...)
621
+ // Prioritize woff, ttf, otf. Avoid woff2 if possible as handling is harder,
622
+ // but if it's the only one, take it (convert logic handles it best effort).
623
+ const matches = srcStr.match(/url\((['"]?)(.*?)\1\)/g);
624
+ if (!matches) return null;
625
+
626
+ // Filter for preferred formats
627
+ let chosenUrl = null;
628
+ for (const match of matches) {
629
+ const urlRaw = match.replace(/url\((['"]?)(.*?)\1\)/, '$2');
630
+ // Skip data URIs for now (unless you want to support base64 embedding)
631
+ if (urlRaw.startsWith('data:')) continue;
632
+
633
+ if (urlRaw.includes('.ttf') || urlRaw.includes('.otf') || urlRaw.includes('.woff')) {
634
+ chosenUrl = urlRaw;
635
+ break; // Found a good one
636
+ }
637
+ // Fallback
638
+ if (!chosenUrl) chosenUrl = urlRaw;
639
+ }
640
+ return chosenUrl;
641
+ };
642
+
643
+ for (const sheet of Array.from(document.styleSheets)) {
644
+ try {
645
+ // Accessing cssRules on cross-origin sheets (like Google Fonts) might fail
646
+ // if CORS headers aren't set. We wrap in try/catch.
647
+ const rules = sheet.cssRules || sheet.rules;
648
+ if (!rules) continue;
649
+
650
+ for (const rule of Array.from(rules)) {
651
+ if (rule.constructor.name === 'CSSFontFaceRule' || rule.type === 5) {
652
+ const familyName = rule.style.getPropertyValue('font-family').replace(/['"]/g, '').trim();
653
+
654
+ if (usedFamilies.has(familyName)) {
655
+ const src = rule.style.getPropertyValue('src');
656
+ const url = extractUrl(src);
657
+
658
+ if (url && !processedUrls.has(url)) {
659
+ processedUrls.add(url);
660
+ foundFonts.push({ name: familyName, url: url });
661
+ }
662
+ }
663
+ }
664
+ }
665
+ } catch (e) {
666
+ // SecurityError is common for external stylesheets (CORS).
667
+ // We cannot scan those automatically via CSSOM.
668
+ console.warn("error:", e);
669
+ console.warn('Cannot scan stylesheet for fonts (CORS restriction):', sheet.href);
670
+ }
671
+ }
672
+
673
+ return foundFonts;
488
674
  }