docgen-utils 1.0.6 → 1.0.7

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.
@@ -43,6 +43,20 @@ export function rgbToHex(rgbStr) {
43
43
  .map((n) => parseInt(n).toString(16).padStart(2, '0'))
44
44
  .join('');
45
45
  }
46
+ /**
47
+ * Check if a CSS color value is fully transparent (alpha = 0).
48
+ * Returns `true` for `transparent`, `rgba(0,0,0,0)`, or any `rgba()` with alpha ≤ 0.
49
+ */
50
+ function isFullyTransparent(colorStr) {
51
+ if (colorStr === 'transparent' || colorStr === 'rgba(0, 0, 0, 0)')
52
+ return true;
53
+ const match = colorStr.match(/rgba\(\d+,\s*\d+,\s*\d+,\s*([\d.]+)\)/);
54
+ if (match) {
55
+ const alpha = parseFloat(match[1]);
56
+ return alpha <= 0;
57
+ }
58
+ return false;
59
+ }
46
60
  /**
47
61
  * Extract transparency percentage from an `rgba()` string.
48
62
  * Returns `null` for opaque or non-rgba values, otherwise 0-100.
@@ -65,7 +79,11 @@ export function extractAlpha(rgbStr) {
65
79
  */
66
80
  export function parseCssGradient(gradientStr) {
67
81
  const colorToHex = (colorStr) => {
68
- colorStr = colorStr.trim();
82
+ colorStr = colorStr.trim().toLowerCase();
83
+ // Handle 'transparent' keyword (equivalent to rgba(0,0,0,0))
84
+ if (colorStr === 'transparent') {
85
+ return '000000';
86
+ }
69
87
  if (colorStr.startsWith('#')) {
70
88
  let hex = colorStr.slice(1);
71
89
  if (hex.length === 3)
@@ -83,7 +101,11 @@ export function parseCssGradient(gradientStr) {
83
101
  return 'FFFFFF';
84
102
  };
85
103
  const extractTransparency = (colorStr) => {
86
- colorStr = colorStr.trim();
104
+ colorStr = colorStr.trim().toLowerCase();
105
+ // Handle 'transparent' keyword (equivalent to rgba(0,0,0,0) - fully transparent)
106
+ if (colorStr === 'transparent') {
107
+ return 100; // 100% transparency = fully transparent
108
+ }
87
109
  const rgbaMatch = colorStr.match(/rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*([\d.]+)\s*\)/);
88
110
  if (rgbaMatch) {
89
111
  const alpha = parseFloat(rgbaMatch[1]);
@@ -220,6 +242,125 @@ function shouldSkipBold(fontFamily) {
220
242
  const normalizedFont = fontFamily.toLowerCase().replace(/['"]/g, '').split(',')[0].trim();
221
243
  return SINGLE_WEIGHT_FONTS.includes(normalizedFont);
222
244
  }
245
+ /**
246
+ * Extract letter-spacing as points from computed style.
247
+ * Returns the value in points, or null if normal/zero.
248
+ */
249
+ function extractLetterSpacing(computed) {
250
+ const ls = computed.letterSpacing;
251
+ if (!ls || ls === 'normal' || ls === '0px')
252
+ return null;
253
+ const pxVal = parseFloat(ls);
254
+ if (isNaN(pxVal) || pxVal === 0)
255
+ return null;
256
+ return pxVal * PT_PER_PX;
257
+ }
258
+ /**
259
+ * Parse CSS text-shadow into a glow effect and/or shadow.
260
+ *
261
+ * Patterns:
262
+ * - `0 0 <blur> <color>` → glow (zero offset, radial blur)
263
+ * - `<offsetX> <offsetY> <blur> <color>` → shadow (with direction)
264
+ *
265
+ * When multiple shadows are present, we pick the most impactful one
266
+ * (largest blur for glow, largest offset for shadow).
267
+ *
268
+ * Returns `{ glow, shadow }` — either or both may be null.
269
+ */
270
+ function parseTextShadow(textShadow) {
271
+ if (!textShadow || textShadow === 'none')
272
+ return { glow: null, shadow: null };
273
+ // Split multiple shadows by comma, but respect parentheses
274
+ const shadows = [];
275
+ let current = '';
276
+ let depth = 0;
277
+ for (const char of textShadow) {
278
+ if (char === '(')
279
+ depth++;
280
+ else if (char === ')')
281
+ depth--;
282
+ if (char === ',' && depth === 0) {
283
+ shadows.push(current.trim());
284
+ current = '';
285
+ }
286
+ else {
287
+ current += char;
288
+ }
289
+ }
290
+ if (current.trim())
291
+ shadows.push(current.trim());
292
+ let bestGlow = null;
293
+ let bestShadow = null;
294
+ let bestGlowBlur = 0;
295
+ let bestShadowOffset = 0;
296
+ for (const shadowStr of shadows) {
297
+ // Extract color (rgb/rgba)
298
+ const colorMatch = shadowStr.match(/rgba?\([^)]+\)/);
299
+ // Extract numeric values (offsetX offsetY blur [spread])
300
+ const numericParts = shadowStr.match(/([-\d.]+)px/g);
301
+ if (!numericParts || numericParts.length < 2)
302
+ continue;
303
+ const offsetX = parseFloat(numericParts[0]);
304
+ const offsetY = parseFloat(numericParts[1]);
305
+ const blur = numericParts.length > 2 ? parseFloat(numericParts[2]) : 0;
306
+ if (blur <= 0)
307
+ continue; // No visual effect without blur
308
+ // Extract alpha from rgba
309
+ let opacity = 1.0;
310
+ if (colorMatch) {
311
+ const alphaMatch = colorMatch[0].match(/rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*([\d.]+)\s*\)/);
312
+ if (alphaMatch) {
313
+ opacity = parseFloat(alphaMatch[1]);
314
+ }
315
+ }
316
+ const color = colorMatch ? rgbToHex(colorMatch[0]) : '000000';
317
+ const isGlow = Math.abs(offsetX) < 1 && Math.abs(offsetY) < 1;
318
+ if (isGlow) {
319
+ // Glow: zero offset, large blur
320
+ // CSS text-shadow blur radius defines the Gaussian blur standard deviation.
321
+ // OOXML <a:glow rad> defines how far the glow extends beyond the shape.
322
+ //
323
+ // Visual testing shows that CSS Gaussian blur appears much larger and more
324
+ // vibrant than OOXML glow at equivalent numeric values. This is because:
325
+ // 1. CSS blur uses a Gaussian distribution that spreads intensity over ~2-3x the radius
326
+ // 2. OOXML glow has a sharper falloff and appears more contained
327
+ // 3. LibreOffice/PowerPoint may render glows more subtly than browsers
328
+ //
329
+ // To achieve visual equivalence:
330
+ // - Scale glow size by 2.5x (CSS blur spreads much wider than OOXML glow)
331
+ // - Scale opacity by 0.3x (OOXML glow appears more intense at same alpha)
332
+ const GLOW_SIZE_SCALE = 2.5;
333
+ const GLOW_OPACITY_SCALE = 0.3;
334
+ if (blur > bestGlowBlur) {
335
+ bestGlowBlur = blur;
336
+ bestGlow = {
337
+ size: blur * PT_PER_PX * GLOW_SIZE_SCALE,
338
+ color,
339
+ opacity: Math.min(opacity * GLOW_OPACITY_SCALE, 1.0),
340
+ };
341
+ }
342
+ }
343
+ else {
344
+ // Shadow: has offset
345
+ const offset = Math.sqrt(offsetX * offsetX + offsetY * offsetY);
346
+ if (offset > bestShadowOffset) {
347
+ bestShadowOffset = offset;
348
+ let angle = Math.atan2(offsetY, offsetX) * (180 / Math.PI);
349
+ if (angle < 0)
350
+ angle += 360;
351
+ bestShadow = {
352
+ type: 'outer',
353
+ angle: Math.round(angle),
354
+ blur: blur * PT_PER_PX,
355
+ color,
356
+ offset: offset * PT_PER_PX,
357
+ opacity,
358
+ };
359
+ }
360
+ }
361
+ }
362
+ return { glow: bestGlow, shadow: bestShadow };
363
+ }
223
364
  function applyTextTransform(text, textTransform) {
224
365
  if (textTransform === 'uppercase')
225
366
  return text.toUpperCase();
@@ -281,6 +422,216 @@ function getPositionAndSize(el, rect, rotation) {
281
422
  h: el.offsetHeight,
282
423
  };
283
424
  }
425
+ /**
426
+ * Apply CSS filter effects (brightness, contrast, saturate) to an image by
427
+ * rendering it through a canvas with the filter applied. Returns a new data
428
+ * URI with the filters baked into the pixel data.
429
+ *
430
+ * This avoids relying on OOXML `<a:lum>` which LibreOffice renders differently
431
+ * than CSS `filter: brightness() contrast()`. By pre-processing the image,
432
+ * the result looks identical regardless of the presentation software.
433
+ *
434
+ * Returns the original src if canvas filter is not supported or fails.
435
+ */
436
+ function applyImageFilter(img, filter) {
437
+ try {
438
+ // Only process if the image is loaded and has dimensions
439
+ if (!img.naturalWidth || !img.naturalHeight)
440
+ return img.src;
441
+ if (!filter || filter === 'none')
442
+ return img.src;
443
+ const canvas = document.createElement('canvas');
444
+ canvas.width = img.naturalWidth;
445
+ canvas.height = img.naturalHeight;
446
+ const ctx = canvas.getContext('2d');
447
+ if (!ctx)
448
+ return img.src;
449
+ // Apply the CSS filter to the canvas context
450
+ // Canvas2D supports the same filter syntax as CSS
451
+ ctx.filter = filter;
452
+ ctx.drawImage(img, 0, 0);
453
+ // Export as JPEG data URI (smaller than PNG for photos)
454
+ return canvas.toDataURL('image/jpeg', 0.90);
455
+ }
456
+ catch {
457
+ // If anything fails (CORS, etc.), return original
458
+ return img.src;
459
+ }
460
+ }
461
+ /**
462
+ * Apply CSS mask-image gradient to an image using canvas.
463
+ *
464
+ * CSS mask-image creates a transparency effect based on the mask's alpha values.
465
+ * We simulate this by:
466
+ * 1. Drawing the image on a canvas
467
+ * 2. Drawing a gradient mask on top using 'destination-in' composite mode
468
+ *
469
+ * Returns a PNG data URI with the mask applied, or null if masking fails.
470
+ *
471
+ * @param img - The image element to mask
472
+ * @param maskGradient - The parsed gradient to use as a mask
473
+ * @param displayWidth - Display width in pixels
474
+ * @param displayHeight - Display height in pixels
475
+ * @param cssOpacity - Optional CSS opacity to bake into the mask (0-1)
476
+ */
477
+ function applyImageMask(img, maskGradient, displayWidth, displayHeight, cssOpacity) {
478
+ try {
479
+ if (!img.naturalWidth || !img.naturalHeight)
480
+ return null;
481
+ if (!img.complete)
482
+ return null;
483
+ const canvas = document.createElement('canvas');
484
+ // Use display dimensions for the masked output
485
+ canvas.width = Math.round(displayWidth);
486
+ canvas.height = Math.round(displayHeight);
487
+ const ctx = canvas.getContext('2d');
488
+ if (!ctx)
489
+ return null;
490
+ // Apply CSS opacity to the image if specified
491
+ if (cssOpacity !== undefined && cssOpacity < 1) {
492
+ ctx.globalAlpha = cssOpacity;
493
+ }
494
+ // Draw the image scaled to display size
495
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
496
+ // Reset globalAlpha for the mask operation
497
+ ctx.globalAlpha = 1;
498
+ // Check if canvas has content (CORS check)
499
+ try {
500
+ ctx.getImageData(0, 0, 1, 1);
501
+ }
502
+ catch {
503
+ // CORS failure - can't read image data
504
+ return null;
505
+ }
506
+ // Apply the mask gradient
507
+ applyMaskGradientToCanvas(ctx, canvas.width, canvas.height, maskGradient);
508
+ // Export as PNG (needs alpha channel for transparency)
509
+ return canvas.toDataURL('image/png');
510
+ }
511
+ catch {
512
+ return null;
513
+ }
514
+ }
515
+ /**
516
+ * Apply mask gradient to a canvas context using destination-in composite mode.
517
+ * This creates transparency based on the gradient's alpha values.
518
+ */
519
+ function applyMaskGradientToCanvas(ctx, width, height, maskGradient) {
520
+ ctx.globalCompositeOperation = 'destination-in';
521
+ // Create gradient for the mask
522
+ let gradient;
523
+ if (maskGradient.type === 'radial') {
524
+ const cx = (maskGradient.centerX ?? 50) / 100 * width;
525
+ const cy = (maskGradient.centerY ?? 50) / 100 * height;
526
+ const radius = Math.max(width, height);
527
+ gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
528
+ }
529
+ else {
530
+ // Linear gradient - CSS angle to canvas coordinates
531
+ // CSS angles: 0° = to top, 90° = to right, 180° = to bottom, 270° = to left
532
+ // We map the gradient to span the full image dimensions along the gradient axis
533
+ const cssAngle = maskGradient.angle ?? 180;
534
+ const angleRad = (cssAngle - 90) * Math.PI / 180;
535
+ // For cardinal directions, use exact edge-to-edge gradients
536
+ // This ensures position 0% and 100% map exactly to the image edges
537
+ let x1, y1, x2, y2;
538
+ if (cssAngle === 0) {
539
+ // to top: bottom to top
540
+ x1 = width / 2;
541
+ y1 = height;
542
+ x2 = width / 2;
543
+ y2 = 0;
544
+ }
545
+ else if (cssAngle === 90) {
546
+ // to right: left to right
547
+ x1 = 0;
548
+ y1 = height / 2;
549
+ x2 = width;
550
+ y2 = height / 2;
551
+ }
552
+ else if (cssAngle === 180) {
553
+ // to bottom: top to bottom
554
+ x1 = width / 2;
555
+ y1 = 0;
556
+ x2 = width / 2;
557
+ y2 = height;
558
+ }
559
+ else if (cssAngle === 270) {
560
+ // to left: right to left
561
+ x1 = width;
562
+ y1 = height / 2;
563
+ x2 = 0;
564
+ y2 = height / 2;
565
+ }
566
+ else {
567
+ // For non-cardinal angles, use diagonal calculation
568
+ const halfDiag = Math.sqrt(width * width + height * height) / 2;
569
+ const cx = width / 2;
570
+ const cy = height / 2;
571
+ x1 = cx - Math.cos(angleRad) * halfDiag;
572
+ y1 = cy - Math.sin(angleRad) * halfDiag;
573
+ x2 = cx + Math.cos(angleRad) * halfDiag;
574
+ y2 = cy + Math.sin(angleRad) * halfDiag;
575
+ }
576
+ gradient = ctx.createLinearGradient(x1, y1, x2, y2);
577
+ }
578
+ // Add color stops with alpha from the mask gradient
579
+ for (const stop of maskGradient.stops) {
580
+ // transparency is 0-100 where 100 = fully transparent
581
+ // Canvas alpha is 0-1 where 1 = fully opaque
582
+ const alpha = stop.transparency !== undefined ? (100 - stop.transparency) / 100 : 1;
583
+ gradient.addColorStop(stop.position / 100, `rgba(255,255,255,${alpha})`);
584
+ }
585
+ ctx.fillStyle = gradient;
586
+ ctx.fillRect(0, 0, width, height);
587
+ }
588
+ /**
589
+ * Parse CSS `filter` property to extract brightness, contrast, and saturate values.
590
+ *
591
+ * Returns an object with the extracted values in OOXML-compatible format:
592
+ * - brightness: percent change (-100 to 100). brightness(0.6) → -40.
593
+ * - contrast: percent change (-100 to 100). contrast(1.1) → 10.
594
+ * - saturation: percent (0 = grayscale, 100 = normal). saturate(1.1) → 110.
595
+ *
596
+ * Returns null if no relevant filter functions found.
597
+ */
598
+ function parseCssFilter(filter) {
599
+ if (!filter || filter === 'none')
600
+ return null;
601
+ const result = {};
602
+ let hasValues = false;
603
+ // Match brightness(value) — value is a multiplier (0.6 = 60% brightness)
604
+ const brightnessMatch = filter.match(/brightness\(([\d.]+)\)/);
605
+ if (brightnessMatch) {
606
+ const val = parseFloat(brightnessMatch[1]);
607
+ if (val !== 1.0) {
608
+ // Convert: brightness(0.6) → bright = (0.6 - 1) * 100 = -40
609
+ result.brightness = Math.round((val - 1) * 100);
610
+ hasValues = true;
611
+ }
612
+ }
613
+ // Match contrast(value) — value is a multiplier (1.1 = 110% contrast)
614
+ const contrastMatch = filter.match(/contrast\(([\d.]+)\)/);
615
+ if (contrastMatch) {
616
+ const val = parseFloat(contrastMatch[1]);
617
+ if (val !== 1.0) {
618
+ // Convert: contrast(1.1) → contrast = (1.1 - 1) * 100 = 10
619
+ result.contrast = Math.round((val - 1) * 100);
620
+ hasValues = true;
621
+ }
622
+ }
623
+ // Match saturate(value) — value is a multiplier (1.1 = 110% saturation)
624
+ const saturateMatch = filter.match(/saturate\(([\d.]+)\)/);
625
+ if (saturateMatch) {
626
+ const val = parseFloat(saturateMatch[1]);
627
+ if (val !== 1.0) {
628
+ // Convert: saturate(1.1) → saturation = 110
629
+ result.saturation = Math.round(val * 100);
630
+ hasValues = true;
631
+ }
632
+ }
633
+ return hasValues ? result : null;
634
+ }
284
635
  function parseBoxShadow(boxShadow) {
285
636
  if (!boxShadow || boxShadow === 'none')
286
637
  return null;
@@ -317,6 +668,134 @@ function parseBoxShadow(boxShadow) {
317
668
  opacity,
318
669
  };
319
670
  }
671
+ /**
672
+ * Extract visible CSS pseudo-elements (::before, ::after) from an element.
673
+ *
674
+ * Pseudo-elements are invisible to the DOM but can create visual content via CSS.
675
+ * Common patterns:
676
+ * - Accent lines: thin gradient lines at edges of cards/titles
677
+ * - Overlays: gradient fades over images
678
+ * - Background glows: large low-opacity radial gradients
679
+ *
680
+ * Returns an array of ShapeElements representing the pseudo-elements.
681
+ */
682
+ function extractPseudoElements(el, win) {
683
+ const results = [];
684
+ const parentRect = el.getBoundingClientRect();
685
+ if (parentRect.width <= 0 || parentRect.height <= 0)
686
+ return results;
687
+ for (const pseudo of ['::before', '::after']) {
688
+ const pComputed = win.getComputedStyle(el, pseudo);
689
+ // Skip if no content or content is 'none'
690
+ const content = pComputed.content;
691
+ if (!content || content === 'none' || content === 'normal')
692
+ continue;
693
+ // Skip if display is none
694
+ if (pComputed.display === 'none')
695
+ continue;
696
+ // Parse dimensions
697
+ let pWidth = parseFloat(pComputed.width);
698
+ let pHeight = parseFloat(pComputed.height);
699
+ // If dimensions are auto/0, infer from parent and positioning
700
+ if (isNaN(pWidth) || pWidth <= 0) {
701
+ // Check if left/right both set (→ width = parent width)
702
+ const left = parseFloat(pComputed.left);
703
+ const right = parseFloat(pComputed.right);
704
+ if (!isNaN(left) && !isNaN(right)) {
705
+ pWidth = parentRect.width - left - right;
706
+ }
707
+ else {
708
+ pWidth = parentRect.width;
709
+ }
710
+ }
711
+ if (isNaN(pHeight) || pHeight <= 0) {
712
+ // Check if top/bottom both set (→ height = parent height)
713
+ const top = parseFloat(pComputed.top);
714
+ const bottom = parseFloat(pComputed.bottom);
715
+ if (!isNaN(top) && !isNaN(bottom)) {
716
+ pHeight = parentRect.height - top - bottom;
717
+ }
718
+ else {
719
+ pHeight = parentRect.height;
720
+ }
721
+ }
722
+ // Skip tiny elements that won't be visible
723
+ if (pWidth < 1 || pHeight < 1)
724
+ continue;
725
+ // Determine position (relative to parent)
726
+ let pLeft = parseFloat(pComputed.left);
727
+ let pTop = parseFloat(pComputed.top);
728
+ if (isNaN(pLeft))
729
+ pLeft = 0;
730
+ if (isNaN(pTop))
731
+ pTop = 0;
732
+ // Convert to absolute page coordinates
733
+ const absLeft = parentRect.left + pLeft;
734
+ const absTop = parentRect.top + pTop;
735
+ // Check for visual content: background color, gradient, or border
736
+ const hasBg = pComputed.backgroundColor && pComputed.backgroundColor !== 'rgba(0, 0, 0, 0)';
737
+ const bgImage = pComputed.backgroundImage;
738
+ const hasGradient = bgImage && bgImage !== 'none' &&
739
+ (bgImage.includes('linear-gradient') || bgImage.includes('radial-gradient'));
740
+ const hasBgImage = bgImage && bgImage !== 'none' && bgImage.includes('url(');
741
+ if (!hasBg && !hasGradient && !hasBgImage)
742
+ continue;
743
+ // Parse gradient if present
744
+ let gradient = null;
745
+ if (hasGradient) {
746
+ gradient = parseCssGradient(bgImage);
747
+ }
748
+ // Parse opacity
749
+ const elementOpacity = parseFloat(pComputed.opacity);
750
+ const hasOpacity = !isNaN(elementOpacity) && elementOpacity < 1;
751
+ // Parse border-radius
752
+ let rectRadius = 0;
753
+ const borderRadius = pComputed.borderRadius;
754
+ const radiusValue = parseFloat(borderRadius);
755
+ if (radiusValue > 0) {
756
+ if (borderRadius.includes('%')) {
757
+ if (radiusValue >= 50) {
758
+ rectRadius = 1; // Fully rounded (pill shape)
759
+ }
760
+ else {
761
+ const minDim = Math.min(pWidth, pHeight);
762
+ rectRadius = (radiusValue / 100) * pxToInch(minDim);
763
+ }
764
+ }
765
+ else {
766
+ rectRadius = pxToInch(radiusValue);
767
+ }
768
+ }
769
+ // Parse box-shadow
770
+ const shadow = parseBoxShadow(pComputed.boxShadow);
771
+ // Create shape element
772
+ const shapeElement = {
773
+ type: 'shape',
774
+ text: '',
775
+ textRuns: null,
776
+ style: null,
777
+ position: {
778
+ x: pxToInch(absLeft),
779
+ y: pxToInch(absTop),
780
+ w: pxToInch(pWidth),
781
+ h: pxToInch(pHeight),
782
+ },
783
+ shape: {
784
+ fill: hasBg ? rgbToHex(pComputed.backgroundColor) : null,
785
+ gradient: gradient,
786
+ transparency: hasBg ? extractAlpha(pComputed.backgroundColor) : null,
787
+ line: null,
788
+ rectRadius: rectRadius,
789
+ shadow: shadow,
790
+ opacity: hasOpacity ? elementOpacity : null,
791
+ isEllipse: false,
792
+ softEdge: null,
793
+ },
794
+ };
795
+ results.push(shapeElement);
796
+ }
797
+ return results;
798
+ }
320
799
  function parseInlineFormatting(element, baseOptions, runs, baseTextTransform, win) {
321
800
  let prevNodeIsText = false;
322
801
  let pendingSoftBreak = false;
@@ -346,12 +825,13 @@ function parseInlineFormatting(element, baseOptions, runs, baseTextTransform, wi
346
825
  const el = node;
347
826
  const options = { ...baseOptions };
348
827
  const computed = win.getComputedStyle(el);
349
- if (el.tagName === 'SPAN' ||
350
- el.tagName === 'B' ||
351
- el.tagName === 'STRONG' ||
352
- el.tagName === 'I' ||
353
- el.tagName === 'EM' ||
354
- el.tagName === 'U') {
828
+ // Handle all inline formatting elements
829
+ const inlineTags = new Set([
830
+ 'SPAN', 'B', 'STRONG', 'I', 'EM', 'U',
831
+ 'CODE', 'A', 'MARK', 'SUB', 'SUP', 'SMALL', 'S', 'DEL', 'INS',
832
+ 'ABBR', 'TIME', 'CITE', 'Q', 'DFN', 'KBD', 'SAMP', 'VAR',
833
+ ]);
834
+ if (inlineTags.has(el.tagName)) {
355
835
  const isBold = computed.fontWeight === 'bold' || parseInt(computed.fontWeight) >= 600;
356
836
  if (isBold && !shouldSkipBold(computed.fontFamily))
357
837
  options.bold = true;
@@ -367,6 +847,12 @@ function parseInlineFormatting(element, baseOptions, runs, baseTextTransform, wi
367
847
  }
368
848
  if (computed.fontSize)
369
849
  options.fontSize = pxToPoints(computed.fontSize);
850
+ if (computed.fontFamily) {
851
+ options.fontFace = computed.fontFamily.split(',')[0].replace(/['"]/g, '').trim();
852
+ }
853
+ const runLetterSpacing = extractLetterSpacing(computed);
854
+ if (runLetterSpacing !== null)
855
+ options.charSpacing = runLetterSpacing;
370
856
  if (computed.textTransform && computed.textTransform !== 'none') {
371
857
  const transformStr = computed.textTransform;
372
858
  textTransform = (text) => applyTextTransform(text, transformStr);
@@ -436,6 +922,9 @@ export function parseSlideHtml(doc) {
436
922
  const placeholders = [];
437
923
  const textTags = ['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'UL', 'OL', 'LI', 'SPAN'];
438
924
  const processed = new Set();
925
+ // Extract pseudo-elements from body (e.g., body::before radial glows)
926
+ const bodyPseudoElements = extractPseudoElements(body, win);
927
+ elements.push(...bodyPseudoElements);
439
928
  doc.querySelectorAll('*').forEach((el) => {
440
929
  if (processed.has(el))
441
930
  return;
@@ -465,7 +954,12 @@ export function parseSlideHtml(doc) {
465
954
  (computed.borderRightWidth && parseFloat(computed.borderRightWidth) > 0) ||
466
955
  (computed.borderBottomWidth && parseFloat(computed.borderBottomWidth) > 0) ||
467
956
  (computed.borderLeftWidth && parseFloat(computed.borderLeftWidth) > 0);
468
- if (hasBg || hasBorder) {
957
+ // Check for gradient background (backgroundImage contains linear-gradient or radial-gradient)
958
+ const spanBgImage = computed.backgroundImage;
959
+ const hasGradientBg = spanBgImage &&
960
+ spanBgImage !== 'none' &&
961
+ (spanBgImage.includes('linear-gradient') || spanBgImage.includes('radial-gradient'));
962
+ if (hasBg || hasBorder || hasGradientBg) {
469
963
  const rect = htmlEl.getBoundingClientRect();
470
964
  if (rect.width > 0 && rect.height > 0) {
471
965
  const text = el.textContent.trim();
@@ -473,7 +967,13 @@ export function parseSlideHtml(doc) {
473
967
  const borderRadius = computed.borderRadius;
474
968
  const radiusValue = parseFloat(borderRadius);
475
969
  let rectRadius = 0;
476
- if (radiusValue > 0) {
970
+ // Detect ellipse: border-radius >= 50% on roughly square elements
971
+ const isCircularRadius = borderRadius.includes('%')
972
+ ? radiusValue >= 50
973
+ : (radiusValue > 0 && radiusValue >= Math.min(rect.width, rect.height) / 2 - 1);
974
+ const aspectRatio = rect.width / rect.height;
975
+ const spanIsEllipse = isCircularRadius && aspectRatio > 0.5 && aspectRatio < 2.0;
976
+ if (radiusValue > 0 && !spanIsEllipse) {
477
977
  if (borderRadius.includes('%')) {
478
978
  const minDim = Math.min(rect.width, rect.height);
479
979
  rectRadius = (radiusValue / 100) * pxToInch(minDim);
@@ -490,6 +990,11 @@ export function parseSlideHtml(doc) {
490
990
  borderTop === borderRight &&
491
991
  borderRight === borderBottom &&
492
992
  borderBottom === borderLeft;
993
+ // Extract opacity
994
+ const spanOpacity = parseFloat(computed.opacity);
995
+ const hasSpanOpacity = !isNaN(spanOpacity) && spanOpacity < 1;
996
+ // Extract box-shadow
997
+ const spanShadow = parseBoxShadow(computed.boxShadow);
493
998
  const shapeElement = {
494
999
  type: 'shape',
495
1000
  position: {
@@ -500,26 +1005,30 @@ export function parseSlideHtml(doc) {
500
1005
  },
501
1006
  text: text,
502
1007
  textRuns: null,
503
- style: {
1008
+ style: text ? {
504
1009
  fontSize: pxToPoints(computed.fontSize),
505
1010
  fontFace: computed.fontFamily.split(',')[0].replace(/['"]/g, '').trim(),
506
1011
  color: rgbToHex(computed.color),
507
1012
  bold: parseInt(computed.fontWeight) >= 600,
508
1013
  align: 'center',
509
1014
  valign: 'middle',
510
- },
1015
+ } : null,
511
1016
  shape: {
512
1017
  fill: hasBg ? rgbToHex(computed.backgroundColor) : null,
513
1018
  gradient: bgGradient,
514
- transparency: null,
1019
+ transparency: hasBg ? extractAlpha(computed.backgroundColor) : null,
515
1020
  line: hasUniformBorder
516
1021
  ? {
517
1022
  color: rgbToHex(computed.borderColor),
518
1023
  width: pxToPoints(borderTop),
1024
+ transparency: extractAlpha(computed.borderColor),
519
1025
  }
520
1026
  : null,
521
- rectRadius: rectRadius,
522
- shadow: null,
1027
+ rectRadius: spanIsEllipse ? 0 : rectRadius,
1028
+ shadow: spanShadow,
1029
+ opacity: hasSpanOpacity ? spanOpacity : null,
1030
+ isEllipse: spanIsEllipse,
1031
+ softEdge: null,
523
1032
  },
524
1033
  };
525
1034
  elements.push(shapeElement);
@@ -598,52 +1107,266 @@ export function parseSlideHtml(doc) {
598
1107
  const nearOrigin = rect.left <= 10 && rect.top <= 10;
599
1108
  const isFullSlideImage = coversWidth && coversHeight && nearOrigin;
600
1109
  const objectFit = imgComputed.objectFit;
601
- // Check for ancestor with overflow:hidden and border-radius
1110
+ // Check for ancestor with overflow:hidden for border-radius AND clipping.
1111
+ // getBoundingClientRect() on IMG returns the FULL layout box regardless of
1112
+ // ancestor overflow:hidden — we must manually intersect with clipping ancestors
1113
+ // to get the visible area.
602
1114
  let imgRectRadius = null;
1115
+ let clipLeft = rect.left;
1116
+ let clipTop = rect.top;
1117
+ let clipRight = rect.right;
1118
+ let clipBottom = rect.bottom;
603
1119
  let ancestor = el.parentElement;
604
1120
  while (ancestor && ancestor !== doc.body) {
605
1121
  const ancestorComputed = win.getComputedStyle(ancestor);
606
1122
  const ancestorOverflow = ancestorComputed.overflow;
607
- const ancestorBorderRadius = ancestorComputed.borderRadius;
608
- if ((ancestorOverflow === 'hidden' || ancestorOverflow === 'clip') &&
609
- ancestorBorderRadius) {
610
- const radiusValue = parseFloat(ancestorBorderRadius);
611
- if (radiusValue > 0) {
612
- if (ancestorBorderRadius.includes('%')) {
613
- const ancestorRect = ancestor.getBoundingClientRect();
614
- const minDim = Math.min(ancestorRect.width, ancestorRect.height);
615
- imgRectRadius = (radiusValue / 100) * pxToInch(minDim);
616
- }
617
- else if (ancestorBorderRadius.includes('pt')) {
618
- imgRectRadius = radiusValue / 72;
619
- }
620
- else {
621
- imgRectRadius = pxToInch(radiusValue);
1123
+ if (ancestorOverflow === 'hidden' || ancestorOverflow === 'clip') {
1124
+ // Compute clip intersection with this ancestor's rect
1125
+ const ancestorRect = ancestor.getBoundingClientRect();
1126
+ clipLeft = Math.max(clipLeft, ancestorRect.left);
1127
+ clipTop = Math.max(clipTop, ancestorRect.top);
1128
+ clipRight = Math.min(clipRight, ancestorRect.right);
1129
+ clipBottom = Math.min(clipBottom, ancestorRect.bottom);
1130
+ // Also extract border-radius for rounded corners
1131
+ const ancestorBorderRadius = ancestorComputed.borderRadius;
1132
+ if (ancestorBorderRadius && imgRectRadius === null) {
1133
+ const radiusValue = parseFloat(ancestorBorderRadius);
1134
+ if (radiusValue > 0) {
1135
+ if (ancestorBorderRadius.includes('%')) {
1136
+ const minDim = Math.min(ancestorRect.width, ancestorRect.height);
1137
+ imgRectRadius = (radiusValue / 100) * pxToInch(minDim);
1138
+ }
1139
+ else if (ancestorBorderRadius.includes('pt')) {
1140
+ imgRectRadius = radiusValue / 72;
1141
+ }
1142
+ else {
1143
+ imgRectRadius = pxToInch(radiusValue);
1144
+ }
622
1145
  }
623
- break;
624
1146
  }
625
1147
  }
626
1148
  ancestor = ancestor.parentElement;
627
1149
  }
1150
+ // Use the clipped rect (intersection with overflow:hidden ancestors)
1151
+ const clippedW = Math.max(0, clipRight - clipLeft);
1152
+ const clippedH = Math.max(0, clipBottom - clipTop);
1153
+ const wasClipped = clippedW < rect.width - 1 || clippedH < rect.height - 1;
1154
+ // Pre-bake CSS filter effects (brightness, contrast, saturate) into the
1155
+ // image pixels via canvas rendering. This avoids relying on OOXML <a:lum>
1156
+ // which LibreOffice renders differently than CSS filters.
1157
+ const cssFilter = imgComputed.filter;
1158
+ let imgSrc = el.src;
1159
+ let filterPreBaked = false;
1160
+ if (cssFilter && cssFilter !== 'none') {
1161
+ const bakedSrc = applyImageFilter(el, cssFilter);
1162
+ if (bakedSrc !== imgSrc) {
1163
+ imgSrc = bakedSrc;
1164
+ filterPreBaked = true;
1165
+ }
1166
+ }
1167
+ // Pre-bake CSS mask-image gradient into the image using canvas.
1168
+ // This creates a transparency gradient effect that PPTX can display natively.
1169
+ const maskImageProp = imgComputed.maskImage ||
1170
+ imgComputed.webkitMaskImage ||
1171
+ imgComputed.getPropertyValue('mask-image') ||
1172
+ imgComputed.getPropertyValue('-webkit-mask-image');
1173
+ // Extract CSS opacity early so we can bake it into the mask if present
1174
+ const imgOpacity = parseFloat(imgComputed.opacity);
1175
+ const hasOpacity = !isNaN(imgOpacity) && imgOpacity < 1 && imgOpacity >= 0;
1176
+ // Pre-bake CSS mask-image gradient into the image using canvas.
1177
+ // This creates a transparency gradient effect (PNG with alpha) that PPTX displays natively.
1178
+ // When a mask is applied, we also bake in the CSS opacity to ensure correct alpha blending.
1179
+ let maskApplied = false;
1180
+ if (maskImageProp && maskImageProp !== 'none' &&
1181
+ (maskImageProp.includes('linear-gradient') || maskImageProp.includes('radial-gradient'))) {
1182
+ const maskGradient = parseCssGradient(maskImageProp);
1183
+ if (maskGradient) {
1184
+ const displayW = wasClipped ? clippedW : rect.width;
1185
+ const displayH = wasClipped ? clippedH : rect.height;
1186
+ // Pass CSS opacity to bake into the mask - this ensures the gradient transparency
1187
+ // is combined with the overall image opacity in a single PNG alpha channel
1188
+ const maskedSrc = applyImageMask(el, maskGradient, displayW, displayH, hasOpacity ? imgOpacity : undefined);
1189
+ if (maskedSrc) {
1190
+ imgSrc = maskedSrc;
1191
+ maskApplied = true;
1192
+ }
1193
+ }
1194
+ }
1195
+ // Read intrinsic image dimensions for object-fit:cover crop calculation
1196
+ const imgEl = el;
1197
+ const natW = imgEl.naturalWidth;
1198
+ const natH = imgEl.naturalHeight;
628
1199
  const imageElement = {
629
1200
  type: isFullSlideImage ? 'slideBackgroundImage' : 'image',
630
- src: el.src,
1201
+ src: imgSrc,
631
1202
  position: {
632
- x: pxToInch(rect.left),
633
- y: pxToInch(rect.top),
634
- w: pxToInch(rect.width),
635
- h: pxToInch(rect.height),
1203
+ x: pxToInch(wasClipped ? clipLeft : rect.left),
1204
+ y: pxToInch(wasClipped ? clipTop : rect.top),
1205
+ w: pxToInch(wasClipped ? clippedW : rect.width),
1206
+ h: pxToInch(wasClipped ? clippedH : rect.height),
636
1207
  },
637
1208
  sizing: objectFit === 'cover' ? { type: 'cover' } : null,
638
1209
  };
1210
+ // Store natural dimensions for cover crop calculation in convert.ts
1211
+ if (objectFit === 'cover' && natW > 0 && natH > 0 && !isFullSlideImage) {
1212
+ imageElement.naturalWidth = natW;
1213
+ imageElement.naturalHeight = natH;
1214
+ }
639
1215
  if (imgRectRadius !== null) {
640
1216
  imageElement.rectRadius = imgRectRadius;
641
1217
  }
1218
+ // If canvas pre-bake failed (e.g. CORS), fall back to OOXML filter properties.
1219
+ // These use <a:lum> / <a:duotone> which LibreOffice may render differently,
1220
+ // but it's better than no filter at all.
1221
+ if (!filterPreBaked && cssFilter && cssFilter !== 'none') {
1222
+ const filterValues = parseCssFilter(cssFilter);
1223
+ if (filterValues) {
1224
+ if (filterValues.brightness !== undefined) {
1225
+ imageElement.brightness = filterValues.brightness;
1226
+ }
1227
+ if (filterValues.contrast !== undefined) {
1228
+ imageElement.contrast = filterValues.contrast;
1229
+ }
1230
+ if (filterValues.saturation !== undefined) {
1231
+ imageElement.saturation = filterValues.saturation;
1232
+ }
1233
+ }
1234
+ }
1235
+ // Extract CSS opacity on the image element itself
1236
+ // Skip if mask was applied since opacity is already baked into the PNG
1237
+ if (hasOpacity && !maskApplied) {
1238
+ imageElement.transparency = Math.round((1 - imgOpacity) * 100);
1239
+ }
642
1240
  elements.push(imageElement);
643
1241
  processed.add(el);
644
1242
  return;
645
1243
  }
646
1244
  }
1245
+ // Extract inline SVG elements as images
1246
+ // Inline SVGs are converted to data URI images for PPTX embedding.
1247
+ // This handles icons, illustrations, and other vector graphics in the HTML.
1248
+ if (el.tagName === 'svg') {
1249
+ const rect = htmlEl.getBoundingClientRect();
1250
+ if (rect.width > 0 && rect.height > 0) {
1251
+ // Skip SVGs with very low opacity - these are usually decorative overlays
1252
+ // (e.g., grain textures, noise patterns) that won't render well in PPTX
1253
+ const computedStyle = win.getComputedStyle(htmlEl);
1254
+ const opacity = parseFloat(computedStyle.opacity || '1');
1255
+ if (opacity < 0.1) {
1256
+ processed.add(el);
1257
+ el.querySelectorAll('*').forEach((child) => processed.add(child));
1258
+ return;
1259
+ }
1260
+ // Skip SVGs that rely primarily on filter or pattern elements for rendering.
1261
+ // These use feTurbulence, feGaussianBlur, pattern fills, etc. that won't render
1262
+ // correctly when converted to static PNG for PPTX embedding.
1263
+ const svgEl = el;
1264
+ const hasFilter = svgEl.querySelector('filter, pattern');
1265
+ const hasRenderableContent = svgEl.querySelector('path, circle, ellipse, line, polyline, polygon, text, image');
1266
+ // Check for rects that use filter references (filter="url(...)")
1267
+ const allRects = svgEl.querySelectorAll('rect');
1268
+ const hasRectWithoutFilter = Array.from(allRects).some((r) => !r.getAttribute('filter')?.startsWith('url('));
1269
+ const hasOnlyFilterPattern = hasFilter && !hasRenderableContent && !hasRectWithoutFilter;
1270
+ if (hasOnlyFilterPattern) {
1271
+ processed.add(el);
1272
+ el.querySelectorAll('*').forEach((child) => processed.add(child));
1273
+ return;
1274
+ }
1275
+ // Also skip SVGs where ALL visible elements use filters (noise/grain overlays)
1276
+ // even if they have other rect elements for background
1277
+ const usesFilterRef = svgEl.querySelector('[filter^="url("]');
1278
+ if (hasFilter && usesFilterRef && !hasRenderableContent) {
1279
+ processed.add(el);
1280
+ el.querySelectorAll('*').forEach((child) => processed.add(child));
1281
+ return;
1282
+ }
1283
+ // Clone the SVG to avoid modifying the original
1284
+ const svgClone = el.cloneNode(true);
1285
+ // Ensure the SVG has proper xmlns attribute for standalone rendering
1286
+ if (!svgClone.hasAttribute('xmlns')) {
1287
+ svgClone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
1288
+ }
1289
+ // ALWAYS set width/height to the rendered dimensions.
1290
+ // The original SVG may have unitless width/height (e.g., width="40") which
1291
+ // are NOT scaled by transform.ts's px→pt conversion. Using the rendered
1292
+ // bounding rect ensures the SVG image matches the actual displayed size.
1293
+ svgClone.setAttribute('width', String(rect.width));
1294
+ svgClone.setAttribute('height', String(rect.height));
1295
+ // Resolve 'currentColor' to the actual computed color value.
1296
+ // SVGs often use fill="currentColor" to inherit the text color from CSS.
1297
+ // When serializing for PPTX embedding, we must replace currentColor with
1298
+ // the actual hex/rgb value since PowerPoint doesn't support CSS inheritance.
1299
+ const computedColor = win.getComputedStyle(htmlEl).color;
1300
+ const rootStyles = win.getComputedStyle(doc.documentElement);
1301
+ // Helper to resolve CSS color values including var() custom properties
1302
+ const resolveColorValue = (value) => {
1303
+ if (!value)
1304
+ return null;
1305
+ // Handle currentColor
1306
+ if (value === 'currentColor') {
1307
+ return computedColor || null;
1308
+ }
1309
+ // Handle CSS custom properties: var(--xxx) or var(--xxx, fallback)
1310
+ const varMatch = value.match(/^var\(\s*(--[\w-]+)(?:\s*,\s*(.+))?\s*\)$/);
1311
+ if (varMatch) {
1312
+ const propName = varMatch[1];
1313
+ const fallback = varMatch[2];
1314
+ const resolvedValue = rootStyles.getPropertyValue(propName).trim();
1315
+ return resolvedValue || fallback || null;
1316
+ }
1317
+ return null; // Not a dynamic value, keep as-is
1318
+ };
1319
+ // Replace dynamic color values (currentColor, var()) in fill and stroke attributes
1320
+ const resolveDynamicColors = (element) => {
1321
+ const fill = element.getAttribute('fill');
1322
+ const resolvedFill = resolveColorValue(fill);
1323
+ if (resolvedFill) {
1324
+ element.setAttribute('fill', resolvedFill);
1325
+ }
1326
+ const stroke = element.getAttribute('stroke');
1327
+ const resolvedStroke = resolveColorValue(stroke);
1328
+ if (resolvedStroke) {
1329
+ element.setAttribute('stroke', resolvedStroke);
1330
+ }
1331
+ // Recurse into child elements
1332
+ element.querySelectorAll('*').forEach(resolveDynamicColors);
1333
+ };
1334
+ resolveDynamicColors(svgClone);
1335
+ // Serialize SVG to string
1336
+ const serializer = new XMLSerializer();
1337
+ const svgString = serializer.serializeToString(svgClone);
1338
+ // Convert to data URI (base64 encoded for better compatibility)
1339
+ const svgBase64 = btoa(unescape(encodeURIComponent(svgString)));
1340
+ const dataUri = `data:image/svg+xml;base64,${svgBase64}`;
1341
+ const imageElement = {
1342
+ type: 'image',
1343
+ src: dataUri,
1344
+ position: {
1345
+ x: pxToInch(rect.left),
1346
+ y: pxToInch(rect.top),
1347
+ w: pxToInch(rect.width),
1348
+ h: pxToInch(rect.height),
1349
+ },
1350
+ sizing: null,
1351
+ };
1352
+ elements.push(imageElement);
1353
+ processed.add(el);
1354
+ // Also mark all SVG children as processed to avoid re-processing
1355
+ el.querySelectorAll('*').forEach((child) => processed.add(child));
1356
+ return;
1357
+ }
1358
+ }
1359
+ // Handle <i> elements — skip Font Awesome / icon font elements cleanly.
1360
+ // Font Awesome uses ::before pseudo-elements with font-family "Font Awesome ..." to render icons
1361
+ // as PUA (Private Use Area) Unicode characters. These PUA characters don't have visual
1362
+ // representations in standard fonts used by PowerPoint/LibreOffice, and Unicode symbol
1363
+ // approximations (⊞, ↗, etc.) look too different from the original FA icons, causing
1364
+ // pixel regressions. The icon container shapes (icon-circle DIVs) still render as shapes,
1365
+ // providing visual context. We simply skip the <i> elements entirely.
1366
+ if (el.tagName === 'I') {
1367
+ processed.add(el);
1368
+ return;
1369
+ }
647
1370
  // Extract DIVs with backgrounds/borders as shapes
648
1371
  const isContainer = el.tagName === 'DIV' && !textTags.includes(el.tagName);
649
1372
  if (isContainer) {
@@ -684,7 +1407,7 @@ export function parseSlideHtml(doc) {
684
1407
  const y = pxToInch(rect.top);
685
1408
  const w = pxToInch(rect.width);
686
1409
  const h = pxToInch(rect.height);
687
- if (parseFloat(borderTop) > 0) {
1410
+ if (parseFloat(borderTop) > 0 && !isFullyTransparent(computed.borderTopColor)) {
688
1411
  const widthPt = pxToPoints(borderTop);
689
1412
  const inset = widthPt / 72 / 2;
690
1413
  borderLines.push({
@@ -695,9 +1418,10 @@ export function parseSlideHtml(doc) {
695
1418
  y2: y + inset,
696
1419
  width: widthPt,
697
1420
  color: rgbToHex(computed.borderTopColor),
1421
+ transparency: extractAlpha(computed.borderTopColor),
698
1422
  });
699
1423
  }
700
- if (parseFloat(borderRight) > 0) {
1424
+ if (parseFloat(borderRight) > 0 && !isFullyTransparent(computed.borderRightColor)) {
701
1425
  const widthPt = pxToPoints(borderRight);
702
1426
  const inset = widthPt / 72 / 2;
703
1427
  borderLines.push({
@@ -708,9 +1432,10 @@ export function parseSlideHtml(doc) {
708
1432
  y2: y + h,
709
1433
  width: widthPt,
710
1434
  color: rgbToHex(computed.borderRightColor),
1435
+ transparency: extractAlpha(computed.borderRightColor),
711
1436
  });
712
1437
  }
713
- if (parseFloat(borderBottom) > 0) {
1438
+ if (parseFloat(borderBottom) > 0 && !isFullyTransparent(computed.borderBottomColor)) {
714
1439
  const widthPt = pxToPoints(borderBottom);
715
1440
  const inset = widthPt / 72 / 2;
716
1441
  borderLines.push({
@@ -721,9 +1446,10 @@ export function parseSlideHtml(doc) {
721
1446
  y2: y + h - inset,
722
1447
  width: widthPt,
723
1448
  color: rgbToHex(computed.borderBottomColor),
1449
+ transparency: extractAlpha(computed.borderBottomColor),
724
1450
  });
725
1451
  }
726
- if (parseFloat(borderLeft) > 0) {
1452
+ if (parseFloat(borderLeft) > 0 && !isFullyTransparent(computed.borderLeftColor)) {
727
1453
  const widthPt = pxToPoints(borderLeft);
728
1454
  const inset = widthPt / 72 / 2;
729
1455
  borderLines.push({
@@ -734,6 +1460,7 @@ export function parseSlideHtml(doc) {
734
1460
  y2: y + h,
735
1461
  width: widthPt,
736
1462
  color: rgbToHex(computed.borderLeftColor),
1463
+ transparency: extractAlpha(computed.borderLeftColor),
737
1464
  });
738
1465
  }
739
1466
  }
@@ -763,11 +1490,65 @@ export function parseSlideHtml(doc) {
763
1490
  };
764
1491
  elements.push(bgImgElement);
765
1492
  }
766
- // Check for text children
767
- const textChildren = Array.from(el.children).filter((child) => ['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'SPAN'].includes(child.tagName));
768
- const nonTextChildren = Array.from(el.children).filter((child) => !['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'SPAN'].includes(child.tagName));
1493
+ // Check for text children — include standard text tags plus leaf DIVs with only direct text
1494
+ const textTagSet = new Set(['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'SPAN']);
1495
+ const allChildren = Array.from(el.children);
1496
+ // Collect text-bearing children: standard text tags AND leaf DIVs (no child elements, only text nodes)
1497
+ const textChildren = allChildren.filter((child) => {
1498
+ if (textTagSet.has(child.tagName))
1499
+ return true;
1500
+ // Include text-only DIVs: leaf (no child elements) OR only <br>/<p> children
1501
+ // The transformer wraps bare text in <p> tags, so a DIV with a single <p> child
1502
+ // is still effectively a text-only DIV. Examples after transformation:
1503
+ // <div id="stat-value-1"><p>1.1°C</p></div>
1504
+ // <div id="stat-label-1">Global temperature rise since<br>pre-industrial era</div>
1505
+ if (child.tagName === 'DIV') {
1506
+ const childElements = Array.from(child.children);
1507
+ // A text-only DIV has:
1508
+ // - no child elements, OR
1509
+ // - only <br> children (inline breaks), OR
1510
+ // - a single <p> child (injected by transform.ts text wrapping)
1511
+ const isTextOnlyDiv = childElements.length === 0 ||
1512
+ childElements.every(ce => ce.tagName === 'BR') ||
1513
+ (childElements.length === 1 && childElements[0].tagName === 'P' &&
1514
+ childElements[0].children.length === 0);
1515
+ if (!isTextOnlyDiv)
1516
+ return false;
1517
+ const childText = child.textContent?.trim();
1518
+ if (!childText)
1519
+ return false;
1520
+ const childComputed = win.getComputedStyle(child);
1521
+ const childHasBg = childComputed.backgroundColor && childComputed.backgroundColor !== 'rgba(0, 0, 0, 0)';
1522
+ const childHasBgImg = childComputed.backgroundImage && childComputed.backgroundImage !== 'none';
1523
+ const childBorders = [
1524
+ childComputed.borderTopWidth,
1525
+ childComputed.borderRightWidth,
1526
+ childComputed.borderBottomWidth,
1527
+ childComputed.borderLeftWidth,
1528
+ ].map(b => parseFloat(b) || 0);
1529
+ const childHasBorder = childBorders.some(b => b > 0);
1530
+ // Only treat as text child if the DIV is unstyled text-only div
1531
+ return !childHasBg && !childHasBgImg && !childHasBorder;
1532
+ }
1533
+ return false;
1534
+ });
1535
+ // Non-text children exclude decorative elements (icons, SVGs) for text extraction purposes
1536
+ const decorativeTags = new Set(['I', 'SVG', 'CANVAS', 'VIDEO', 'AUDIO', 'IFRAME']);
1537
+ const nonTextChildren = allChildren.filter((child) => !textTagSet.has(child.tagName) &&
1538
+ !decorativeTags.has(child.tagName) &&
1539
+ !(child.tagName === 'DIV' && textChildren.includes(child)));
769
1540
  const isSingleTextChild = textChildren.length === 1 &&
770
- textChildren[0].children.length === 0 &&
1541
+ (() => {
1542
+ const tc = textChildren[0];
1543
+ const tcChildren = Array.from(tc.children);
1544
+ // A text child is "simple" (no further structural content) if:
1545
+ // - it has no child elements, OR
1546
+ // - all children are <BR> tags (inline breaks), OR
1547
+ // - it has a single <P> child with no further child elements (from transform.ts wrapping)
1548
+ return tcChildren.length === 0 ||
1549
+ tcChildren.every(ce => ce.tagName === 'BR') ||
1550
+ (tcChildren.length === 1 && tcChildren[0].tagName === 'P' && tcChildren[0].children.length === 0);
1551
+ })() &&
771
1552
  nonTextChildren.length === 0;
772
1553
  // Detect flexbox alignment
773
1554
  const display = computed.display;
@@ -830,8 +1611,16 @@ export function parseSlideHtml(doc) {
830
1611
  let shapeText = '';
831
1612
  let shapeTextRuns = null;
832
1613
  let shapeStyle = null;
833
- const hasTextChildren = textChildren.length > 0 && nonTextChildren.length === 0;
834
- if (hasTextChildren) {
1614
+ const hasTextChildren = textChildren.length > 0;
1615
+ // When a container has BOTH non-text children (styled shapes like icon-circles)
1616
+ // AND multiple text children, don't merge the text into the parent shape.
1617
+ // The text children have their own positioning via flex/grid layout and should
1618
+ // remain as standalone elements for better fidelity. Only merge text when:
1619
+ // - It's a single text child (isSingleTextChild), OR
1620
+ // - There are ONLY text children (no non-text siblings to compete with for space)
1621
+ const shouldMergeText = hasTextChildren && (isSingleTextChild ||
1622
+ nonTextChildren.length === 0);
1623
+ if (shouldMergeText) {
835
1624
  if (isSingleTextChild) {
836
1625
  const textEl = textChildren[0];
837
1626
  const textComputed = win.getComputedStyle(textEl);
@@ -902,37 +1691,87 @@ export function parseSlideHtml(doc) {
902
1691
  inset: 0,
903
1692
  wrap: !shouldNotWrap,
904
1693
  };
1694
+ const shapeLetterSpacing = extractLetterSpacing(textComputed);
1695
+ if (shapeLetterSpacing !== null)
1696
+ shapeStyle.charSpacing = shapeLetterSpacing;
1697
+ // Extract text-shadow for shape text
1698
+ const shapeTextShadow = parseTextShadow(textComputed.textShadow);
1699
+ if (shapeTextShadow.glow)
1700
+ shapeStyle.glow = shapeTextShadow.glow;
1701
+ if (shapeTextShadow.shadow)
1702
+ shapeStyle.textShadow = shapeTextShadow.shadow;
905
1703
  processed.add(textEl);
1704
+ // Also mark descendants as processed (e.g., <p> from transform.ts wrapping)
1705
+ textEl.querySelectorAll('*').forEach(desc => processed.add(desc));
906
1706
  }
907
1707
  else {
908
1708
  shapeTextRuns = [];
909
1709
  textChildren.forEach((textChild, idx) => {
910
1710
  const textEl = textChild;
911
1711
  const textComputed = win.getComputedStyle(textEl);
912
- const text = textEl.textContent.trim();
913
- if (!text)
1712
+ const fullText = textEl.textContent.trim();
1713
+ if (!fullText)
914
1714
  return;
915
1715
  const isBold = textComputed.fontWeight === 'bold' ||
916
1716
  parseInt(textComputed.fontWeight) >= 600;
917
1717
  const isItalic = textComputed.fontStyle === 'italic';
918
1718
  const isUnderline = textComputed.textDecoration &&
919
1719
  textComputed.textDecoration.includes('underline');
920
- const runText = idx > 0 && shapeTextRuns.length > 0 ? ' ' + text : text;
921
- shapeTextRuns.push({
922
- text: runText,
923
- options: {
924
- fontSize: pxToPoints(textComputed.fontSize),
925
- fontFace: textComputed.fontFamily
926
- .split(',')[0]
927
- .replace(/['"]/g, '')
928
- .trim(),
929
- color: rgbToHex(textComputed.color),
930
- bold: isBold,
931
- italic: isItalic,
932
- underline: isUnderline || false,
933
- },
934
- });
1720
+ const baseRunOptions = {
1721
+ fontSize: pxToPoints(textComputed.fontSize),
1722
+ fontFace: textComputed.fontFamily
1723
+ .split(',')[0]
1724
+ .replace(/['"]/g, '')
1725
+ .trim(),
1726
+ color: rgbToHex(textComputed.color),
1727
+ bold: isBold,
1728
+ italic: isItalic,
1729
+ underline: isUnderline || false,
1730
+ ...(extractLetterSpacing(textComputed) !== null ? { charSpacing: extractLetterSpacing(textComputed) } : {}),
1731
+ ...(() => {
1732
+ const ts = parseTextShadow(textComputed.textShadow);
1733
+ return ts.glow ? { glow: ts.glow } : {};
1734
+ })(),
1735
+ };
1736
+ // Check if this element contains <br> tags — split into multiple runs with line breaks
1737
+ const hasBrChildren = textEl.querySelector('br') !== null;
1738
+ if (hasBrChildren) {
1739
+ // Walk child nodes to split text at <br> boundaries
1740
+ let segments = [];
1741
+ let currentSegment = '';
1742
+ for (const node of Array.from(textEl.childNodes)) {
1743
+ if (node.tagName === 'BR') {
1744
+ segments.push(currentSegment.trim());
1745
+ currentSegment = '';
1746
+ }
1747
+ else {
1748
+ currentSegment += node.textContent || '';
1749
+ }
1750
+ }
1751
+ if (currentSegment.trim())
1752
+ segments.push(currentSegment.trim());
1753
+ segments = segments.filter(s => s.length > 0);
1754
+ segments.forEach((segment, segIdx) => {
1755
+ const prefix = (segIdx === 0 && idx > 0 && shapeTextRuns.length > 0) ? '\n' : '';
1756
+ const runText = prefix + segment;
1757
+ const options = { ...baseRunOptions };
1758
+ if (segIdx > 0) {
1759
+ options.softBreakBefore = true;
1760
+ }
1761
+ shapeTextRuns.push({ text: runText, options });
1762
+ });
1763
+ }
1764
+ else {
1765
+ const runText = idx > 0 && shapeTextRuns.length > 0 ? '\n' + fullText : fullText;
1766
+ const options = idx > 0 && shapeTextRuns.length > 0
1767
+ ? { ...baseRunOptions, breakLine: true }
1768
+ : baseRunOptions;
1769
+ shapeTextRuns.push({ text: runText, options });
1770
+ }
935
1771
  processed.add(textEl);
1772
+ // Also mark all descendant elements as processed (e.g., <p> tags injected
1773
+ // by transform.ts wrapping, <br> tags, <span> inline formatting)
1774
+ textEl.querySelectorAll('*').forEach(desc => processed.add(desc));
936
1775
  });
937
1776
  shapeStyle = {
938
1777
  align: align,
@@ -942,6 +1781,42 @@ export function parseSlideHtml(doc) {
942
1781
  }
943
1782
  }
944
1783
  if (hasBg || hasUniformBorder || bgGradient) {
1784
+ // Detect element-level opacity
1785
+ const elementOpacity = parseFloat(computed.opacity);
1786
+ const hasOpacity = !isNaN(elementOpacity) && elementOpacity < 1;
1787
+ // Detect CSS filter: blur() for soft-edge effect
1788
+ let softEdgePt = null;
1789
+ const filterStr = computed.filter;
1790
+ if (filterStr && filterStr !== 'none') {
1791
+ const blurMatch = filterStr.match(/blur\(([\d.]+)px\)/);
1792
+ if (blurMatch) {
1793
+ const blurPx = parseFloat(blurMatch[1]);
1794
+ if (blurPx > 0) {
1795
+ softEdgePt = blurPx * PT_PER_PX;
1796
+ }
1797
+ }
1798
+ }
1799
+ // Detect ellipse shape: border-radius >= 50% on roughly square elements
1800
+ const borderRadiusStr = computed.borderRadius;
1801
+ const borderRadiusVal = parseFloat(borderRadiusStr);
1802
+ const isCircularRadius = borderRadiusStr.includes('%')
1803
+ ? borderRadiusVal >= 50
1804
+ : (borderRadiusVal > 0 && borderRadiusVal >= Math.min(rect.width, rect.height) / 2 - 1);
1805
+ const aspectRatio = rect.width / rect.height;
1806
+ const isEllipse = isCircularRadius && aspectRatio > 0.5 && aspectRatio < 2.0;
1807
+ // Note: backdrop-filter: blur() has no PPTX equivalent. The
1808
+ // --has-backdrop-filter marker from transform.ts is available but we
1809
+ // intentionally do NOT adjust fill opacity here — attempting to
1810
+ // compensate creates large opaque panels that look worse than the
1811
+ // slightly different (non-frosted) appearance. The HTML screenshot
1812
+ // comparison uses the raw HTML which renders backdrop-filter, so
1813
+ // slight differences are expected and acceptable.
1814
+ // Extract padding for text body insets
1815
+ const paddingTop = parseFloat(computed.paddingTop) || 0;
1816
+ const paddingRight = parseFloat(computed.paddingRight) || 0;
1817
+ const paddingBottom = parseFloat(computed.paddingBottom) || 0;
1818
+ const paddingLeft = parseFloat(computed.paddingLeft) || 0;
1819
+ const hasPadding = paddingTop > 2 || paddingRight > 2 || paddingBottom > 2 || paddingLeft > 2;
945
1820
  const shapeElement = {
946
1821
  type: 'shape',
947
1822
  text: shapeText,
@@ -961,9 +1836,12 @@ export function parseSlideHtml(doc) {
961
1836
  ? {
962
1837
  color: rgbToHex(computed.borderColor),
963
1838
  width: pxToPoints(computed.borderWidth),
1839
+ transparency: extractAlpha(computed.borderColor),
964
1840
  }
965
1841
  : null,
966
1842
  rectRadius: (() => {
1843
+ if (isEllipse)
1844
+ return 0; // Ellipses don't need rectRadius
967
1845
  const radius = computed.borderRadius;
968
1846
  const radiusValue = parseFloat(radius);
969
1847
  if (radiusValue === 0)
@@ -979,8 +1857,20 @@ export function parseSlideHtml(doc) {
979
1857
  return radiusValue / PX_PER_IN;
980
1858
  })(),
981
1859
  shadow: shadow,
1860
+ opacity: hasOpacity ? elementOpacity : null,
1861
+ isEllipse: isEllipse,
1862
+ softEdge: softEdgePt,
982
1863
  },
983
1864
  };
1865
+ // Apply CSS padding as text body insets when shape has text content
1866
+ if (hasPadding && shapeElement.style && (shapeText || (shapeTextRuns && shapeTextRuns.length > 0))) {
1867
+ shapeElement.style.margin = [
1868
+ paddingLeft * PT_PER_PX, // left
1869
+ paddingRight * PT_PER_PX, // right
1870
+ paddingBottom * PT_PER_PX, // bottom
1871
+ paddingTop * PT_PER_PX, // top
1872
+ ];
1873
+ }
984
1874
  elements.push(shapeElement);
985
1875
  }
986
1876
  else if (shapeStyle && shapeStyle.fontFill) {
@@ -1005,6 +1895,79 @@ export function parseSlideHtml(doc) {
1005
1895
  return;
1006
1896
  }
1007
1897
  }
1898
+ // Plain DIVs with DIRECT text node content but no visual styling
1899
+ // (e.g. slide-footer "6 / 6", slide-number "03 / 06", stat values/labels)
1900
+ // CRITICAL: Only extract if the DIV has meaningful direct text nodes,
1901
+ // NOT just text inherited from descendant elements.
1902
+ if (isContainer) {
1903
+ const rect = htmlEl.getBoundingClientRect();
1904
+ if (rect.width > 0 && rect.height > 0) {
1905
+ // Collect direct text nodes (not from children)
1906
+ // Also handle <p>-wrapped text from transform.ts which wraps bare text in <p> tags
1907
+ let directText = '';
1908
+ for (const child of Array.from(el.childNodes)) {
1909
+ if (child.nodeType === Node.TEXT_NODE) {
1910
+ directText += child.textContent || '';
1911
+ }
1912
+ }
1913
+ directText = directText.trim();
1914
+ // If no direct text nodes, check for a single <p> wrapper (from transform.ts)
1915
+ const childElements = Array.from(el.children);
1916
+ if (!directText && childElements.length === 1 && childElements[0].tagName === 'P' &&
1917
+ childElements[0].children.length === 0) {
1918
+ directText = childElements[0].textContent?.trim() || '';
1919
+ }
1920
+ // Only proceed if this DIV has meaningful text content
1921
+ // AND has no structural child elements that would be extracted separately
1922
+ const hasStructuralChildren = childElements.length > 0 &&
1923
+ !childElements.every(ce => ce.tagName === 'BR') &&
1924
+ !(childElements.length === 1 && childElements[0].tagName === 'P' && childElements[0].children.length === 0);
1925
+ if (directText && !hasStructuralChildren) {
1926
+ const computed2 = win.getComputedStyle(el);
1927
+ const fontSizePx = parseFloat(computed2.fontSize);
1928
+ const lineHeightPx = parseFloat(computed2.lineHeight);
1929
+ const lineHeightMultiplier = fontSizePx > 0 && !isNaN(lineHeightPx) ? lineHeightPx / fontSizePx : 1.0;
1930
+ const textElement = {
1931
+ type: 'p',
1932
+ text: [{ text: directText, options: {} }],
1933
+ position: {
1934
+ x: pxToInch(rect.left),
1935
+ y: pxToInch(rect.top),
1936
+ w: pxToInch(rect.width),
1937
+ h: pxToInch(rect.height),
1938
+ },
1939
+ style: {
1940
+ fontSize: pxToPoints(computed2.fontSize),
1941
+ fontFace: computed2.fontFamily.split(',')[0].replace(/['"]/g, '').trim(),
1942
+ color: rgbToHex(computed2.color),
1943
+ bold: parseInt(computed2.fontWeight) >= 600,
1944
+ italic: computed2.fontStyle === 'italic',
1945
+ align: computed2.textAlign === 'center'
1946
+ ? 'center'
1947
+ : computed2.textAlign === 'right' || computed2.textAlign === 'end'
1948
+ ? 'right'
1949
+ : 'left',
1950
+ valign: 'middle',
1951
+ lineSpacing: lineHeightMultiplier * pxToPoints(computed2.fontSize),
1952
+ },
1953
+ };
1954
+ // Check for text transparency
1955
+ const textTransparency = extractAlpha(computed2.color);
1956
+ if (textTransparency !== null) {
1957
+ textElement.style.transparency = textTransparency;
1958
+ }
1959
+ // Check for letter-spacing
1960
+ const ls = extractLetterSpacing(computed2);
1961
+ if (ls !== null)
1962
+ textElement.style.charSpacing = ls;
1963
+ elements.push(textElement);
1964
+ processed.add(el);
1965
+ // Also mark <p> or <br> children as processed
1966
+ el.querySelectorAll('*').forEach(desc => processed.add(desc));
1967
+ return;
1968
+ }
1969
+ }
1970
+ }
1008
1971
  }
1009
1972
  // Extract bullet lists
1010
1973
  if (el.tagName === 'UL' || el.tagName === 'OL') {
@@ -1017,12 +1980,37 @@ export function parseSlideHtml(doc) {
1017
1980
  const ulPaddingLeftPt = pxToPoints(ulComputed.paddingLeft);
1018
1981
  const marginLeft = ulPaddingLeftPt * 0.5;
1019
1982
  const textIndent = ulPaddingLeftPt * 0.5;
1983
+ // Check if the list has explicit list-style: none — if so, the HTML is using
1984
+ // custom bullet decorations (e.g. icon shapes) instead of native bullets.
1985
+ // We should not add PptxGenJS bullets to avoid double-bullet artifacts.
1986
+ const listStyleType = ulComputed.listStyleType;
1987
+ const hasNativeBullets = listStyleType !== 'none';
1988
+ // Check if LI items are flex containers — in this case, each child element
1989
+ // (icon shapes, text nodes) should be processed individually at its actual
1990
+ // position rather than merged into a single list element. This handles
1991
+ // modern CSS patterns like icon+text list items where positioning is
1992
+ // determined by flexbox, not by list indentation.
1993
+ const hasFlexLiItems = liElements.some((li) => {
1994
+ const liComputed = win.getComputedStyle(li);
1995
+ return liComputed.display === 'flex' || liComputed.display === 'inline-flex';
1996
+ });
1997
+ // For flex list items without native bullets, skip the merged list processing.
1998
+ // Let the normal DOM walk handle each child element individually.
1999
+ // This ensures icons, shapes, and text are each positioned at their actual
2000
+ // rendered location rather than being merged into one text box.
2001
+ if (hasFlexLiItems && !hasNativeBullets) {
2002
+ // Don't mark anything as processed — let the DOM walk continue
2003
+ // and process each child element (SVGs, spans, text nodes) individually
2004
+ return;
2005
+ }
1020
2006
  liElements.forEach((li, idx) => {
1021
2007
  const isLast = idx === liElements.length - 1;
1022
2008
  const runs = parseInlineFormatting(li, { breakLine: false }, [], (x) => x, win);
1023
2009
  if (runs.length > 0) {
1024
2010
  runs[0].text = runs[0].text.replace(/^[•\-\*\u25AA\u25B8]\s*/, '');
1025
- runs[0].options.bullet = { indent: textIndent };
2011
+ if (hasNativeBullets) {
2012
+ runs[0].options.bullet = { indent: textIndent };
2013
+ }
1026
2014
  }
1027
2015
  if (runs.length > 0 && !isLast) {
1028
2016
  runs[runs.length - 1].options.breakLine = true;
@@ -1066,17 +2054,42 @@ export function parseSlideHtml(doc) {
1066
2054
  // Extract text elements
1067
2055
  if (!textTags.includes(el.tagName) || el.tagName === 'SPAN')
1068
2056
  return;
1069
- const rect = htmlEl.getBoundingClientRect();
1070
- const text = el.textContent.trim();
2057
+ let rect = htmlEl.getBoundingClientRect();
2058
+ let text = el.textContent.trim();
1071
2059
  if (rect.width === 0 || rect.height === 0 || !text)
1072
2060
  return;
2061
+ const computed = win.getComputedStyle(el);
2062
+ // For flex containers with multiple children (e.g., LI with icon + text),
2063
+ // find the actual text position using a Range on the text nodes.
2064
+ // This ensures text is positioned at its visual location, not the container's.
2065
+ const isFlexContainer = computed.display === 'flex' || computed.display === 'inline-flex';
2066
+ if (isFlexContainer && el.children.length > 0) {
2067
+ // Collect direct text nodes (not in child elements)
2068
+ const textNodes = [];
2069
+ el.childNodes.forEach((node) => {
2070
+ if (node.nodeType === Node.TEXT_NODE && node.textContent.trim()) {
2071
+ textNodes.push(node);
2072
+ }
2073
+ });
2074
+ if (textNodes.length > 0) {
2075
+ // Create a Range to measure the actual text position
2076
+ const range = doc.createRange();
2077
+ range.setStartBefore(textNodes[0]);
2078
+ range.setEndAfter(textNodes[textNodes.length - 1]);
2079
+ const textRect = range.getBoundingClientRect();
2080
+ // Use the text's actual position if it differs from the element's
2081
+ if (textRect.width > 0 && textRect.height > 0) {
2082
+ rect = textRect;
2083
+ text = textNodes.map((n) => n.textContent.trim()).join(' ').trim();
2084
+ }
2085
+ }
2086
+ }
1073
2087
  if (el.tagName !== 'LI' &&
1074
2088
  /^[•\-\*\u25AA\u25B8\u25CB\u25CF\u25C6\u25C7\u25A0\u25A1]\s/.test(text.trimStart())) {
1075
2089
  errors.push(`Text element <${el.tagName.toLowerCase()}> starts with bullet symbol "${text.substring(0, 20)}...". ` +
1076
2090
  'Use <ul> or <ol> lists instead of manual bullet symbols.');
1077
2091
  return;
1078
2092
  }
1079
- const computed = win.getComputedStyle(el);
1080
2093
  const rotation = getRotation(computed.transform, computed.writingMode);
1081
2094
  const { x, y, w, h } = getPositionAndSize(htmlEl, rect, rotation);
1082
2095
  const fontSizePx = parseFloat(computed.fontSize);
@@ -1128,6 +2141,15 @@ export function parseSlideHtml(doc) {
1128
2141
  const transparency = extractAlpha(computed.color);
1129
2142
  if (transparency !== null)
1130
2143
  baseStyle.transparency = transparency;
2144
+ const letterSpacing = extractLetterSpacing(computed);
2145
+ if (letterSpacing !== null)
2146
+ baseStyle.charSpacing = letterSpacing;
2147
+ // Extract text-shadow → glow and/or shadow
2148
+ const textShadowResult = parseTextShadow(computed.textShadow);
2149
+ if (textShadowResult.glow)
2150
+ baseStyle.glow = textShadowResult.glow;
2151
+ if (textShadowResult.shadow)
2152
+ baseStyle.textShadow = textShadowResult.shadow;
1131
2153
  if (rotation !== null)
1132
2154
  baseStyle.rotate = rotation;
1133
2155
  const bgClip = computed.webkitBackgroundClip || computed.backgroundClip;
@@ -1149,7 +2171,7 @@ export function parseSlideHtml(doc) {
1149
2171
  }
1150
2172
  }
1151
2173
  }
1152
- const hasFormatting = el.querySelector('b, i, u, strong, em, span, br');
2174
+ const hasFormatting = el.querySelector('b, i, u, strong, em, span, br, code, a, mark, sub, sup, small, s, del, ins, abbr, time, cite, q, dfn, kbd, samp, var');
1153
2175
  if (hasFormatting) {
1154
2176
  const transformStr = computed.textTransform;
1155
2177
  const runs = parseInlineFormatting(el, {}, [], (str) => applyTextTransform(str, transformStr), win);
@@ -1180,6 +2202,30 @@ export function parseSlideHtml(doc) {
1180
2202
  }
1181
2203
  processed.add(el);
1182
2204
  });
2205
+ // Second pass: extract pseudo-elements for all processed elements
2206
+ // This must be done after the main pass so we know which elements have been processed
2207
+ processed.forEach((processedEl) => {
2208
+ const htmlEl = processedEl;
2209
+ if (htmlEl.tagName === 'BODY')
2210
+ return; // Already handled above
2211
+ const pseudoElements = extractPseudoElements(htmlEl, win);
2212
+ elements.push(...pseudoElements);
2213
+ });
2214
+ // Third pass: extract pseudo-elements from unprocessed container DIVs
2215
+ // Some DIVs (e.g., .image-side with overflow:hidden) have no bg/border/gradient
2216
+ // themselves but have ::before/::after pseudo-elements with gradient overlays.
2217
+ doc.querySelectorAll('div').forEach((divEl) => {
2218
+ if (processed.has(divEl))
2219
+ return; // Already handled in second pass
2220
+ const htmlDiv = divEl;
2221
+ if (htmlDiv === body)
2222
+ return; // Body already handled
2223
+ const rect = htmlDiv.getBoundingClientRect();
2224
+ if (rect.width <= 0 || rect.height <= 0)
2225
+ return;
2226
+ const pseudoElements = extractPseudoElements(htmlDiv, win);
2227
+ elements.push(...pseudoElements);
2228
+ });
1183
2229
  return { background, elements, placeholders, errors };
1184
2230
  }
1185
2231
  //# sourceMappingURL=parse.js.map