docgen-utils 1.0.22 → 1.0.24

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.
@@ -110,6 +110,40 @@ function extractRotationAngle(computed) {
110
110
  }
111
111
  return null;
112
112
  }
113
+ /**
114
+ * Extract translation (translateX, translateY) from a CSS transform matrix.
115
+ * CSS transforms like `translate(-50%, 20px)` are resolved to a matrix by the browser.
116
+ * The matrix format is: matrix(a, b, c, d, tx, ty) where tx/ty are the translations.
117
+ * Returns {x: number, y: number} in pixels, or null if no translation.
118
+ */
119
+ function extractTranslation(computed) {
120
+ const transform = computed.transform;
121
+ if (!transform || transform === 'none')
122
+ return null;
123
+ // matrix(a, b, c, d, tx, ty) - 2D transform
124
+ const matrix2dMatch = transform.match(/matrix\(([-\d.e]+),\s*([-\d.e]+),\s*([-\d.e]+),\s*([-\d.e]+),\s*([-\d.e]+),\s*([-\d.e]+)\)/);
125
+ if (matrix2dMatch) {
126
+ const tx = parseFloat(matrix2dMatch[5]);
127
+ const ty = parseFloat(matrix2dMatch[6]);
128
+ if (tx !== 0 || ty !== 0) {
129
+ return { x: tx, y: ty };
130
+ }
131
+ }
132
+ // matrix3d(m11, m12, m13, m14, m21, m22, m23, m24, m31, m32, m33, m34, m41, m42, m43, m44)
133
+ // tx = m41, ty = m42
134
+ const matrix3dMatch = transform.match(/matrix3d\(([^)]+)\)/);
135
+ if (matrix3dMatch) {
136
+ const values = matrix3dMatch[1].split(',').map(v => parseFloat(v.trim()));
137
+ if (values.length >= 14) {
138
+ const tx = values[12]; // m41
139
+ const ty = values[13]; // m42
140
+ if (tx !== 0 || ty !== 0) {
141
+ return { x: tx, y: ty };
142
+ }
143
+ }
144
+ }
145
+ return null;
146
+ }
113
147
  // ---------------------------------------------------------------------------
114
148
  // Effective opacity (accumulated from ancestor chain)
115
149
  // ---------------------------------------------------------------------------
@@ -1426,8 +1460,9 @@ function extractPseudoElements(el, win) {
1426
1460
  pHeight = parentRect.height;
1427
1461
  }
1428
1462
  }
1429
- // Skip tiny elements that won't be visible
1430
- if (pWidth < 1 || pHeight < 1)
1463
+ // Skip elements that are effectively invisible (less than 0.5px in either dimension).
1464
+ // The threshold is low to handle scaled HTML (1280px 960px means 1px 0.75px).
1465
+ if (pWidth < 0.5 || pHeight < 0.5)
1431
1466
  continue;
1432
1467
  // Determine position (relative to parent)
1433
1468
  let pLeft = parseFloat(pComputed.left);
@@ -1605,8 +1640,13 @@ function extractPseudoElements(el, win) {
1605
1640
  borderOffsetLeft = parseFloat(parentComputedForBorder.borderLeftWidth) || 0;
1606
1641
  borderOffsetTop = parseFloat(parentComputedForBorder.borderTopWidth) || 0;
1607
1642
  }
1608
- const absLeft = parentRect.left + pLeft + borderOffsetLeft;
1609
- const absTop = parentRect.top + pTop + borderOffsetTop;
1643
+ // Apply CSS transform translation if present
1644
+ // Transforms like translateX/translateY/translate are resolved to a matrix by the browser
1645
+ const translation = extractTranslation(pComputed);
1646
+ const transformOffsetX = translation?.x ?? 0;
1647
+ const transformOffsetY = translation?.y ?? 0;
1648
+ const absLeft = parentRect.left + pLeft + borderOffsetLeft + transformOffsetX;
1649
+ const absTop = parentRect.top + pTop + borderOffsetTop + transformOffsetY;
1610
1650
  // Check for visual content: background color, gradient, or border
1611
1651
  const hasBg = pComputed.backgroundColor && pComputed.backgroundColor !== 'rgba(0, 0, 0, 0)';
1612
1652
  const bgImage = pComputed.backgroundImage;
@@ -1822,7 +1862,22 @@ function parseInlineFormatting(element, baseOptions, runs, baseTextTransform, wi
1822
1862
  const transformStr = computed.textTransform;
1823
1863
  textTransform = (text) => applyTextTransform(text, transformStr);
1824
1864
  }
1865
+ // If a <br> preceded this element, apply softBreakBefore to the first run
1866
+ // generated by the recursive call. We do this by noting the current runs
1867
+ // count, recursing, then marking the first new run (if any) with softBreakBefore.
1868
+ const runsBeforeRecurse = runs.length;
1869
+ const hadPendingSoftBreak = pendingSoftBreak;
1870
+ if (pendingSoftBreak) {
1871
+ pendingSoftBreak = false; // Clear before recursion
1872
+ }
1825
1873
  parseInlineFormatting(el, options, runs, textTransform, win);
1874
+ // Apply softBreakBefore to the first run added by the recursive call
1875
+ if (hadPendingSoftBreak && runs.length > runsBeforeRecurse) {
1876
+ runs[runsBeforeRecurse].options = {
1877
+ ...runs[runsBeforeRecurse].options,
1878
+ softBreakBefore: true,
1879
+ };
1880
+ }
1826
1881
  }
1827
1882
  prevNodeIsText = false;
1828
1883
  }
@@ -2207,6 +2262,33 @@ export function parseSlideHtml(doc) {
2207
2262
  const imgEl = el;
2208
2263
  const natW = imgEl.naturalWidth;
2209
2264
  const natH = imgEl.naturalHeight;
2265
+ // Parse object-position for cover crop anchor point.
2266
+ // CSS object-position defaults to "50% 50%" (center center).
2267
+ // We convert to [xFraction, yFraction] in the range 0.0–1.0.
2268
+ let objectPosition;
2269
+ const objPos = imgComputed.objectPosition;
2270
+ if (objPos && objectFit === 'cover') {
2271
+ const parts = objPos.trim().split(/\s+/);
2272
+ const parseFraction = (val, dimension) => {
2273
+ if (val.endsWith('%')) {
2274
+ return parseFloat(val) / 100;
2275
+ }
2276
+ // px or pt value — convert to fraction of the display dimension
2277
+ const px = parseFloat(val);
2278
+ if (!isNaN(px) && dimension > 0) {
2279
+ return px / dimension;
2280
+ }
2281
+ return 0.5; // default center
2282
+ };
2283
+ const displayW = wasClipped ? clippedW : rect.width;
2284
+ const displayH = wasClipped ? clippedH : rect.height;
2285
+ const xFrac = parts.length >= 1 ? parseFraction(parts[0], displayW) : 0.5;
2286
+ const yFrac = parts.length >= 2 ? parseFraction(parts[1], displayH) : xFrac;
2287
+ // Only store if non-default to keep output clean
2288
+ if (Math.abs(xFrac - 0.5) > 0.001 || Math.abs(yFrac - 0.5) > 0.001) {
2289
+ objectPosition = [xFrac, yFrac];
2290
+ }
2291
+ }
2210
2292
  const imageElement = {
2211
2293
  type: isFullSlideImage ? 'slideBackgroundImage' : 'image',
2212
2294
  src: imgSrc,
@@ -2219,10 +2301,14 @@ export function parseSlideHtml(doc) {
2219
2301
  sizing: objectFit === 'cover' ? { type: 'cover' } : null,
2220
2302
  };
2221
2303
  // Store natural dimensions for cover crop calculation in convert.ts
2222
- if (objectFit === 'cover' && natW > 0 && natH > 0 && !isFullSlideImage) {
2304
+ if (objectFit === 'cover' && natW > 0 && natH > 0) {
2223
2305
  imageElement.naturalWidth = natW;
2224
2306
  imageElement.naturalHeight = natH;
2225
2307
  }
2308
+ // Store object-position for cover crop anchor
2309
+ if (objectPosition) {
2310
+ imageElement.objectPosition = objectPosition;
2311
+ }
2226
2312
  if (imgRectRadius !== null) {
2227
2313
  imageElement.rectRadius = imgRectRadius;
2228
2314
  }
@@ -2772,8 +2858,34 @@ export function parseSlideHtml(doc) {
2772
2858
  const allChildren = Array.from(el.children);
2773
2859
  // Collect text-bearing children: standard text tags AND leaf DIVs (no child elements, only text nodes)
2774
2860
  const textChildren = allChildren.filter((child) => {
2775
- if (textTagSet.has(child.tagName))
2861
+ if (textTagSet.has(child.tagName)) {
2862
+ // SPANs with background or border styling should NOT be merged as text children.
2863
+ // They need to be processed independently as shape elements to preserve their styling.
2864
+ if (child.tagName === 'SPAN') {
2865
+ const spanComputed = win.getComputedStyle(child);
2866
+ const spanHasBg = spanComputed.backgroundColor && spanComputed.backgroundColor !== 'rgba(0, 0, 0, 0)';
2867
+ const spanHasBorder = (parseFloat(spanComputed.borderTopWidth) || 0) > 0 ||
2868
+ (parseFloat(spanComputed.borderRightWidth) || 0) > 0 ||
2869
+ (parseFloat(spanComputed.borderBottomWidth) || 0) > 0 ||
2870
+ (parseFloat(spanComputed.borderLeftWidth) || 0) > 0;
2871
+ const spanHasGradient = spanComputed.backgroundImage &&
2872
+ spanComputed.backgroundImage !== 'none' &&
2873
+ (spanComputed.backgroundImage.includes('linear-gradient') ||
2874
+ spanComputed.backgroundImage.includes('radial-gradient'));
2875
+ // Check if this is gradient text (background-clip: text) - those SHOULD be text children
2876
+ const spanBgClip = spanComputed.webkitBackgroundClip || spanComputed.backgroundClip;
2877
+ const spanTextFillColor = spanComputed.webkitTextFillColor;
2878
+ const isGradientText = spanBgClip === 'text' &&
2879
+ (spanTextFillColor === 'transparent' ||
2880
+ spanTextFillColor === 'rgba(0, 0, 0, 0)' ||
2881
+ (spanTextFillColor && spanTextFillColor.includes('rgba') && spanTextFillColor.endsWith(', 0)')));
2882
+ // Exclude styled SPANs (but not gradient text SPANs)
2883
+ if ((spanHasBg || spanHasBorder || spanHasGradient) && !isGradientText) {
2884
+ return false;
2885
+ }
2886
+ }
2776
2887
  return true;
2888
+ }
2777
2889
  // Include text-only DIVs: DIVs that contain only inline/text content
2778
2890
  // The transformer wraps bare text in <p> tags, so a DIV with a single <p> child
2779
2891
  // is still effectively a text-only DIV. Examples after transformation:
@@ -2926,8 +3038,17 @@ export function parseSlideHtml(doc) {
2926
3038
  // - It's a single text child (isSingleTextChild), OR
2927
3039
  // - There are ONLY text children (no non-text siblings to compete with for space), OR
2928
3040
  // - There's direct text content (text nodes, not child elements)
2929
- const shouldMergeText = hasTextChildren && (isSingleTextChild ||
2930
- nonTextChildren.length === 0) || hasDirectText;
3041
+ //
3042
+ // CRITICAL: If the parent styled DIV is a flex-row container with multiple
3043
+ // children, the children are laid out horizontally and must be extracted
3044
+ // separately with their own positions. Merging them into a single text box
3045
+ // would stack them vertically, breaking the layout.
3046
+ const isParentFlexRow = isFlexContainer &&
3047
+ (computed.flexDirection === 'row' || computed.flexDirection === 'row-reverse' || !computed.flexDirection) &&
3048
+ allChildren.length > 1;
3049
+ const isParentGrid = display === 'grid' || display === 'inline-grid';
3050
+ const shouldMergeText = !isParentFlexRow && !isParentGrid && (hasTextChildren && (isSingleTextChild ||
3051
+ nonTextChildren.length === 0) || hasDirectText);
2931
3052
  if (shouldMergeText) {
2932
3053
  if (isSingleTextChild) {
2933
3054
  const textEl = textChildren[0];
@@ -3409,16 +3530,55 @@ export function parseSlideHtml(doc) {
3409
3530
  (plainDivFlexDir === 'row' || plainDivFlexDir === 'row-reverse') &&
3410
3531
  plainDivChildCount > 1;
3411
3532
  const isPlainDivGrid = plainDivDisplay === 'grid' || plainDivDisplay === 'inline-grid';
3533
+ // Helper to check if a child element is inline text (not a styled SPAN/A that needs shape treatment)
3534
+ const isInlineTextChild = (ce) => {
3535
+ const tagName = ce.tagName.toUpperCase();
3536
+ if (!inlineTextTagsSet.has(tagName))
3537
+ return false;
3538
+ // SPANs and A elements with background/border styling need to be processed as shapes
3539
+ // They should NOT be treated as inline text children
3540
+ if (tagName === 'SPAN' || tagName === 'A') {
3541
+ const ceComputed = win.getComputedStyle(ce);
3542
+ const ceHasBg = ceComputed.backgroundColor && ceComputed.backgroundColor !== 'rgba(0, 0, 0, 0)';
3543
+ const ceHasBorder = (parseFloat(ceComputed.borderTopWidth) || 0) > 0 ||
3544
+ (parseFloat(ceComputed.borderRightWidth) || 0) > 0 ||
3545
+ (parseFloat(ceComputed.borderBottomWidth) || 0) > 0 ||
3546
+ (parseFloat(ceComputed.borderLeftWidth) || 0) > 0;
3547
+ const ceHasGradient = ceComputed.backgroundImage &&
3548
+ ceComputed.backgroundImage !== 'none' &&
3549
+ (ceComputed.backgroundImage.includes('linear-gradient') ||
3550
+ ceComputed.backgroundImage.includes('radial-gradient'));
3551
+ // Check if this is gradient text (background-clip: text) - those ARE inline text
3552
+ const ceBgClip = ceComputed.webkitBackgroundClip || ceComputed.backgroundClip;
3553
+ const ceTextFillColor = ceComputed.webkitTextFillColor;
3554
+ const isGradientText = ceBgClip === 'text' &&
3555
+ (ceTextFillColor === 'transparent' ||
3556
+ ceTextFillColor === 'rgba(0, 0, 0, 0)' ||
3557
+ (ceTextFillColor && ceTextFillColor.includes('rgba') && ceTextFillColor.endsWith(', 0)')));
3558
+ // Styled elements (except gradient text) should NOT be treated as inline text
3559
+ if ((ceHasBg || ceHasBorder || ceHasGradient) && !isGradientText) {
3560
+ return false;
3561
+ }
3562
+ }
3563
+ return true;
3564
+ };
3412
3565
  const allChildrenAreInlineText = !isPlainDivFlexRow && !isPlainDivGrid && (childElements.length === 0 ||
3413
- childElements.every(ce => inlineTextTagsSet.has(ce.tagName.toUpperCase())));
3566
+ childElements.every(ce => isInlineTextChild(ce)));
3414
3567
  // Only proceed if this DIV has meaningful text content
3415
3568
  // AND has no structural child elements that would be extracted separately
3416
3569
  // SVG children are decorative (already extracted as images) and shouldn't block text extraction
3570
+ // Styled SPANs/A elements (with bg/border) ARE structural - they become shapes
3417
3571
  const structuralChildElements = childElements.filter(ce => {
3418
3572
  const tagName = ce.tagName.toUpperCase();
3419
- return tagName !== 'BR' &&
3420
- tagName !== 'SVG' &&
3421
- !inlineTextTagsSet.has(tagName);
3573
+ if (tagName === 'BR' || tagName === 'SVG')
3574
+ return false;
3575
+ // If it's not an inline text element, it's structural
3576
+ if (!inlineTextTagsSet.has(tagName))
3577
+ return true;
3578
+ // For SPAN/A, check if it's styled (then it's structural)
3579
+ if (!isInlineTextChild(ce))
3580
+ return true;
3581
+ return false;
3422
3582
  });
3423
3583
  const hasStructuralChildren = structuralChildElements.length > 0;
3424
3584
  // If all children are inline text elements (or no children), use the full textContent
@@ -3475,6 +3635,11 @@ export function parseSlideHtml(doc) {
3475
3635
  // use parseInlineFormatting to preserve per-run styling (color, bold, etc.).
3476
3636
  // Otherwise, create a single text run.
3477
3637
  let textRuns;
3638
+ // Check if this is a flex-column container - children are stacked vertically
3639
+ // and should be separated by line breaks in the PPTX output
3640
+ const isFlexColumn = (plainDivDisplay === 'flex' || plainDivDisplay === 'inline-flex') &&
3641
+ (plainDivFlexDir === 'column' || plainDivFlexDir === 'column-reverse') &&
3642
+ childElements.length > 1;
3478
3643
  if (allChildrenAreInlineText && childElements.length > 0) {
3479
3644
  const baseRunOptions = {
3480
3645
  fontSize: pxToPoints(computed2.fontSize),
@@ -3490,7 +3655,57 @@ export function parseSlideHtml(doc) {
3490
3655
  if (computed2.textTransform && computed2.textTransform !== 'none') {
3491
3656
  textTransformFn = (text) => applyTextTransform(text, computed2.textTransform);
3492
3657
  }
3493
- textRuns = parseInlineFormatting(el, baseRunOptions, [], textTransformFn, win);
3658
+ // For flex-column containers, process each child separately and add
3659
+ // line breaks between them to preserve the vertical stacking
3660
+ if (isFlexColumn) {
3661
+ textRuns = [];
3662
+ for (let ci = 0; ci < childElements.length; ci++) {
3663
+ const childEl = childElements[ci];
3664
+ const childComputed = win.getComputedStyle(childEl);
3665
+ const childText = getTransformedText(childEl, childComputed);
3666
+ if (!childText)
3667
+ continue;
3668
+ // Apply text-transform
3669
+ const transformedChildText = textTransformFn(childText);
3670
+ // Build run options for this child, inheriting from parent but overriding
3671
+ // with child-specific styles
3672
+ const childRunOptions = { ...baseRunOptions };
3673
+ const childFontSize = pxToPoints(childComputed.fontSize);
3674
+ if (childFontSize)
3675
+ childRunOptions.fontSize = childFontSize;
3676
+ const childFontFace = extractFontFace(childComputed.fontFamily);
3677
+ if (childFontFace)
3678
+ childRunOptions.fontFace = childFontFace;
3679
+ const childColor = rgbToHex(childComputed.color);
3680
+ if (childColor)
3681
+ childRunOptions.color = childColor;
3682
+ if (parseInt(childComputed.fontWeight) >= 600) {
3683
+ childRunOptions.bold = true;
3684
+ }
3685
+ else if (parseInt(childComputed.fontWeight) < 600) {
3686
+ childRunOptions.bold = false;
3687
+ }
3688
+ if (childComputed.fontStyle === 'italic') {
3689
+ childRunOptions.italic = true;
3690
+ }
3691
+ else if (childComputed.fontStyle !== 'italic') {
3692
+ childRunOptions.italic = false;
3693
+ }
3694
+ // Check for letter-spacing
3695
+ const childLs = extractLetterSpacing(childComputed);
3696
+ if (childLs !== null)
3697
+ childRunOptions.charSpacing = childLs;
3698
+ // For all children except the last, add a newline at the end of the text
3699
+ // to create vertical stacking in the PPTX output
3700
+ const textWithBreak = ci < childElements.length - 1
3701
+ ? transformedChildText + '\n'
3702
+ : transformedChildText;
3703
+ textRuns.push({ text: textWithBreak, options: childRunOptions });
3704
+ }
3705
+ }
3706
+ else {
3707
+ textRuns = parseInlineFormatting(el, baseRunOptions, [], textTransformFn, win);
3708
+ }
3494
3709
  // Fallback to single run if parseInlineFormatting produced nothing
3495
3710
  if (textRuns.length === 0) {
3496
3711
  textRuns = [{ text: extractedText, options: {} }];
@@ -3924,9 +4139,9 @@ export function parseSlideHtml(doc) {
3924
4139
  // Some DIVs (e.g., .image-side with overflow:hidden) have no bg/border/gradient
3925
4140
  // themselves but have ::before/::after pseudo-elements with gradient overlays.
3926
4141
  doc.querySelectorAll('div').forEach((divEl) => {
4142
+ const htmlDiv = divEl;
3927
4143
  if (processed.has(divEl))
3928
4144
  return; // Already handled in second pass
3929
- const htmlDiv = divEl;
3930
4145
  if (htmlDiv === body)
3931
4146
  return; // Body already handled
3932
4147
  const rect = htmlDiv.getBoundingClientRect();