docgen-utils 1.0.27 → 1.0.28

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