docgen-utils 1.0.27 → 1.0.29

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.
Files changed (41) hide show
  1. package/README.md +3 -3
  2. package/dist/bundle.js +2896 -389
  3. package/dist/bundle.min.js +105 -103
  4. package/dist/cli.js +142 -79
  5. package/dist/packages/cli/commands/export-docs.d.ts +1 -0
  6. package/dist/packages/cli/commands/export-docs.d.ts.map +1 -1
  7. package/dist/packages/cli/commands/export-docs.js +15 -5
  8. package/dist/packages/cli/commands/export-docs.js.map +1 -1
  9. package/dist/packages/cli/commands/export-slides.d.ts +1 -1
  10. package/dist/packages/cli/commands/export-slides.d.ts.map +1 -1
  11. package/dist/packages/cli/commands/export-slides.js +15 -5
  12. package/dist/packages/cli/commands/export-slides.js.map +1 -1
  13. package/dist/packages/cli/index.js +9 -6
  14. package/dist/packages/cli/index.js.map +1 -1
  15. package/dist/packages/docs/convert.d.ts.map +1 -1
  16. package/dist/packages/docs/convert.js +0 -197
  17. package/dist/packages/docs/convert.js.map +1 -1
  18. package/dist/packages/docs/import-docx.d.ts.map +1 -1
  19. package/dist/packages/docs/import-docx.js +1 -6
  20. package/dist/packages/docs/import-docx.js.map +1 -1
  21. package/dist/packages/docs/parse.js +1 -1
  22. package/dist/packages/docs/parse.js.map +1 -1
  23. package/dist/packages/shared/convert-to-pdf.d.ts +16 -0
  24. package/dist/packages/shared/convert-to-pdf.d.ts.map +1 -0
  25. package/dist/packages/shared/convert-to-pdf.js +65 -0
  26. package/dist/packages/shared/convert-to-pdf.js.map +1 -0
  27. package/dist/packages/slides/common.d.ts +44 -2
  28. package/dist/packages/slides/common.d.ts.map +1 -1
  29. package/dist/packages/slides/convert.d.ts.map +1 -1
  30. package/dist/packages/slides/convert.js +66 -14
  31. package/dist/packages/slides/convert.js.map +1 -1
  32. package/dist/packages/slides/parse.d.ts.map +1 -1
  33. package/dist/packages/slides/parse.js +3546 -393
  34. package/dist/packages/slides/parse.js.map +1 -1
  35. package/dist/packages/slides/transform.d.ts.map +1 -1
  36. package/dist/packages/slides/transform.js +2 -1
  37. package/dist/packages/slides/transform.js.map +1 -1
  38. package/dist/packages/slides/vendor/VENDORING.md +1 -0
  39. package/dist/packages/slides/vendor/pptxgen.d.ts +2 -2
  40. package/dist/packages/slides/vendor/pptxgen.js +8 -2
  41. package/package.json +1 -1
@@ -71,6 +71,46 @@ function extractAlpha(rgbStr) {
71
71
  const alpha = parseFloat(match[4]);
72
72
  return Math.round((1 - alpha) * 100);
73
73
  }
74
+ /**
75
+ * Extract z-index from computed style.
76
+ * Returns undefined if z-index is 'auto' or not explicitly set.
77
+ * Used for element stacking order in PPTX.
78
+ */
79
+ function extractZIndex(computed) {
80
+ const zIndex = computed.zIndex;
81
+ if (zIndex === 'auto' || zIndex === '')
82
+ return undefined;
83
+ const parsed = parseInt(zIndex, 10);
84
+ if (isNaN(parsed))
85
+ return undefined;
86
+ return parsed;
87
+ }
88
+ /**
89
+ * Compute the effective (accumulated) opacity for an element by multiplying
90
+ * the element's own opacity with all ancestor opacities.
91
+ *
92
+ * CSS opacity does NOT inherit — a child element with opacity:1 inside a parent
93
+ * with opacity:0.5 renders at 50% opacity visually, but getComputedStyle on the
94
+ * child returns opacity:1. This function walks up the DOM tree to compute the
95
+ * true visual opacity.
96
+ *
97
+ * @param element - The element to check
98
+ * @param win - The window object for getComputedStyle
99
+ * @returns The effective opacity (0-1), or 1 if all ancestors are fully opaque
100
+ */
101
+ function getEffectiveOpacity(element, win) {
102
+ let effectiveOpacity = 1;
103
+ let current = element;
104
+ while (current && current !== win.document.body && current !== win.document.documentElement) {
105
+ const computed = win.getComputedStyle(current);
106
+ const opacity = parseFloat(computed.opacity);
107
+ if (!isNaN(opacity) && opacity < 1) {
108
+ effectiveOpacity *= opacity;
109
+ }
110
+ current = current.parentElement;
111
+ }
112
+ return effectiveOpacity;
113
+ }
74
114
  /**
75
115
  * Convert CSS border-style to PPTX dashType.
76
116
  * Returns undefined for solid borders (the default).
@@ -147,26 +187,6 @@ function extractTranslation(computed) {
147
187
  // ---------------------------------------------------------------------------
148
188
  // Effective opacity (accumulated from ancestor chain)
149
189
  // ---------------------------------------------------------------------------
150
- /**
151
- * Compute the effective (accumulated) opacity of an element by walking up the
152
- * ancestor chain. CSS `opacity` is NOT inherited — each element has its own
153
- * computed value. However, visually the browser composites each stacking
154
- * context, so the real visual opacity is the product of all ancestor opacities.
155
- *
156
- * Stops at `<body>` since that is the slide boundary.
157
- */
158
- function getEffectiveOpacity(el, win) {
159
- let opacity = 1;
160
- let node = el;
161
- while (node && node.tagName !== 'HTML') {
162
- const style = win.getComputedStyle(node);
163
- const nodeOpacity = parseFloat(style.opacity);
164
- if (!isNaN(nodeOpacity))
165
- opacity *= nodeOpacity;
166
- node = node.parentElement;
167
- }
168
- return opacity;
169
- }
170
190
  // ---------------------------------------------------------------------------
171
191
  // Split multiple CSS background gradients
172
192
  // ---------------------------------------------------------------------------
@@ -692,13 +712,67 @@ function applyTextTransform(text, textTransform) {
692
712
  }
693
713
  return text;
694
714
  }
715
+ /**
716
+ * List of monospace font families that indicate code/preformatted content.
717
+ * When these fonts are used, leading/trailing whitespace should be preserved.
718
+ */
719
+ const monospaceFonts = new Set([
720
+ 'consolas', 'courier', 'courier new', 'monospace', 'monaco',
721
+ 'lucida console', 'dejavu sans mono', 'source code pro', 'fira code',
722
+ 'roboto mono', 'jetbrains mono', 'inconsolata', 'menlo', 'sf mono',
723
+ 'ubuntu mono', 'droid sans mono', 'andale mono', 'liberation mono',
724
+ ]);
725
+ /**
726
+ * Check if the font family indicates monospace/code content.
727
+ * Monospace fonts typically mean code where whitespace indentation matters.
728
+ */
729
+ function isMonospaceFont(fontFamily) {
730
+ // Extract the primary font family (first in the list)
731
+ const primaryFont = fontFamily.split(',')[0].trim().replace(/['"]/g, '').toLowerCase();
732
+ if (monospaceFonts.has(primaryFont)) {
733
+ return true;
734
+ }
735
+ // Also check if any of the fallback fonts is 'monospace'
736
+ const fonts = fontFamily.toLowerCase();
737
+ return fonts.includes('monospace') || fonts.includes('consolas') || fonts.includes('courier');
738
+ }
739
+ /**
740
+ * Determine if whitespace should be preserved for an element.
741
+ * This is true when:
742
+ * 1. white-space is 'pre', 'pre-wrap', or 'pre-line' (CSS preformatted text)
743
+ * 2. The font-family is a monospace font (indicates code content where indentation matters)
744
+ */
745
+ function shouldPreserveWhitespace(computed) {
746
+ const whiteSpace = computed.whiteSpace;
747
+ if (whiteSpace === 'pre' || whiteSpace === 'pre-wrap' || whiteSpace === 'pre-line') {
748
+ return true;
749
+ }
750
+ // Check if it's a monospace font - indicates code content
751
+ return isMonospaceFont(computed.fontFamily);
752
+ }
695
753
  /**
696
754
  * Extract text content from an element with text-transform CSS applied.
697
755
  * This ensures that CSS text-transform (uppercase, lowercase, capitalize)
698
756
  * is baked into the exported text since PPTX doesn't support text-transform.
757
+ *
758
+ * When white-space is 'pre', 'pre-wrap', or 'pre-line', or when the font is monospace
759
+ * (indicating code content), whitespace and newlines are preserved.
699
760
  */
700
761
  function getTransformedText(element, computed) {
701
- const rawText = element.textContent?.trim() || '';
762
+ const preserveWhitespace = shouldPreserveWhitespace(computed);
763
+ let rawText;
764
+ if (preserveWhitespace) {
765
+ // Preserve whitespace and newlines for preformatted text
766
+ // Use innerText which respects CSS whitespace handling, or fall back to textContent
767
+ rawText = element.innerText || element.textContent || '';
768
+ // Don't trim - preserve leading/trailing whitespace
769
+ }
770
+ else {
771
+ rawText = element.textContent?.trim() || '';
772
+ }
773
+ // Convert non-breaking spaces (&nbsp; / \u00A0) to regular spaces
774
+ // This is important for proper rendering in PPTX, especially for indentation
775
+ rawText = rawText.replace(/\u00A0/g, ' ');
702
776
  const textTransform = computed.textTransform;
703
777
  if (textTransform && textTransform !== 'none') {
704
778
  return applyTextTransform(rawText, textTransform);
@@ -1425,6 +1499,9 @@ function extractPseudoElements(el, win) {
1425
1499
  const parentRect = el.getBoundingClientRect();
1426
1500
  if (parentRect.width <= 0 || parentRect.height <= 0)
1427
1501
  return results;
1502
+ // Get parent element's z-index for inheritance by pseudo-elements
1503
+ const parentComputed = win.getComputedStyle(el);
1504
+ const parentZIndex = extractZIndex(parentComputed);
1428
1505
  for (const pseudo of ['::before', '::after']) {
1429
1506
  const pComputed = win.getComputedStyle(el, pseudo);
1430
1507
  // Skip if no content or content is 'none'
@@ -1647,13 +1724,24 @@ function extractPseudoElements(el, win) {
1647
1724
  const transformOffsetY = translation?.y ?? 0;
1648
1725
  const absLeft = parentRect.left + pLeft + borderOffsetLeft + transformOffsetX;
1649
1726
  const absTop = parentRect.top + pTop + borderOffsetTop + transformOffsetY;
1650
- // Check for visual content: background color, gradient, or border
1727
+ // Check for visual content: background color, gradient, border, or background image
1651
1728
  const hasBg = pComputed.backgroundColor && pComputed.backgroundColor !== 'rgba(0, 0, 0, 0)';
1652
1729
  const bgImage = pComputed.backgroundImage;
1653
1730
  const hasGradient = bgImage && bgImage !== 'none' &&
1654
1731
  (bgImage.includes('linear-gradient') || bgImage.includes('radial-gradient'));
1655
1732
  const hasBgImage = bgImage && bgImage !== 'none' && bgImage.includes('url(');
1656
- if (!hasBg && !hasGradient && !hasBgImage)
1733
+ // Check for border
1734
+ const pseudoBorderTopWidth = parseFloat(pComputed.borderTopWidth) || 0;
1735
+ const pseudoBorderRightWidth = parseFloat(pComputed.borderRightWidth) || 0;
1736
+ const pseudoBorderBottomWidth = parseFloat(pComputed.borderBottomWidth) || 0;
1737
+ const pseudoBorderLeftWidth = parseFloat(pComputed.borderLeftWidth) || 0;
1738
+ const hasPseudoBorder = pseudoBorderTopWidth > 0 || pseudoBorderRightWidth > 0 ||
1739
+ pseudoBorderBottomWidth > 0 || pseudoBorderLeftWidth > 0;
1740
+ const hasUniformPseudoBorder = hasPseudoBorder &&
1741
+ pseudoBorderTopWidth === pseudoBorderRightWidth &&
1742
+ pseudoBorderRightWidth === pseudoBorderBottomWidth &&
1743
+ pseudoBorderBottomWidth === pseudoBorderLeftWidth;
1744
+ if (!hasBg && !hasGradient && !hasBgImage && !hasPseudoBorder)
1657
1745
  continue;
1658
1746
  // Parse gradient(s) — a single background-image property may contain multiple gradients
1659
1747
  let gradient = null;
@@ -1674,16 +1762,26 @@ function extractPseudoElements(el, win) {
1674
1762
  const elementOpacity = parentEffectiveOpacity * (isNaN(pseudoOwnOpacity) ? 1 : pseudoOwnOpacity);
1675
1763
  const hasOpacity = elementOpacity < 1;
1676
1764
  // Parse border-radius and detect ellipse shape
1765
+ // Also parse individual corner radii for non-uniform border-radius detection
1677
1766
  let rectRadius = 0;
1678
1767
  const borderRadius = pComputed.borderRadius;
1679
1768
  const radiusValue = parseFloat(borderRadius);
1769
+ // Parse individual corner radii (for non-uniform border-radius like "16px 16px 0 0")
1770
+ const pseudoRadiusTL = parseFloat(pComputed.borderTopLeftRadius) || 0;
1771
+ const pseudoRadiusTR = parseFloat(pComputed.borderTopRightRadius) || 0;
1772
+ const pseudoRadiusBL = parseFloat(pComputed.borderBottomLeftRadius) || 0;
1773
+ const pseudoRadiusBR = parseFloat(pComputed.borderBottomRightRadius) || 0;
1774
+ const hasPseudoRadius = pseudoRadiusTL > 0 || pseudoRadiusTR > 0 || pseudoRadiusBL > 0 || pseudoRadiusBR > 0;
1775
+ const hasNonUniformPseudoRadius = hasPseudoRadius &&
1776
+ (pseudoRadiusTL !== pseudoRadiusTR || pseudoRadiusTL !== pseudoRadiusBL || pseudoRadiusTL !== pseudoRadiusBR);
1680
1777
  // Detect ellipse: border-radius >= 50% on roughly square elements
1681
1778
  const isCircularRadius = borderRadius.includes('%')
1682
1779
  ? radiusValue >= 50
1683
1780
  : (radiusValue > 0 && radiusValue >= Math.min(pWidth, pHeight) / 2 - 1);
1684
1781
  const aspectRatio = pWidth / pHeight;
1685
1782
  const isEllipse = isCircularRadius && aspectRatio > 0.5 && aspectRatio < 2.0;
1686
- if (radiusValue > 0 && !isEllipse) {
1783
+ if (radiusValue > 0 && !isEllipse && !hasNonUniformPseudoRadius) {
1784
+ // Only use uniform rectRadius if all corners have the same radius
1687
1785
  if (borderRadius.includes('%')) {
1688
1786
  const minDim = Math.min(pWidth, pHeight);
1689
1787
  rectRadius = (radiusValue / 100) * pxToInch(minDim);
@@ -1694,6 +1792,8 @@ function extractPseudoElements(el, win) {
1694
1792
  }
1695
1793
  // Parse box-shadow
1696
1794
  const shadow = parseBoxShadow(pComputed.boxShadow);
1795
+ // Extract rotation angle from CSS transform
1796
+ const pseudoRotation = extractRotationAngle(pComputed);
1697
1797
  // Inherit parent border-radius when pseudo-element is flush at the edge of a
1698
1798
  // parent with overflow:hidden. In CSS, the parent clips the pseudo-element;
1699
1799
  // in PPTX there's no clipping group, so we must round the pseudo-element's
@@ -1730,6 +1830,326 @@ function extractPseudoElements(el, win) {
1730
1830
  }
1731
1831
  }
1732
1832
  }
1833
+ // Generate custom geometry for thin bars that need overflow clipping
1834
+ // When a pseudo-element is thinner than the parent's border-radius, the standard
1835
+ // roundRect approach doesn't work because PptxGenJS clamps the radius.
1836
+ // We need custom geometry to follow the parent's arc correctly.
1837
+ let asymmetricCornerGeometry = null;
1838
+ if (rectRadius === 0 && !isEllipse && effectiveRectRadius > 0) {
1839
+ // We've determined this element should have rounded corners from parent overflow
1840
+ const parentComputed = win.getComputedStyle(el);
1841
+ const parentRadius = parseFloat(parentComputed.borderRadius);
1842
+ // Check if element is thinner than the radius in any dimension
1843
+ const isThinHorizontal = pHeight < parentRadius && pWidth >= parentRadius * 2;
1844
+ const isThinVertical = pWidth < parentRadius && pHeight >= parentRadius * 2;
1845
+ if (isThinHorizontal || isThinVertical) {
1846
+ // Determine which corners need rounding based on flush edges
1847
+ const parentBorderLeft = parseFloat(parentComputed.borderLeftWidth) || 0;
1848
+ const parentBorderRight = parseFloat(parentComputed.borderRightWidth) || 0;
1849
+ const parentBorderTop = parseFloat(parentComputed.borderTopWidth) || 0;
1850
+ const parentBorderBottom = parseFloat(parentComputed.borderBottomWidth) || 0;
1851
+ const parentInnerWidth = parentRect.width - parentBorderLeft - parentBorderRight;
1852
+ const parentInnerHeight = parentRect.height - parentBorderTop - parentBorderBottom;
1853
+ const flushTop = Math.abs(pTop) < 2;
1854
+ const flushLeft = Math.abs(pLeft) < 2;
1855
+ const flushRight = Math.abs(pLeft + pWidth - parentRect.width) < 2 ||
1856
+ Math.abs(pLeft + pWidth - parentInnerWidth) < 2;
1857
+ const flushBottom = Math.abs(pTop + pHeight - parentRect.height) < 2 ||
1858
+ Math.abs(pTop + pHeight - parentInnerHeight) < 2;
1859
+ // For thin elements, we need to follow the parent's arc, not just use clamped radius.
1860
+ // At y=h (bottom of a top bar), the parent's arc is at x = arcXAtY(r, h) from the corner.
1861
+ // arcXAtY(r, y) = r - sqrt(r² - (r-y)²) for y < r
1862
+ const arcXAtY = (r, y) => {
1863
+ if (y <= 0)
1864
+ return r;
1865
+ if (y >= r)
1866
+ return 0;
1867
+ const dy = r - y;
1868
+ return r - Math.sqrt(r * r - dy * dy);
1869
+ };
1870
+ // Calculate the arc intersection point for each dimension
1871
+ const h = pHeight; // bar height
1872
+ const w = pWidth; // bar width
1873
+ const r = parentRadius;
1874
+ const EMU_PER_PX = 914400 / 96;
1875
+ const points = [];
1876
+ if (isThinHorizontal && flushTop) {
1877
+ // Thin horizontal bar at TOP - approximate arc with multiple line segments
1878
+ // At y=0, the arc starts at x=r from the corner
1879
+ // At y=h, the arc is at x=arcXAtY(r, h) from the corner
1880
+ // Use 4 segments to approximate the arc for smooth appearance
1881
+ // Generate intermediate points along the arc
1882
+ const segments = 8;
1883
+ const arcPointsLeft = [];
1884
+ const arcPointsRight = [];
1885
+ for (let i = 0; i <= segments; i++) {
1886
+ const t = i / segments;
1887
+ const y = t * h;
1888
+ const x = arcXAtY(r, y);
1889
+ arcPointsLeft.push({
1890
+ x: Math.round(x * EMU_PER_PX),
1891
+ y: Math.round(y * EMU_PER_PX)
1892
+ });
1893
+ arcPointsRight.push({
1894
+ x: Math.round((w - x) * EMU_PER_PX),
1895
+ y: Math.round(y * EMU_PER_PX)
1896
+ });
1897
+ }
1898
+ // Build path: TL arc → top edge → TR arc → bottom edge
1899
+ // Start at top of TL arc
1900
+ points.push({ x: arcPointsLeft[0].x, y: arcPointsLeft[0].y, moveTo: true });
1901
+ // Top edge to TR
1902
+ points.push({ x: arcPointsRight[0].x, y: arcPointsRight[0].y });
1903
+ // TR arc going down
1904
+ for (let i = 1; i <= segments; i++) {
1905
+ points.push({ x: arcPointsRight[i].x, y: arcPointsRight[i].y });
1906
+ }
1907
+ // Bottom edge from TR to TL
1908
+ points.push({ x: arcPointsLeft[segments].x, y: arcPointsLeft[segments].y });
1909
+ // TL arc going up (skip i=0 since that's the start point, close will handle it)
1910
+ for (let i = segments - 1; i >= 1; i--) {
1911
+ points.push({ x: arcPointsLeft[i].x, y: arcPointsLeft[i].y });
1912
+ }
1913
+ // Close the path - this draws a line from the last point (arcPointsLeft[1])
1914
+ // back to the start point (arcPointsLeft[0])
1915
+ points.push({ x: 0, y: 0, close: true });
1916
+ asymmetricCornerGeometry = points;
1917
+ }
1918
+ else if (isThinHorizontal && flushBottom) {
1919
+ // Thin horizontal bar at BOTTOM - approximate arc with multiple line segments
1920
+ // Generate intermediate points along the arc (from top to bottom of bar)
1921
+ const segments = 8;
1922
+ const arcPointsLeft = [];
1923
+ const arcPointsRight = [];
1924
+ for (let i = 0; i <= segments; i++) {
1925
+ const t = i / segments;
1926
+ const y = t * h; // y from 0 to h within the bar
1927
+ // For bottom bar, we need to flip: at y=0 (top of bar), arc is closer to edge
1928
+ // at y=h (bottom of bar), arc is at full radius
1929
+ const arcDistance = arcXAtY(r, h - y); // distance from corner at this y
1930
+ arcPointsLeft.push({
1931
+ x: Math.round(arcDistance * EMU_PER_PX),
1932
+ y: Math.round(y * EMU_PER_PX)
1933
+ });
1934
+ arcPointsRight.push({
1935
+ x: Math.round((w - arcDistance) * EMU_PER_PX),
1936
+ y: Math.round(y * EMU_PER_PX)
1937
+ });
1938
+ }
1939
+ // Build path: start at top-left corner, go right, down TR arc, left, up TL arc
1940
+ points.push({ x: arcPointsLeft[0].x, y: arcPointsLeft[0].y, moveTo: true });
1941
+ points.push({ x: arcPointsRight[0].x, y: arcPointsRight[0].y });
1942
+ // TR arc going down
1943
+ for (let i = 1; i <= segments; i++) {
1944
+ points.push({ x: arcPointsRight[i].x, y: arcPointsRight[i].y });
1945
+ }
1946
+ // Bottom edge from BR to BL
1947
+ points.push({ x: arcPointsLeft[segments].x, y: arcPointsLeft[segments].y });
1948
+ // BL arc going up
1949
+ for (let i = segments - 1; i >= 0; i--) {
1950
+ if (i === 0) {
1951
+ points.push({ x: arcPointsLeft[i].x, y: arcPointsLeft[i].y, close: true });
1952
+ }
1953
+ else {
1954
+ points.push({ x: arcPointsLeft[i].x, y: arcPointsLeft[i].y });
1955
+ }
1956
+ }
1957
+ asymmetricCornerGeometry = points;
1958
+ }
1959
+ else if (isThinVertical && flushLeft) {
1960
+ // Thin vertical bar on LEFT - approximate arc with multiple line segments
1961
+ // For vertical bar, we need arcYAtX: at x=w, where is the arc?
1962
+ // arcYAtX(r, x) = r - sqrt(r² - (r-x)²) for x < r
1963
+ const arcYAtX = (radius, x) => {
1964
+ if (x <= 0)
1965
+ return radius;
1966
+ if (x >= radius)
1967
+ return 0;
1968
+ const dx = radius - x;
1969
+ return radius - Math.sqrt(radius * radius - dx * dx);
1970
+ };
1971
+ // Generate intermediate points along the arc
1972
+ const segments = 8;
1973
+ const arcPointsTop = [];
1974
+ const arcPointsBottom = [];
1975
+ for (let i = 0; i <= segments; i++) {
1976
+ const t = i / segments;
1977
+ const x = t * w; // x from 0 to w within the bar
1978
+ const arcDistance = arcYAtX(r, x); // y distance from corner at this x
1979
+ arcPointsTop.push({
1980
+ x: Math.round(x * EMU_PER_PX),
1981
+ y: Math.round(arcDistance * EMU_PER_PX)
1982
+ });
1983
+ arcPointsBottom.push({
1984
+ x: Math.round(x * EMU_PER_PX),
1985
+ y: Math.round((h - arcDistance) * EMU_PER_PX)
1986
+ });
1987
+ }
1988
+ // Build path: start at TL arc (left edge, y=r), go right along TL arc,
1989
+ // down right edge, left along BL arc, up left edge
1990
+ points.push({ x: arcPointsTop[0].x, y: arcPointsTop[0].y, moveTo: true });
1991
+ // TL arc going right
1992
+ for (let i = 1; i <= segments; i++) {
1993
+ points.push({ x: arcPointsTop[i].x, y: arcPointsTop[i].y });
1994
+ }
1995
+ // Right edge down to BL arc start
1996
+ points.push({ x: arcPointsBottom[segments].x, y: arcPointsBottom[segments].y });
1997
+ // BL arc going left
1998
+ for (let i = segments - 1; i >= 0; i--) {
1999
+ points.push({ x: arcPointsBottom[i].x, y: arcPointsBottom[i].y });
2000
+ }
2001
+ // Close back to start
2002
+ points.push({ x: arcPointsTop[0].x, y: arcPointsTop[0].y, close: true });
2003
+ asymmetricCornerGeometry = points;
2004
+ }
2005
+ else if (isThinVertical && flushRight) {
2006
+ // Thin vertical bar on RIGHT - approximate arc with multiple line segments
2007
+ const arcYAtX = (radius, x) => {
2008
+ if (x <= 0)
2009
+ return radius;
2010
+ if (x >= radius)
2011
+ return 0;
2012
+ const dx = radius - x;
2013
+ return radius - Math.sqrt(radius * radius - dx * dx);
2014
+ };
2015
+ // Generate intermediate points along the arc
2016
+ const segments = 8;
2017
+ const arcPointsTop = [];
2018
+ const arcPointsBottom = [];
2019
+ for (let i = 0; i <= segments; i++) {
2020
+ const t = i / segments;
2021
+ const x = t * w; // x from 0 to w within the bar
2022
+ // For right bar, we flip: at x=0 (left edge of bar), arc is closer to edge
2023
+ // at x=w (right edge), arc is at full radius
2024
+ const arcDistance = arcYAtX(r, w - x); // y distance from corner at this x
2025
+ arcPointsTop.push({
2026
+ x: Math.round(x * EMU_PER_PX),
2027
+ y: Math.round(arcDistance * EMU_PER_PX)
2028
+ });
2029
+ arcPointsBottom.push({
2030
+ x: Math.round(x * EMU_PER_PX),
2031
+ y: Math.round((h - arcDistance) * EMU_PER_PX)
2032
+ });
2033
+ }
2034
+ // Build path: start at left edge TR arc intersection, go right along TR arc,
2035
+ // down right edge, left along BR arc, up left edge
2036
+ points.push({ x: arcPointsTop[0].x, y: arcPointsTop[0].y, moveTo: true });
2037
+ // TR arc going right
2038
+ for (let i = 1; i <= segments; i++) {
2039
+ points.push({ x: arcPointsTop[i].x, y: arcPointsTop[i].y });
2040
+ }
2041
+ // Right edge down to BR arc start
2042
+ points.push({ x: arcPointsBottom[segments].x, y: arcPointsBottom[segments].y });
2043
+ // BR arc going left
2044
+ for (let i = segments - 1; i >= 0; i--) {
2045
+ points.push({ x: arcPointsBottom[i].x, y: arcPointsBottom[i].y });
2046
+ }
2047
+ // Close back to start
2048
+ points.push({ x: arcPointsTop[0].x, y: arcPointsTop[0].y, close: true });
2049
+ asymmetricCornerGeometry = points;
2050
+ }
2051
+ // If we generated custom geometry, clear the rect radius
2052
+ // (custom geometry replaces roundRect)
2053
+ if (asymmetricCornerGeometry) {
2054
+ effectiveRectRadius = 0;
2055
+ }
2056
+ }
2057
+ }
2058
+ // Generate custom geometry for non-uniform border-radius on the pseudo-element itself
2059
+ // (e.g., border-radius: 16px 16px 0 0 for only top corners rounded)
2060
+ if (!asymmetricCornerGeometry && hasNonUniformPseudoRadius && !isEllipse) {
2061
+ const w = pWidth;
2062
+ const h = pHeight;
2063
+ const EMU_PER_PX = 914400 / 96;
2064
+ const widthEmu = Math.round(w * EMU_PER_PX);
2065
+ const heightEmu = Math.round(h * EMU_PER_PX);
2066
+ const segments = 8;
2067
+ const points = [];
2068
+ // Generate arc points for a corner
2069
+ const addCornerArc = (cornerRadius, isRight, isBottom) => {
2070
+ if (cornerRadius <= 0)
2071
+ return; // No arc for square corner
2072
+ for (let i = 0; i <= segments; i++) {
2073
+ const t = i / segments;
2074
+ const angle = t * Math.PI / 2;
2075
+ let x, y;
2076
+ if (!isRight && !isBottom) {
2077
+ // TL corner: arc from (radius, 0) to (0, radius)
2078
+ x = cornerRadius * (1 - Math.sin(angle));
2079
+ y = cornerRadius * (1 - Math.cos(angle));
2080
+ }
2081
+ else if (isRight && !isBottom) {
2082
+ // TR corner: arc from (w-radius, 0) to (w, radius)
2083
+ x = w - cornerRadius + cornerRadius * Math.sin(angle);
2084
+ y = cornerRadius * (1 - Math.cos(angle));
2085
+ }
2086
+ else if (!isRight && isBottom) {
2087
+ // BL corner: arc from (0, h-radius) to (radius, h)
2088
+ x = cornerRadius * (1 - Math.cos(angle));
2089
+ y = h - cornerRadius + cornerRadius * Math.sin(angle);
2090
+ }
2091
+ else {
2092
+ // BR corner: arc from (w, h-radius) to (w-radius, h)
2093
+ x = w - cornerRadius + cornerRadius * Math.cos(angle);
2094
+ y = h - cornerRadius + cornerRadius * Math.sin(angle);
2095
+ }
2096
+ // Clamp to element bounds
2097
+ x = Math.max(0, Math.min(w, x));
2098
+ y = Math.max(0, Math.min(h, y));
2099
+ points.push({
2100
+ x: Math.round(x * EMU_PER_PX),
2101
+ y: Math.round(y * EMU_PER_PX),
2102
+ ...(points.length === 0 ? { moveTo: true } : {})
2103
+ });
2104
+ }
2105
+ };
2106
+ // Build path clockwise: start at top-left, go right to top-right, down to bottom-right,
2107
+ // left to bottom-left, up back to top-left
2108
+ if (pseudoRadiusTL > 0) {
2109
+ points.push({ x: Math.round(pseudoRadiusTL * EMU_PER_PX), y: 0, moveTo: true });
2110
+ }
2111
+ else {
2112
+ points.push({ x: 0, y: 0, moveTo: true });
2113
+ }
2114
+ // Top edge to top-right
2115
+ if (pseudoRadiusTR > 0) {
2116
+ points.push({ x: Math.round((w - pseudoRadiusTR) * EMU_PER_PX), y: 0 });
2117
+ addCornerArc(pseudoRadiusTR, true, false);
2118
+ }
2119
+ else {
2120
+ points.push({ x: widthEmu, y: 0 });
2121
+ }
2122
+ // Right edge to bottom-right
2123
+ if (pseudoRadiusBR > 0) {
2124
+ points.push({ x: widthEmu, y: Math.round((h - pseudoRadiusBR) * EMU_PER_PX) });
2125
+ addCornerArc(pseudoRadiusBR, true, true);
2126
+ }
2127
+ else {
2128
+ points.push({ x: widthEmu, y: heightEmu });
2129
+ }
2130
+ // Bottom edge to bottom-left
2131
+ if (pseudoRadiusBL > 0) {
2132
+ points.push({ x: Math.round(pseudoRadiusBL * EMU_PER_PX), y: heightEmu });
2133
+ addCornerArc(pseudoRadiusBL, false, true);
2134
+ }
2135
+ else {
2136
+ points.push({ x: 0, y: heightEmu });
2137
+ }
2138
+ // Left edge back to top-left
2139
+ if (pseudoRadiusTL > 0) {
2140
+ points.push({ x: 0, y: Math.round(pseudoRadiusTL * EMU_PER_PX) });
2141
+ addCornerArc(pseudoRadiusTL, false, false);
2142
+ }
2143
+ // Close the path
2144
+ if (points.length > 0) {
2145
+ const firstPoint = points[0];
2146
+ points.push({ x: firstPoint.x, y: firstPoint.y, close: true });
2147
+ }
2148
+ if (points.length > 5) {
2149
+ asymmetricCornerGeometry = points;
2150
+ effectiveRectRadius = 0;
2151
+ }
2152
+ }
1733
2153
  // Create shape element
1734
2154
  const shapeElement = {
1735
2155
  type: 'shape',
@@ -1746,15 +2166,24 @@ function extractPseudoElements(el, win) {
1746
2166
  fill: hasBg ? rgbToHex(pComputed.backgroundColor) : null,
1747
2167
  gradient: gradient,
1748
2168
  transparency: hasBg ? extractAlpha(pComputed.backgroundColor) : null,
1749
- line: null,
2169
+ line: hasUniformPseudoBorder
2170
+ ? {
2171
+ color: rgbToHex(pComputed.borderColor),
2172
+ width: pxToPoints(pComputed.borderTopWidth),
2173
+ transparency: extractAlpha(pComputed.borderColor),
2174
+ dashType: extractDashType(pComputed.borderStyle),
2175
+ }
2176
+ : null,
1750
2177
  rectRadius: effectiveRectRadius,
1751
2178
  shadow: shadow,
1752
2179
  opacity: hasOpacity ? elementOpacity : null,
1753
2180
  isEllipse: isEllipse,
1754
2181
  softEdge: null,
1755
- rotate: null,
1756
- customGeometry: null,
2182
+ rotate: pseudoRotation,
2183
+ cssTriangle: null,
2184
+ customGeometry: asymmetricCornerGeometry,
1757
2185
  },
2186
+ zIndex: parentZIndex,
1758
2187
  };
1759
2188
  results.push(shapeElement);
1760
2189
  // Add additional shape elements for extra gradient layers
@@ -1776,15 +2205,17 @@ function extractPseudoElements(el, win) {
1776
2205
  isEllipse: false,
1777
2206
  softEdge: null,
1778
2207
  rotate: null,
2208
+ cssTriangle: null,
1779
2209
  customGeometry: null,
1780
2210
  },
2211
+ zIndex: parentZIndex,
1781
2212
  };
1782
2213
  results.push(extraShape);
1783
2214
  }
1784
2215
  }
1785
2216
  return results;
1786
2217
  }
1787
- function parseInlineFormatting(element, baseOptions, runs, baseTextTransform, win) {
2218
+ function parseInlineFormatting(element, baseOptions, runs, baseTextTransform, win, preserveWhitespace = false) {
1788
2219
  let prevNodeIsText = false;
1789
2220
  let pendingSoftBreak = false;
1790
2221
  element.childNodes.forEach((node) => {
@@ -1794,7 +2225,43 @@ function parseInlineFormatting(element, baseOptions, runs, baseTextTransform, wi
1794
2225
  prevNodeIsText = false;
1795
2226
  }
1796
2227
  else if (node.nodeType === Node.TEXT_NODE) {
1797
- const text = textTransform(node.textContent.replace(/\s+/g, ' '));
2228
+ // For preformatted text (white-space: pre), preserve whitespace and newlines
2229
+ // by converting newlines to softBreakBefore markers
2230
+ let rawText = node.textContent || '';
2231
+ // Convert non-breaking spaces (&nbsp; / \u00A0) to regular spaces
2232
+ rawText = rawText.replace(/\u00A0/g, ' ');
2233
+ let text;
2234
+ if (preserveWhitespace) {
2235
+ // Split by newlines and create separate runs with softBreakBefore
2236
+ const lines = rawText.split('\n');
2237
+ for (let i = 0; i < lines.length; i++) {
2238
+ let lineText = lines[i];
2239
+ // Preserve spaces (don't collapse to single space)
2240
+ lineText = textTransform(lineText);
2241
+ const prevRun = runs[runs.length - 1];
2242
+ if (i === 0 && prevNodeIsText && prevRun && !pendingSoftBreak) {
2243
+ // First line continues previous text
2244
+ prevRun.text += lineText;
2245
+ }
2246
+ else {
2247
+ // Create a new run for this line
2248
+ const runOptions = { ...baseOptions };
2249
+ if (i > 0 || pendingSoftBreak) {
2250
+ runOptions.softBreakBefore = true;
2251
+ pendingSoftBreak = false;
2252
+ }
2253
+ // For empty lines, use a space to preserve the line break in PPTX
2254
+ // (PPTX doesn't render runs with empty text, so we need at least a space)
2255
+ const runText = lineText.length > 0 ? lineText : ' ';
2256
+ runs.push({ text: runText, options: runOptions });
2257
+ }
2258
+ }
2259
+ prevNodeIsText = true;
2260
+ return; // Early return - we've handled all the text
2261
+ }
2262
+ else {
2263
+ text = textTransform(rawText.replace(/\s+/g, ' '));
2264
+ }
1798
2265
  const prevRun = runs[runs.length - 1];
1799
2266
  if (prevNodeIsText && prevRun && !pendingSoftBreak) {
1800
2267
  prevRun.text += text;
@@ -1833,6 +2300,22 @@ function parseInlineFormatting(element, baseOptions, runs, baseTextTransform, wi
1833
2300
  if (transparency !== null)
1834
2301
  options.transparency = transparency;
1835
2302
  }
2303
+ // Extract background color for inline elements (CODE, SPAN, etc.)
2304
+ // This becomes a highlight/shading on the text run in PPTX
2305
+ if (computed.backgroundColor && computed.backgroundColor !== 'rgba(0, 0, 0, 0)') {
2306
+ const bgAlpha = extractAlpha(computed.backgroundColor);
2307
+ if (bgAlpha !== null && bgAlpha > 0) {
2308
+ // Has transparency - use object form with transparency
2309
+ options.highlight = {
2310
+ color: rgbToHex(computed.backgroundColor),
2311
+ transparency: bgAlpha,
2312
+ };
2313
+ }
2314
+ else {
2315
+ // Fully opaque - use simple hex string
2316
+ options.highlight = rgbToHex(computed.backgroundColor);
2317
+ }
2318
+ }
1836
2319
  // Check for gradient text fill (-webkit-background-clip: text + transparent text-fill-color)
1837
2320
  const bgClip = computed.webkitBackgroundClip || computed.backgroundClip;
1838
2321
  const textFillColor = computed.webkitTextFillColor;
@@ -1850,10 +2333,29 @@ function parseInlineFormatting(element, baseOptions, runs, baseTextTransform, wi
1850
2333
  }
1851
2334
  }
1852
2335
  }
1853
- if (computed.fontSize)
1854
- options.fontSize = pxToPoints(computed.fontSize);
1855
- if (computed.fontFamily) {
1856
- options.fontFace = extractFontFace(computed.fontFamily);
2336
+ // Handle font-size: if baseOptions has a fontSize from a monospace context,
2337
+ // prefer the base fontSize to maintain uniform code block sizing.
2338
+ // This fixes the issue where CSS rules like ".gh-body p { font-size: 13px }"
2339
+ // override ".code-block { font-size: 10.5px }" for inline elements inside P tags
2340
+ // that were inserted by transform.ts.
2341
+ //
2342
+ // The key insight: transform.ts wraps text in P tags which breaks font inheritance,
2343
+ // causing inline SPANs to inherit from P (Calibri 7.3pt) instead of the code-block
2344
+ // (Consolas 5.9pt). We detect this by checking if baseOptions came from a monospace
2345
+ // context - if so, we keep the base fontSize for uniform sizing.
2346
+ const inlineFontFace = computed.fontFamily ? extractFontFace(computed.fontFamily) : null;
2347
+ const baseFontFaceForCheck = baseOptions.fontFace || '';
2348
+ const baseFontFaceIsMonospace = baseFontFaceForCheck && isMonospaceFont(baseFontFaceForCheck);
2349
+ // If base came from a monospace context AND base already has a fontSize,
2350
+ // keep the base fontSize (don't let CSS inheritance override code block sizing)
2351
+ if (computed.fontSize) {
2352
+ const shouldKeepBaseFontSize = baseOptions.fontSize && baseFontFaceIsMonospace;
2353
+ if (!shouldKeepBaseFontSize) {
2354
+ options.fontSize = pxToPoints(computed.fontSize);
2355
+ }
2356
+ }
2357
+ if (inlineFontFace) {
2358
+ options.fontFace = inlineFontFace;
1857
2359
  }
1858
2360
  const runLetterSpacing = extractLetterSpacing(computed);
1859
2361
  if (runLetterSpacing !== null)
@@ -1870,7 +2372,7 @@ function parseInlineFormatting(element, baseOptions, runs, baseTextTransform, wi
1870
2372
  if (pendingSoftBreak) {
1871
2373
  pendingSoftBreak = false; // Clear before recursion
1872
2374
  }
1873
- parseInlineFormatting(el, options, runs, textTransform, win);
2375
+ parseInlineFormatting(el, options, runs, textTransform, win, preserveWhitespace);
1874
2376
  // Apply softBreakBefore to the first run added by the recursive call
1875
2377
  if (hadPendingSoftBreak && runs.length > runsBeforeRecurse) {
1876
2378
  runs[runsBeforeRecurse].options = {
@@ -1879,10 +2381,73 @@ function parseInlineFormatting(element, baseOptions, runs, baseTextTransform, wi
1879
2381
  };
1880
2382
  }
1881
2383
  }
2384
+ else {
2385
+ // Non-inline element (like P, DIV, etc.) - recurse into it with block-level formatting
2386
+ // This allows inline formatting inside block elements to be extracted while
2387
+ // preserving the block element's own styling (font-size, font-weight, color, etc.)
2388
+ const blockTags = new Set(['P', 'DIV', 'LI', 'BLOCKQUOTE', 'PRE', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6']);
2389
+ if (blockTags.has(el.tagName)) {
2390
+ const runsBeforeRecurse = runs.length;
2391
+ const hadPendingSoftBreak = pendingSoftBreak;
2392
+ if (pendingSoftBreak) {
2393
+ pendingSoftBreak = false;
2394
+ }
2395
+ // For block elements like PRE, check if they have white-space: pre
2396
+ let blockPreserveWhitespace = preserveWhitespace;
2397
+ const blockComputed = win.getComputedStyle(el);
2398
+ if (el.tagName === 'PRE') {
2399
+ const ws = blockComputed.whiteSpace;
2400
+ blockPreserveWhitespace = ws === 'pre' || ws === 'pre-wrap' || ws === 'pre-line';
2401
+ }
2402
+ // Extract block element's formatting to pass to children
2403
+ // This ensures nested DIVs like .data-value (bold, 20px) and .data-desc (muted, 14px)
2404
+ // each get their own formatting instead of inheriting only from the parent
2405
+ const blockOptions = { ...baseOptions };
2406
+ const blockIsBold = blockComputed.fontWeight === 'bold' || parseInt(blockComputed.fontWeight) >= 600;
2407
+ if (blockIsBold && !shouldSkipBold(blockComputed.fontFamily)) {
2408
+ blockOptions.bold = true;
2409
+ }
2410
+ if (blockComputed.fontStyle === 'italic') {
2411
+ blockOptions.italic = true;
2412
+ }
2413
+ if (blockComputed.textDecoration?.includes('underline')) {
2414
+ blockOptions.underline = true;
2415
+ }
2416
+ // Extract color if different from default/base
2417
+ if (blockComputed.color && blockComputed.color !== 'rgb(0, 0, 0)') {
2418
+ blockOptions.color = rgbToHex(blockComputed.color);
2419
+ const transparency = extractAlpha(blockComputed.color);
2420
+ if (transparency !== null)
2421
+ blockOptions.transparency = transparency;
2422
+ }
2423
+ // Extract font size if specified
2424
+ if (blockComputed.fontSize) {
2425
+ blockOptions.fontSize = pxToPoints(blockComputed.fontSize);
2426
+ }
2427
+ // Extract font family if specified
2428
+ if (blockComputed.fontFamily) {
2429
+ blockOptions.fontFace = extractFontFace(blockComputed.fontFamily);
2430
+ }
2431
+ parseInlineFormatting(el, blockOptions, runs, textTransform, win, blockPreserveWhitespace);
2432
+ // Block elements should start on a new line if there's content before them.
2433
+ // Apply breakLine to the first run added by the recursive call.
2434
+ if (runs.length > runsBeforeRecurse) {
2435
+ if (hadPendingSoftBreak || runsBeforeRecurse > 0) {
2436
+ // There's either a pending <br> or existing content before this block,
2437
+ // so start this block on a new line
2438
+ runs[runsBeforeRecurse].options = {
2439
+ ...runs[runsBeforeRecurse].options,
2440
+ breakLine: true,
2441
+ };
2442
+ }
2443
+ }
2444
+ }
2445
+ }
1882
2446
  prevNodeIsText = false;
1883
2447
  }
1884
2448
  });
1885
- if (runs.length > 0) {
2449
+ // Only trim leading/trailing whitespace when not preserving whitespace
2450
+ if (!preserveWhitespace && runs.length > 0) {
1886
2451
  runs[0].text = runs[0].text.replace(/^\s+/, '');
1887
2452
  runs[runs.length - 1].text = runs[runs.length - 1].text.replace(/\s+$/, '');
1888
2453
  }
@@ -1940,7 +2505,7 @@ export function parseSlideHtml(doc) {
1940
2505
  // -------------------------------------------------------------------------
1941
2506
  const elements = [];
1942
2507
  const placeholders = [];
1943
- const textTags = ['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'UL', 'OL', 'LI', 'SPAN'];
2508
+ const textTags = ['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'UL', 'OL', 'LI', 'SPAN', 'PRE'];
1944
2509
  const processed = new Set();
1945
2510
  // Extract pseudo-elements from body (e.g., body::before radial glows)
1946
2511
  const bodyPseudoElements = extractPseudoElements(body, win);
@@ -1960,55 +2525,34 @@ export function parseSlideHtml(doc) {
1960
2525
  (computed.borderLeftWidth && parseFloat(computed.borderLeftWidth) > 0);
1961
2526
  const hasShadow = computed.boxShadow && computed.boxShadow !== 'none';
1962
2527
  if (hasBg || hasBorder || hasShadow) {
1963
- errors.push(`Text element <${el.tagName.toLowerCase()}> has ${hasBg ? 'background' : hasBorder ? 'border' : 'shadow'}. ` +
1964
- 'Backgrounds, borders, and shadows are only supported on <div> elements, not text elements.');
1965
- return;
1966
- }
1967
- }
1968
- // Handle SPAN and A elements with backgrounds/borders as shapes
1969
- // <a> tags styled as buttons (e.g. CTA buttons with gradient backgrounds,
1970
- // border-radius, box-shadow) need the same shape treatment as SPANs.
1971
- if (el.tagName === 'SPAN' || el.tagName === 'A') {
1972
- const computed = win.getComputedStyle(el);
1973
- const hasBg = computed.backgroundColor && computed.backgroundColor !== 'rgba(0, 0, 0, 0)';
1974
- const hasBorder = (computed.borderWidth && parseFloat(computed.borderWidth) > 0) ||
1975
- (computed.borderTopWidth && parseFloat(computed.borderTopWidth) > 0) ||
1976
- (computed.borderRightWidth && parseFloat(computed.borderRightWidth) > 0) ||
1977
- (computed.borderBottomWidth && parseFloat(computed.borderBottomWidth) > 0) ||
1978
- (computed.borderLeftWidth && parseFloat(computed.borderLeftWidth) > 0);
1979
- // Check for gradient background (backgroundImage contains linear-gradient or radial-gradient)
1980
- const spanBgImage = computed.backgroundImage;
1981
- const hasGradientBg = spanBgImage &&
1982
- spanBgImage !== 'none' &&
1983
- (spanBgImage.includes('linear-gradient') || spanBgImage.includes('radial-gradient'));
1984
- // Check if this gradient is used for gradient text fill (background-clip: text)
1985
- // In that case, we should NOT create a shape element - the gradient fills the text itself
1986
- const spanBgClip = computed.webkitBackgroundClip || computed.backgroundClip;
1987
- const spanTextFillColor = computed.webkitTextFillColor;
1988
- const isGradientTextFill = hasGradientBg &&
1989
- spanBgClip === 'text' &&
1990
- (spanTextFillColor === 'transparent' ||
1991
- spanTextFillColor === 'rgba(0, 0, 0, 0)' ||
1992
- (spanTextFillColor && spanTextFillColor.includes('rgba') && spanTextFillColor.endsWith(', 0)')));
1993
- // Skip creating shape for gradient text fills - let text element handler create text with gradient fill
1994
- if (isGradientTextFill) {
1995
- // Don't process as shape, let it fall through to text handling
1996
- }
1997
- else if (hasBg || hasBorder || hasGradientBg) {
2528
+ // Treat styled text elements (H1-H6, P, etc.) as shapes, similar to SPAN/A handling
1998
2529
  const rect = htmlEl.getBoundingClientRect();
1999
2530
  if (rect.width > 0 && rect.height > 0) {
2000
- const text = getTransformedText(htmlEl, computed);
2531
+ // Check if this is a flex container with multiple children — if so, don't merge
2532
+ // text into the shape. The children will be extracted separately at their own
2533
+ // positions (e.g., flex LI items with icon shapes + text labels).
2534
+ const styledTextDisplay = computed.display;
2535
+ const styledTextFlexDir = computed.flexDirection || 'row';
2536
+ const styledTextChildCount = el.children.length;
2537
+ const isStyledTextFlexRow = (styledTextDisplay === 'flex' || styledTextDisplay === 'inline-flex') &&
2538
+ (styledTextFlexDir === 'row' || styledTextFlexDir === 'row-reverse') &&
2539
+ styledTextChildCount > 1;
2540
+ const isStyledTextFlexCol = (styledTextDisplay === 'flex' || styledTextDisplay === 'inline-flex') &&
2541
+ (styledTextFlexDir === 'column' || styledTextFlexDir === 'column-reverse') &&
2542
+ styledTextChildCount > 1;
2543
+ const isStyledTextGrid = styledTextDisplay === 'grid' || styledTextDisplay === 'inline-grid';
2544
+ const shouldMergeStyledText = !isStyledTextFlexRow && !isStyledTextFlexCol && !isStyledTextGrid;
2545
+ const text = shouldMergeStyledText ? getTransformedText(htmlEl, computed) : '';
2001
2546
  const bgGradient = parseCssGradient(computed.backgroundImage);
2002
2547
  const borderRadius = computed.borderRadius;
2003
2548
  const radiusValue = parseFloat(borderRadius);
2004
2549
  let rectRadius = 0;
2005
- // Detect ellipse: border-radius >= 50% on roughly square elements
2006
2550
  const isCircularRadius = borderRadius.includes('%')
2007
2551
  ? radiusValue >= 50
2008
2552
  : (radiusValue > 0 && radiusValue >= Math.min(rect.width, rect.height) / 2 - 1);
2009
2553
  const aspectRatio = rect.width / rect.height;
2010
- const spanIsEllipse = isCircularRadius && aspectRatio > 0.5 && aspectRatio < 2.0;
2011
- if (radiusValue > 0 && !spanIsEllipse) {
2554
+ const styledTextIsEllipse = isCircularRadius && aspectRatio > 0.5 && aspectRatio < 2.0;
2555
+ if (radiusValue > 0 && !styledTextIsEllipse) {
2012
2556
  if (borderRadius.includes('%')) {
2013
2557
  const minDim = Math.min(rect.width, rect.height);
2014
2558
  rectRadius = (radiusValue / 100) * pxToInch(minDim);
@@ -2021,21 +2565,23 @@ export function parseSlideHtml(doc) {
2021
2565
  const borderRight = computed.borderRightWidth;
2022
2566
  const borderBottom = computed.borderBottomWidth;
2023
2567
  const borderLeft = computed.borderLeftWidth;
2568
+ const borders = [parseFloat(borderTop) || 0, parseFloat(borderRight) || 0, parseFloat(borderBottom) || 0, parseFloat(borderLeft) || 0];
2024
2569
  const hasUniformBorder = hasBorder &&
2025
- borderTop === borderRight &&
2026
- borderRight === borderBottom &&
2027
- borderBottom === borderLeft;
2028
- // Extract effective opacity (including ancestor chain)
2029
- const spanOpacity = getEffectiveOpacity(el, win);
2030
- const hasSpanOpacity = spanOpacity < 1;
2570
+ borders.every((b) => b === borders[0]);
2571
+ // Extract effective opacity
2572
+ const styledTextOpacity = getEffectiveOpacity(el, win);
2573
+ const hasStyledTextOpacity = styledTextOpacity < 1;
2031
2574
  // Extract box-shadow
2032
- const spanShadow = parseBoxShadow(computed.boxShadow);
2033
- // For small elements (badges, labels), disable text wrapping to prevent overflow
2034
- const spanWhiteSpace = computed.whiteSpace;
2035
- const spanShouldNotWrap = spanWhiteSpace === 'nowrap' ||
2036
- spanWhiteSpace === 'pre' ||
2037
- rect.width < 100 ||
2038
- rect.height < 50;
2575
+ const styledTextShadow = parseBoxShadow(computed.boxShadow);
2576
+ // Determine text alignment from computed style
2577
+ const computedAlign = computed.textAlign;
2578
+ let textAlign = 'left';
2579
+ if (computedAlign === 'center')
2580
+ textAlign = 'center';
2581
+ else if (computedAlign === 'right')
2582
+ textAlign = 'right';
2583
+ else if (computedAlign === 'justify')
2584
+ textAlign = 'justify';
2039
2585
  const shapeElement = {
2040
2586
  type: 'shape',
2041
2587
  position: {
@@ -2051,9 +2597,10 @@ export function parseSlideHtml(doc) {
2051
2597
  fontFace: extractFontFace(computed.fontFamily),
2052
2598
  color: rgbToHex(computed.color),
2053
2599
  bold: parseInt(computed.fontWeight) >= 600,
2054
- align: 'center',
2600
+ align: textAlign,
2055
2601
  valign: 'middle',
2056
- wrap: !spanShouldNotWrap,
2602
+ wrap: true,
2603
+ ...(extractAlpha(computed.color) !== null ? { transparency: extractAlpha(computed.color) } : {}),
2057
2604
  } : null,
2058
2605
  shape: {
2059
2606
  fill: hasBg ? rgbToHex(computed.backgroundColor) : null,
@@ -2067,60 +2614,1101 @@ export function parseSlideHtml(doc) {
2067
2614
  dashType: extractDashType(computed.borderStyle),
2068
2615
  }
2069
2616
  : null,
2070
- rectRadius: spanIsEllipse ? 0 : rectRadius,
2071
- shadow: spanShadow,
2072
- opacity: hasSpanOpacity ? spanOpacity : null,
2073
- isEllipse: spanIsEllipse,
2617
+ rectRadius: styledTextIsEllipse ? 0 : rectRadius,
2618
+ shadow: styledTextShadow,
2619
+ opacity: hasStyledTextOpacity ? styledTextOpacity : null,
2620
+ isEllipse: styledTextIsEllipse,
2074
2621
  softEdge: null,
2075
2622
  rotate: null,
2623
+ cssTriangle: null,
2076
2624
  customGeometry: null,
2077
2625
  },
2626
+ zIndex: extractZIndex(computed),
2078
2627
  };
2079
2628
  elements.push(shapeElement);
2080
- processed.add(el);
2081
- return;
2082
- }
2083
- }
2084
- // Handle plain SPANs that are direct children of DIV elements
2085
- const parent = el.parentElement;
2086
- if (parent && parent.tagName === 'DIV') {
2087
- const rect = htmlEl.getBoundingClientRect();
2088
- const computed2 = win.getComputedStyle(el);
2089
- const text = getTransformedText(htmlEl, computed2);
2090
- if (rect.width > 0 && rect.height > 0 && text) {
2091
- const fontSizePx = parseFloat(computed2.fontSize);
2092
- const lineHeightPx = parseFloat(computed2.lineHeight);
2093
- const lineHeightMultiplier = fontSizePx > 0 && !isNaN(lineHeightPx) ? lineHeightPx / fontSizePx : 1.0;
2094
- // Check for gradient text fill on plain SPAN elements
2095
- const span2BgClip = computed2.webkitBackgroundClip || computed2.backgroundClip;
2096
- const span2TextFillColor = computed2.webkitTextFillColor;
2097
- const span2IsGradientText = span2BgClip === 'text' &&
2098
- (span2TextFillColor === 'transparent' ||
2099
- span2TextFillColor === 'rgba(0, 0, 0, 0)' ||
2100
- (span2TextFillColor && span2TextFillColor.includes('rgba') && span2TextFillColor.endsWith(', 0)')));
2101
- const span2BgImage = computed2.backgroundImage;
2102
- const span2HasGradientBg = span2BgImage &&
2103
- span2BgImage !== 'none' &&
2104
- (span2BgImage.includes('linear-gradient') || span2BgImage.includes('radial-gradient'));
2105
- let spanFontFill = undefined;
2106
- let spanTextColor = rgbToHex(computed2.color);
2107
- if (span2IsGradientText && span2HasGradientBg) {
2108
- const spanGradient = parseCssGradient(span2BgImage);
2109
- if (spanGradient) {
2110
- spanFontFill = { type: 'gradient', gradient: spanGradient };
2111
- spanTextColor = null; // Gradient fill takes priority
2629
+ // Handle accent borders (border-left/border-right) that are thicker than the base
2630
+ // or have a different color than the main shape line
2631
+ if (hasBorder && !hasUniformBorder) {
2632
+ // Find the minimum border width among top/right/bottom (excluding left for comparison)
2633
+ const topRightBottomBorders = [borders[0], borders[1], borders[2]].filter(b => b > 0);
2634
+ const minTopRightBottom = topRightBottomBorders.length > 0 ? Math.min(...topRightBottomBorders) : 0;
2635
+ // Find the minimum border width among top/left/bottom (excluding right for comparison)
2636
+ const topLeftBottomBorders = [borders[0], borders[3], borders[2]].filter(b => b > 0);
2637
+ const minTopLeftBottom = topLeftBottomBorders.length > 0 ? Math.min(...topLeftBottomBorders) : 0;
2638
+ const borderLeftW = borders[3];
2639
+ const borderRightW = borders[1];
2640
+ // Get colors for comparison
2641
+ const borderLeftHex = rgbToHex(computed.borderLeftColor);
2642
+ const borderRightHex = rgbToHex(computed.borderRightColor);
2643
+ const borderTopHex = rgbToHex(computed.borderTopColor);
2644
+ // Get alpha values - border-left might have higher opacity than semi-transparent top/bottom
2645
+ const borderLeftAlpha = extractAlpha(computed.borderLeftColor);
2646
+ const borderTopAlpha = extractAlpha(computed.borderTopColor);
2647
+ const borderRightAlpha = extractAlpha(computed.borderRightColor);
2648
+ // Border-left accent: create a filled shape when:
2649
+ // 1. border-left is thicker than the minimum of other borders, OR
2650
+ // 2. border-left is the only border on this element, OR
2651
+ // 3. border-left has a different RGB color than the base shape line (top border), OR
2652
+ // 4. border-left is more opaque than the base shape line (even if same RGB)
2653
+ const borderLeftHasDifferentColor = borderTopHex !== borderLeftHex;
2654
+ const borderLeftIsMoreOpaque = (borderLeftAlpha ?? 0) < (borderTopAlpha ?? 0); // lower value = more opaque
2655
+ if (borderLeftW > 0 && !isFullyTransparent(computed.borderLeftColor) &&
2656
+ (borderLeftW > minTopRightBottom || minTopRightBottom === 0 || borderLeftHasDifferentColor || borderLeftIsMoreOpaque)) {
2657
+ // If there's a border-radius, use custom geometry to curve the outer (left) edge
2658
+ if (radiusValue > 0) {
2659
+ // Generate custom geometry for thin vertical bar on LEFT
2660
+ const w = borderLeftW;
2661
+ const h = rect.height;
2662
+ const r = radiusValue;
2663
+ const EMU_PER_PX = 914400 / 96;
2664
+ // arcYAtX: at x distance from the left edge, how far is the arc from the top/bottom?
2665
+ const arcYAtX = (radius, x) => {
2666
+ if (x <= 0)
2667
+ return radius;
2668
+ if (x >= radius)
2669
+ return 0;
2670
+ const dx = radius - x;
2671
+ return radius - Math.sqrt(radius * radius - dx * dx);
2672
+ };
2673
+ // Generate intermediate points along the arc
2674
+ const segments = 8;
2675
+ const arcPointsTop = [];
2676
+ const arcPointsBottom = [];
2677
+ for (let i = 0; i <= segments; i++) {
2678
+ const t = i / segments;
2679
+ const x = t * w;
2680
+ const arcDistance = arcYAtX(r, x);
2681
+ arcPointsTop.push({
2682
+ x: Math.round(x * EMU_PER_PX),
2683
+ y: Math.round(arcDistance * EMU_PER_PX)
2684
+ });
2685
+ arcPointsBottom.push({
2686
+ x: Math.round(x * EMU_PER_PX),
2687
+ y: Math.round((h - arcDistance) * EMU_PER_PX)
2688
+ });
2689
+ }
2690
+ // Build path
2691
+ const points = [];
2692
+ points.push({ x: arcPointsTop[0].x, y: arcPointsTop[0].y, moveTo: true });
2693
+ for (let i = 1; i <= segments; i++) {
2694
+ points.push({ x: arcPointsTop[i].x, y: arcPointsTop[i].y });
2695
+ }
2696
+ points.push({ x: arcPointsBottom[segments].x, y: arcPointsBottom[segments].y });
2697
+ for (let i = segments - 1; i >= 0; i--) {
2698
+ points.push({ x: arcPointsBottom[i].x, y: arcPointsBottom[i].y });
2699
+ }
2700
+ points.push({ x: arcPointsTop[0].x, y: arcPointsTop[0].y, close: true });
2701
+ const borderLeftShape = {
2702
+ type: 'shape',
2703
+ text: '',
2704
+ textRuns: null,
2705
+ style: null,
2706
+ position: {
2707
+ x: pxToInch(rect.left),
2708
+ y: pxToInch(rect.top),
2709
+ w: pxToInch(borderLeftW),
2710
+ h: pxToInch(rect.height),
2711
+ },
2712
+ shape: {
2713
+ fill: rgbToHex(computed.borderLeftColor),
2714
+ gradient: null,
2715
+ transparency: extractAlpha(computed.borderLeftColor),
2716
+ line: null,
2717
+ rectRadius: 0,
2718
+ shadow: null,
2719
+ opacity: null,
2720
+ isEllipse: false,
2721
+ softEdge: null,
2722
+ rotate: null,
2723
+ cssTriangle: null,
2724
+ customGeometry: points,
2725
+ },
2726
+ };
2727
+ elements.push(borderLeftShape);
2728
+ }
2729
+ else {
2730
+ // No border-radius - use simple rectangle
2731
+ const borderLeftShape = {
2732
+ type: 'shape',
2733
+ text: '',
2734
+ textRuns: null,
2735
+ style: null,
2736
+ position: {
2737
+ x: pxToInch(rect.left),
2738
+ y: pxToInch(rect.top),
2739
+ w: pxToInch(borderLeftW),
2740
+ h: pxToInch(rect.height),
2741
+ },
2742
+ shape: {
2743
+ fill: rgbToHex(computed.borderLeftColor),
2744
+ gradient: null,
2745
+ transparency: extractAlpha(computed.borderLeftColor),
2746
+ line: null,
2747
+ rectRadius: 0,
2748
+ shadow: null,
2749
+ opacity: null,
2750
+ isEllipse: false,
2751
+ softEdge: null,
2752
+ rotate: null,
2753
+ cssTriangle: null,
2754
+ customGeometry: null,
2755
+ },
2756
+ };
2757
+ elements.push(borderLeftShape);
2758
+ }
2112
2759
  }
2113
- }
2114
- const textElement = {
2115
- type: 'p',
2116
- text: [{ text: text, options: spanFontFill ? { fontFill: spanFontFill } : {} }],
2117
- position: {
2118
- x: pxToInch(rect.left),
2119
- y: pxToInch(rect.top),
2120
- w: pxToInch(rect.width),
2121
- h: pxToInch(rect.height),
2122
- },
2123
- style: {
2760
+ // Border-right accent: create a filled shape when:
2761
+ // 1. border-right is thicker than the minimum of other borders, OR
2762
+ // 2. border-right is the only border on this element, OR
2763
+ // 3. border-right has a different color than the base shape line (top border)
2764
+ const borderRightHasDifferentColor = borderTopHex !== borderRightHex;
2765
+ const borderRightIsMoreOpaque = (borderRightAlpha ?? 0) < (borderTopAlpha ?? 0); // lower value = more opaque
2766
+ if (borderRightW > 0 && !isFullyTransparent(computed.borderRightColor) &&
2767
+ (borderRightW > minTopLeftBottom || minTopLeftBottom === 0 || borderRightHasDifferentColor || borderRightIsMoreOpaque)) {
2768
+ // If there's a border-radius, use custom geometry to curve the outer (right) edge
2769
+ if (radiusValue > 0) {
2770
+ const w = borderRightW;
2771
+ const h = rect.height;
2772
+ const r = radiusValue;
2773
+ const EMU_PER_PX = 914400 / 96;
2774
+ const arcYAtX = (radius, x) => {
2775
+ if (x <= 0)
2776
+ return radius;
2777
+ if (x >= radius)
2778
+ return 0;
2779
+ const dx = radius - x;
2780
+ return radius - Math.sqrt(radius * radius - dx * dx);
2781
+ };
2782
+ const segments = 8;
2783
+ const arcPointsTop = [];
2784
+ const arcPointsBottom = [];
2785
+ for (let i = 0; i <= segments; i++) {
2786
+ const t = i / segments;
2787
+ const x = t * w;
2788
+ const arcDistance = arcYAtX(r, w - x);
2789
+ arcPointsTop.push({
2790
+ x: Math.round(x * EMU_PER_PX),
2791
+ y: Math.round(arcDistance * EMU_PER_PX)
2792
+ });
2793
+ arcPointsBottom.push({
2794
+ x: Math.round(x * EMU_PER_PX),
2795
+ y: Math.round((h - arcDistance) * EMU_PER_PX)
2796
+ });
2797
+ }
2798
+ const points = [];
2799
+ points.push({ x: arcPointsTop[0].x, y: arcPointsTop[0].y, moveTo: true });
2800
+ for (let i = 1; i <= segments; i++) {
2801
+ points.push({ x: arcPointsTop[i].x, y: arcPointsTop[i].y });
2802
+ }
2803
+ points.push({ x: arcPointsBottom[segments].x, y: arcPointsBottom[segments].y });
2804
+ for (let i = segments - 1; i >= 0; i--) {
2805
+ points.push({ x: arcPointsBottom[i].x, y: arcPointsBottom[i].y });
2806
+ }
2807
+ points.push({ x: arcPointsTop[0].x, y: arcPointsTop[0].y, close: true });
2808
+ const borderRightShape = {
2809
+ type: 'shape',
2810
+ text: '',
2811
+ textRuns: null,
2812
+ style: null,
2813
+ position: {
2814
+ x: pxToInch(rect.left + rect.width - borderRightW),
2815
+ y: pxToInch(rect.top),
2816
+ w: pxToInch(borderRightW),
2817
+ h: pxToInch(rect.height),
2818
+ },
2819
+ shape: {
2820
+ fill: rgbToHex(computed.borderRightColor),
2821
+ gradient: null,
2822
+ transparency: extractAlpha(computed.borderRightColor),
2823
+ line: null,
2824
+ rectRadius: 0,
2825
+ shadow: null,
2826
+ opacity: null,
2827
+ isEllipse: false,
2828
+ softEdge: null,
2829
+ rotate: null,
2830
+ cssTriangle: null,
2831
+ customGeometry: points,
2832
+ },
2833
+ };
2834
+ elements.push(borderRightShape);
2835
+ }
2836
+ else {
2837
+ const borderRightShape = {
2838
+ type: 'shape',
2839
+ text: '',
2840
+ textRuns: null,
2841
+ style: null,
2842
+ position: {
2843
+ x: pxToInch(rect.left + rect.width - borderRightW),
2844
+ y: pxToInch(rect.top),
2845
+ w: pxToInch(borderRightW),
2846
+ h: pxToInch(rect.height),
2847
+ },
2848
+ shape: {
2849
+ fill: rgbToHex(computed.borderRightColor),
2850
+ gradient: null,
2851
+ transparency: extractAlpha(computed.borderRightColor),
2852
+ line: null,
2853
+ rectRadius: 0,
2854
+ shadow: null,
2855
+ opacity: null,
2856
+ isEllipse: false,
2857
+ softEdge: null,
2858
+ rotate: null,
2859
+ cssTriangle: null,
2860
+ customGeometry: null,
2861
+ },
2862
+ };
2863
+ elements.push(borderRightShape);
2864
+ }
2865
+ }
2866
+ }
2867
+ // Find the minimum border width among left/right/bottom (excluding top for comparison)
2868
+ const leftRightBottomBorders = [borders[3], borders[1], borders[2]].filter(b => b > 0);
2869
+ const minLeftRightBottom = leftRightBottomBorders.length > 0 ? Math.min(...leftRightBottomBorders) : 0;
2870
+ // Find the minimum border width among left/right/top (excluding bottom for comparison)
2871
+ const leftRightTopBorders = [borders[3], borders[1], borders[0]].filter(b => b > 0);
2872
+ const minLeftRightTop = leftRightTopBorders.length > 0 ? Math.min(...leftRightTopBorders) : 0;
2873
+ // Border-top accent: create a filled rectangle when border-top is thicker than others
2874
+ // or when border-top is the only border on this element
2875
+ const borderTopW = borders[0];
2876
+ if (borderTopW > 0 && !isFullyTransparent(computed.borderTopColor) &&
2877
+ (borderTopW > minLeftRightBottom || minLeftRightBottom === 0)) {
2878
+ if (radiusValue > 0) {
2879
+ // Generate custom geometry for thin horizontal bar on TOP
2880
+ const w = rect.width;
2881
+ const h = borderTopW;
2882
+ const r = radiusValue;
2883
+ const EMU_PER_PX = 914400 / 96;
2884
+ const arcXAtY = (radius, y) => {
2885
+ if (y <= 0)
2886
+ return radius;
2887
+ if (y >= radius)
2888
+ return 0;
2889
+ const dy = radius - y;
2890
+ return radius - Math.sqrt(radius * radius - dy * dy);
2891
+ };
2892
+ const segments = 8;
2893
+ const arcPointsLeft = [];
2894
+ const arcPointsRight = [];
2895
+ for (let i = 0; i <= segments; i++) {
2896
+ const t = i / segments;
2897
+ const y = t * h;
2898
+ const arcDistance = arcXAtY(r, y);
2899
+ arcPointsLeft.push({
2900
+ x: Math.round(arcDistance * EMU_PER_PX),
2901
+ y: Math.round(y * EMU_PER_PX)
2902
+ });
2903
+ arcPointsRight.push({
2904
+ x: Math.round((w - arcDistance) * EMU_PER_PX),
2905
+ y: Math.round(y * EMU_PER_PX)
2906
+ });
2907
+ }
2908
+ const points = [];
2909
+ points.push({ x: arcPointsLeft[0].x, y: arcPointsLeft[0].y, moveTo: true });
2910
+ for (let i = 1; i <= segments; i++) {
2911
+ points.push({ x: arcPointsLeft[i].x, y: arcPointsLeft[i].y });
2912
+ }
2913
+ points.push({ x: arcPointsRight[segments].x, y: arcPointsRight[segments].y });
2914
+ for (let i = segments - 1; i >= 0; i--) {
2915
+ points.push({ x: arcPointsRight[i].x, y: arcPointsRight[i].y });
2916
+ }
2917
+ points.push({ x: arcPointsLeft[0].x, y: arcPointsLeft[0].y, close: true });
2918
+ const borderTopShape = {
2919
+ type: 'shape',
2920
+ text: '',
2921
+ textRuns: null,
2922
+ style: null,
2923
+ position: {
2924
+ x: pxToInch(rect.left),
2925
+ y: pxToInch(rect.top),
2926
+ w: pxToInch(rect.width),
2927
+ h: pxToInch(borderTopW),
2928
+ },
2929
+ shape: {
2930
+ fill: rgbToHex(computed.borderTopColor),
2931
+ gradient: null,
2932
+ transparency: extractAlpha(computed.borderTopColor),
2933
+ line: null,
2934
+ rectRadius: 0,
2935
+ shadow: null,
2936
+ opacity: null,
2937
+ isEllipse: false,
2938
+ softEdge: null,
2939
+ rotate: null,
2940
+ cssTriangle: null,
2941
+ customGeometry: points,
2942
+ },
2943
+ };
2944
+ elements.push(borderTopShape);
2945
+ }
2946
+ else {
2947
+ const borderTopShape = {
2948
+ type: 'shape',
2949
+ text: '',
2950
+ textRuns: null,
2951
+ style: null,
2952
+ position: {
2953
+ x: pxToInch(rect.left),
2954
+ y: pxToInch(rect.top),
2955
+ w: pxToInch(rect.width),
2956
+ h: pxToInch(borderTopW),
2957
+ },
2958
+ shape: {
2959
+ fill: rgbToHex(computed.borderTopColor),
2960
+ gradient: null,
2961
+ transparency: extractAlpha(computed.borderTopColor),
2962
+ line: null,
2963
+ rectRadius: 0,
2964
+ shadow: null,
2965
+ opacity: null,
2966
+ isEllipse: false,
2967
+ softEdge: null,
2968
+ rotate: null,
2969
+ cssTriangle: null,
2970
+ customGeometry: null,
2971
+ },
2972
+ };
2973
+ elements.push(borderTopShape);
2974
+ }
2975
+ }
2976
+ // Border-bottom accent: create a filled rectangle when border-bottom is thicker than others
2977
+ // or when border-bottom is the only border on this element
2978
+ const borderBottomW = borders[2];
2979
+ if (borderBottomW > 0 && !isFullyTransparent(computed.borderBottomColor) &&
2980
+ (borderBottomW > minLeftRightTop || minLeftRightTop === 0)) {
2981
+ if (radiusValue > 0) {
2982
+ // Generate custom geometry for thin horizontal bar on BOTTOM
2983
+ const w = rect.width;
2984
+ const h = borderBottomW;
2985
+ const r = radiusValue;
2986
+ const EMU_PER_PX = 914400 / 96;
2987
+ const arcXAtY = (radius, y) => {
2988
+ if (y <= 0)
2989
+ return radius;
2990
+ if (y >= radius)
2991
+ return 0;
2992
+ const dy = radius - y;
2993
+ return radius - Math.sqrt(radius * radius - dy * dy);
2994
+ };
2995
+ const segments = 8;
2996
+ const arcPointsLeft = [];
2997
+ const arcPointsRight = [];
2998
+ for (let i = 0; i <= segments; i++) {
2999
+ const t = i / segments;
3000
+ const y = t * h;
3001
+ const arcDistance = arcXAtY(r, h - y);
3002
+ arcPointsLeft.push({
3003
+ x: Math.round(arcDistance * EMU_PER_PX),
3004
+ y: Math.round(y * EMU_PER_PX)
3005
+ });
3006
+ arcPointsRight.push({
3007
+ x: Math.round((w - arcDistance) * EMU_PER_PX),
3008
+ y: Math.round(y * EMU_PER_PX)
3009
+ });
3010
+ }
3011
+ const points = [];
3012
+ points.push({ x: arcPointsLeft[0].x, y: arcPointsLeft[0].y, moveTo: true });
3013
+ for (let i = 1; i <= segments; i++) {
3014
+ points.push({ x: arcPointsLeft[i].x, y: arcPointsLeft[i].y });
3015
+ }
3016
+ points.push({ x: arcPointsRight[segments].x, y: arcPointsRight[segments].y });
3017
+ for (let i = segments - 1; i >= 0; i--) {
3018
+ points.push({ x: arcPointsRight[i].x, y: arcPointsRight[i].y });
3019
+ }
3020
+ points.push({ x: arcPointsLeft[0].x, y: arcPointsLeft[0].y, close: true });
3021
+ const borderBottomShape = {
3022
+ type: 'shape',
3023
+ text: '',
3024
+ textRuns: null,
3025
+ style: null,
3026
+ position: {
3027
+ x: pxToInch(rect.left),
3028
+ y: pxToInch(rect.top + rect.height - borderBottomW),
3029
+ w: pxToInch(rect.width),
3030
+ h: pxToInch(borderBottomW),
3031
+ },
3032
+ shape: {
3033
+ fill: rgbToHex(computed.borderBottomColor),
3034
+ gradient: null,
3035
+ transparency: extractAlpha(computed.borderBottomColor),
3036
+ line: null,
3037
+ rectRadius: 0,
3038
+ shadow: null,
3039
+ opacity: null,
3040
+ isEllipse: false,
3041
+ softEdge: null,
3042
+ rotate: null,
3043
+ cssTriangle: null,
3044
+ customGeometry: points,
3045
+ },
3046
+ };
3047
+ elements.push(borderBottomShape);
3048
+ }
3049
+ else {
3050
+ const borderBottomShape = {
3051
+ type: 'shape',
3052
+ text: '',
3053
+ textRuns: null,
3054
+ style: null,
3055
+ position: {
3056
+ x: pxToInch(rect.left),
3057
+ y: pxToInch(rect.top + rect.height - borderBottomW),
3058
+ w: pxToInch(rect.width),
3059
+ h: pxToInch(borderBottomW),
3060
+ },
3061
+ shape: {
3062
+ fill: rgbToHex(computed.borderBottomColor),
3063
+ gradient: null,
3064
+ transparency: extractAlpha(computed.borderBottomColor),
3065
+ line: null,
3066
+ rectRadius: 0,
3067
+ shadow: null,
3068
+ opacity: null,
3069
+ isEllipse: false,
3070
+ softEdge: null,
3071
+ rotate: null,
3072
+ cssTriangle: null,
3073
+ customGeometry: null,
3074
+ },
3075
+ };
3076
+ elements.push(borderBottomShape);
3077
+ }
3078
+ }
3079
+ }
3080
+ processed.add(el);
3081
+ return;
3082
+ }
3083
+ }
3084
+ // Handle SPAN, A, and CODE elements with backgrounds/borders as shapes
3085
+ // <a> tags styled as buttons (e.g. CTA buttons with gradient backgrounds,
3086
+ // border-radius, box-shadow) need the same shape treatment as SPANs.
3087
+ // <code> tags with styled backgrounds (e.g., inline code snippets) also need this.
3088
+ //
3089
+ // IMPORTANT: Skip if parent is a text element (P, H1-H6, etc.) - in that case,
3090
+ // the styled inline element should remain part of the text flow, not be extracted
3091
+ // as a separate absolutely-positioned shape (which would cause text duplication).
3092
+ if (el.tagName === 'SPAN' || el.tagName === 'A' || el.tagName === 'CODE') {
3093
+ // Check if this is an empty decorative element (like a bullet dot)
3094
+ // Empty SPANs with background color should be extracted as shapes even if
3095
+ // they're inside text parent elements (e.g., LI items with custom bullet styling)
3096
+ const spanText = el.textContent?.trim() || '';
3097
+ const isEmptyDecorative = spanText.length === 0;
3098
+ // Check if parent is a text element - if so, don't extract as separate shape
3099
+ // UNLESS it's an empty decorative element with styling (like a bullet dot)
3100
+ const parentTag = el.parentElement?.tagName;
3101
+ const textParentTags = new Set(['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'TD', 'TH', 'FIGCAPTION', 'BLOCKQUOTE', 'LABEL']);
3102
+ if (parentTag && textParentTags.has(parentTag) && !isEmptyDecorative) {
3103
+ // This is an inline element with text within a text paragraph - don't extract as shape
3104
+ // The text will be included in the parent's text runs via parseInlineFormatting
3105
+ processed.add(el);
3106
+ return;
3107
+ }
3108
+ const computed = win.getComputedStyle(el);
3109
+ const hasBg = computed.backgroundColor && computed.backgroundColor !== 'rgba(0, 0, 0, 0)';
3110
+ const hasBorder = (computed.borderWidth && parseFloat(computed.borderWidth) > 0) ||
3111
+ (computed.borderTopWidth && parseFloat(computed.borderTopWidth) > 0) ||
3112
+ (computed.borderRightWidth && parseFloat(computed.borderRightWidth) > 0) ||
3113
+ (computed.borderBottomWidth && parseFloat(computed.borderBottomWidth) > 0) ||
3114
+ (computed.borderLeftWidth && parseFloat(computed.borderLeftWidth) > 0);
3115
+ // Check for gradient background (backgroundImage contains linear-gradient or radial-gradient)
3116
+ const spanBgImage = computed.backgroundImage;
3117
+ const hasGradientBg = spanBgImage &&
3118
+ spanBgImage !== 'none' &&
3119
+ (spanBgImage.includes('linear-gradient') || spanBgImage.includes('radial-gradient'));
3120
+ // Check if this gradient is used for gradient text fill (background-clip: text)
3121
+ // In that case, we should NOT create a shape element - the gradient fills the text itself
3122
+ const spanBgClip = computed.webkitBackgroundClip || computed.backgroundClip;
3123
+ const spanTextFillColor = computed.webkitTextFillColor;
3124
+ const isGradientTextFill = hasGradientBg &&
3125
+ spanBgClip === 'text' &&
3126
+ (spanTextFillColor === 'transparent' ||
3127
+ spanTextFillColor === 'rgba(0, 0, 0, 0)' ||
3128
+ (spanTextFillColor && spanTextFillColor.includes('rgba') && spanTextFillColor.endsWith(', 0)')));
3129
+ // Skip creating shape for gradient text fills - let text element handler create text with gradient fill
3130
+ if (isGradientTextFill) {
3131
+ // Don't process as shape, let it fall through to text handling
3132
+ }
3133
+ else if (hasBg || hasBorder || hasGradientBg) {
3134
+ const rect = htmlEl.getBoundingClientRect();
3135
+ if (rect.width > 0 && rect.height > 0) {
3136
+ const text = getTransformedText(htmlEl, computed);
3137
+ const bgGradient = parseCssGradient(computed.backgroundImage);
3138
+ const borderRadius = computed.borderRadius;
3139
+ const radiusValue = parseFloat(borderRadius);
3140
+ let rectRadius = 0;
3141
+ // Detect ellipse: border-radius >= 50% on roughly square elements
3142
+ const isCircularRadius = borderRadius.includes('%')
3143
+ ? radiusValue >= 50
3144
+ : (radiusValue > 0 && radiusValue >= Math.min(rect.width, rect.height) / 2 - 1);
3145
+ const aspectRatio = rect.width / rect.height;
3146
+ const spanIsEllipse = isCircularRadius && aspectRatio > 0.5 && aspectRatio < 2.0;
3147
+ if (radiusValue > 0 && !spanIsEllipse) {
3148
+ if (borderRadius.includes('%')) {
3149
+ const minDim = Math.min(rect.width, rect.height);
3150
+ rectRadius = (radiusValue / 100) * pxToInch(minDim);
3151
+ }
3152
+ else {
3153
+ rectRadius = pxToInch(radiusValue);
3154
+ }
3155
+ }
3156
+ const borderTop = computed.borderTopWidth;
3157
+ const borderRight = computed.borderRightWidth;
3158
+ const borderBottom = computed.borderBottomWidth;
3159
+ const borderLeft = computed.borderLeftWidth;
3160
+ const borders = [parseFloat(borderTop) || 0, parseFloat(borderRight) || 0, parseFloat(borderBottom) || 0, parseFloat(borderLeft) || 0];
3161
+ const hasUniformBorder = hasBorder &&
3162
+ borders.every((b) => b === borders[0]);
3163
+ // Extract effective opacity (including ancestor chain)
3164
+ const spanOpacity = getEffectiveOpacity(el, win);
3165
+ const hasSpanOpacity = spanOpacity < 1;
3166
+ // Extract box-shadow
3167
+ const spanShadow = parseBoxShadow(computed.boxShadow);
3168
+ // For small elements (badges, labels), disable text wrapping to prevent overflow
3169
+ const spanWhiteSpace = computed.whiteSpace;
3170
+ const spanShouldNotWrap = spanWhiteSpace === 'nowrap' ||
3171
+ spanWhiteSpace === 'pre' ||
3172
+ rect.width < 100 ||
3173
+ rect.height < 50;
3174
+ const shapeElement = {
3175
+ type: 'shape',
3176
+ position: {
3177
+ x: pxToInch(rect.left),
3178
+ y: pxToInch(rect.top),
3179
+ w: pxToInch(rect.width),
3180
+ h: pxToInch(rect.height),
3181
+ },
3182
+ text: text,
3183
+ textRuns: null,
3184
+ style: text ? {
3185
+ fontSize: pxToPoints(computed.fontSize),
3186
+ fontFace: extractFontFace(computed.fontFamily),
3187
+ color: rgbToHex(computed.color),
3188
+ bold: parseInt(computed.fontWeight) >= 600,
3189
+ align: 'center',
3190
+ valign: 'middle',
3191
+ wrap: !spanShouldNotWrap,
3192
+ ...(extractAlpha(computed.color) !== null ? { transparency: extractAlpha(computed.color) } : {}),
3193
+ } : null,
3194
+ shape: {
3195
+ fill: hasBg ? rgbToHex(computed.backgroundColor) : null,
3196
+ gradient: bgGradient,
3197
+ transparency: hasBg ? extractAlpha(computed.backgroundColor) : null,
3198
+ line: hasUniformBorder
3199
+ ? {
3200
+ color: rgbToHex(computed.borderColor),
3201
+ width: pxToPoints(borderTop),
3202
+ transparency: extractAlpha(computed.borderColor),
3203
+ dashType: extractDashType(computed.borderStyle),
3204
+ }
3205
+ : null,
3206
+ rectRadius: spanIsEllipse ? 0 : rectRadius,
3207
+ shadow: spanShadow,
3208
+ opacity: hasSpanOpacity ? spanOpacity : null,
3209
+ isEllipse: spanIsEllipse,
3210
+ softEdge: null,
3211
+ rotate: null,
3212
+ cssTriangle: null,
3213
+ customGeometry: null,
3214
+ },
3215
+ zIndex: extractZIndex(computed),
3216
+ };
3217
+ elements.push(shapeElement);
3218
+ // Handle accent borders (border-left/border-right) that are thicker than the base
3219
+ // or have a different color than the main shape line
3220
+ if (hasBorder && !hasUniformBorder) {
3221
+ // Find the minimum border width among top/right/bottom (excluding left for comparison)
3222
+ const topRightBottomBorders = [borders[0], borders[1], borders[2]].filter(b => b > 0);
3223
+ const minTopRightBottom = topRightBottomBorders.length > 0 ? Math.min(...topRightBottomBorders) : 0;
3224
+ // Find the minimum border width among top/left/bottom (excluding right for comparison)
3225
+ const topLeftBottomBorders = [borders[0], borders[3], borders[2]].filter(b => b > 0);
3226
+ const minTopLeftBottom = topLeftBottomBorders.length > 0 ? Math.min(...topLeftBottomBorders) : 0;
3227
+ const borderLeftW = borders[3];
3228
+ const borderRightW = borders[1];
3229
+ // Get colors for comparison
3230
+ const borderLeftHex = rgbToHex(computed.borderLeftColor);
3231
+ const borderRightHex = rgbToHex(computed.borderRightColor);
3232
+ const borderTopHex = rgbToHex(computed.borderTopColor);
3233
+ // Get alpha values - border-left might have higher opacity than semi-transparent top/bottom
3234
+ const borderLeftAlpha = extractAlpha(computed.borderLeftColor);
3235
+ const borderTopAlpha = extractAlpha(computed.borderTopColor);
3236
+ const borderRightAlpha = extractAlpha(computed.borderRightColor);
3237
+ // Border-left accent: create a filled shape when:
3238
+ // 1. border-left is thicker than the minimum of other borders, OR
3239
+ // 2. border-left is the only border on this element, OR
3240
+ // 3. border-left has a different RGB color than the base shape line (top border), OR
3241
+ // 4. border-left is more opaque than the base shape line (even if same RGB)
3242
+ const borderLeftHasDifferentColor = borderTopHex !== borderLeftHex;
3243
+ const borderLeftIsMoreOpaque = (borderLeftAlpha ?? 0) < (borderTopAlpha ?? 0); // lower value = more opaque
3244
+ if (borderLeftW > 0 && !isFullyTransparent(computed.borderLeftColor) &&
3245
+ (borderLeftW > minTopRightBottom || minTopRightBottom === 0 || borderLeftHasDifferentColor || borderLeftIsMoreOpaque)) {
3246
+ // If there's a border-radius, use custom geometry to curve the outer (left) edge
3247
+ if (radiusValue > 0) {
3248
+ // Generate custom geometry for thin vertical bar on LEFT
3249
+ const w = borderLeftW;
3250
+ const h = rect.height;
3251
+ const r = radiusValue;
3252
+ const EMU_PER_PX = 914400 / 96;
3253
+ // arcYAtX: at x distance from the left edge, how far is the arc from the top/bottom?
3254
+ const arcYAtX = (radius, x) => {
3255
+ if (x <= 0)
3256
+ return radius;
3257
+ if (x >= radius)
3258
+ return 0;
3259
+ const dx = radius - x;
3260
+ return radius - Math.sqrt(radius * radius - dx * dx);
3261
+ };
3262
+ // Generate intermediate points along the arc
3263
+ const segments = 8;
3264
+ const arcPointsTop = [];
3265
+ const arcPointsBottom = [];
3266
+ for (let i = 0; i <= segments; i++) {
3267
+ const t = i / segments;
3268
+ const x = t * w;
3269
+ const arcDistance = arcYAtX(r, x);
3270
+ arcPointsTop.push({
3271
+ x: Math.round(x * EMU_PER_PX),
3272
+ y: Math.round(arcDistance * EMU_PER_PX)
3273
+ });
3274
+ arcPointsBottom.push({
3275
+ x: Math.round(x * EMU_PER_PX),
3276
+ y: Math.round((h - arcDistance) * EMU_PER_PX)
3277
+ });
3278
+ }
3279
+ // Build path
3280
+ const points = [];
3281
+ points.push({ x: arcPointsTop[0].x, y: arcPointsTop[0].y, moveTo: true });
3282
+ for (let i = 1; i <= segments; i++) {
3283
+ points.push({ x: arcPointsTop[i].x, y: arcPointsTop[i].y });
3284
+ }
3285
+ points.push({ x: arcPointsBottom[segments].x, y: arcPointsBottom[segments].y });
3286
+ for (let i = segments - 1; i >= 0; i--) {
3287
+ points.push({ x: arcPointsBottom[i].x, y: arcPointsBottom[i].y });
3288
+ }
3289
+ points.push({ x: arcPointsTop[0].x, y: arcPointsTop[0].y, close: true });
3290
+ const borderLeftShape = {
3291
+ type: 'shape',
3292
+ text: '',
3293
+ textRuns: null,
3294
+ style: null,
3295
+ position: {
3296
+ x: pxToInch(rect.left),
3297
+ y: pxToInch(rect.top),
3298
+ w: pxToInch(borderLeftW),
3299
+ h: pxToInch(rect.height),
3300
+ },
3301
+ shape: {
3302
+ fill: rgbToHex(computed.borderLeftColor),
3303
+ gradient: null,
3304
+ transparency: extractAlpha(computed.borderLeftColor),
3305
+ line: null,
3306
+ rectRadius: 0,
3307
+ shadow: null,
3308
+ opacity: null,
3309
+ isEllipse: false,
3310
+ softEdge: null,
3311
+ rotate: null,
3312
+ cssTriangle: null,
3313
+ customGeometry: points,
3314
+ },
3315
+ };
3316
+ elements.push(borderLeftShape);
3317
+ }
3318
+ else {
3319
+ // No border-radius - use simple rectangle
3320
+ const borderLeftShape = {
3321
+ type: 'shape',
3322
+ text: '',
3323
+ textRuns: null,
3324
+ style: null,
3325
+ position: {
3326
+ x: pxToInch(rect.left),
3327
+ y: pxToInch(rect.top),
3328
+ w: pxToInch(borderLeftW),
3329
+ h: pxToInch(rect.height),
3330
+ },
3331
+ shape: {
3332
+ fill: rgbToHex(computed.borderLeftColor),
3333
+ gradient: null,
3334
+ transparency: extractAlpha(computed.borderLeftColor),
3335
+ line: null,
3336
+ rectRadius: 0,
3337
+ shadow: null,
3338
+ opacity: null,
3339
+ isEllipse: false,
3340
+ softEdge: null,
3341
+ rotate: null,
3342
+ cssTriangle: null,
3343
+ customGeometry: null,
3344
+ },
3345
+ };
3346
+ elements.push(borderLeftShape);
3347
+ }
3348
+ }
3349
+ // Border-right accent: create a filled shape when:
3350
+ // 1. border-right is thicker than the minimum of other borders, OR
3351
+ // 2. border-right is the only border on this element, OR
3352
+ // 3. border-right has a different color than the base shape line (top border)
3353
+ const borderRightHasDifferentColor = borderTopHex !== borderRightHex;
3354
+ const borderRightIsMoreOpaque = (borderRightAlpha ?? 0) < (borderTopAlpha ?? 0); // lower value = more opaque
3355
+ if (borderRightW > 0 && !isFullyTransparent(computed.borderRightColor) &&
3356
+ (borderRightW > minTopLeftBottom || minTopLeftBottom === 0 || borderRightHasDifferentColor || borderRightIsMoreOpaque)) {
3357
+ // If there's a border-radius, use custom geometry to curve the outer (right) edge
3358
+ if (radiusValue > 0) {
3359
+ const w = borderRightW;
3360
+ const h = rect.height;
3361
+ const r = radiusValue;
3362
+ const EMU_PER_PX = 914400 / 96;
3363
+ const arcYAtX = (radius, x) => {
3364
+ if (x <= 0)
3365
+ return radius;
3366
+ if (x >= radius)
3367
+ return 0;
3368
+ const dx = radius - x;
3369
+ return radius - Math.sqrt(radius * radius - dx * dx);
3370
+ };
3371
+ const segments = 8;
3372
+ const arcPointsTop = [];
3373
+ const arcPointsBottom = [];
3374
+ for (let i = 0; i <= segments; i++) {
3375
+ const t = i / segments;
3376
+ const x = t * w;
3377
+ const arcDistance = arcYAtX(r, w - x);
3378
+ arcPointsTop.push({
3379
+ x: Math.round(x * EMU_PER_PX),
3380
+ y: Math.round(arcDistance * EMU_PER_PX)
3381
+ });
3382
+ arcPointsBottom.push({
3383
+ x: Math.round(x * EMU_PER_PX),
3384
+ y: Math.round((h - arcDistance) * EMU_PER_PX)
3385
+ });
3386
+ }
3387
+ const points = [];
3388
+ points.push({ x: arcPointsTop[0].x, y: arcPointsTop[0].y, moveTo: true });
3389
+ for (let i = 1; i <= segments; i++) {
3390
+ points.push({ x: arcPointsTop[i].x, y: arcPointsTop[i].y });
3391
+ }
3392
+ points.push({ x: arcPointsBottom[segments].x, y: arcPointsBottom[segments].y });
3393
+ for (let i = segments - 1; i >= 0; i--) {
3394
+ points.push({ x: arcPointsBottom[i].x, y: arcPointsBottom[i].y });
3395
+ }
3396
+ points.push({ x: arcPointsTop[0].x, y: arcPointsTop[0].y, close: true });
3397
+ const borderRightShape = {
3398
+ type: 'shape',
3399
+ text: '',
3400
+ textRuns: null,
3401
+ style: null,
3402
+ position: {
3403
+ x: pxToInch(rect.left + rect.width - borderRightW),
3404
+ y: pxToInch(rect.top),
3405
+ w: pxToInch(borderRightW),
3406
+ h: pxToInch(rect.height),
3407
+ },
3408
+ shape: {
3409
+ fill: rgbToHex(computed.borderRightColor),
3410
+ gradient: null,
3411
+ transparency: extractAlpha(computed.borderRightColor),
3412
+ line: null,
3413
+ rectRadius: 0,
3414
+ shadow: null,
3415
+ opacity: null,
3416
+ isEllipse: false,
3417
+ softEdge: null,
3418
+ rotate: null,
3419
+ cssTriangle: null,
3420
+ customGeometry: points,
3421
+ },
3422
+ };
3423
+ elements.push(borderRightShape);
3424
+ }
3425
+ else {
3426
+ const borderRightShape = {
3427
+ type: 'shape',
3428
+ text: '',
3429
+ textRuns: null,
3430
+ style: null,
3431
+ position: {
3432
+ x: pxToInch(rect.left + rect.width - borderRightW),
3433
+ y: pxToInch(rect.top),
3434
+ w: pxToInch(borderRightW),
3435
+ h: pxToInch(rect.height),
3436
+ },
3437
+ shape: {
3438
+ fill: rgbToHex(computed.borderRightColor),
3439
+ gradient: null,
3440
+ transparency: extractAlpha(computed.borderRightColor),
3441
+ line: null,
3442
+ rectRadius: 0,
3443
+ shadow: null,
3444
+ opacity: null,
3445
+ isEllipse: false,
3446
+ softEdge: null,
3447
+ rotate: null,
3448
+ cssTriangle: null,
3449
+ customGeometry: null,
3450
+ },
3451
+ };
3452
+ elements.push(borderRightShape);
3453
+ }
3454
+ }
3455
+ }
3456
+ // Find the minimum border width among left/right/bottom (excluding top for comparison)
3457
+ const leftRightBottomBorders = [borders[3], borders[1], borders[2]].filter(b => b > 0);
3458
+ const minLeftRightBottom = leftRightBottomBorders.length > 0 ? Math.min(...leftRightBottomBorders) : 0;
3459
+ // Find the minimum border width among left/right/top (excluding bottom for comparison)
3460
+ const leftRightTopBorders = [borders[3], borders[1], borders[0]].filter(b => b > 0);
3461
+ const minLeftRightTop = leftRightTopBorders.length > 0 ? Math.min(...leftRightTopBorders) : 0;
3462
+ // Border-top accent: create a filled rectangle when border-top is thicker than others
3463
+ // or when border-top is the only border on this element
3464
+ const borderTopW = borders[0];
3465
+ if (borderTopW > 0 && !isFullyTransparent(computed.borderTopColor) &&
3466
+ (borderTopW > minLeftRightBottom || minLeftRightBottom === 0)) {
3467
+ if (radiusValue > 0) {
3468
+ // Generate custom geometry for thin horizontal bar on TOP
3469
+ const w = rect.width;
3470
+ const h = borderTopW;
3471
+ const r = radiusValue;
3472
+ const EMU_PER_PX = 914400 / 96;
3473
+ const arcXAtY = (radius, y) => {
3474
+ if (y <= 0)
3475
+ return radius;
3476
+ if (y >= radius)
3477
+ return 0;
3478
+ const dy = radius - y;
3479
+ return radius - Math.sqrt(radius * radius - dy * dy);
3480
+ };
3481
+ const segments = 8;
3482
+ const arcPointsLeft = [];
3483
+ const arcPointsRight = [];
3484
+ for (let i = 0; i <= segments; i++) {
3485
+ const t = i / segments;
3486
+ const y = t * h;
3487
+ const arcDistance = arcXAtY(r, y);
3488
+ arcPointsLeft.push({
3489
+ x: Math.round(arcDistance * EMU_PER_PX),
3490
+ y: Math.round(y * EMU_PER_PX)
3491
+ });
3492
+ arcPointsRight.push({
3493
+ x: Math.round((w - arcDistance) * EMU_PER_PX),
3494
+ y: Math.round(y * EMU_PER_PX)
3495
+ });
3496
+ }
3497
+ const points = [];
3498
+ points.push({ x: arcPointsLeft[0].x, y: arcPointsLeft[0].y, moveTo: true });
3499
+ for (let i = 1; i <= segments; i++) {
3500
+ points.push({ x: arcPointsLeft[i].x, y: arcPointsLeft[i].y });
3501
+ }
3502
+ points.push({ x: arcPointsRight[segments].x, y: arcPointsRight[segments].y });
3503
+ for (let i = segments - 1; i >= 0; i--) {
3504
+ points.push({ x: arcPointsRight[i].x, y: arcPointsRight[i].y });
3505
+ }
3506
+ points.push({ x: arcPointsLeft[0].x, y: arcPointsLeft[0].y, close: true });
3507
+ const borderTopShape = {
3508
+ type: 'shape',
3509
+ text: '',
3510
+ textRuns: null,
3511
+ style: null,
3512
+ position: {
3513
+ x: pxToInch(rect.left),
3514
+ y: pxToInch(rect.top),
3515
+ w: pxToInch(rect.width),
3516
+ h: pxToInch(borderTopW),
3517
+ },
3518
+ shape: {
3519
+ fill: rgbToHex(computed.borderTopColor),
3520
+ gradient: null,
3521
+ transparency: extractAlpha(computed.borderTopColor),
3522
+ line: null,
3523
+ rectRadius: 0,
3524
+ shadow: null,
3525
+ opacity: null,
3526
+ isEllipse: false,
3527
+ softEdge: null,
3528
+ rotate: null,
3529
+ cssTriangle: null,
3530
+ customGeometry: points,
3531
+ },
3532
+ };
3533
+ elements.push(borderTopShape);
3534
+ }
3535
+ else {
3536
+ const borderTopShape = {
3537
+ type: 'shape',
3538
+ text: '',
3539
+ textRuns: null,
3540
+ style: null,
3541
+ position: {
3542
+ x: pxToInch(rect.left),
3543
+ y: pxToInch(rect.top),
3544
+ w: pxToInch(rect.width),
3545
+ h: pxToInch(borderTopW),
3546
+ },
3547
+ shape: {
3548
+ fill: rgbToHex(computed.borderTopColor),
3549
+ gradient: null,
3550
+ transparency: extractAlpha(computed.borderTopColor),
3551
+ line: null,
3552
+ rectRadius: 0,
3553
+ shadow: null,
3554
+ opacity: null,
3555
+ isEllipse: false,
3556
+ softEdge: null,
3557
+ rotate: null,
3558
+ cssTriangle: null,
3559
+ customGeometry: null,
3560
+ },
3561
+ };
3562
+ elements.push(borderTopShape);
3563
+ }
3564
+ }
3565
+ // Border-bottom accent: create a filled rectangle when border-bottom is thicker than others
3566
+ // or when border-bottom is the only border on this element
3567
+ const borderBottomW = borders[2];
3568
+ if (borderBottomW > 0 && !isFullyTransparent(computed.borderBottomColor) &&
3569
+ (borderBottomW > minLeftRightTop || minLeftRightTop === 0)) {
3570
+ if (radiusValue > 0) {
3571
+ // Generate custom geometry for thin horizontal bar on BOTTOM
3572
+ const w = rect.width;
3573
+ const h = borderBottomW;
3574
+ const r = radiusValue;
3575
+ const EMU_PER_PX = 914400 / 96;
3576
+ const arcXAtY = (radius, y) => {
3577
+ if (y <= 0)
3578
+ return radius;
3579
+ if (y >= radius)
3580
+ return 0;
3581
+ const dy = radius - y;
3582
+ return radius - Math.sqrt(radius * radius - dy * dy);
3583
+ };
3584
+ const segments = 8;
3585
+ const arcPointsLeft = [];
3586
+ const arcPointsRight = [];
3587
+ for (let i = 0; i <= segments; i++) {
3588
+ const t = i / segments;
3589
+ const y = t * h;
3590
+ const arcDistance = arcXAtY(r, h - y);
3591
+ arcPointsLeft.push({
3592
+ x: Math.round(arcDistance * EMU_PER_PX),
3593
+ y: Math.round(y * EMU_PER_PX)
3594
+ });
3595
+ arcPointsRight.push({
3596
+ x: Math.round((w - arcDistance) * EMU_PER_PX),
3597
+ y: Math.round(y * EMU_PER_PX)
3598
+ });
3599
+ }
3600
+ const points = [];
3601
+ points.push({ x: arcPointsLeft[0].x, y: arcPointsLeft[0].y, moveTo: true });
3602
+ for (let i = 1; i <= segments; i++) {
3603
+ points.push({ x: arcPointsLeft[i].x, y: arcPointsLeft[i].y });
3604
+ }
3605
+ points.push({ x: arcPointsRight[segments].x, y: arcPointsRight[segments].y });
3606
+ for (let i = segments - 1; i >= 0; i--) {
3607
+ points.push({ x: arcPointsRight[i].x, y: arcPointsRight[i].y });
3608
+ }
3609
+ points.push({ x: arcPointsLeft[0].x, y: arcPointsLeft[0].y, close: true });
3610
+ const borderBottomShape = {
3611
+ type: 'shape',
3612
+ text: '',
3613
+ textRuns: null,
3614
+ style: null,
3615
+ position: {
3616
+ x: pxToInch(rect.left),
3617
+ y: pxToInch(rect.top + rect.height - borderBottomW),
3618
+ w: pxToInch(rect.width),
3619
+ h: pxToInch(borderBottomW),
3620
+ },
3621
+ shape: {
3622
+ fill: rgbToHex(computed.borderBottomColor),
3623
+ gradient: null,
3624
+ transparency: extractAlpha(computed.borderBottomColor),
3625
+ line: null,
3626
+ rectRadius: 0,
3627
+ shadow: null,
3628
+ opacity: null,
3629
+ isEllipse: false,
3630
+ softEdge: null,
3631
+ rotate: null,
3632
+ cssTriangle: null,
3633
+ customGeometry: points,
3634
+ },
3635
+ };
3636
+ elements.push(borderBottomShape);
3637
+ }
3638
+ else {
3639
+ const borderBottomShape = {
3640
+ type: 'shape',
3641
+ text: '',
3642
+ textRuns: null,
3643
+ style: null,
3644
+ position: {
3645
+ x: pxToInch(rect.left),
3646
+ y: pxToInch(rect.top + rect.height - borderBottomW),
3647
+ w: pxToInch(rect.width),
3648
+ h: pxToInch(borderBottomW),
3649
+ },
3650
+ shape: {
3651
+ fill: rgbToHex(computed.borderBottomColor),
3652
+ gradient: null,
3653
+ transparency: extractAlpha(computed.borderBottomColor),
3654
+ line: null,
3655
+ rectRadius: 0,
3656
+ shadow: null,
3657
+ opacity: null,
3658
+ isEllipse: false,
3659
+ softEdge: null,
3660
+ rotate: null,
3661
+ cssTriangle: null,
3662
+ customGeometry: null,
3663
+ },
3664
+ };
3665
+ elements.push(borderBottomShape);
3666
+ }
3667
+ }
3668
+ }
3669
+ processed.add(el);
3670
+ return;
3671
+ }
3672
+ // Handle plain SPANs that are direct children of DIV elements
3673
+ const parent = el.parentElement;
3674
+ if (parent && parent.tagName === 'DIV') {
3675
+ const rect = htmlEl.getBoundingClientRect();
3676
+ const computed2 = win.getComputedStyle(el);
3677
+ const text = getTransformedText(htmlEl, computed2);
3678
+ if (rect.width > 0 && rect.height > 0 && text) {
3679
+ const fontSizePx = parseFloat(computed2.fontSize);
3680
+ const lineHeightPx = parseFloat(computed2.lineHeight);
3681
+ const lineHeightMultiplier = fontSizePx > 0 && !isNaN(lineHeightPx) ? lineHeightPx / fontSizePx : 1.0;
3682
+ // Check for gradient text fill on plain SPAN elements
3683
+ const span2BgClip = computed2.webkitBackgroundClip || computed2.backgroundClip;
3684
+ const span2TextFillColor = computed2.webkitTextFillColor;
3685
+ const span2IsGradientText = span2BgClip === 'text' &&
3686
+ (span2TextFillColor === 'transparent' ||
3687
+ span2TextFillColor === 'rgba(0, 0, 0, 0)' ||
3688
+ (span2TextFillColor && span2TextFillColor.includes('rgba') && span2TextFillColor.endsWith(', 0)')));
3689
+ const span2BgImage = computed2.backgroundImage;
3690
+ const span2HasGradientBg = span2BgImage &&
3691
+ span2BgImage !== 'none' &&
3692
+ (span2BgImage.includes('linear-gradient') || span2BgImage.includes('radial-gradient'));
3693
+ let spanFontFill = undefined;
3694
+ let spanTextColor = rgbToHex(computed2.color);
3695
+ if (span2IsGradientText && span2HasGradientBg) {
3696
+ const spanGradient = parseCssGradient(span2BgImage);
3697
+ if (spanGradient) {
3698
+ spanFontFill = { type: 'gradient', gradient: spanGradient };
3699
+ spanTextColor = null; // Gradient fill takes priority
3700
+ }
3701
+ }
3702
+ const textElement = {
3703
+ type: 'p',
3704
+ text: [{ text: text, options: spanFontFill ? { fontFill: spanFontFill } : {} }],
3705
+ position: {
3706
+ x: pxToInch(rect.left),
3707
+ y: pxToInch(rect.top),
3708
+ w: pxToInch(rect.width),
3709
+ h: pxToInch(rect.height),
3710
+ },
3711
+ style: {
2124
3712
  fontSize: pxToPoints(computed2.fontSize),
2125
3713
  fontFace: extractFontFace(computed2.fontFamily),
2126
3714
  color: spanTextColor,
@@ -2134,6 +3722,7 @@ export function parseSlideHtml(doc) {
2134
3722
  valign: 'middle',
2135
3723
  lineSpacing: lineHeightMultiplier * pxToPoints(computed2.fontSize),
2136
3724
  fontFill: spanFontFill,
3725
+ ...(extractAlpha(computed2.color) !== null ? { transparency: extractAlpha(computed2.color) } : {}),
2137
3726
  },
2138
3727
  };
2139
3728
  elements.push(textElement);
@@ -2385,12 +3974,23 @@ export function parseSlideHtml(doc) {
2385
3974
  if (!svgClone.hasAttribute('xmlns')) {
2386
3975
  svgClone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
2387
3976
  }
2388
- // ALWAYS set width/height to the rendered dimensions.
3977
+ // Set width/height for the SVG data URI used by PptxGenJS to create PNG fallback.
2389
3978
  // The original SVG may have unitless width/height (e.g., width="40") which
2390
3979
  // are NOT scaled by transform.ts's px→pt conversion. Using the rendered
2391
3980
  // bounding rect ensures the SVG image matches the actual displayed size.
2392
- svgClone.setAttribute('width', String(rect.width));
2393
- svgClone.setAttribute('height', String(rect.height));
3981
+ //
3982
+ // IMPORTANT: For small SVGs (< 64px), we scale up the SVG dimensions in the
3983
+ // data URI to ensure the PNG fallback has sufficient resolution. LibreOffice
3984
+ // uses the PNG fallback instead of the SVG, and tiny PNGs (e.g., 14x14)
3985
+ // become invisible or extremely blurry when rendered. By setting a minimum
3986
+ // size of 64px while preserving the viewBox, the PNG fallback will have
3987
+ // good quality. The actual PPTX display size is controlled by
3988
+ // ImageElement.position.w/h which uses the original rect dimensions.
3989
+ const MIN_SVG_RENDER_SIZE = 128;
3990
+ const svgRenderWidth = Math.max(rect.width, MIN_SVG_RENDER_SIZE);
3991
+ const svgRenderHeight = Math.max(rect.height, MIN_SVG_RENDER_SIZE);
3992
+ svgClone.setAttribute('width', String(svgRenderWidth));
3993
+ svgClone.setAttribute('height', String(svgRenderHeight));
2394
3994
  // Resolve 'currentColor' to the actual computed color value.
2395
3995
  // SVGs often use fill="currentColor" to inherit the text color from CSS.
2396
3996
  // When serializing for PPTX embedding, we must replace currentColor with
@@ -2481,15 +4081,18 @@ export function parseSlideHtml(doc) {
2481
4081
  const svgBase64 = btoa(unescape(encodeURIComponent(svgString)));
2482
4082
  const dataUri = `data:image/svg+xml;base64,${svgBase64}`;
2483
4083
  // Calculate SVG position, accounting for vertical-align
2484
- // NOTE: getBoundingClientRect() already returns the correct position when the
2485
- // parent uses flexbox layout. Only apply vertical-align adjustment for non-flex
2486
- // parents where inline vertical-align actually affects positioning.
4084
+ // NOTE: getBoundingClientRect() already returns the correct position for block-level
4085
+ // elements with margin: auto centering - the browser computes the actual pixel values.
4086
+ // Only apply vertical-align adjustment for INLINE SVGs where vertical-align affects positioning.
4087
+ const svgX = rect.left;
2487
4088
  let svgY = rect.top;
2488
4089
  const computedAlign = win.getComputedStyle(htmlEl);
2489
4090
  const verticalAlign = computedAlign.verticalAlign;
4091
+ const svgDisplay = computedAlign.display;
2490
4092
  // If SVG has vertical-align: middle and a parent, center it within parent's height
2491
4093
  // BUT skip this if parent is a flex container (flexbox already handles positioning)
2492
- if (verticalAlign === 'middle' && el.parentElement) {
4094
+ // Also skip if SVG has display: block - vertical-align only affects inline elements
4095
+ if (verticalAlign === 'middle' && svgDisplay !== 'block' && el.parentElement) {
2493
4096
  const parentComputed = win.getComputedStyle(el.parentElement);
2494
4097
  const parentIsFlex = parentComputed.display === 'flex' || parentComputed.display === 'inline-flex';
2495
4098
  if (!parentIsFlex) {
@@ -2504,14 +4107,33 @@ export function parseSlideHtml(doc) {
2504
4107
  // PPTX doesn't have opacity containers, so the visual opacity must be
2505
4108
  // applied directly to each element.
2506
4109
  const svgEffectiveOpacity = getEffectiveOpacity(el, win);
4110
+ let displayW = pxToInch(rect.width);
4111
+ let displayH = pxToInch(rect.height);
4112
+ let displayX = pxToInch(svgX);
4113
+ let displayY = pxToInch(svgY);
4114
+ // Scale up very small SVGs to a minimum display size.
4115
+ // LibreOffice doesn't render images smaller than ~13-14px clearly.
4116
+ // Only scale if BOTH dimensions are below threshold (truly tiny icons).
4117
+ // Use 0.145" (~14px) as threshold to catch 13px icons but not 15px ones.
4118
+ const MIN_SVG_DISPLAY_SIZE_IN = 0.145;
4119
+ if (displayW < MIN_SVG_DISPLAY_SIZE_IN && displayH < MIN_SVG_DISPLAY_SIZE_IN) {
4120
+ const originalCenterX = displayX + displayW / 2;
4121
+ const originalCenterY = displayY + displayH / 2;
4122
+ const scaleFactor = Math.max(MIN_SVG_DISPLAY_SIZE_IN / displayW, MIN_SVG_DISPLAY_SIZE_IN / displayH);
4123
+ displayW = displayW * scaleFactor;
4124
+ displayH = displayH * scaleFactor;
4125
+ // Re-center at the original center point
4126
+ displayX = originalCenterX - displayW / 2;
4127
+ displayY = originalCenterY - displayH / 2;
4128
+ }
2507
4129
  const imageElement = {
2508
4130
  type: 'image',
2509
4131
  src: dataUri,
2510
4132
  position: {
2511
- x: pxToInch(rect.left),
2512
- y: pxToInch(svgY),
2513
- w: pxToInch(rect.width),
2514
- h: pxToInch(rect.height),
4133
+ x: displayX,
4134
+ y: displayY,
4135
+ w: displayW,
4136
+ h: displayH,
2515
4137
  },
2516
4138
  sizing: null,
2517
4139
  transparency: svgEffectiveOpacity < 1 ? Math.round((1 - svgEffectiveOpacity) * 100) : undefined,
@@ -2618,8 +4240,11 @@ export function parseSlideHtml(doc) {
2618
4240
  processed.add(el);
2619
4241
  return;
2620
4242
  }
2621
- // Extract DIVs with backgrounds/borders as shapes
2622
- const isContainer = el.tagName === 'DIV' && !textTags.includes(el.tagName);
4243
+ // Extract DIVs and TABLE-related elements with backgrounds/borders as shapes
4244
+ // TABLE, TR, TD, TH, TBODY, THEAD, TFOOT, CENTER elements need the same shape treatment
4245
+ // as DIVs to properly render HTML pages using TABLE-based layouts (like Hacker News).
4246
+ const containerTags = new Set(['DIV', 'TABLE', 'TR', 'TD', 'TH', 'TBODY', 'THEAD', 'TFOOT', 'CENTER']);
4247
+ const isContainer = containerTags.has(el.tagName) && !textTags.includes(el.tagName);
2623
4248
  if (isContainer) {
2624
4249
  const computed = win.getComputedStyle(el);
2625
4250
  // === LARGE BLUR ELEMENTS: render as image ===
@@ -2673,160 +4298,676 @@ export function parseSlideHtml(doc) {
2673
4298
  src: conicDataUri,
2674
4299
  position: {
2675
4300
  x: pxToInch(rect.left),
2676
- y: pxToInch(rect.top),
4301
+ y: pxToInch(rect.top),
4302
+ w: pxToInch(rect.width),
4303
+ h: pxToInch(rect.height),
4304
+ },
4305
+ sizing: null,
4306
+ rectRadius: 0,
4307
+ };
4308
+ elements.push(imgElement);
4309
+ hasConicGradientImage = true;
4310
+ // Don't mark as fully processed — children (text, pseudo-elements) still need extraction
4311
+ // Just skip the shape creation for this element's background
4312
+ }
4313
+ }
4314
+ }
4315
+ // === CSS TRIANGLE DETECTION ===
4316
+ // Detect CSS triangle technique: width:0; height:0 with border-based coloring.
4317
+ // Pattern: two transparent borders (left+right) and one colored border (top or bottom)
4318
+ // creates a triangle pointing in the direction of the colored border.
4319
+ // Use clientWidth/clientHeight which give content box size (excluding borders).
4320
+ // For CSS triangles, the content box is 0x0 but borders create the visual shape.
4321
+ const contentWidth = htmlEl.clientWidth;
4322
+ const contentHeight = htmlEl.clientHeight;
4323
+ const borderTopW = parseFloat(computed.borderTopWidth) || 0;
4324
+ const borderRightW = parseFloat(computed.borderRightWidth) || 0;
4325
+ const borderBottomW = parseFloat(computed.borderBottomWidth) || 0;
4326
+ const borderLeftW = parseFloat(computed.borderLeftWidth) || 0;
4327
+ // Check if this is a CSS triangle: zero content area with borders
4328
+ if (contentWidth === 0 && contentHeight === 0 &&
4329
+ (borderTopW > 0 || borderBottomW > 0 || borderLeftW > 0 || borderRightW > 0)) {
4330
+ // A border is "effectively transparent" if its width is 0 OR its color is fully transparent
4331
+ const isTopEffectivelyTransparent = borderTopW === 0 || isFullyTransparent(computed.borderTopColor);
4332
+ const isBottomEffectivelyTransparent = borderBottomW === 0 || isFullyTransparent(computed.borderBottomColor);
4333
+ const isLeftEffectivelyTransparent = borderLeftW === 0 || isFullyTransparent(computed.borderLeftColor);
4334
+ const isRightEffectivelyTransparent = borderRightW === 0 || isFullyTransparent(computed.borderRightColor);
4335
+ // Determine triangle direction based on which border is colored
4336
+ let triangleDirection = null;
4337
+ let triangleColor = '';
4338
+ let triangleTransparency = 0;
4339
+ let triangleWidth = 0; // horizontal extent
4340
+ let triangleHeight = 0; // vertical extent
4341
+ // Triangle pointing UP: border-bottom is colored, left+right transparent (or zero-width)
4342
+ if (!isBottomEffectivelyTransparent && isLeftEffectivelyTransparent && isRightEffectivelyTransparent && isTopEffectivelyTransparent) {
4343
+ triangleDirection = 'up';
4344
+ triangleColor = rgbToHex(computed.borderBottomColor);
4345
+ triangleTransparency = extractAlpha(computed.borderBottomColor) ?? 0;
4346
+ triangleWidth = borderLeftW + borderRightW;
4347
+ triangleHeight = borderBottomW;
4348
+ }
4349
+ // Triangle pointing DOWN: border-top is colored, left+right transparent (or zero-width)
4350
+ else if (!isTopEffectivelyTransparent && isLeftEffectivelyTransparent && isRightEffectivelyTransparent && isBottomEffectivelyTransparent) {
4351
+ triangleDirection = 'down';
4352
+ triangleColor = rgbToHex(computed.borderTopColor);
4353
+ triangleTransparency = extractAlpha(computed.borderTopColor) ?? 0;
4354
+ triangleWidth = borderLeftW + borderRightW;
4355
+ triangleHeight = borderTopW;
4356
+ }
4357
+ // Triangle pointing LEFT: border-right is colored, top+bottom transparent (or zero-width)
4358
+ else if (!isRightEffectivelyTransparent && isTopEffectivelyTransparent && isBottomEffectivelyTransparent && isLeftEffectivelyTransparent) {
4359
+ triangleDirection = 'left';
4360
+ triangleColor = rgbToHex(computed.borderRightColor);
4361
+ triangleTransparency = extractAlpha(computed.borderRightColor) ?? 0;
4362
+ triangleWidth = borderRightW;
4363
+ triangleHeight = borderTopW + borderBottomW;
4364
+ }
4365
+ // Triangle pointing RIGHT: border-left is colored, top+bottom transparent (or zero-width)
4366
+ else if (!isLeftEffectivelyTransparent && isTopEffectivelyTransparent && isBottomEffectivelyTransparent && isRightEffectivelyTransparent) {
4367
+ triangleDirection = 'right';
4368
+ triangleColor = rgbToHex(computed.borderLeftColor);
4369
+ triangleTransparency = extractAlpha(computed.borderLeftColor) ?? 0;
4370
+ triangleWidth = borderLeftW;
4371
+ triangleHeight = borderTopW + borderBottomW;
4372
+ }
4373
+ if (triangleDirection && triangleWidth > 0 && triangleHeight > 0) {
4374
+ // Get the element's bounding rect for position
4375
+ const triRect = htmlEl.getBoundingClientRect();
4376
+ // The bounding rect for a CSS triangle is the border-box
4377
+ const triX = pxToInch(triRect.left);
4378
+ const triY = pxToInch(triRect.top);
4379
+ const triW = pxToInch(triangleWidth);
4380
+ const triH = pxToInch(triangleHeight);
4381
+ // Extract rotation
4382
+ const triRotation = extractRotationAngle(computed);
4383
+ const triangleShape = {
4384
+ type: 'shape',
4385
+ text: '',
4386
+ textRuns: null,
4387
+ style: null,
4388
+ position: { x: triX, y: triY, w: triW, h: triH },
4389
+ shape: {
4390
+ fill: triangleColor,
4391
+ gradient: null,
4392
+ transparency: triangleTransparency > 0 ? triangleTransparency : null,
4393
+ line: null,
4394
+ rectRadius: 0,
4395
+ shadow: null,
4396
+ opacity: null,
4397
+ isEllipse: false,
4398
+ softEdge: null,
4399
+ rotate: triRotation,
4400
+ cssTriangle: { direction: triangleDirection },
4401
+ customGeometry: null,
4402
+ },
4403
+ };
4404
+ elements.push(triangleShape);
4405
+ processed.add(el);
4406
+ return;
4407
+ }
4408
+ }
4409
+ const hasBg = computed.backgroundColor && computed.backgroundColor !== 'rgba(0, 0, 0, 0)';
4410
+ // Check for clip-path: polygon() — store for custom geometry
4411
+ const clipPathValue = computed.clipPath || computed.webkitClipPath || '';
4412
+ const clipPathPolygon = parseClipPathPolygon(clipPathValue);
4413
+ // Check for background images or gradients
4414
+ const elBgImage = computed.backgroundImage;
4415
+ let bgImageUrl = null;
4416
+ let bgImageSize = null;
4417
+ let bgImagePosition = null;
4418
+ let bgGradient = null;
4419
+ const extraDivGradients = [];
4420
+ if (elBgImage && elBgImage !== 'none') {
4421
+ if (elBgImage.includes('linear-gradient') ||
4422
+ elBgImage.includes('radial-gradient')) {
4423
+ const gradParts = splitCssBackgroundGradients(elBgImage);
4424
+ // Detect complex repeating-pattern backgrounds (4+ gradient layers,
4425
+ // often combined with a small background-size for tiling). These CSS
4426
+ // patterns (hex grids, noise textures, etc.) cannot be faithfully
4427
+ // reproduced as PPTX gradients, so we rasterize them to a canvas image.
4428
+ const bgSize = computed.backgroundSize;
4429
+ const isRepeatingPattern = gradParts.length >= 4 &&
4430
+ bgSize &&
4431
+ bgSize !== 'auto' &&
4432
+ !bgSize.includes('100%');
4433
+ if (isRepeatingPattern) {
4434
+ // Complex repeating CSS pattern — rasterize to canvas image.
4435
+ // PPTX gradients cannot represent tiled multi-layer patterns like
4436
+ // hex grids, but the Canvas 2D API can draw them faithfully.
4437
+ const rect = htmlEl.getBoundingClientRect();
4438
+ const patternDataUri = renderRepeatingGradientPatternAsImage(elBgImage, bgSize, computed.backgroundPosition || '0px 0px', rect.width, rect.height, win.document);
4439
+ if (patternDataUri) {
4440
+ const effOp = getEffectiveOpacity(htmlEl, win);
4441
+ const imgElement = {
4442
+ type: 'image',
4443
+ src: patternDataUri,
4444
+ position: {
4445
+ x: pxToInch(rect.left),
4446
+ y: pxToInch(rect.top),
4447
+ w: pxToInch(rect.width),
4448
+ h: pxToInch(rect.height),
4449
+ },
4450
+ sizing: null,
4451
+ rectRadius: 0,
4452
+ transparency: effOp < 1 ? Math.round((1 - effOp) * 100) : undefined,
4453
+ };
4454
+ elements.push(imgElement);
4455
+ }
4456
+ bgGradient = null;
4457
+ }
4458
+ else if (gradParts.length > 0) {
4459
+ bgGradient = parseCssGradient(gradParts[0]);
4460
+ // Collect additional gradient layers as separate shapes (up to 3 extra)
4461
+ for (let gi = 1; gi < Math.min(gradParts.length, 4); gi++) {
4462
+ const g = parseCssGradient(gradParts[gi]);
4463
+ if (g)
4464
+ extraDivGradients.push(g);
4465
+ }
4466
+ }
4467
+ }
4468
+ else {
4469
+ const urlMatch = elBgImage.match(/url\(["']?([^"')]+)["']?\)/);
4470
+ if (urlMatch) {
4471
+ bgImageUrl = urlMatch[1];
4472
+ bgImageSize = computed.backgroundSize || 'auto';
4473
+ bgImagePosition = computed.backgroundPosition || '0% 0%';
4474
+ }
4475
+ }
4476
+ }
4477
+ // Check for borders
4478
+ const borderTop = computed.borderTopWidth;
4479
+ const borderRight = computed.borderRightWidth;
4480
+ const borderBottom = computed.borderBottomWidth;
4481
+ const borderLeft = computed.borderLeftWidth;
4482
+ const borders = [borderTop, borderRight, borderBottom, borderLeft].map((b) => parseFloat(b) || 0);
4483
+ const hasBorder = borders.some((b) => b > 0);
4484
+ const hasUniformBorder = hasBorder && borders.every((b) => b === borders[0]);
4485
+ const borderShapes = [];
4486
+ // Parse border-radius for non-uniform border handling
4487
+ const divBorderRadius = parseFloat(computed.borderRadius) || 0;
4488
+ // For non-uniform borders, only create accent shapes for the thicker borders (border-left/border-right).
4489
+ // Don't add the base border to the shape's line property - the thin semi-transparent borders
4490
+ // are often barely visible in HTML and should be skipped for better visual fidelity.
4491
+ if (hasBorder && !hasUniformBorder) {
4492
+ const rect = htmlEl.getBoundingClientRect();
4493
+ // Find the minimum border width among top/right/bottom (excluding left for comparison)
4494
+ const topRightBottomBorders = [borders[0], borders[1], borders[2]].filter(b => b > 0);
4495
+ const minTopRightBottom = topRightBottomBorders.length > 0 ? Math.min(...topRightBottomBorders) : 0;
4496
+ // Find the minimum border width among top/left/bottom (excluding right for comparison)
4497
+ const topLeftBottomBorders = [borders[0], borders[3], borders[2]].filter(b => b > 0);
4498
+ const minTopLeftBottom = topLeftBottomBorders.length > 0 ? Math.min(...topLeftBottomBorders) : 0;
4499
+ // Get colors for comparison
4500
+ const borderLeftHex = rgbToHex(computed.borderLeftColor);
4501
+ const borderRightHex = rgbToHex(computed.borderRightColor);
4502
+ const borderTopHex = rgbToHex(computed.borderTopColor);
4503
+ // Get alpha values - border-left might have higher opacity than semi-transparent top/bottom
4504
+ const borderLeftAlpha = extractAlpha(computed.borderLeftColor);
4505
+ const borderTopAlpha = extractAlpha(computed.borderTopColor);
4506
+ const borderRightAlpha = extractAlpha(computed.borderRightColor);
4507
+ // Border-left accent: create a filled shape when:
4508
+ // 1. border-left is thicker than the minimum of other borders, OR
4509
+ // 2. border-left is the only border on this element, OR
4510
+ // 3. border-left has a different RGB color than the base shape line (top border), OR
4511
+ // 4. border-left is more opaque than the base shape line (even if same RGB)
4512
+ const borderLeftW = borders[3];
4513
+ const borderLeftHasDifferentColor = borderTopHex !== borderLeftHex;
4514
+ const borderLeftIsMoreOpaque = (borderLeftAlpha ?? 0) < (borderTopAlpha ?? 0); // lower value = more opaque
4515
+ if (borderLeftW > 0 && !isFullyTransparent(computed.borderLeftColor) &&
4516
+ (borderLeftW > minTopRightBottom || minTopRightBottom === 0 || borderLeftHasDifferentColor || borderLeftIsMoreOpaque)) {
4517
+ // If the parent has border-radius, create a custom geometry shape that follows
4518
+ // the parent's arc on the outer (left) edge while keeping the inner (right) edge straight.
4519
+ // A simple rectRadius doesn't work because it curves ALL corners equally.
4520
+ if (divBorderRadius > 0) {
4521
+ // Generate custom geometry for thin vertical bar on LEFT
4522
+ // that follows the parent's arc at the outer (left) edge
4523
+ const w = borderLeftW;
4524
+ const h = rect.height;
4525
+ const r = divBorderRadius;
4526
+ const EMU_PER_PX = 914400 / 96;
4527
+ // arcYAtX: at x distance from the left edge, how far is the arc from the top/bottom?
4528
+ const arcYAtX = (radius, x) => {
4529
+ if (x <= 0)
4530
+ return radius;
4531
+ if (x >= radius)
4532
+ return 0;
4533
+ const dx = radius - x;
4534
+ return radius - Math.sqrt(radius * radius - dx * dx);
4535
+ };
4536
+ // Generate intermediate points along the arc
4537
+ const segments = 8;
4538
+ const arcPointsTop = [];
4539
+ const arcPointsBottom = [];
4540
+ for (let i = 0; i <= segments; i++) {
4541
+ const t = i / segments;
4542
+ const x = t * w; // x from 0 to w within the bar
4543
+ const arcDistance = arcYAtX(r, x); // y distance from corner at this x
4544
+ arcPointsTop.push({
4545
+ x: Math.round(x * EMU_PER_PX),
4546
+ y: Math.round(arcDistance * EMU_PER_PX)
4547
+ });
4548
+ arcPointsBottom.push({
4549
+ x: Math.round(x * EMU_PER_PX),
4550
+ y: Math.round((h - arcDistance) * EMU_PER_PX)
4551
+ });
4552
+ }
4553
+ // Build path: start at TL arc (left edge, y=r), go right along TL arc,
4554
+ // down right edge, left along BL arc, up left edge
4555
+ const points = [];
4556
+ points.push({ x: arcPointsTop[0].x, y: arcPointsTop[0].y, moveTo: true });
4557
+ // TL arc going right (toward the right edge of the border)
4558
+ for (let i = 1; i <= segments; i++) {
4559
+ points.push({ x: arcPointsTop[i].x, y: arcPointsTop[i].y });
4560
+ }
4561
+ // Right edge down to BL arc start (straight line)
4562
+ points.push({ x: arcPointsBottom[segments].x, y: arcPointsBottom[segments].y });
4563
+ // BL arc going left (back toward the left edge)
4564
+ for (let i = segments - 1; i >= 0; i--) {
4565
+ points.push({ x: arcPointsBottom[i].x, y: arcPointsBottom[i].y });
4566
+ }
4567
+ // Close back to start
4568
+ points.push({ x: arcPointsTop[0].x, y: arcPointsTop[0].y, close: true });
4569
+ borderShapes.push({
4570
+ type: 'shape',
4571
+ text: '',
4572
+ textRuns: null,
4573
+ style: null,
4574
+ position: {
4575
+ x: pxToInch(rect.left),
4576
+ y: pxToInch(rect.top),
4577
+ w: pxToInch(borderLeftW),
4578
+ h: pxToInch(rect.height),
4579
+ },
4580
+ shape: {
4581
+ fill: rgbToHex(computed.borderLeftColor),
4582
+ gradient: null,
4583
+ transparency: extractAlpha(computed.borderLeftColor),
4584
+ line: null,
4585
+ rectRadius: 0, // Using custom geometry instead
4586
+ shadow: null,
4587
+ opacity: null,
4588
+ isEllipse: false,
4589
+ softEdge: null,
4590
+ rotate: null,
4591
+ cssTriangle: null,
4592
+ customGeometry: points,
4593
+ },
4594
+ });
4595
+ }
4596
+ else {
4597
+ // No border-radius - use a simple straight rectangle
4598
+ borderShapes.push({
4599
+ type: 'shape',
4600
+ text: '',
4601
+ textRuns: null,
4602
+ style: null,
4603
+ position: {
4604
+ x: pxToInch(rect.left),
4605
+ y: pxToInch(rect.top),
4606
+ w: pxToInch(borderLeftW),
4607
+ h: pxToInch(rect.height),
4608
+ },
4609
+ shape: {
4610
+ fill: rgbToHex(computed.borderLeftColor),
4611
+ gradient: null,
4612
+ transparency: extractAlpha(computed.borderLeftColor),
4613
+ line: null,
4614
+ rectRadius: 0,
4615
+ shadow: null,
4616
+ opacity: null,
4617
+ isEllipse: false,
4618
+ softEdge: null,
4619
+ rotate: null,
4620
+ cssTriangle: null,
4621
+ customGeometry: null,
4622
+ },
4623
+ });
4624
+ }
4625
+ }
4626
+ // Border-right accent: create a filled shape when:
4627
+ // 1. border-right is thicker than the minimum of other borders, OR
4628
+ // 2. border-right is the only border on this element, OR
4629
+ // 3. border-right has a different color than the base shape line (top border), OR
4630
+ // 4. border-right is more opaque than the base shape line (even if same RGB)
4631
+ const borderRightW = borders[1];
4632
+ const borderRightHasDifferentColor = borderTopHex !== borderRightHex;
4633
+ const borderRightIsMoreOpaque = (borderRightAlpha ?? 0) < (borderTopAlpha ?? 0); // lower value = more opaque
4634
+ if (borderRightW > 0 && !isFullyTransparent(computed.borderRightColor) &&
4635
+ (borderRightW > minTopLeftBottom || minTopLeftBottom === 0 || borderRightHasDifferentColor || borderRightIsMoreOpaque)) {
4636
+ // Similar to border-left: if parent has border-radius, use custom geometry
4637
+ if (divBorderRadius > 0) {
4638
+ // Generate custom geometry for thin vertical bar on RIGHT
4639
+ // that follows the parent's arc at the outer (right) edge
4640
+ const w = borderRightW;
4641
+ const h = rect.height;
4642
+ const r = divBorderRadius;
4643
+ const EMU_PER_PX = 914400 / 96;
4644
+ // arcYAtX: at x distance from the edge, how far is the arc from the top/bottom?
4645
+ const arcYAtX = (radius, x) => {
4646
+ if (x <= 0)
4647
+ return radius;
4648
+ if (x >= radius)
4649
+ return 0;
4650
+ const dx = radius - x;
4651
+ return radius - Math.sqrt(radius * radius - dx * dx);
4652
+ };
4653
+ // Generate intermediate points along the arc
4654
+ const segments = 8;
4655
+ const arcPointsTop = [];
4656
+ const arcPointsBottom = [];
4657
+ for (let i = 0; i <= segments; i++) {
4658
+ const t = i / segments;
4659
+ const x = t * w; // x from 0 to w within the bar
4660
+ // For right bar, we flip: at x=0 (left edge of bar, toward center), arc is closer to edge
4661
+ // at x=w (right edge, outer), arc is at full radius
4662
+ const arcDistance = arcYAtX(r, w - x); // y distance from corner at this x
4663
+ arcPointsTop.push({
4664
+ x: Math.round(x * EMU_PER_PX),
4665
+ y: Math.round(arcDistance * EMU_PER_PX)
4666
+ });
4667
+ arcPointsBottom.push({
4668
+ x: Math.round(x * EMU_PER_PX),
4669
+ y: Math.round((h - arcDistance) * EMU_PER_PX)
4670
+ });
4671
+ }
4672
+ // Build path: start at left edge TR arc intersection, go right along TR arc,
4673
+ // down right edge, left along BR arc, up left edge
4674
+ const points = [];
4675
+ points.push({ x: arcPointsTop[0].x, y: arcPointsTop[0].y, moveTo: true });
4676
+ // TR arc going right
4677
+ for (let i = 1; i <= segments; i++) {
4678
+ points.push({ x: arcPointsTop[i].x, y: arcPointsTop[i].y });
4679
+ }
4680
+ // Right edge down to BR arc start
4681
+ points.push({ x: arcPointsBottom[segments].x, y: arcPointsBottom[segments].y });
4682
+ // BR arc going left
4683
+ for (let i = segments - 1; i >= 0; i--) {
4684
+ points.push({ x: arcPointsBottom[i].x, y: arcPointsBottom[i].y });
4685
+ }
4686
+ // Close back to start
4687
+ points.push({ x: arcPointsTop[0].x, y: arcPointsTop[0].y, close: true });
4688
+ borderShapes.push({
4689
+ type: 'shape',
4690
+ text: '',
4691
+ textRuns: null,
4692
+ style: null,
4693
+ position: {
4694
+ x: pxToInch(rect.left + rect.width - borderRightW),
4695
+ y: pxToInch(rect.top),
4696
+ w: pxToInch(borderRightW),
4697
+ h: pxToInch(rect.height),
4698
+ },
4699
+ shape: {
4700
+ fill: rgbToHex(computed.borderRightColor),
4701
+ gradient: null,
4702
+ transparency: extractAlpha(computed.borderRightColor),
4703
+ line: null,
4704
+ rectRadius: 0, // Using custom geometry instead
4705
+ shadow: null,
4706
+ opacity: null,
4707
+ isEllipse: false,
4708
+ softEdge: null,
4709
+ rotate: null,
4710
+ cssTriangle: null,
4711
+ customGeometry: points,
4712
+ },
4713
+ });
4714
+ }
4715
+ else {
4716
+ borderShapes.push({
4717
+ type: 'shape',
4718
+ text: '',
4719
+ textRuns: null,
4720
+ style: null,
4721
+ position: {
4722
+ x: pxToInch(rect.left + rect.width - borderRightW),
4723
+ y: pxToInch(rect.top),
4724
+ w: pxToInch(borderRightW),
4725
+ h: pxToInch(rect.height),
4726
+ },
4727
+ shape: {
4728
+ fill: rgbToHex(computed.borderRightColor),
4729
+ gradient: null,
4730
+ transparency: extractAlpha(computed.borderRightColor),
4731
+ line: null,
4732
+ rectRadius: 0,
4733
+ shadow: null,
4734
+ opacity: null,
4735
+ isEllipse: false,
4736
+ softEdge: null,
4737
+ rotate: null,
4738
+ cssTriangle: null,
4739
+ customGeometry: null,
4740
+ },
4741
+ });
4742
+ }
4743
+ }
4744
+ // Find the minimum border width among left/right/bottom (excluding top for comparison)
4745
+ const leftRightBottomBorders = [borders[3], borders[1], borders[2]].filter(b => b > 0);
4746
+ const minLeftRightBottom = leftRightBottomBorders.length > 0 ? Math.min(...leftRightBottomBorders) : 0;
4747
+ // Find the minimum border width among left/right/top (excluding bottom for comparison)
4748
+ const leftRightTopBorders = [borders[3], borders[1], borders[0]].filter(b => b > 0);
4749
+ const minLeftRightTop = leftRightTopBorders.length > 0 ? Math.min(...leftRightTopBorders) : 0;
4750
+ // Border-top accent: create a filled rectangle when border-top is thicker than others
4751
+ // or when border-top is the only border on this element
4752
+ const borderTopW = borders[0];
4753
+ if (borderTopW > 0 && !isFullyTransparent(computed.borderTopColor) &&
4754
+ (borderTopW > minLeftRightBottom || minLeftRightBottom === 0)) {
4755
+ if (divBorderRadius > 0) {
4756
+ // Generate custom geometry for thin horizontal bar on TOP
4757
+ // that follows the parent's arc at the outer (top) edge
4758
+ const w = rect.width;
4759
+ const h = borderTopW;
4760
+ const r = divBorderRadius;
4761
+ const EMU_PER_PX = 914400 / 96;
4762
+ // arcXAtY: at y distance from the top edge, how far is the arc from the left/right?
4763
+ const arcXAtY = (radius, y) => {
4764
+ if (y <= 0)
4765
+ return radius;
4766
+ if (y >= radius)
4767
+ return 0;
4768
+ const dy = radius - y;
4769
+ return radius - Math.sqrt(radius * radius - dy * dy);
4770
+ };
4771
+ const segments = 8;
4772
+ const arcPointsLeft = [];
4773
+ const arcPointsRight = [];
4774
+ for (let i = 0; i <= segments; i++) {
4775
+ const t = i / segments;
4776
+ const y = t * h; // y from 0 to h within the bar
4777
+ const arcDistance = arcXAtY(r, y); // x distance from corner at this y
4778
+ arcPointsLeft.push({
4779
+ x: Math.round(arcDistance * EMU_PER_PX),
4780
+ y: Math.round(y * EMU_PER_PX)
4781
+ });
4782
+ arcPointsRight.push({
4783
+ x: Math.round((w - arcDistance) * EMU_PER_PX),
4784
+ y: Math.round(y * EMU_PER_PX)
4785
+ });
4786
+ }
4787
+ // Build path: start at TL arc (top, x=r), go down along TL arc,
4788
+ // right along bottom edge, up along TR arc, left along top edge
4789
+ const points = [];
4790
+ points.push({ x: arcPointsLeft[0].x, y: arcPointsLeft[0].y, moveTo: true });
4791
+ // TL arc going down
4792
+ for (let i = 1; i <= segments; i++) {
4793
+ points.push({ x: arcPointsLeft[i].x, y: arcPointsLeft[i].y });
4794
+ }
4795
+ // Bottom edge going right to TR arc
4796
+ points.push({ x: arcPointsRight[segments].x, y: arcPointsRight[segments].y });
4797
+ // TR arc going up
4798
+ for (let i = segments - 1; i >= 0; i--) {
4799
+ points.push({ x: arcPointsRight[i].x, y: arcPointsRight[i].y });
4800
+ }
4801
+ // Close back to start
4802
+ points.push({ x: arcPointsLeft[0].x, y: arcPointsLeft[0].y, close: true });
4803
+ borderShapes.push({
4804
+ type: 'shape',
4805
+ text: '',
4806
+ textRuns: null,
4807
+ style: null,
4808
+ position: {
4809
+ x: pxToInch(rect.left),
4810
+ y: pxToInch(rect.top),
4811
+ w: pxToInch(rect.width),
4812
+ h: pxToInch(borderTopW),
4813
+ },
4814
+ shape: {
4815
+ fill: rgbToHex(computed.borderTopColor),
4816
+ gradient: null,
4817
+ transparency: extractAlpha(computed.borderTopColor),
4818
+ line: null,
4819
+ rectRadius: 0,
4820
+ shadow: null,
4821
+ opacity: null,
4822
+ isEllipse: false,
4823
+ softEdge: null,
4824
+ rotate: null,
4825
+ cssTriangle: null,
4826
+ customGeometry: points,
4827
+ },
4828
+ });
4829
+ }
4830
+ else {
4831
+ borderShapes.push({
4832
+ type: 'shape',
4833
+ text: '',
4834
+ textRuns: null,
4835
+ style: null,
4836
+ position: {
4837
+ x: pxToInch(rect.left),
4838
+ y: pxToInch(rect.top),
4839
+ w: pxToInch(rect.width),
4840
+ h: pxToInch(borderTopW),
4841
+ },
4842
+ shape: {
4843
+ fill: rgbToHex(computed.borderTopColor),
4844
+ gradient: null,
4845
+ transparency: extractAlpha(computed.borderTopColor),
4846
+ line: null,
4847
+ rectRadius: 0,
4848
+ shadow: null,
4849
+ opacity: null,
4850
+ isEllipse: false,
4851
+ softEdge: null,
4852
+ rotate: null,
4853
+ cssTriangle: null,
4854
+ customGeometry: null,
4855
+ },
4856
+ });
4857
+ }
4858
+ }
4859
+ // Border-bottom accent: create a filled rectangle when border-bottom is thicker than others
4860
+ // or when border-bottom is the only border on this element
4861
+ const borderBottomW = borders[2];
4862
+ if (borderBottomW > 0 && !isFullyTransparent(computed.borderBottomColor) &&
4863
+ (borderBottomW > minLeftRightTop || minLeftRightTop === 0)) {
4864
+ if (divBorderRadius > 0) {
4865
+ // Generate custom geometry for thin horizontal bar on BOTTOM
4866
+ // that follows the parent's arc at the outer (bottom) edge
4867
+ const w = rect.width;
4868
+ const h = borderBottomW;
4869
+ const r = divBorderRadius;
4870
+ const EMU_PER_PX = 914400 / 96;
4871
+ // arcXAtY: at y distance from the edge, how far is the arc from the left/right?
4872
+ const arcXAtY = (radius, y) => {
4873
+ if (y <= 0)
4874
+ return radius;
4875
+ if (y >= radius)
4876
+ return 0;
4877
+ const dy = radius - y;
4878
+ return radius - Math.sqrt(radius * radius - dy * dy);
4879
+ };
4880
+ const segments = 8;
4881
+ const arcPointsLeft = [];
4882
+ const arcPointsRight = [];
4883
+ for (let i = 0; i <= segments; i++) {
4884
+ const t = i / segments;
4885
+ const y = t * h; // y from 0 to h within the bar
4886
+ // For bottom bar, we flip: at y=0 (top edge of bar, toward center), arc is closer to edge
4887
+ // at y=h (bottom edge, outer), arc is at full radius
4888
+ const arcDistance = arcXAtY(r, h - y);
4889
+ arcPointsLeft.push({
4890
+ x: Math.round(arcDistance * EMU_PER_PX),
4891
+ y: Math.round(y * EMU_PER_PX)
4892
+ });
4893
+ arcPointsRight.push({
4894
+ x: Math.round((w - arcDistance) * EMU_PER_PX),
4895
+ y: Math.round(y * EMU_PER_PX)
4896
+ });
4897
+ }
4898
+ // Build path: start at BL arc (left edge, y=0), go down along BL arc,
4899
+ // right along bottom edge, up along BR arc, left along top edge
4900
+ const points = [];
4901
+ points.push({ x: arcPointsLeft[0].x, y: arcPointsLeft[0].y, moveTo: true });
4902
+ // BL arc going down
4903
+ for (let i = 1; i <= segments; i++) {
4904
+ points.push({ x: arcPointsLeft[i].x, y: arcPointsLeft[i].y });
4905
+ }
4906
+ // Bottom edge going right to BR arc
4907
+ points.push({ x: arcPointsRight[segments].x, y: arcPointsRight[segments].y });
4908
+ // BR arc going up
4909
+ for (let i = segments - 1; i >= 0; i--) {
4910
+ points.push({ x: arcPointsRight[i].x, y: arcPointsRight[i].y });
4911
+ }
4912
+ // Close back to start
4913
+ points.push({ x: arcPointsLeft[0].x, y: arcPointsLeft[0].y, close: true });
4914
+ borderShapes.push({
4915
+ type: 'shape',
4916
+ text: '',
4917
+ textRuns: null,
4918
+ style: null,
4919
+ position: {
4920
+ x: pxToInch(rect.left),
4921
+ y: pxToInch(rect.top + rect.height - borderBottomW),
2677
4922
  w: pxToInch(rect.width),
2678
- h: pxToInch(rect.height),
4923
+ h: pxToInch(borderBottomW),
2679
4924
  },
2680
- sizing: null,
2681
- rectRadius: 0,
2682
- };
2683
- elements.push(imgElement);
2684
- hasConicGradientImage = true;
2685
- // Don't mark as fully processed — children (text, pseudo-elements) still need extraction
2686
- // Just skip the shape creation for this element's background
2687
- }
2688
- }
2689
- }
2690
- const hasBg = computed.backgroundColor && computed.backgroundColor !== 'rgba(0, 0, 0, 0)';
2691
- // Check for clip-path: polygon() — store for custom geometry
2692
- const clipPathValue = computed.clipPath || computed.webkitClipPath || '';
2693
- const clipPathPolygon = parseClipPathPolygon(clipPathValue);
2694
- // Check for background images or gradients
2695
- const elBgImage = computed.backgroundImage;
2696
- let bgImageUrl = null;
2697
- let bgImageSize = null;
2698
- let bgImagePosition = null;
2699
- let bgGradient = null;
2700
- const extraDivGradients = [];
2701
- if (elBgImage && elBgImage !== 'none') {
2702
- if (elBgImage.includes('linear-gradient') ||
2703
- elBgImage.includes('radial-gradient')) {
2704
- const gradParts = splitCssBackgroundGradients(elBgImage);
2705
- // Detect complex repeating-pattern backgrounds (4+ gradient layers,
2706
- // often combined with a small background-size for tiling). These CSS
2707
- // patterns (hex grids, noise textures, etc.) cannot be faithfully
2708
- // reproduced as PPTX gradients, so we rasterize them to a canvas image.
2709
- const bgSize = computed.backgroundSize;
2710
- const isRepeatingPattern = gradParts.length >= 4 &&
2711
- bgSize &&
2712
- bgSize !== 'auto' &&
2713
- !bgSize.includes('100%');
2714
- if (isRepeatingPattern) {
2715
- // Complex repeating CSS pattern — rasterize to canvas image.
2716
- // PPTX gradients cannot represent tiled multi-layer patterns like
2717
- // hex grids, but the Canvas 2D API can draw them faithfully.
2718
- const rect = htmlEl.getBoundingClientRect();
2719
- const patternDataUri = renderRepeatingGradientPatternAsImage(elBgImage, bgSize, computed.backgroundPosition || '0px 0px', rect.width, rect.height, win.document);
2720
- if (patternDataUri) {
2721
- const effOp = getEffectiveOpacity(htmlEl, win);
2722
- const imgElement = {
2723
- type: 'image',
2724
- src: patternDataUri,
2725
- position: {
2726
- x: pxToInch(rect.left),
2727
- y: pxToInch(rect.top),
2728
- w: pxToInch(rect.width),
2729
- h: pxToInch(rect.height),
2730
- },
2731
- sizing: null,
4925
+ shape: {
4926
+ fill: rgbToHex(computed.borderBottomColor),
4927
+ gradient: null,
4928
+ transparency: extractAlpha(computed.borderBottomColor),
4929
+ line: null,
2732
4930
  rectRadius: 0,
2733
- transparency: effOp < 1 ? Math.round((1 - effOp) * 100) : undefined,
2734
- };
2735
- elements.push(imgElement);
2736
- }
2737
- bgGradient = null;
2738
- }
2739
- else if (gradParts.length > 0) {
2740
- bgGradient = parseCssGradient(gradParts[0]);
2741
- // Collect additional gradient layers as separate shapes (up to 3 extra)
2742
- for (let gi = 1; gi < Math.min(gradParts.length, 4); gi++) {
2743
- const g = parseCssGradient(gradParts[gi]);
2744
- if (g)
2745
- extraDivGradients.push(g);
2746
- }
4931
+ shadow: null,
4932
+ opacity: null,
4933
+ isEllipse: false,
4934
+ softEdge: null,
4935
+ rotate: null,
4936
+ cssTriangle: null,
4937
+ customGeometry: points,
4938
+ },
4939
+ });
2747
4940
  }
2748
- }
2749
- else {
2750
- const urlMatch = elBgImage.match(/url\(["']?([^"')]+)["']?\)/);
2751
- if (urlMatch) {
2752
- bgImageUrl = urlMatch[1];
2753
- bgImageSize = computed.backgroundSize || 'auto';
2754
- bgImagePosition = computed.backgroundPosition || '0% 0%';
4941
+ else {
4942
+ borderShapes.push({
4943
+ type: 'shape',
4944
+ text: '',
4945
+ textRuns: null,
4946
+ style: null,
4947
+ position: {
4948
+ x: pxToInch(rect.left),
4949
+ y: pxToInch(rect.top + rect.height - borderBottomW),
4950
+ w: pxToInch(rect.width),
4951
+ h: pxToInch(borderBottomW),
4952
+ },
4953
+ shape: {
4954
+ fill: rgbToHex(computed.borderBottomColor),
4955
+ gradient: null,
4956
+ transparency: extractAlpha(computed.borderBottomColor),
4957
+ line: null,
4958
+ rectRadius: 0,
4959
+ shadow: null,
4960
+ opacity: null,
4961
+ isEllipse: false,
4962
+ softEdge: null,
4963
+ rotate: null,
4964
+ cssTriangle: null,
4965
+ customGeometry: null,
4966
+ },
4967
+ });
2755
4968
  }
2756
4969
  }
2757
4970
  }
2758
- // Check for borders
2759
- const borderTop = computed.borderTopWidth;
2760
- const borderRight = computed.borderRightWidth;
2761
- const borderBottom = computed.borderBottomWidth;
2762
- const borderLeft = computed.borderLeftWidth;
2763
- const borders = [borderTop, borderRight, borderBottom, borderLeft].map((b) => parseFloat(b) || 0);
2764
- const hasBorder = borders.some((b) => b > 0);
2765
- const hasUniformBorder = hasBorder && borders.every((b) => b === borders[0]);
2766
- const borderLines = [];
2767
- if (hasBorder && !hasUniformBorder) {
2768
- const rect = htmlEl.getBoundingClientRect();
2769
- const x = pxToInch(rect.left);
2770
- const y = pxToInch(rect.top);
2771
- const w = pxToInch(rect.width);
2772
- const h = pxToInch(rect.height);
2773
- if (parseFloat(borderTop) > 0 && !isFullyTransparent(computed.borderTopColor)) {
2774
- const widthPt = pxToPoints(borderTop);
2775
- const inset = widthPt / 72 / 2;
2776
- borderLines.push({
2777
- type: 'line',
2778
- x1: x,
2779
- y1: y + inset,
2780
- x2: x + w,
2781
- y2: y + inset,
2782
- width: widthPt,
2783
- color: rgbToHex(computed.borderTopColor),
2784
- transparency: extractAlpha(computed.borderTopColor),
2785
- });
2786
- }
2787
- if (parseFloat(borderRight) > 0 && !isFullyTransparent(computed.borderRightColor)) {
2788
- const widthPt = pxToPoints(borderRight);
2789
- const inset = widthPt / 72 / 2;
2790
- borderLines.push({
2791
- type: 'line',
2792
- x1: x + w - inset,
2793
- y1: y,
2794
- x2: x + w - inset,
2795
- y2: y + h,
2796
- width: widthPt,
2797
- color: rgbToHex(computed.borderRightColor),
2798
- transparency: extractAlpha(computed.borderRightColor),
2799
- });
2800
- }
2801
- if (parseFloat(borderBottom) > 0 && !isFullyTransparent(computed.borderBottomColor)) {
2802
- const widthPt = pxToPoints(borderBottom);
2803
- const inset = widthPt / 72 / 2;
2804
- borderLines.push({
2805
- type: 'line',
2806
- x1: x,
2807
- y1: y + h - inset,
2808
- x2: x + w,
2809
- y2: y + h - inset,
2810
- width: widthPt,
2811
- color: rgbToHex(computed.borderBottomColor),
2812
- transparency: extractAlpha(computed.borderBottomColor),
2813
- });
2814
- }
2815
- if (parseFloat(borderLeft) > 0 && !isFullyTransparent(computed.borderLeftColor)) {
2816
- const widthPt = pxToPoints(borderLeft);
2817
- const inset = widthPt / 72 / 2;
2818
- borderLines.push({
2819
- type: 'line',
2820
- x1: x + inset,
2821
- y1: y,
2822
- x2: x + inset,
2823
- y2: y + h,
2824
- width: widthPt,
2825
- color: rgbToHex(computed.borderLeftColor),
2826
- transparency: extractAlpha(computed.borderLeftColor),
2827
- });
2828
- }
2829
- }
2830
4971
  if (hasBg || hasBorder || bgImageUrl || bgGradient || hasConicGradientImage) {
2831
4972
  const rect = htmlEl.getBoundingClientRect();
2832
4973
  if (rect.width > 0 && rect.height > 0) {
@@ -2854,7 +4995,7 @@ export function parseSlideHtml(doc) {
2854
4995
  elements.push(bgImgElement);
2855
4996
  }
2856
4997
  // Check for text children — include standard text tags plus leaf DIVs with only direct text
2857
- const textTagSet = new Set(['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'SPAN']);
4998
+ const textTagSet = new Set(['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'SPAN', 'PRE']);
2858
4999
  const allChildren = Array.from(el.children);
2859
5000
  // Collect text-bearing children: standard text tags AND leaf DIVs (no child elements, only text nodes)
2860
5001
  const textChildren = allChildren.filter((child) => {
@@ -2884,6 +5025,20 @@ export function parseSlideHtml(doc) {
2884
5025
  return false;
2885
5026
  }
2886
5027
  }
5028
+ // H1-H6 and P elements with backgrounds, borders, or shadows should NOT be merged
5029
+ // as text children. They need to be processed independently as shape elements.
5030
+ if (['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'P'].includes(child.tagName)) {
5031
+ const textTagComputed = win.getComputedStyle(child);
5032
+ const textTagHasBg = textTagComputed.backgroundColor && textTagComputed.backgroundColor !== 'rgba(0, 0, 0, 0)';
5033
+ const textTagHasBorder = (parseFloat(textTagComputed.borderTopWidth) || 0) > 0 ||
5034
+ (parseFloat(textTagComputed.borderRightWidth) || 0) > 0 ||
5035
+ (parseFloat(textTagComputed.borderBottomWidth) || 0) > 0 ||
5036
+ (parseFloat(textTagComputed.borderLeftWidth) || 0) > 0;
5037
+ const textTagHasShadow = textTagComputed.boxShadow && textTagComputed.boxShadow !== 'none';
5038
+ if (textTagHasBg || textTagHasBorder || textTagHasShadow) {
5039
+ return false;
5040
+ }
5041
+ }
2887
5042
  return true;
2888
5043
  }
2889
5044
  // Include text-only DIVs: DIVs that contain only inline/text content
@@ -2938,11 +5093,55 @@ export function parseSlideHtml(doc) {
2938
5093
  }
2939
5094
  return false;
2940
5095
  });
2941
- // Non-text children exclude decorative elements (icons, SVGs) for text extraction purposes
2942
- const decorativeTags = new Set(['I', 'SVG', 'CANVAS', 'VIDEO', 'AUDIO', 'IFRAME']);
2943
- const nonTextChildren = allChildren.filter((child) => !textTagSet.has(child.tagName) &&
2944
- !decorativeTags.has(child.tagName) &&
2945
- !(child.tagName === 'DIV' && textChildren.includes(child)));
5096
+ // Non-text children exclude decorative elements (icons, SVGs) for text extraction purposes.
5097
+ // Also exclude empty spacer DIVs (DIVs with no text and no non-spacer children) to prevent
5098
+ // them from blocking text merging in code blocks and similar containers.
5099
+ const decorativeTags = new Set(['I', 'CANVAS', 'VIDEO', 'AUDIO', 'IFRAME']);
5100
+ const nonTextChildren = allChildren.filter((child) => {
5101
+ const childTagUpper = child.tagName.toUpperCase();
5102
+ // Exclude standard text tags
5103
+ if (textTagSet.has(childTagUpper))
5104
+ return false;
5105
+ // Exclude decorative tags (CANVAS, VIDEO, etc. are lowercase in HTML5)
5106
+ if (decorativeTags.has(childTagUpper))
5107
+ return false;
5108
+ // SVG elements with display:block should NOT be excluded - they are structural
5109
+ // and need independent positioning. Only exclude inline SVGs (decorative icons).
5110
+ if (childTagUpper === 'SVG') {
5111
+ const svgComputed = win.getComputedStyle(child);
5112
+ const svgDisplay = svgComputed.display;
5113
+ // Block-level SVGs are structural - don't exclude them
5114
+ if (svgDisplay === 'block')
5115
+ return true;
5116
+ // Inline SVGs are decorative - exclude them
5117
+ return false;
5118
+ }
5119
+ // Exclude DIVs that are textChildren
5120
+ if (child.tagName === 'DIV' && textChildren.includes(child))
5121
+ return false;
5122
+ // Exclude empty spacer DIVs (no text content, no meaningful children)
5123
+ if (child.tagName === 'DIV') {
5124
+ const divText = child.textContent?.trim();
5125
+ if (!divText) {
5126
+ // Check if it has any content children (text OR visual elements like SVG/IMG)
5127
+ // Also check for <i> tags which may contain CSS-rendered icons (e.g., Font Awesome)
5128
+ const childElements = Array.from(child.children);
5129
+ // Note: SVG/CANVAS/VIDEO tagNames are lowercase in HTML5 namespace
5130
+ const hasContentChildren = childElements.some(c => {
5131
+ const tagUpper = c.tagName.toUpperCase();
5132
+ return c.textContent?.trim() ||
5133
+ tagUpper === 'SVG' ||
5134
+ tagUpper === 'IMG' ||
5135
+ tagUpper === 'CANVAS' ||
5136
+ tagUpper === 'VIDEO' ||
5137
+ tagUpper === 'I'; // Font Awesome and other icon fonts use <i> tags
5138
+ });
5139
+ if (!hasContentChildren)
5140
+ return false; // Empty spacer DIV
5141
+ }
5142
+ }
5143
+ return true;
5144
+ });
2946
5145
  // Inline text elements that don't break "simple" text child status
2947
5146
  const inlineTextTagsForSimple = new Set(['STRONG', 'EM', 'B', 'I', 'A', 'BR', 'SPAN', 'MARK', 'SMALL', 'SUB', 'SUP', 'CODE', 'U', 'S', 'Q', 'CITE', 'ABBR', 'TIME', 'DATA']);
2948
5147
  const isSingleTextChild = textChildren.length === 1 &&
@@ -3017,20 +5216,28 @@ export function parseSlideHtml(doc) {
3017
5216
  // Check for direct text nodes (not wrapped in elements) — handles badges like:
3018
5217
  // <div class="badge"><svg>icon</svg> Heated stone floors</div>
3019
5218
  // where "Heated stone floors" is a direct text node, not a child element
5219
+ // Also check for mixed content like:
5220
+ // <div>Plan: <span>1 to add</span>, <span>1 to change</span></div>
5221
+ // where "Plan: " and ", " are direct text nodes mixed with SPAN children
3020
5222
  let directTextContent = '';
3021
- if (!hasTextChildren) {
3022
- for (const node of Array.from(el.childNodes)) {
3023
- if (node.nodeType === Node.TEXT_NODE) {
3024
- directTextContent += node.textContent || '';
3025
- }
3026
- }
3027
- directTextContent = directTextContent.trim();
3028
- // Apply text-transform from parent element to direct text nodes
3029
- if (directTextContent && computed.textTransform && computed.textTransform !== 'none') {
3030
- directTextContent = applyTextTransform(directTextContent, computed.textTransform);
5223
+ for (const node of Array.from(el.childNodes)) {
5224
+ if (node.nodeType === Node.TEXT_NODE) {
5225
+ directTextContent += node.textContent || '';
3031
5226
  }
3032
5227
  }
5228
+ directTextContent = directTextContent.trim();
5229
+ // Apply text-transform from parent element to direct text nodes
5230
+ if (directTextContent && computed.textTransform && computed.textTransform !== 'none') {
5231
+ directTextContent = applyTextTransform(directTextContent, computed.textTransform);
5232
+ }
3033
5233
  const hasDirectText = directTextContent.length > 0;
5234
+ // Check if this element has mixed content (direct text nodes + inline elements like SPANs)
5235
+ // In this case, we should use parseInlineFormatting on the parent to capture everything
5236
+ // Note: Inline formatting elements (EM, STRONG, B, I, etc.) are NOT in textTagSet,
5237
+ // so we need to check for them separately.
5238
+ const inlineFormattingTagsForMixed = new Set(['STRONG', 'EM', 'B', 'I', 'A', 'SPAN', 'MARK', 'SMALL', 'SUB', 'SUP', 'CODE', 'U', 'S', 'Q', 'CITE', 'ABBR', 'TIME', 'DATA']);
5239
+ const hasInlineFormattingChildren = allChildren.some(child => inlineFormattingTagsForMixed.has(child.tagName));
5240
+ const hasMixedInlineContent = hasDirectText && (hasTextChildren || hasInlineFormattingChildren);
3034
5241
  // When a container has BOTH non-text children (styled shapes like icon-circles)
3035
5242
  // AND multiple text children, don't merge the text into the parent shape.
3036
5243
  // The text children have their own positioning via flex/grid layout and should
@@ -3039,21 +5246,72 @@ export function parseSlideHtml(doc) {
3039
5246
  // - There are ONLY text children (no non-text siblings to compete with for space), OR
3040
5247
  // - There's direct text content (text nodes, not child elements)
3041
5248
  //
3042
- // CRITICAL: If the parent styled DIV is a flex-row container with multiple
3043
- // children, the children are laid out horizontally and must be extracted
3044
- // separately with their own positions. Merging them into a single text box
3045
- // would stack them vertically, breaking the layout.
5249
+ // CRITICAL: If the parent styled DIV is a flex container with multiple children,
5250
+ // the children must be extracted separately with their own positions.
5251
+ // For flex-row: children are laid out horizontally.
5252
+ // For flex-column: children are laid out vertically at different Y positions.
5253
+ // Merging them into a single text box would place them at the wrong positions.
3046
5254
  const isParentFlexRow = isFlexContainer &&
3047
5255
  (computed.flexDirection === 'row' || computed.flexDirection === 'row-reverse' || !computed.flexDirection) &&
3048
5256
  allChildren.length > 1;
5257
+ const isParentFlexColumn = isFlexContainer &&
5258
+ (computed.flexDirection === 'column' || computed.flexDirection === 'column-reverse') &&
5259
+ allChildren.length > 1;
3049
5260
  const isParentGrid = display === 'grid' || display === 'inline-grid';
3050
- const shouldMergeText = !isParentFlexRow && !isParentGrid && (hasTextChildren && (isSingleTextChild ||
3051
- nonTextChildren.length === 0) || hasDirectText);
5261
+ const shouldMergeText = !isParentFlexRow && !isParentFlexColumn && !isParentGrid && (hasTextChildren && (isSingleTextChild ||
5262
+ nonTextChildren.length === 0) || hasDirectText || hasMixedInlineContent);
3052
5263
  if (shouldMergeText) {
3053
- if (isSingleTextChild) {
5264
+ // Handle mixed inline content first: direct text nodes mixed with inline elements (SPANs)
5265
+ // Use parseInlineFormatting on the parent element to capture everything
5266
+ if (hasMixedInlineContent) {
5267
+ const isBold = computed.fontWeight === 'bold' ||
5268
+ parseInt(computed.fontWeight) >= 600;
5269
+ const isItalic = computed.fontStyle === 'italic';
5270
+ const isUnderline = computed.textDecoration &&
5271
+ computed.textDecoration.includes('underline');
5272
+ const baseRunOptions = {
5273
+ fontSize: pxToPoints(computed.fontSize),
5274
+ fontFace: extractFontFace(computed.fontFamily),
5275
+ color: rgbToHex(computed.color),
5276
+ bold: isBold,
5277
+ italic: isItalic,
5278
+ underline: isUnderline || false,
5279
+ ...(extractLetterSpacing(computed) !== null ? { charSpacing: extractLetterSpacing(computed) } : {}),
5280
+ ...(extractAlpha(computed.color) !== null ? { transparency: extractAlpha(computed.color) } : {}),
5281
+ ...(() => {
5282
+ const ts = parseTextShadow(computed.textShadow);
5283
+ return ts.glow ? { glow: ts.glow } : {};
5284
+ })(),
5285
+ };
5286
+ shapeTextRuns = [];
5287
+ const textTransformFn = (s) => applyTextTransform(s, computed.textTransform || 'none');
5288
+ parseInlineFormatting(el, baseRunOptions, shapeTextRuns, textTransformFn, win, false);
5289
+ shapeStyle = {
5290
+ fontSize: pxToPoints(computed.fontSize),
5291
+ fontFace: extractFontFace(computed.fontFamily),
5292
+ color: rgbToHex(computed.color),
5293
+ bold: isBold,
5294
+ italic: isItalic,
5295
+ valign: valign,
5296
+ align: align,
5297
+ lineSpacing: pxToPoints(computed.lineHeight) || undefined,
5298
+ margin: [
5299
+ pxToPoints(computed.paddingLeft),
5300
+ pxToPoints(computed.paddingRight),
5301
+ pxToPoints(computed.paddingBottom),
5302
+ pxToPoints(computed.paddingTop),
5303
+ ],
5304
+ ...(extractLetterSpacing(computed) !== null ? { charSpacing: extractLetterSpacing(computed) } : {}),
5305
+ ...(extractAlpha(computed.color) !== null ? { transparency: extractAlpha(computed.color) } : {}),
5306
+ };
5307
+ // Mark all child elements as processed to avoid duplicate extraction
5308
+ el.querySelectorAll('*').forEach(desc => processed.add(desc));
5309
+ }
5310
+ else if (isSingleTextChild) {
3054
5311
  const textEl = textChildren[0];
3055
5312
  const textComputed = win.getComputedStyle(textEl);
3056
- shapeText = getTransformedText(textEl, textComputed);
5313
+ // Check if the text child has inline formatting elements that need to be preserved
5314
+ const hasInlineFormatting = textEl.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');
3057
5315
  let fontFill = null;
3058
5316
  const textBgClip = textComputed.webkitBackgroundClip ||
3059
5317
  textComputed.backgroundClip;
@@ -3126,16 +5384,42 @@ export function parseSlideHtml(doc) {
3126
5384
  shapeStyle.glow = shapeTextShadow.glow;
3127
5385
  if (shapeTextShadow.shadow)
3128
5386
  shapeStyle.textShadow = shapeTextShadow.shadow;
5387
+ // If the text child has inline formatting, use parseInlineFormatting to preserve it
5388
+ if (hasInlineFormatting) {
5389
+ const textTransformFn = (s) => applyTextTransform(s, textComputed.textTransform || 'none');
5390
+ const baseRunOptions = {
5391
+ fontSize: shapeStyle.fontSize,
5392
+ fontFace: shapeStyle.fontFace,
5393
+ color: shapeStyle.color ?? undefined,
5394
+ bold: shapeStyle.bold,
5395
+ };
5396
+ // Check if whitespace should be preserved (white-space: pre or monospace font)
5397
+ // Use parent DIV's computed style if it has a monospace font (for code blocks
5398
+ // where transform.ts wrapped text in P tags that inherit non-monospace fonts)
5399
+ const textPreserveWs = shouldPreserveWhitespace(textComputed) || isMonospaceFont(computed.fontFamily);
5400
+ shapeTextRuns = parseInlineFormatting(textEl, baseRunOptions, [], textTransformFn, win, textPreserveWs);
5401
+ // Clear shapeText since we're using textRuns instead
5402
+ shapeText = '';
5403
+ }
5404
+ else {
5405
+ // Also check parent's font for whitespace preservation in non-inline-formatting case
5406
+ const effectivePreserveWs = shouldPreserveWhitespace(textComputed) || isMonospaceFont(computed.fontFamily);
5407
+ shapeText = getTransformedText(textEl, effectivePreserveWs ? computed : textComputed);
5408
+ }
3129
5409
  processed.add(textEl);
3130
5410
  // Also mark descendants as processed (e.g., <p> from transform.ts wrapping)
3131
5411
  textEl.querySelectorAll('*').forEach(desc => processed.add(desc));
3132
5412
  }
3133
5413
  else if (hasTextChildren) {
3134
5414
  shapeTextRuns = [];
5415
+ // Check if parent has monospace font (for whitespace preservation in code blocks)
5416
+ const parentHasMonospace = isMonospaceFont(computed.fontFamily);
3135
5417
  textChildren.forEach((textChild, idx) => {
3136
5418
  const textEl = textChild;
3137
5419
  const textComputed = win.getComputedStyle(textEl);
3138
- const fullText = getTransformedText(textEl, textComputed);
5420
+ // Use parent's computed style for whitespace preservation if parent has monospace font
5421
+ const effectiveWsStyle = parentHasMonospace ? computed : textComputed;
5422
+ const fullText = getTransformedText(textEl, effectiveWsStyle);
3139
5423
  if (!fullText)
3140
5424
  return;
3141
5425
  const isBold = textComputed.fontWeight === 'bold' ||
@@ -3156,44 +5440,63 @@ export function parseSlideHtml(doc) {
3156
5440
  return ts.glow ? { glow: ts.glow } : {};
3157
5441
  })(),
3158
5442
  };
3159
- // Check if this element contains <br> tags split into multiple runs with line breaks
3160
- const hasBrChildren = textEl.querySelector('br') !== null;
3161
- if (hasBrChildren) {
3162
- // Walk child nodes to split text at <br> boundaries
3163
- let segments = [];
3164
- let currentSegment = '';
3165
- for (const node of Array.from(textEl.childNodes)) {
3166
- if (node.tagName === 'BR') {
3167
- segments.push(currentSegment.trim());
3168
- currentSegment = '';
5443
+ // Check if this text child has inline formatting elements that need to be preserved
5444
+ const hasInlineFormattingInChild = textEl.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');
5445
+ if (hasInlineFormattingInChild) {
5446
+ // Use parseInlineFormatting to preserve inline styling (colors, bold, italic, etc.)
5447
+ const textTransformFn = (s) => applyTextTransform(s, textComputed.textTransform || 'none');
5448
+ // Add line break before this text child's runs (if not the first)
5449
+ if (idx > 0 && shapeTextRuns.length > 0) {
5450
+ // Mark the last run to have a line break after it
5451
+ const lastRun = shapeTextRuns[shapeTextRuns.length - 1];
5452
+ lastRun.options = { ...lastRun.options, breakLine: true };
5453
+ }
5454
+ // Check if whitespace should be preserved (white-space: pre or monospace font)
5455
+ // Also check parent DIV's font for code blocks where transform.ts wraps text
5456
+ const childPreserveWs = shouldPreserveWhitespace(textComputed) || isMonospaceFont(computed.fontFamily);
5457
+ parseInlineFormatting(textEl, baseRunOptions, shapeTextRuns, textTransformFn, win, childPreserveWs);
5458
+ }
5459
+ else {
5460
+ // No inline formatting - use simple text extraction
5461
+ // Check if this element contains <br> tags — split into multiple runs with line breaks
5462
+ const hasBrChildren = textEl.querySelector('br') !== null;
5463
+ if (hasBrChildren) {
5464
+ // Walk child nodes to split text at <br> boundaries
5465
+ let segments = [];
5466
+ let currentSegment = '';
5467
+ for (const node of Array.from(textEl.childNodes)) {
5468
+ if (node.tagName === 'BR') {
5469
+ segments.push(currentSegment.trim());
5470
+ currentSegment = '';
5471
+ }
5472
+ else {
5473
+ currentSegment += node.textContent || '';
5474
+ }
3169
5475
  }
3170
- else {
3171
- currentSegment += node.textContent || '';
5476
+ if (currentSegment.trim())
5477
+ segments.push(currentSegment.trim());
5478
+ segments = segments.filter(s => s.length > 0);
5479
+ // Apply text-transform to each segment
5480
+ if (textComputed.textTransform && textComputed.textTransform !== 'none') {
5481
+ segments = segments.map(s => applyTextTransform(s, textComputed.textTransform));
3172
5482
  }
5483
+ segments.forEach((segment, segIdx) => {
5484
+ const prefix = (segIdx === 0 && idx > 0 && shapeTextRuns.length > 0) ? '\n' : '';
5485
+ const runText = prefix + segment;
5486
+ const options = { ...baseRunOptions };
5487
+ if (segIdx > 0) {
5488
+ options.softBreakBefore = true;
5489
+ }
5490
+ shapeTextRuns.push({ text: runText, options });
5491
+ });
3173
5492
  }
3174
- if (currentSegment.trim())
3175
- segments.push(currentSegment.trim());
3176
- segments = segments.filter(s => s.length > 0);
3177
- // Apply text-transform to each segment
3178
- if (textComputed.textTransform && textComputed.textTransform !== 'none') {
3179
- segments = segments.map(s => applyTextTransform(s, textComputed.textTransform));
3180
- }
3181
- segments.forEach((segment, segIdx) => {
3182
- const prefix = (segIdx === 0 && idx > 0 && shapeTextRuns.length > 0) ? '\n' : '';
3183
- const runText = prefix + segment;
3184
- const options = { ...baseRunOptions };
3185
- if (segIdx > 0) {
3186
- options.softBreakBefore = true;
3187
- }
5493
+ else {
5494
+ const runText = idx > 0 && shapeTextRuns.length > 0 ? '\n' + fullText : fullText;
5495
+ const options = idx > 0 && shapeTextRuns.length > 0
5496
+ ? { ...baseRunOptions, breakLine: true }
5497
+ : baseRunOptions;
3188
5498
  shapeTextRuns.push({ text: runText, options });
3189
- });
3190
- }
3191
- else {
3192
- const runText = idx > 0 && shapeTextRuns.length > 0 ? '\n' + fullText : fullText;
3193
- const options = idx > 0 && shapeTextRuns.length > 0
3194
- ? { ...baseRunOptions, breakLine: true }
3195
- : baseRunOptions;
3196
- shapeTextRuns.push({ text: runText, options });
5499
+ }
3197
5500
  }
3198
5501
  processed.add(textEl);
3199
5502
  // Also mark all descendant elements as processed (e.g., <p> tags injected
@@ -3228,6 +5531,7 @@ export function parseSlideHtml(doc) {
3228
5531
  valign: valign,
3229
5532
  inset: 0,
3230
5533
  wrap: !shouldNotWrap,
5534
+ ...(extractAlpha(computed.color) !== null ? { transparency: extractAlpha(computed.color) } : {}),
3231
5535
  };
3232
5536
  // Extract letter-spacing for direct text
3233
5537
  const ls = extractLetterSpacing(computed);
@@ -3235,7 +5539,10 @@ export function parseSlideHtml(doc) {
3235
5539
  shapeStyle.charSpacing = ls;
3236
5540
  }
3237
5541
  }
3238
- if (hasBg || hasUniformBorder || bgGradient) {
5542
+ // Create shape element if there's a visible background/border/gradient,
5543
+ // OR if there's text content with a border (even non-uniform, like border-top only)
5544
+ const hasTextToRender = shapeText || (shapeTextRuns && shapeTextRuns.length > 0);
5545
+ if (hasBg || hasUniformBorder || bgGradient || (hasBorder && hasTextToRender)) {
3239
5546
  // Detect effective opacity (including ancestor chain)
3240
5547
  const elementOpacity = getEffectiveOpacity(htmlEl, win);
3241
5548
  const hasOpacity = elementOpacity < 1;
@@ -3295,11 +5602,596 @@ export function parseSlideHtml(doc) {
3295
5602
  shapeY = cy - origH / 2;
3296
5603
  }
3297
5604
  }
5605
+ // --- Overflow clipping with rounded corners ---
5606
+ // Check for ancestor with overflow:hidden + border-radius.
5607
+ // If the element extends into the rounded corners, we need custom geometry
5608
+ // to clip it properly (since PPTX has no clipping groups).
5609
+ //
5610
+ // IMPORTANT: The browser applies overflow:hidden in two stages:
5611
+ // 1. Clip to parent rectangle (elements extending outside are cropped)
5612
+ // 2. Apply rounded corner clipping (arc intersection in corner zones)
5613
+ //
5614
+ // We must emulate both: first adjust shape bounds to fit within parent,
5615
+ // then generate custom geometry for any corners that need arc clipping.
5616
+ let overflowClipGeometry = null;
5617
+ let clippedShapeX = shapeX;
5618
+ let clippedShapeY = shapeY;
5619
+ let clippedShapeW = shapeW;
5620
+ let clippedShapeH = shapeH;
5621
+ let wasClippedToParent = false;
5622
+ // DEBUG: Log overflow clipping detection
5623
+ const DEBUG_OVERFLOW = false; // Set to true for debugging
5624
+ if (DEBUG_OVERFLOW)
5625
+ console.log('[OverflowClip] Processing element:', { shapeX, shapeY, shapeW, shapeH, bg: computed.backgroundColor });
5626
+ let overflowAncestor = htmlEl.parentElement;
5627
+ while (overflowAncestor && overflowAncestor !== doc.body) {
5628
+ const ancestorComputed = win.getComputedStyle(overflowAncestor);
5629
+ const ancestorOverflow = ancestorComputed.overflow;
5630
+ if (ancestorOverflow === 'hidden' || ancestorOverflow === 'clip') {
5631
+ const ancestorBorderRadius = parseFloat(ancestorComputed.borderRadius) || 0;
5632
+ const ancestorRect = overflowAncestor.getBoundingClientRect();
5633
+ if (DEBUG_OVERFLOW)
5634
+ console.log('[OverflowClip] Found overflow ancestor:', { overflow: ancestorOverflow, borderRadius: ancestorBorderRadius, ancestorRect: { left: ancestorRect.left, top: ancestorRect.top, width: ancestorRect.width, height: ancestorRect.height } });
5635
+ // IMPORTANT: getBoundingClientRect() returns VISIBLE bounds after clipping.
5636
+ // To detect if an element was clipped, we compare CSS dimensions to rect dimensions.
5637
+ // If the element has explicit CSS width/height larger than rect, it was clipped.
5638
+ const cssWidth = parseFloat(computed.width);
5639
+ const cssHeight = parseFloat(computed.height);
5640
+ const cssTop = parseFloat(computed.top);
5641
+ const cssRight = parseFloat(computed.right);
5642
+ const cssBottom = parseFloat(computed.bottom);
5643
+ const cssLeft = parseFloat(computed.left);
5644
+ // Determine original element dimensions (before clipping)
5645
+ const origW = !isNaN(cssWidth) && cssWidth > 0 ? cssWidth : shapeW;
5646
+ const origH = !isNaN(cssHeight) && cssHeight > 0 ? cssHeight : shapeH;
5647
+ // Calculate original position relative to ancestor
5648
+ // For position:absolute elements, we can use the CSS positioning values
5649
+ let origRelLeft;
5650
+ let origRelTop;
5651
+ const position = computed.position;
5652
+ if (position === 'absolute' || position === 'fixed') {
5653
+ // For absolute positioning, calculate from CSS properties
5654
+ if (!isNaN(cssLeft)) {
5655
+ origRelLeft = cssLeft;
5656
+ }
5657
+ else if (!isNaN(cssRight)) {
5658
+ origRelLeft = ancestorRect.width - cssRight - origW;
5659
+ }
5660
+ else {
5661
+ origRelLeft = shapeX - ancestorRect.left;
5662
+ }
5663
+ if (!isNaN(cssTop)) {
5664
+ origRelTop = cssTop;
5665
+ }
5666
+ else if (!isNaN(cssBottom)) {
5667
+ origRelTop = ancestorRect.height - cssBottom - origH;
5668
+ }
5669
+ else {
5670
+ origRelTop = shapeY - ancestorRect.top;
5671
+ }
5672
+ }
5673
+ else {
5674
+ // For non-absolute positioning, use rect (best we can do)
5675
+ origRelLeft = shapeX - ancestorRect.left;
5676
+ origRelTop = shapeY - ancestorRect.top;
5677
+ }
5678
+ const origRelRight = origRelLeft + origW;
5679
+ const origRelBottom = origRelTop + origH;
5680
+ if (DEBUG_OVERFLOW) {
5681
+ console.log('[OverflowClip] Element original position:', {
5682
+ origRelLeft, origRelTop, origW, origH,
5683
+ cssTop, cssRight, cssBottom, cssLeft,
5684
+ rectW: shapeW, rectH: shapeH,
5685
+ bgColor: computed.backgroundColor,
5686
+ isEllipse,
5687
+ borderRadius: borderRadiusStr
5688
+ });
5689
+ }
5690
+ // Calculate element position relative to ancestor (using original shape coords)
5691
+ const relLeft = origRelLeft;
5692
+ const relTop = origRelTop;
5693
+ const relRight = origRelRight;
5694
+ const relBottom = origRelBottom;
5695
+ // Step 1: Clip shape to parent rectangle bounds
5696
+ // If element extends outside parent, we need to crop it
5697
+ const needsRectClip = relLeft < 0 ||
5698
+ relTop < 0 ||
5699
+ relRight > ancestorRect.width ||
5700
+ relBottom > ancestorRect.height;
5701
+ if (needsRectClip) {
5702
+ if (DEBUG_OVERFLOW)
5703
+ console.log('[OverflowClip] Element needs rect clipping:', { relLeft, relTop, relRight, relBottom });
5704
+ // Calculate clipped bounds (intersection with parent)
5705
+ const clippedRelLeft = Math.max(0, relLeft);
5706
+ const clippedRelTop = Math.max(0, relTop);
5707
+ const clippedRelRight = Math.min(ancestorRect.width, relRight);
5708
+ const clippedRelBottom = Math.min(ancestorRect.height, relBottom);
5709
+ // Update shape position and size to clipped bounds
5710
+ clippedShapeX = ancestorRect.left + clippedRelLeft;
5711
+ clippedShapeY = ancestorRect.top + clippedRelTop;
5712
+ clippedShapeW = clippedRelRight - clippedRelLeft;
5713
+ clippedShapeH = clippedRelBottom - clippedRelTop;
5714
+ wasClippedToParent = true;
5715
+ }
5716
+ // Step 2: Check for rounded corner intersection (using clipped coordinates)
5717
+ if (ancestorBorderRadius > 0) {
5718
+ const r = ancestorBorderRadius;
5719
+ // Use clipped coordinates for corner detection
5720
+ const clippedRelLeft = clippedShapeX - ancestorRect.left;
5721
+ const clippedRelTop = clippedShapeY - ancestorRect.top;
5722
+ const clippedRelRight = clippedRelLeft + clippedShapeW;
5723
+ const clippedRelBottom = clippedRelTop + clippedShapeH;
5724
+ // Determine which corners the CLIPPED element overlaps
5725
+ const overlapsTL = clippedRelLeft < r && clippedRelTop < r;
5726
+ const overlapsTR = clippedRelRight > ancestorRect.width - r && clippedRelTop < r;
5727
+ const overlapsBL = clippedRelLeft < r && clippedRelBottom > ancestorRect.height - r;
5728
+ const overlapsBR = clippedRelRight > ancestorRect.width - r && clippedRelBottom > ancestorRect.height - r;
5729
+ if ((overlapsTL || overlapsTR || overlapsBL || overlapsBR) && wasClippedToParent) {
5730
+ if (DEBUG_OVERFLOW)
5731
+ console.log('[OverflowClip] Element overlaps corners:', { overlapsTL, overlapsTR, overlapsBL, overlapsBR, clippedRelLeft, clippedRelTop, clippedRelRight, clippedRelBottom, r, isEllipse, wasClippedToParent });
5732
+ // Element overlaps at least one rounded corner - need custom geometry
5733
+ const w = clippedShapeW;
5734
+ const h = clippedShapeH;
5735
+ // EMU conversion
5736
+ const EMU_PER_PX = 914400 / 96;
5737
+ const widthEmu = Math.round(w * EMU_PER_PX);
5738
+ const heightEmu = Math.round(h * EMU_PER_PX);
5739
+ const segments = 8;
5740
+ const points = [];
5741
+ // Generate arc points for a corner, tracing along the arc within the element
5742
+ const addCornerArc = (cornerRelX, // corner center X relative to ancestor (0 for TL/BL, ancestorWidth for TR/BR)
5743
+ cornerRelY, // corner center Y relative to ancestor (0 for TL/TR, ancestorHeight for BL/BR)
5744
+ isRight, // is this a right-side corner
5745
+ isBottom, // is this a bottom corner
5746
+ goingClockwise // direction along the arc
5747
+ ) => {
5748
+ // Calculate the portion of the arc that intersects our element
5749
+ // Arc center is at (cornerRelX ± r, cornerRelY ± r)
5750
+ const arcCenterX = isRight ? cornerRelX - r : cornerRelX + r;
5751
+ const arcCenterY = isBottom ? cornerRelY - r : cornerRelY + r;
5752
+ // Generate arc points from 0 to 90 degrees
5753
+ for (let i = 0; i <= segments; i++) {
5754
+ const t = goingClockwise ? i / segments : 1 - i / segments;
5755
+ const angle = t * Math.PI / 2;
5756
+ // Point on the arc (in arc-center-relative coords)
5757
+ let arcRelX, arcRelY;
5758
+ if (!isRight && !isBottom) {
5759
+ // TL corner: arc goes from (0, -r) to (-r, 0) as t goes 0->1
5760
+ arcRelX = -r * Math.sin(angle);
5761
+ arcRelY = -r * Math.cos(angle);
5762
+ }
5763
+ else if (isRight && !isBottom) {
5764
+ // TR corner: arc goes from (r, 0) to (0, -r) as t goes 0->1
5765
+ arcRelX = r * Math.cos(angle);
5766
+ arcRelY = -r * Math.sin(angle);
5767
+ }
5768
+ else if (!isRight && isBottom) {
5769
+ // BL corner: arc goes from (-r, 0) to (0, r) as t goes 0->1
5770
+ arcRelX = -r * Math.cos(angle);
5771
+ arcRelY = r * Math.sin(angle);
5772
+ }
5773
+ else {
5774
+ // BR corner: arc goes from (0, r) to (r, 0) as t goes 0->1
5775
+ arcRelX = r * Math.sin(angle);
5776
+ arcRelY = r * Math.cos(angle);
5777
+ }
5778
+ // Convert to element-relative coordinates
5779
+ const x = (arcCenterX + arcRelX) - clippedRelLeft;
5780
+ const y = (arcCenterY + arcRelY) - clippedRelTop;
5781
+ // Only include points within element bounds
5782
+ if (x >= -0.1 && x <= w + 0.1 && y >= -0.1 && y <= h + 0.1) {
5783
+ points.push({
5784
+ x: Math.round(Math.max(0, Math.min(w, x)) * EMU_PER_PX),
5785
+ y: Math.round(Math.max(0, Math.min(h, y)) * EMU_PER_PX),
5786
+ ...(points.length === 0 ? { moveTo: true } : {})
5787
+ });
5788
+ }
5789
+ }
5790
+ };
5791
+ // Build the clipped shape path.
5792
+ // The path traces the visible area of the element after clipping.
5793
+ // For each corner that overlaps the parent's rounded corner, we trace an arc.
5794
+ // For edges fully inside the parent, we preserve the element's own shape.
5795
+ //
5796
+ // CRITICAL: If the element itself is an ellipse/circle, we must preserve
5797
+ // its circular shape on edges that aren't clipped by the parent.
5798
+ //
5799
+ // We trace CLOCKWISE starting from top-left for correct winding order
5800
+ if (isEllipse && wasClippedToParent) {
5801
+ // For ellipse/circle elements that were clipped by parent overflow,
5802
+ // generate a custom geometry that preserves the ellipse's curved shape
5803
+ // on the visible portion, while following the clip boundary edges.
5804
+ //
5805
+ // The shape we need to draw:
5806
+ // 1. The visible arc of the ellipse (curved part)
5807
+ // 2. Straight lines along the clip boundaries to close the shape
5808
+ //
5809
+ // Example: circle at top-right, clipped at y=0 and x=w:
5810
+ // - Arc from where it exits at x=w to where it exits at y=0
5811
+ // - Straight line along y=0 to corner (w,0)
5812
+ // - Straight line along x=w back to arc start
5813
+ // Use original ellipse dimensions (already calculated in outer scope)
5814
+ // origW, origH are the original CSS dimensions before clipping
5815
+ const ellipseRx = origW / 2;
5816
+ const ellipseRy = origH / 2;
5817
+ // Ellipse center relative to the CLIPPED shape's coordinate system
5818
+ // relLeft/relTop are original positions, clippedRelLeft/clippedRelTop are clipped
5819
+ const centerXInClipped = relLeft + ellipseRx - clippedRelLeft;
5820
+ const centerYInClipped = relTop + ellipseRy - clippedRelTop;
5821
+ if (DEBUG_OVERFLOW) {
5822
+ console.log('[OverflowClip] Ellipse clipping - origW:', origW, 'origH:', origH);
5823
+ console.log('[OverflowClip] Ellipse clipping - rx:', ellipseRx, 'ry:', ellipseRy);
5824
+ console.log('[OverflowClip] Ellipse clipping - relLeft:', relLeft, 'relTop:', relTop);
5825
+ console.log('[OverflowClip] Ellipse clipping - clippedRelLeft:', clippedRelLeft, 'clippedRelTop:', clippedRelTop);
5826
+ console.log('[OverflowClip] Ellipse clipping - centerX:', centerXInClipped, 'centerY:', centerYInClipped);
5827
+ console.log('[OverflowClip] Ellipse clipping - w:', w, 'h:', h);
5828
+ console.log('[OverflowClip] Ellipse clipping - clippedShapeW:', clippedShapeW, 'clippedShapeH:', clippedShapeH);
5829
+ }
5830
+ // Generate dense arc points for smooth curves
5831
+ const arcSegments = 64;
5832
+ const pathPoints = [];
5833
+ for (let i = 0; i <= arcSegments; i++) {
5834
+ const angle = (i / arcSegments) * 2 * Math.PI;
5835
+ const px = centerXInClipped + ellipseRx * Math.cos(angle);
5836
+ const py = centerYInClipped + ellipseRy * Math.sin(angle);
5837
+ const isInside = px >= -0.5 && px <= w + 0.5 && py >= -0.5 && py <= h + 0.5;
5838
+ if (i > 0) {
5839
+ const prevAngle = ((i - 1) / arcSegments) * 2 * Math.PI;
5840
+ const prevPx = centerXInClipped + ellipseRx * Math.cos(prevAngle);
5841
+ const prevPy = centerYInClipped + ellipseRy * Math.sin(prevAngle);
5842
+ const prevInside = prevPx >= -0.5 && prevPx <= w + 0.5 && prevPy >= -0.5 && prevPy <= h + 0.5;
5843
+ // Crossing boundary - find intersection
5844
+ if (isInside !== prevInside) {
5845
+ // Binary search for exact crossing point
5846
+ let lowT = 0, highT = 1;
5847
+ for (let iter = 0; iter < 10; iter++) {
5848
+ const midT = (lowT + highT) / 2;
5849
+ const midAngle = prevAngle + midT * (angle - prevAngle);
5850
+ const midPx = centerXInClipped + ellipseRx * Math.cos(midAngle);
5851
+ const midPy = centerYInClipped + ellipseRy * Math.sin(midAngle);
5852
+ const midInside = midPx >= -0.5 && midPx <= w + 0.5 && midPy >= -0.5 && midPy <= h + 0.5;
5853
+ if (midInside === prevInside)
5854
+ lowT = midT;
5855
+ else
5856
+ highT = midT;
5857
+ }
5858
+ const crossT = (lowT + highT) / 2;
5859
+ const crossAngle = prevAngle + crossT * (angle - prevAngle);
5860
+ const crossPx = centerXInClipped + ellipseRx * Math.cos(crossAngle);
5861
+ const crossPy = centerYInClipped + ellipseRy * Math.sin(crossAngle);
5862
+ // Determine which edge we crossed
5863
+ let edge = '';
5864
+ if (Math.abs(crossPy) < 1)
5865
+ edge = 'top';
5866
+ else if (Math.abs(crossPy - h) < 1)
5867
+ edge = 'bottom';
5868
+ else if (Math.abs(crossPx) < 1)
5869
+ edge = 'left';
5870
+ else if (Math.abs(crossPx - w) < 1)
5871
+ edge = 'right';
5872
+ pathPoints.push({
5873
+ x: Math.max(0, Math.min(w, crossPx)),
5874
+ y: Math.max(0, Math.min(h, crossPy)),
5875
+ angle: crossAngle,
5876
+ isEntry: isInside,
5877
+ isExit: !isInside,
5878
+ edge
5879
+ });
5880
+ }
5881
+ }
5882
+ if (isInside) {
5883
+ pathPoints.push({
5884
+ x: Math.max(0, Math.min(w, px)),
5885
+ y: Math.max(0, Math.min(h, py)),
5886
+ angle
5887
+ });
5888
+ }
5889
+ }
5890
+ if (pathPoints.length >= 3) {
5891
+ if (DEBUG_OVERFLOW) {
5892
+ console.log('[OverflowClip] pathPoints count:', pathPoints.length);
5893
+ const entryPt = pathPoints.find(p => p.isEntry);
5894
+ const exitPt = pathPoints.find(p => p.isExit);
5895
+ console.log('[OverflowClip] entry:', entryPt);
5896
+ console.log('[OverflowClip] exit:', exitPt);
5897
+ }
5898
+ // Build the final path: arc points + boundary edges
5899
+ const finalPoints = [];
5900
+ // Add all path points (the arc)
5901
+ for (const p of pathPoints) {
5902
+ // Skip duplicates
5903
+ if (finalPoints.length > 0) {
5904
+ const last = finalPoints[finalPoints.length - 1];
5905
+ if (Math.abs(p.x - last.x) < 0.5 && Math.abs(p.y - last.y) < 0.5)
5906
+ continue;
5907
+ }
5908
+ finalPoints.push({ x: p.x, y: p.y });
5909
+ }
5910
+ // Now close the path by following the clip boundary
5911
+ // From the last point (exit) back to the first point (entry)
5912
+ if (finalPoints.length >= 2) {
5913
+ // If exit and entry are on different edges, add corner points
5914
+ const exitEdge = pathPoints.find(p => p.isExit)?.edge;
5915
+ const entryEdge = pathPoints.find(p => p.isEntry)?.edge;
5916
+ if (exitEdge && entryEdge && exitEdge !== entryEdge) {
5917
+ // Add corner point(s) to connect the edges
5918
+ // Order: top -> right -> bottom -> left -> top (clockwise)
5919
+ const edgeOrder = ['top', 'right', 'bottom', 'left'];
5920
+ // Define which corners are rounded (overlapped by element)
5921
+ const cornerHasArc = {
5922
+ 'top-right': overlapsTR && r > 0,
5923
+ 'right-bottom': overlapsBR && r > 0,
5924
+ 'bottom-left': overlapsBL && r > 0,
5925
+ 'left-top': overlapsTL && r > 0
5926
+ };
5927
+ // Corner positions in element-local coordinates
5928
+ // The element is positioned at (clippedRelLeft, clippedRelTop) relative to ancestor
5929
+ const cornerPositions = {
5930
+ 'top-right': {
5931
+ x: ancestorRect.width - clippedRelLeft,
5932
+ y: 0,
5933
+ arcCenterX: ancestorRect.width - r - clippedRelLeft,
5934
+ arcCenterY: r - clippedRelTop
5935
+ },
5936
+ 'right-bottom': {
5937
+ x: ancestorRect.width - clippedRelLeft,
5938
+ y: ancestorRect.height - clippedRelTop,
5939
+ arcCenterX: ancestorRect.width - r - clippedRelLeft,
5940
+ arcCenterY: ancestorRect.height - r - clippedRelTop
5941
+ },
5942
+ 'bottom-left': {
5943
+ x: 0 - clippedRelLeft,
5944
+ y: ancestorRect.height - clippedRelTop,
5945
+ arcCenterX: r - clippedRelLeft,
5946
+ arcCenterY: ancestorRect.height - r - clippedRelTop
5947
+ },
5948
+ 'left-top': {
5949
+ x: 0 - clippedRelLeft,
5950
+ y: 0,
5951
+ arcCenterX: r - clippedRelLeft,
5952
+ arcCenterY: r - clippedRelTop
5953
+ }
5954
+ };
5955
+ let exitIdx = edgeOrder.indexOf(exitEdge);
5956
+ const entryIdxTarget = edgeOrder.indexOf(entryEdge);
5957
+ // Walk clockwise from exit to entry, adding corners or arcs
5958
+ while (exitIdx !== entryIdxTarget) {
5959
+ const nextIdx = (exitIdx + 1) % 4;
5960
+ const cornerKey = `${edgeOrder[exitIdx]}-${edgeOrder[nextIdx]}`;
5961
+ const hasArc = cornerHasArc[cornerKey];
5962
+ const cornerPos = cornerPositions[cornerKey];
5963
+ if (hasArc && cornerPos) {
5964
+ // Add arc points along the parent's corner
5965
+ const arcCX = cornerPos.arcCenterX;
5966
+ const arcCY = cornerPos.arcCenterY;
5967
+ const arcSegs = 8;
5968
+ // Determine start and end angles for this corner arc
5969
+ // top-right: arc from top (270°) to right (0°)
5970
+ // right-bottom: arc from right (0°) to bottom (90°)
5971
+ // bottom-left: arc from bottom (90°) to left (180°)
5972
+ // left-top: arc from left (180°) to top (270°)
5973
+ let startAngle, endAngle;
5974
+ switch (cornerKey) {
5975
+ case 'top-right':
5976
+ startAngle = -Math.PI / 2; // top (-90° = 270°)
5977
+ endAngle = 0; // right
5978
+ break;
5979
+ case 'right-bottom':
5980
+ startAngle = 0; // right
5981
+ endAngle = Math.PI / 2; // bottom
5982
+ break;
5983
+ case 'bottom-left':
5984
+ startAngle = Math.PI / 2; // bottom
5985
+ endAngle = Math.PI; // left
5986
+ break;
5987
+ case 'left-top':
5988
+ startAngle = Math.PI; // left
5989
+ endAngle = Math.PI * 1.5; // top (270°)
5990
+ break;
5991
+ default:
5992
+ startAngle = 0;
5993
+ endAngle = Math.PI / 2;
5994
+ }
5995
+ // Generate arc points
5996
+ for (let ai = 0; ai <= arcSegs; ai++) {
5997
+ const t = ai / arcSegs;
5998
+ const angle = startAngle + t * (endAngle - startAngle);
5999
+ const arcX = arcCX + r * Math.cos(angle);
6000
+ const arcY = arcCY + r * Math.sin(angle);
6001
+ // Only add if within element bounds
6002
+ if (arcX >= -0.5 && arcX <= w + 0.5 && arcY >= -0.5 && arcY <= h + 0.5) {
6003
+ const clampedX = Math.max(0, Math.min(w, arcX));
6004
+ const clampedY = Math.max(0, Math.min(h, arcY));
6005
+ // Skip if too close to last point
6006
+ if (finalPoints.length > 0) {
6007
+ const last = finalPoints[finalPoints.length - 1];
6008
+ if (Math.abs(clampedX - last.x) < 0.5 && Math.abs(clampedY - last.y) < 0.5)
6009
+ continue;
6010
+ }
6011
+ finalPoints.push({ x: clampedX, y: clampedY });
6012
+ }
6013
+ }
6014
+ }
6015
+ else if (cornerPos) {
6016
+ // Add single corner point for non-rounded corner
6017
+ const clampedX = Math.max(0, Math.min(w, cornerPos.x));
6018
+ const clampedY = Math.max(0, Math.min(h, cornerPos.y));
6019
+ finalPoints.push({ x: clampedX, y: clampedY });
6020
+ }
6021
+ exitIdx = nextIdx;
6022
+ }
6023
+ }
6024
+ }
6025
+ // Convert to EMU and create path
6026
+ if (finalPoints.length >= 3) {
6027
+ if (DEBUG_OVERFLOW) {
6028
+ console.log('[OverflowClip] finalPoints count:', finalPoints.length);
6029
+ console.log('[OverflowClip] first 3 points:', JSON.stringify(finalPoints.slice(0, 3)));
6030
+ console.log('[OverflowClip] last 3 points:', JSON.stringify(finalPoints.slice(-3)));
6031
+ }
6032
+ for (let i = 0; i < finalPoints.length; i++) {
6033
+ points.push({
6034
+ x: Math.round(finalPoints[i].x * EMU_PER_PX),
6035
+ y: Math.round(finalPoints[i].y * EMU_PER_PX),
6036
+ ...(i === 0 ? { moveTo: true } : {})
6037
+ });
6038
+ }
6039
+ points.push({ x: points[0].x, y: points[0].y, close: true });
6040
+ }
6041
+ }
6042
+ }
6043
+ else if (!isEllipse) {
6044
+ // Non-circular element: use parent arcs for overlapping corners,
6045
+ // element's own border-radius for non-overlapping corners
6046
+ // Get the element's own border-radius (for corners not clipped by parent)
6047
+ const elementBorderRadius = parseFloat(borderRadiusStr) || 0;
6048
+ const elemR = elementBorderRadius;
6049
+ // Helper function to add element's own corner arc (for non-clipped corners)
6050
+ // The path goes clockwise: TL → (down left edge) → BL → (along bottom) → BR → (up right edge) → TR → (along top) → back to TL
6051
+ const addElementCornerArc = (isRight, isBottom) => {
6052
+ if (elemR <= 0)
6053
+ return; // No radius, skip
6054
+ for (let i = 0; i <= segments; i++) {
6055
+ const t = i / segments;
6056
+ const angle = t * Math.PI / 2;
6057
+ let arcX, arcY;
6058
+ if (!isRight && !isBottom) {
6059
+ // TL corner: arc from (elemR, 0) to (0, elemR) - going clockwise
6060
+ // Center at (elemR, elemR), radius elemR
6061
+ // At t=0: angle=0, point at (elemR, 0)
6062
+ // At t=1: angle=90°, point at (0, elemR)
6063
+ arcX = elemR * Math.cos(angle);
6064
+ arcY = elemR * Math.sin(angle);
6065
+ }
6066
+ else if (!isRight && isBottom) {
6067
+ // BL corner: arc from (0, h-elemR) to (elemR, h) - going clockwise
6068
+ // Center at (elemR, h-elemR), radius elemR
6069
+ // At t=0: angle=180°, point at (0, h-elemR)
6070
+ // At t=1: angle=270°, point at (elemR, h)
6071
+ arcX = elemR - elemR * Math.cos(angle);
6072
+ arcY = (h - elemR) + elemR * Math.sin(angle);
6073
+ }
6074
+ else if (isRight && isBottom) {
6075
+ // BR corner: arc from (w-elemR, h) to (w, h-elemR) - going clockwise
6076
+ // Center at (w-elemR, h-elemR), radius elemR
6077
+ // At t=0: angle=90°, point at (w-elemR, h)
6078
+ // At t=1: angle=0°, point at (w, h-elemR)
6079
+ arcX = (w - elemR) + elemR * Math.sin(angle);
6080
+ arcY = (h - elemR) + elemR * Math.cos(angle);
6081
+ }
6082
+ else {
6083
+ // TR corner: arc from (w, elemR) to (w-elemR, 0) - going clockwise
6084
+ // Center at (w-elemR, elemR), radius elemR
6085
+ // At t=0: angle=0°, point at (w, elemR)
6086
+ // At t=1: angle=-90°, point at (w-elemR, 0)
6087
+ arcX = (w - elemR) + elemR * Math.cos(angle);
6088
+ arcY = elemR - elemR * Math.sin(angle);
6089
+ }
6090
+ points.push({
6091
+ x: Math.round(arcX * EMU_PER_PX),
6092
+ y: Math.round(arcY * EMU_PER_PX),
6093
+ ...(points.length === 0 ? { moveTo: true } : {})
6094
+ });
6095
+ }
6096
+ };
6097
+ // Start at top-left: either parent arc (if overlapping) or element's own arc
6098
+ // Only apply element's own border-radius if the corner is NOT at the clip boundary
6099
+ // (i.e., the element wasn't clipped at that edge)
6100
+ const atLeftClipBoundary = wasClippedToParent && clippedRelLeft <= 0.1;
6101
+ const atTopClipBoundary = wasClippedToParent && clippedRelTop <= 0.1;
6102
+ const atRightClipBoundary = wasClippedToParent && clippedRelRight >= ancestorRect.width - 0.1;
6103
+ const atBottomClipBoundary = wasClippedToParent && clippedRelBottom >= ancestorRect.height - 0.1;
6104
+ if (overlapsTL) {
6105
+ // Arc from (r, 0) -> (0, r) going counter-clockwise
6106
+ addCornerArc(0, 0, false, false, true);
6107
+ }
6108
+ else if (elemR > 0 && !atLeftClipBoundary && !atTopClipBoundary) {
6109
+ // Use element's own border-radius for TL corner (only if not at clip boundary)
6110
+ addElementCornerArc(false, false);
6111
+ }
6112
+ else {
6113
+ points.push({ x: 0, y: 0, moveTo: true });
6114
+ }
6115
+ // Continue down the left edge to bottom-left
6116
+ if (overlapsBL) {
6117
+ // Arc from (0, h-r) -> (r, h) going counter-clockwise
6118
+ addCornerArc(0, ancestorRect.height, false, true, true);
6119
+ }
6120
+ else if (elemR > 0 && !atLeftClipBoundary && !atBottomClipBoundary) {
6121
+ // Use element's own border-radius for BL corner (only if not at clip boundary)
6122
+ addElementCornerArc(false, true);
6123
+ }
6124
+ else {
6125
+ points.push({ x: 0, y: heightEmu });
6126
+ }
6127
+ // Continue along bottom edge to bottom-right
6128
+ if (overlapsBR) {
6129
+ // Arc from (w-r, h) -> (w, h-r) going counter-clockwise
6130
+ addCornerArc(ancestorRect.width, ancestorRect.height, true, true, true);
6131
+ }
6132
+ else if (elemR > 0 && !atRightClipBoundary && !atBottomClipBoundary) {
6133
+ // Use element's own border-radius for BR corner (only if not at clip boundary)
6134
+ addElementCornerArc(true, true);
6135
+ }
6136
+ else {
6137
+ points.push({ x: widthEmu, y: heightEmu });
6138
+ }
6139
+ // Continue up the right edge to top-right
6140
+ if (overlapsTR) {
6141
+ // Arc from (w, r) -> (w-r, 0) going counter-clockwise
6142
+ addCornerArc(ancestorRect.width, 0, true, false, true);
6143
+ }
6144
+ else if (elemR > 0 && !atRightClipBoundary && !atTopClipBoundary) {
6145
+ // Use element's own border-radius for TR corner (only if not at clip boundary)
6146
+ addElementCornerArc(true, false);
6147
+ }
6148
+ else {
6149
+ points.push({ x: widthEmu, y: 0 });
6150
+ }
6151
+ // Close the path back to top-left
6152
+ points.push({ x: 0, y: 0, close: true });
6153
+ }
6154
+ // else: isEllipse but not clipped - don't need custom geometry
6155
+ // Only use custom geometry if we have meaningful arc clipping
6156
+ if (points.length > 5) {
6157
+ overflowClipGeometry = points;
6158
+ if (DEBUG_OVERFLOW)
6159
+ console.log('[OverflowClip] Generated custom geometry with', points.length, 'points');
6160
+ }
6161
+ else {
6162
+ if (DEBUG_OVERFLOW)
6163
+ console.log('[OverflowClip] Not enough points for custom geometry:', points.length);
6164
+ }
6165
+ }
6166
+ }
6167
+ break; // Found the clipping ancestor
6168
+ }
6169
+ overflowAncestor = overflowAncestor.parentElement;
6170
+ }
6171
+ // If shape was clipped to parent bounds, use clipped dimensions
6172
+ // but remember original dimensions for visibility calculations
6173
+ let originalShapeW = shapeW;
6174
+ let originalShapeH = shapeH;
6175
+ if (wasClippedToParent) {
6176
+ originalShapeW = shapeW; // shapeW before update is original from getBoundingClientRect
6177
+ originalShapeH = shapeH;
6178
+ shapeX = clippedShapeX;
6179
+ shapeY = clippedShapeY;
6180
+ shapeW = clippedShapeW;
6181
+ shapeH = clippedShapeH;
6182
+ }
6183
+ // When we have overflow clipping geometry AND text, we need to:
6184
+ // 1. Create the shape without text (fill/geometry only)
6185
+ // 2. Create a separate text shape with the same clipping geometry
6186
+ // This is because PPTX text in shapes is not clipped by custom geometry
6187
+ const hasTextContent = shapeText || (shapeTextRuns && shapeTextRuns.length > 0);
6188
+ const shouldSplitTextFromClippedShape = overflowClipGeometry && hasTextContent;
3298
6189
  const shapeElement = {
3299
6190
  type: 'shape',
3300
- text: shapeText,
3301
- textRuns: shapeTextRuns,
3302
- style: shapeStyle,
6191
+ // If splitting text from clipped shape, don't include text in the main shape
6192
+ text: shouldSplitTextFromClippedShape ? '' : shapeText,
6193
+ textRuns: shouldSplitTextFromClippedShape ? null : shapeTextRuns,
6194
+ style: shouldSplitTextFromClippedShape ? null : shapeStyle,
3303
6195
  position: {
3304
6196
  x: pxToInch(shapeX),
3305
6197
  y: pxToInch(shapeY),
@@ -3340,7 +6232,8 @@ export function parseSlideHtml(doc) {
3340
6232
  isEllipse: isEllipse,
3341
6233
  softEdge: softEdgePt,
3342
6234
  rotate: rotationAngle,
3343
- customGeometry: clipPathPolygon ? (() => {
6235
+ cssTriangle: null,
6236
+ customGeometry: overflowClipGeometry ? overflowClipGeometry : (clipPathPolygon ? (() => {
3344
6237
  // Convert percentage-based polygon points to EMU coordinates
3345
6238
  // relative to the shape's bounding box.
3346
6239
  // PptxGenJS custGeom uses the cx/cy (extent) as the path coordinate space.
@@ -3357,15 +6250,18 @@ export function parseSlideHtml(doc) {
3357
6250
  });
3358
6251
  points.push({ x: 0, y: 0, close: true });
3359
6252
  return points;
3360
- })() : null,
6253
+ })() : null),
3361
6254
  },
6255
+ zIndex: extractZIndex(computed),
3362
6256
  };
3363
6257
  // Apply CSS padding as text body insets when shape has text content
3364
6258
  if (hasPadding && shapeElement.style && (shapeText || (shapeTextRuns && shapeTextRuns.length > 0))) {
3365
6259
  // When there's direct text with sibling elements (e.g., SVG icons),
3366
- // we need to increase the left inset to account for the sibling element width + gap
6260
+ // we need to increase the left inset to account for the sibling element width + gap.
6261
+ // BUT: Skip this for mixed inline content (e.g., "Plan: <span>1 to add</span>, <span>1 to change</span>")
6262
+ // because the spans are PART of the text runs, not separate icons to offset past.
3367
6263
  let effectiveLeftPadding = paddingLeft;
3368
- if (hasDirectText && allChildren.length > 0) {
6264
+ if (hasDirectText && allChildren.length > 0 && !hasMixedInlineContent) {
3369
6265
  // Find preceding sibling elements and calculate total width + gap
3370
6266
  for (const child of allChildren) {
3371
6267
  if (child.nodeType === Node.ELEMENT_NODE) {
@@ -3391,6 +6287,83 @@ export function parseSlideHtml(doc) {
3391
6287
  ];
3392
6288
  }
3393
6289
  elements.push(shapeElement);
6290
+ // When text children exist but weren't merged into the shape (shouldMergeText=false),
6291
+ // un-process them so they can be extracted independently by their tag handlers.
6292
+ // This handles cases like code cards with PRE elements that have sibling DIVs.
6293
+ if (!shouldMergeText && textChildren.length > 0) {
6294
+ textChildren.forEach(tc => {
6295
+ processed.delete(tc);
6296
+ tc.querySelectorAll('*').forEach(desc => processed.delete(desc));
6297
+ });
6298
+ }
6299
+ // If we split text from a clipped shape, create a separate text-only shape
6300
+ // Note: PPTX does NOT clip text to custom geometry paths - text is rendered
6301
+ // within the bounding box regardless of the path shape. So we DON'T apply
6302
+ // custom geometry to the text shape. Instead, we use the clipped rectangular
6303
+ // bounds to constrain the text area to the visible portion.
6304
+ //
6305
+ // If the visible area is too small (less than 50% of original), skip the text
6306
+ // entirely as it won't display properly and will overflow the visible region.
6307
+ if (shouldSplitTextFromClippedShape && overflowClipGeometry) {
6308
+ // Calculate what percentage of the original shape is visible
6309
+ const originalArea = originalShapeW * originalShapeH;
6310
+ const clippedArea = clippedShapeW * clippedShapeH;
6311
+ const visiblePercent = (clippedArea / originalArea) * 100;
6312
+ if (DEBUG_OVERFLOW)
6313
+ console.log('[OverflowClip] Text shape visibility:', {
6314
+ originalShapeW, originalShapeH, originalArea,
6315
+ clippedShapeW, clippedShapeH, clippedArea,
6316
+ visiblePercent: visiblePercent.toFixed(1) + '%'
6317
+ });
6318
+ // Only show text if at least 50% of the shape is visible
6319
+ // This prevents text from overflowing small visible areas
6320
+ if (visiblePercent >= 50) {
6321
+ // Use the clipped dimensions (visible area) for the text shape
6322
+ const clippedTextShape = {
6323
+ type: 'shape',
6324
+ text: shapeText,
6325
+ textRuns: shapeTextRuns,
6326
+ style: shapeStyle,
6327
+ position: {
6328
+ // Use clipped dimensions to constrain text to visible area
6329
+ x: pxToInch(clippedShapeX),
6330
+ y: pxToInch(clippedShapeY),
6331
+ w: pxToInch(clippedShapeW),
6332
+ h: pxToInch(clippedShapeH),
6333
+ },
6334
+ shape: {
6335
+ fill: null, // No fill - just text
6336
+ gradient: null,
6337
+ transparency: null,
6338
+ line: null,
6339
+ rectRadius: 0,
6340
+ shadow: null,
6341
+ opacity: null,
6342
+ isEllipse: false,
6343
+ softEdge: null,
6344
+ rotate: null,
6345
+ cssTriangle: null,
6346
+ customGeometry: null, // NO custom geometry - PPTX doesn't clip text to paths
6347
+ },
6348
+ };
6349
+ // Apply CSS padding as text body insets
6350
+ if (hasPadding && clippedTextShape.style) {
6351
+ clippedTextShape.style.margin = [
6352
+ paddingLeft * PT_PER_PX, // left
6353
+ paddingRight * PT_PER_PX, // right
6354
+ paddingBottom * PT_PER_PX, // bottom
6355
+ paddingTop * PT_PER_PX, // top
6356
+ ];
6357
+ }
6358
+ elements.push(clippedTextShape);
6359
+ if (DEBUG_OVERFLOW)
6360
+ console.log('[OverflowClip] Created separate clipped text shape for:', shapeText);
6361
+ }
6362
+ else {
6363
+ if (DEBUG_OVERFLOW)
6364
+ console.log('[OverflowClip] Skipping text (visible area too small):', shapeText);
6365
+ }
6366
+ }
3394
6367
  // Add additional shape elements for extra gradient layers (multiple CSS background gradients)
3395
6368
  if (extraDivGradients.length > 0) {
3396
6369
  for (const extraGrad of extraDivGradients) {
@@ -3411,6 +6384,7 @@ export function parseSlideHtml(doc) {
3411
6384
  isEllipse: false,
3412
6385
  softEdge: null,
3413
6386
  rotate: null,
6387
+ cssTriangle: null,
3414
6388
  customGeometry: null,
3415
6389
  },
3416
6390
  };
@@ -3458,6 +6432,7 @@ export function parseSlideHtml(doc) {
3458
6432
  isEllipse: false,
3459
6433
  softEdge: null,
3460
6434
  rotate: null,
6435
+ cssTriangle: null,
3461
6436
  customGeometry: null,
3462
6437
  },
3463
6438
  };
@@ -3475,7 +6450,7 @@ export function parseSlideHtml(doc) {
3475
6450
  tc.querySelectorAll('*').forEach(desc => processed.delete(desc));
3476
6451
  });
3477
6452
  }
3478
- elements.push(...borderLines);
6453
+ elements.push(...borderShapes);
3479
6454
  processed.add(el);
3480
6455
  return;
3481
6456
  }
@@ -3640,16 +6615,37 @@ export function parseSlideHtml(doc) {
3640
6615
  const isFlexColumn = (plainDivDisplay === 'flex' || plainDivDisplay === 'inline-flex') &&
3641
6616
  (plainDivFlexDir === 'column' || plainDivFlexDir === 'column-reverse') &&
3642
6617
  childElements.length > 1;
6618
+ // Define baseRunOptions outside the if block so it's available for all text extraction paths
6619
+ const baseRunOptions = {
6620
+ fontSize: pxToPoints(computed2.fontSize),
6621
+ fontFace: extractFontFace(computed2.fontFamily),
6622
+ color: rgbToHex(computed2.color),
6623
+ };
6624
+ if (parseInt(computed2.fontWeight) >= 600)
6625
+ baseRunOptions.bold = true;
6626
+ if (computed2.fontStyle === 'italic')
6627
+ baseRunOptions.italic = true;
6628
+ // Add transparency from color alpha or element-level opacity
6629
+ // Use getEffectiveOpacity to accumulate opacity from all ancestors
6630
+ // (CSS opacity doesn't inherit - child elements report opacity:1 even if parent is 0.2)
6631
+ const colorAlphaTransparency = extractAlpha(computed2.color);
6632
+ const elementOpacityVal = getEffectiveOpacity(el, win);
6633
+ if (colorAlphaTransparency !== null || elementOpacityVal < 1) {
6634
+ // Start with color alpha transparency if present
6635
+ let runTransparency = colorAlphaTransparency !== null ? colorAlphaTransparency : 0;
6636
+ // If effective opacity < 1, combine with color alpha
6637
+ if (elementOpacityVal < 1) {
6638
+ // opacity (0-1) to transparency (0-100): transparency = (1 - opacity) * 100
6639
+ // Combine: final_opacity = existing_opacity * element_opacity
6640
+ const existingOpacity = 1 - (runTransparency / 100);
6641
+ const combinedOpacity = existingOpacity * elementOpacityVal;
6642
+ runTransparency = Math.round((1 - combinedOpacity) * 100);
6643
+ }
6644
+ if (runTransparency > 0) {
6645
+ baseRunOptions.transparency = runTransparency;
6646
+ }
6647
+ }
3643
6648
  if (allChildrenAreInlineText && childElements.length > 0) {
3644
- const baseRunOptions = {
3645
- fontSize: pxToPoints(computed2.fontSize),
3646
- fontFace: extractFontFace(computed2.fontFamily),
3647
- color: rgbToHex(computed2.color),
3648
- };
3649
- if (parseInt(computed2.fontWeight) >= 600)
3650
- baseRunOptions.bold = true;
3651
- if (computed2.fontStyle === 'italic')
3652
- baseRunOptions.italic = true;
3653
6649
  // Apply text-transform function
3654
6650
  let textTransformFn = (s) => s;
3655
6651
  if (computed2.textTransform && computed2.textTransform !== 'none') {
@@ -3679,6 +6675,14 @@ export function parseSlideHtml(doc) {
3679
6675
  const childColor = rgbToHex(childComputed.color);
3680
6676
  if (childColor)
3681
6677
  childRunOptions.color = childColor;
6678
+ const childColorTransparency = extractAlpha(childComputed.color);
6679
+ if (childColorTransparency !== null) {
6680
+ childRunOptions.transparency = childColorTransparency;
6681
+ }
6682
+ else {
6683
+ // Clear any inherited transparency if child color is fully opaque
6684
+ delete childRunOptions.transparency;
6685
+ }
3682
6686
  if (parseInt(childComputed.fontWeight) >= 600) {
3683
6687
  childRunOptions.bold = true;
3684
6688
  }
@@ -3704,15 +6708,17 @@ export function parseSlideHtml(doc) {
3704
6708
  }
3705
6709
  }
3706
6710
  else {
3707
- textRuns = parseInlineFormatting(el, baseRunOptions, [], textTransformFn, win);
6711
+ // Check if whitespace should be preserved (white-space: pre or monospace font)
6712
+ const elPreserveWs = shouldPreserveWhitespace(computed2);
6713
+ textRuns = parseInlineFormatting(el, baseRunOptions, [], textTransformFn, win, elPreserveWs);
3708
6714
  }
3709
6715
  // Fallback to single run if parseInlineFormatting produced nothing
3710
6716
  if (textRuns.length === 0) {
3711
- textRuns = [{ text: extractedText, options: {} }];
6717
+ textRuns = [{ text: extractedText, options: baseRunOptions }];
3712
6718
  }
3713
6719
  }
3714
6720
  else {
3715
- textRuns = [{ text: extractedText, options: {} }];
6721
+ textRuns = [{ text: extractedText, options: baseRunOptions }];
3716
6722
  }
3717
6723
  // Extract padding from computed style for PPTX margin/inset
3718
6724
  const paddingTop = parseFloat(computed2.paddingTop) || 0;
@@ -3747,10 +6753,11 @@ export function parseSlideHtml(doc) {
3747
6753
  : undefined,
3748
6754
  },
3749
6755
  };
3750
- // Check for text transparency
3751
- const textTransparency = extractAlpha(computed2.color);
3752
- if (textTransparency !== null) {
3753
- textElement.style.transparency = textTransparency;
6756
+ // Set element-level transparency (computed from color alpha + CSS opacity)
6757
+ // This mirrors what we set in baseRunOptions.transparency but also
6758
+ // makes it available on the element style for convert.ts
6759
+ if (baseRunOptions.transparency !== undefined && baseRunOptions.transparency > 0) {
6760
+ textElement.style.transparency = baseRunOptions.transparency;
3754
6761
  }
3755
6762
  // Check for letter-spacing
3756
6763
  const ls = extractLetterSpacing(computed2);
@@ -3828,7 +6835,9 @@ export function parseSlideHtml(doc) {
3828
6835
  // Get the text content, offset by padding-left
3829
6836
  const textLeft = liRect.left + liPaddingLeft;
3830
6837
  const textWidth = liRect.width - liPaddingLeft;
3831
- const runs = parseInlineFormatting(liEl, { breakLine: false }, [], (x) => x, win);
6838
+ // Check if whitespace should be preserved (white-space: pre or monospace font)
6839
+ const liPreserveWs = shouldPreserveWhitespace(liComputed);
6840
+ const runs = parseInlineFormatting(liEl, { breakLine: false }, [], (x) => x, win, liPreserveWs);
3832
6841
  if (runs.length === 0)
3833
6842
  return;
3834
6843
  // Strip any leading bullet characters that might have been left over
@@ -3862,7 +6871,11 @@ export function parseSlideHtml(doc) {
3862
6871
  }
3863
6872
  liElements.forEach((li, idx) => {
3864
6873
  const isLast = idx === liElements.length - 1;
3865
- const runs = parseInlineFormatting(li, { breakLine: false }, [], (x) => x, win);
6874
+ // Check if whitespace should be preserved (white-space: pre or monospace font)
6875
+ const liEl = li;
6876
+ const liItemComputed = win.getComputedStyle(liEl);
6877
+ const liItemPreserveWs = shouldPreserveWhitespace(liItemComputed);
6878
+ const runs = parseInlineFormatting(li, { breakLine: false }, [], (x) => x, win, liItemPreserveWs);
3866
6879
  if (runs.length > 0) {
3867
6880
  runs[0].text = runs[0].text.replace(/^[•\-*\u25AA\u25B8]\s*/, '');
3868
6881
  if (hasNativeBullets) {
@@ -3913,9 +6926,56 @@ export function parseSlideHtml(doc) {
3913
6926
  return;
3914
6927
  let rect = htmlEl.getBoundingClientRect();
3915
6928
  const computed = win.getComputedStyle(el);
3916
- let text = getTransformedText(htmlEl, computed);
6929
+ // For P elements wrapped by transform.ts, check if parent DIV has a monospace font
6930
+ // This affects whether whitespace should be preserved (leading spaces/indentation)
6931
+ let effectiveComputedForWhitespace = computed;
6932
+ const pParentEl = htmlEl.parentElement;
6933
+ if (el.tagName === 'P' && pParentEl && pParentEl.tagName === 'DIV') {
6934
+ const pParentChildren = Array.from(pParentEl.children);
6935
+ const nonBrChildren = pParentChildren.filter(c => c.tagName !== 'BR');
6936
+ if (nonBrChildren.length === 1 && nonBrChildren[0] === el) {
6937
+ // This P is a transform.ts wrapper - check if parent has monospace font
6938
+ const pParentComputed = win.getComputedStyle(pParentEl);
6939
+ if (isMonospaceFont(pParentComputed.fontFamily)) {
6940
+ // Use parent's computed style for whitespace preservation check
6941
+ effectiveComputedForWhitespace = pParentComputed;
6942
+ }
6943
+ }
6944
+ }
6945
+ let text = getTransformedText(htmlEl, effectiveComputedForWhitespace);
3917
6946
  if (rect.width === 0 || rect.height === 0 || !text)
3918
6947
  return;
6948
+ // For P elements wrapped by transform.ts, line-height/font-size may be on the parent DIV
6949
+ // (CSS line-height on a parent DIV affects text rendering but P's computed style may differ)
6950
+ let effectiveFontSizeForOverflow = computed.fontSize;
6951
+ let effectiveLineHeightForOverflow = computed.lineHeight;
6952
+ if (el.tagName === 'P' && pParentEl && pParentEl.tagName === 'DIV') {
6953
+ const pParentChildren = Array.from(pParentEl.children);
6954
+ const nonBrChildren = pParentChildren.filter(c => c.tagName !== 'BR');
6955
+ if (nonBrChildren.length === 1 && nonBrChildren[0] === el) {
6956
+ // This P is a transform.ts wrapper - use parent's line-height and font-size
6957
+ const pParentComputedForOverflow = win.getComputedStyle(pParentEl);
6958
+ effectiveFontSizeForOverflow = pParentComputedForOverflow.fontSize;
6959
+ effectiveLineHeightForOverflow = pParentComputedForOverflow.lineHeight;
6960
+ }
6961
+ }
6962
+ // When line-height is smaller than font-size, text can overflow its container box
6963
+ // both upward and downward. We need to:
6964
+ // 1. Expand the box height to fit the actual font size
6965
+ // 2. Shift the y-position DOWN by the overflow amount so the text doesn't overlap with elements above
6966
+ // The overflow amount is (font-size - line-height) because the glyph extends beyond the line box
6967
+ const earlyFontSizePx = parseFloat(effectiveFontSizeForOverflow);
6968
+ const earlyLineHeightPx = parseFloat(effectiveLineHeightForOverflow);
6969
+ if (!isNaN(earlyFontSizePx) && !isNaN(earlyLineHeightPx) && earlyLineHeightPx < earlyFontSizePx) {
6970
+ // Calculate the overflow: when line-height < font-size, text extends beyond the line box
6971
+ // We shift down by the full difference to ensure no overlap with elements above
6972
+ const lineHeightOverflowOffsetPx = earlyFontSizePx - earlyLineHeightPx;
6973
+ // Expand the rect height to fit the font (1.3x font size for breathing room)
6974
+ const minHeightPx = earlyFontSizePx * 1.3;
6975
+ const newHeight = Math.max(rect.height, minHeightPx);
6976
+ // Shift the y-position down by the overflow amount
6977
+ rect = new DOMRect(rect.x, rect.y + lineHeightOverflowOffsetPx, rect.width, newHeight);
6978
+ }
3919
6979
  // For flex containers with multiple children (e.g., LI with icon + text),
3920
6980
  // find the actual text position using a Range on the text nodes.
3921
6981
  // This ensures text is positioned at its visual location, not the container's.
@@ -4034,13 +7094,64 @@ export function parseSlideHtml(doc) {
4034
7094
  valign = 'middle';
4035
7095
  }
4036
7096
  }
7097
+ // When line-height is smaller than font-size, text can overflow its container box.
7098
+ // Set valign to 'top' to anchor text at the top of the box, so any visual overflow
7099
+ // goes downward instead of upward (preventing overlap with elements above).
7100
+ // Note: The box height expansion is done earlier, before getPositionAndSize().
7101
+ // Use effective values from parent DIV for P elements wrapped by transform.ts.
7102
+ if (!isNaN(earlyFontSizePx) && !isNaN(earlyLineHeightPx) && earlyLineHeightPx < earlyFontSizePx) {
7103
+ valign = 'top';
7104
+ }
7105
+ // For P elements that are the only child of a DIV (transform.ts wrapping pattern),
7106
+ // check if the parent DIV has a different explicit color, font, or font-size.
7107
+ // If so, prefer the parent's values to avoid CSS rules like ".container p { color: X; font-family: Y; font-size: Z }"
7108
+ // overriding the intended styling from the parent DIV's class (e.g., ".code-block { font-family: Consolas; font-size: 10.5px }").
7109
+ let effectiveColor = computed.color;
7110
+ let effectiveFontFamily = computed.fontFamily;
7111
+ let effectiveFontSize = computed.fontSize;
7112
+ if (el.tagName === 'P' && parentEl && parentEl.tagName === 'DIV') {
7113
+ // Check if this P is the only significant child of the parent DIV
7114
+ const parentChildren = Array.from(parentEl.children);
7115
+ const nonBrChildren = parentChildren.filter(c => c.tagName !== 'BR');
7116
+ if (nonBrChildren.length === 1 && nonBrChildren[0] === el) {
7117
+ // This P is a transform.ts wrapper - check parent's color and font
7118
+ const parentComputed = win.getComputedStyle(parentEl);
7119
+ // The parent DIV's color may be different if it has an explicit color class/style
7120
+ // Only override if parent's color is different from P's color AND parent's color
7121
+ // is not the default black (which indicates no explicit color on parent)
7122
+ if (parentComputed.color !== computed.color &&
7123
+ parentComputed.color !== 'rgb(0, 0, 0)') {
7124
+ effectiveColor = parentComputed.color;
7125
+ }
7126
+ // Similarly for font-family: if the parent has a different font (especially monospace
7127
+ // for code blocks), prefer the parent's font. This handles cases where CSS rules like
7128
+ // ".gh-body p { font-family: Calibri }" override ".code-block { font-family: Consolas }"
7129
+ if (parentComputed.fontFamily !== computed.fontFamily) {
7130
+ // Check if parent has a monospace font - these typically indicate code blocks
7131
+ // where the font is intentionally different from body text
7132
+ if (isMonospaceFont(parentComputed.fontFamily)) {
7133
+ effectiveFontFamily = parentComputed.fontFamily;
7134
+ }
7135
+ }
7136
+ // Similarly for font-size: if the parent has a different font-size and uses a monospace font,
7137
+ // prefer the parent's font-size. This handles CSS rules like ".gh-body p { font-size: 13px }"
7138
+ // overriding ".code-block { font-size: 10.5px }" for P elements inserted by transform.ts.
7139
+ if (parentComputed.fontSize !== computed.fontSize) {
7140
+ // Check if parent has a monospace font - these typically indicate code blocks
7141
+ // where font-size is intentionally smaller/specific
7142
+ if (isMonospaceFont(parentComputed.fontFamily)) {
7143
+ effectiveFontSize = parentComputed.fontSize;
7144
+ }
7145
+ }
7146
+ }
7147
+ }
4037
7148
  const baseStyle = {
4038
- fontSize: pxToPoints(computed.fontSize),
4039
- fontFace: extractFontFace(computed.fontFamily),
4040
- color: rgbToHex(computed.color),
7149
+ fontSize: pxToPoints(effectiveFontSize),
7150
+ fontFace: extractFontFace(effectiveFontFamily),
7151
+ color: rgbToHex(effectiveColor),
4041
7152
  align: textAlign,
4042
7153
  valign: valign,
4043
- lineSpacing: pxToPoints(computed.fontSize) * lineHeightMultiplier,
7154
+ lineSpacing: pxToPoints(effectiveFontSize) * lineHeightMultiplier,
4044
7155
  paraSpaceBefore: pxToPoints(computed.marginTop),
4045
7156
  paraSpaceAfter: pxToPoints(computed.marginBottom),
4046
7157
  margin: [
@@ -4050,9 +7161,25 @@ export function parseSlideHtml(doc) {
4050
7161
  pxToPoints(computed.paddingTop),
4051
7162
  ],
4052
7163
  };
4053
- const transparency = extractAlpha(computed.color);
4054
- if (transparency !== null)
4055
- baseStyle.transparency = transparency;
7164
+ // Extract transparency from color alpha and CSS opacity
7165
+ // Use getEffectiveOpacity to accumulate opacity from all ancestors
7166
+ // (CSS opacity doesn't inherit - child elements report opacity:1 even if parent is 0.2)
7167
+ const colorAlphaTransparency = extractAlpha(effectiveColor);
7168
+ const effectiveOpacity = getEffectiveOpacity(el, win);
7169
+ if (colorAlphaTransparency !== null || effectiveOpacity < 1) {
7170
+ // Start with color alpha transparency if present
7171
+ let finalTransparency = colorAlphaTransparency !== null ? colorAlphaTransparency : 0;
7172
+ // If effective opacity < 1, combine with color alpha
7173
+ if (effectiveOpacity < 1) {
7174
+ // Combine: final_opacity = existing_opacity * element_opacity
7175
+ const existingOpacity = 1 - (finalTransparency / 100);
7176
+ const combinedOpacity = existingOpacity * effectiveOpacity;
7177
+ finalTransparency = Math.round((1 - combinedOpacity) * 100);
7178
+ }
7179
+ if (finalTransparency > 0) {
7180
+ baseStyle.transparency = finalTransparency;
7181
+ }
7182
+ }
4056
7183
  const letterSpacing = extractLetterSpacing(computed);
4057
7184
  if (letterSpacing !== null)
4058
7185
  baseStyle.charSpacing = letterSpacing;
@@ -4087,10 +7214,27 @@ export function parseSlideHtml(doc) {
4087
7214
  // scope formatting checks & parseInlineFormatting to that child so
4088
7215
  // text from shape-children (e.g. numbered circles) isn't duplicated.
4089
7216
  const formattingRoot = flexContentChild ?? el;
4090
- const hasFormatting = formattingRoot.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');
7217
+ // Include DIV and P in the hasFormatting check because nested block elements
7218
+ // can have different styling (e.g., .data-value with bold/larger font vs
7219
+ // .data-desc with muted color/smaller font). Without this, the text would
7220
+ // be combined into a single run with the parent's formatting, losing the
7221
+ // individual block-level styles.
7222
+ const hasFormatting = formattingRoot.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, div, p');
4091
7223
  if (hasFormatting) {
4092
7224
  const transformStr = computed.textTransform;
4093
- const runs = parseInlineFormatting(formattingRoot, {}, [], (str) => applyTextTransform(str, transformStr), win);
7225
+ // Check if whitespace should be preserved (white-space: pre or monospace font)
7226
+ // Use effectiveComputedForWhitespace which may point to parent DIV's style for P elements wrapped by transform.ts
7227
+ const textPreserveWs = shouldPreserveWhitespace(effectiveComputedForWhitespace);
7228
+ // Pass base font info to parseInlineFormatting so inline elements (SPANs)
7229
+ // inherit the correct font size in monospace contexts (code blocks).
7230
+ // Without this, SPANs would use their computed font size which may be wrong
7231
+ // due to CSS rules like ".gh-body p { font-size: 13px }" overriding the code block's font size.
7232
+ const baseRunOptions = {
7233
+ fontSize: baseStyle.fontSize,
7234
+ fontFace: baseStyle.fontFace,
7235
+ color: baseStyle.color ?? undefined,
7236
+ };
7237
+ const runs = parseInlineFormatting(formattingRoot, baseRunOptions, [], (str) => applyTextTransform(str, transformStr), win, textPreserveWs);
4094
7238
  const textElement = {
4095
7239
  type: el.tagName.toLowerCase(),
4096
7240
  text: runs,
@@ -4150,6 +7294,15 @@ export function parseSlideHtml(doc) {
4150
7294
  const pseudoElements = extractPseudoElements(htmlDiv, win);
4151
7295
  elements.push(...pseudoElements);
4152
7296
  });
7297
+ // Sort elements by z-index for proper stacking order in PPTX.
7298
+ // PPTX renders shapes in XML order (later = on top), so we sort by z-index
7299
+ // to ensure elements with higher z-index appear later in the array.
7300
+ // Elements without z-index (undefined) are treated as z-index: 0.
7301
+ elements.sort((a, b) => {
7302
+ const zIndexA = ('zIndex' in a && a.zIndex !== undefined) ? a.zIndex : 0;
7303
+ const zIndexB = ('zIndex' in b && b.zIndex !== undefined) ? b.zIndex : 0;
7304
+ return zIndexA - zIndexB;
7305
+ });
4153
7306
  return { background, elements, placeholders, errors };
4154
7307
  }
4155
7308
  //# sourceMappingURL=parse.js.map