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