docgen-utils 1.0.12 → 1.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/bundle.js +3189 -1238
  2. package/dist/bundle.min.js +101 -99
  3. package/dist/cli.js +2653 -1117
  4. package/dist/packages/cli/commands/export-docs.d.ts.map +1 -1
  5. package/dist/packages/cli/commands/export-docs.js +131 -2
  6. package/dist/packages/cli/commands/export-docs.js.map +1 -1
  7. package/dist/packages/cli/commands/export-slides.d.ts.map +1 -1
  8. package/dist/packages/cli/commands/export-slides.js +25 -1
  9. package/dist/packages/cli/commands/export-slides.js.map +1 -1
  10. package/dist/packages/docs/common.d.ts +10 -0
  11. package/dist/packages/docs/common.d.ts.map +1 -1
  12. package/dist/packages/docs/common.js.map +1 -1
  13. package/dist/packages/docs/convert.d.ts.map +1 -1
  14. package/dist/packages/docs/convert.js +246 -218
  15. package/dist/packages/docs/convert.js.map +1 -1
  16. package/dist/packages/docs/create-document.d.ts.map +1 -1
  17. package/dist/packages/docs/create-document.js +43 -3
  18. package/dist/packages/docs/create-document.js.map +1 -1
  19. package/dist/packages/docs/export.d.ts +9 -8
  20. package/dist/packages/docs/export.d.ts.map +1 -1
  21. package/dist/packages/docs/export.js +23 -36
  22. package/dist/packages/docs/export.js.map +1 -1
  23. package/dist/packages/docs/parse-colors.d.ts +37 -0
  24. package/dist/packages/docs/parse-colors.d.ts.map +1 -0
  25. package/dist/packages/docs/parse-colors.js +507 -0
  26. package/dist/packages/docs/parse-colors.js.map +1 -0
  27. package/dist/packages/docs/parse-css.d.ts +98 -0
  28. package/dist/packages/docs/parse-css.d.ts.map +1 -0
  29. package/dist/packages/docs/parse-css.js +1592 -0
  30. package/dist/packages/docs/parse-css.js.map +1 -0
  31. package/dist/packages/docs/parse-helpers.d.ts +45 -0
  32. package/dist/packages/docs/parse-helpers.d.ts.map +1 -0
  33. package/dist/packages/docs/parse-helpers.js +214 -0
  34. package/dist/packages/docs/parse-helpers.js.map +1 -0
  35. package/dist/packages/docs/parse-inline.d.ts +41 -0
  36. package/dist/packages/docs/parse-inline.d.ts.map +1 -0
  37. package/dist/packages/docs/parse-inline.js +473 -0
  38. package/dist/packages/docs/parse-inline.js.map +1 -0
  39. package/dist/packages/docs/parse-layout.d.ts +57 -0
  40. package/dist/packages/docs/parse-layout.d.ts.map +1 -0
  41. package/dist/packages/docs/parse-layout.js +295 -0
  42. package/dist/packages/docs/parse-layout.js.map +1 -0
  43. package/dist/packages/docs/parse-special.d.ts +51 -0
  44. package/dist/packages/docs/parse-special.d.ts.map +1 -0
  45. package/dist/packages/docs/parse-special.js +251 -0
  46. package/dist/packages/docs/parse-special.js.map +1 -0
  47. package/dist/packages/docs/parse-units.d.ts +68 -0
  48. package/dist/packages/docs/parse-units.d.ts.map +1 -0
  49. package/dist/packages/docs/parse-units.js +275 -0
  50. package/dist/packages/docs/parse-units.js.map +1 -0
  51. package/dist/packages/docs/parse.d.ts.map +1 -1
  52. package/dist/packages/docs/parse.js +957 -2800
  53. package/dist/packages/docs/parse.js.map +1 -1
  54. package/dist/packages/slides/common.d.ts +7 -0
  55. package/dist/packages/slides/common.d.ts.map +1 -1
  56. package/dist/packages/slides/convert.d.ts.map +1 -1
  57. package/dist/packages/slides/convert.js +92 -7
  58. package/dist/packages/slides/convert.js.map +1 -1
  59. package/dist/packages/slides/parse.d.ts.map +1 -1
  60. package/dist/packages/slides/parse.js +723 -40
  61. package/dist/packages/slides/parse.js.map +1 -1
  62. package/dist/packages/slides/transform.d.ts.map +1 -1
  63. package/dist/packages/slides/transform.js +12 -7
  64. package/dist/packages/slides/transform.js.map +1 -1
  65. package/package.json +1 -1
@@ -87,6 +87,273 @@ function extractDashType(borderStyle) {
87
87
  }
88
88
  }
89
89
  // ---------------------------------------------------------------------------
90
+ // CSS transform rotation extraction
91
+ // ---------------------------------------------------------------------------
92
+ /**
93
+ * Extract the rotation angle (in degrees) from a computed CSS `transform`.
94
+ *
95
+ * Browsers resolve any CSS transform to a `matrix(a, b, c, d, e, f)` value.
96
+ * The rotation component is `atan2(b, a)`. Returns `null` for no rotation
97
+ * or for rotations smaller than 0.5° (effectively none).
98
+ */
99
+ function extractRotationAngle(computed) {
100
+ const transform = computed.transform;
101
+ if (!transform || transform === 'none')
102
+ return null;
103
+ const matrixMatch = transform.match(/matrix\(([-\d.e]+),\s*([-\d.e]+)/);
104
+ if (matrixMatch) {
105
+ const a = parseFloat(matrixMatch[1]);
106
+ const b = parseFloat(matrixMatch[2]);
107
+ const angle = Math.atan2(b, a) * (180 / Math.PI);
108
+ if (Math.abs(angle) > 0.5)
109
+ return Math.round(angle * 10) / 10;
110
+ }
111
+ return null;
112
+ }
113
+ // ---------------------------------------------------------------------------
114
+ // Effective opacity (accumulated from ancestor chain)
115
+ // ---------------------------------------------------------------------------
116
+ /**
117
+ * Compute the effective (accumulated) opacity of an element by walking up the
118
+ * ancestor chain. CSS `opacity` is NOT inherited — each element has its own
119
+ * computed value. However, visually the browser composites each stacking
120
+ * context, so the real visual opacity is the product of all ancestor opacities.
121
+ *
122
+ * Stops at `<body>` since that is the slide boundary.
123
+ */
124
+ function getEffectiveOpacity(el, win) {
125
+ let opacity = 1;
126
+ let node = el;
127
+ while (node && node.tagName !== 'HTML') {
128
+ const style = win.getComputedStyle(node);
129
+ const nodeOpacity = parseFloat(style.opacity);
130
+ if (!isNaN(nodeOpacity))
131
+ opacity *= nodeOpacity;
132
+ node = node.parentElement;
133
+ }
134
+ return opacity;
135
+ }
136
+ // ---------------------------------------------------------------------------
137
+ // Split multiple CSS background gradients
138
+ // ---------------------------------------------------------------------------
139
+ /**
140
+ * Split a CSS `background-image` value that may contain multiple
141
+ * comma-separated gradients into individual gradient strings.
142
+ *
143
+ * Correctly handles nesting (e.g. `rgba()` inside `linear-gradient()`).
144
+ *
145
+ * Example:
146
+ * "radial-gradient(...), radial-gradient(...)" → ["radial-gradient(...)", "radial-gradient(...)"]
147
+ */
148
+ function splitCssBackgroundGradients(bgImage) {
149
+ if (!bgImage || bgImage === 'none')
150
+ return [];
151
+ const parts = [];
152
+ let depth = 0;
153
+ let current = '';
154
+ for (const char of bgImage) {
155
+ if (char === '(')
156
+ depth++;
157
+ else if (char === ')')
158
+ depth--;
159
+ if (char === ',' && depth === 0) {
160
+ const trimmed = current.trim();
161
+ if (trimmed)
162
+ parts.push(trimmed);
163
+ current = '';
164
+ }
165
+ else {
166
+ current += char;
167
+ }
168
+ }
169
+ const trimmed = current.trim();
170
+ if (trimmed)
171
+ parts.push(trimmed);
172
+ return parts.filter((p) => p.includes('linear-gradient') || p.includes('radial-gradient'));
173
+ }
174
+ // ---------------------------------------------------------------------------
175
+ // Rasterize complex repeating CSS gradient patterns to canvas image
176
+ // ---------------------------------------------------------------------------
177
+ /**
178
+ * Draw a single CSS linear-gradient on a canvas context.
179
+ *
180
+ * Parses the gradient string for direction (deg or `to` keyword) and color
181
+ * stops, then creates a native CanvasGradient and fills the context.
182
+ *
183
+ * The CSS color strings coming from `getComputedStyle` are already resolved
184
+ * (no CSS variables) and are valid canvas color values.
185
+ */
186
+ function drawCssLinearGradientOnCanvas(ctx, gradStr, w, h) {
187
+ // Extract content inside linear-gradient(...)
188
+ const idx = gradStr.indexOf('linear-gradient(');
189
+ if (idx === -1)
190
+ return;
191
+ let depth = 0;
192
+ const start = idx + 'linear-gradient('.length;
193
+ let end = start;
194
+ for (let i = start; i < gradStr.length; i++) {
195
+ if (gradStr[i] === '(')
196
+ depth++;
197
+ else if (gradStr[i] === ')') {
198
+ if (depth === 0) {
199
+ end = i;
200
+ break;
201
+ }
202
+ depth--;
203
+ }
204
+ }
205
+ const content = gradStr.substring(start, end);
206
+ // Split by commas respecting parentheses
207
+ const parts = [];
208
+ let current = '';
209
+ let d = 0;
210
+ for (const char of content) {
211
+ if (char === '(')
212
+ d++;
213
+ else if (char === ')')
214
+ d--;
215
+ if (char === ',' && d === 0) {
216
+ parts.push(current.trim());
217
+ current = '';
218
+ }
219
+ else {
220
+ current += char;
221
+ }
222
+ }
223
+ if (current.trim())
224
+ parts.push(current.trim());
225
+ // Parse angle
226
+ let cssAngle = 180; // default: "to bottom"
227
+ let colorStops = parts;
228
+ const angleMatch = parts[0]?.match(/^(-?[\d.]+)deg$/);
229
+ if (angleMatch) {
230
+ cssAngle = parseFloat(angleMatch[1]);
231
+ colorStops = parts.slice(1);
232
+ }
233
+ else if (parts[0]?.startsWith('to ')) {
234
+ const dir = parts[0].replace('to ', '').trim();
235
+ const dirMap = {
236
+ 'top': 0, 'right': 90, 'bottom': 180, 'left': 270,
237
+ 'top right': 45, 'right top': 45, 'bottom right': 135,
238
+ 'right bottom': 135, 'bottom left': 225, 'left bottom': 225,
239
+ 'top left': 315, 'left top': 315,
240
+ };
241
+ cssAngle = dirMap[dir] ?? 180;
242
+ colorStops = parts.slice(1);
243
+ }
244
+ // Compute gradient line endpoints per CSS spec:
245
+ // direction = (sin(θ), -cos(θ)) in canvas coords (y-down)
246
+ // half-length = (|w·sin(θ)| + |h·cos(θ)|) / 2
247
+ const rad = cssAngle * Math.PI / 180;
248
+ const dx = Math.sin(rad);
249
+ const dy = -Math.cos(rad);
250
+ const halfLen = (Math.abs(w * Math.sin(rad)) + Math.abs(h * Math.cos(rad))) / 2;
251
+ const cx = w / 2;
252
+ const cy = h / 2;
253
+ const grad = ctx.createLinearGradient(cx - halfLen * dx, cy - halfLen * dy, cx + halfLen * dx, cy + halfLen * dy);
254
+ // Add color stops — CSS color strings from computedStyle work natively in canvas
255
+ for (let i = 0; i < colorStops.length; i++) {
256
+ const stop = colorStops[i].trim();
257
+ const posMatch = stop.match(/([\d.]+)%\s*$/);
258
+ const position = posMatch
259
+ ? parseFloat(posMatch[1]) / 100
260
+ : i / Math.max(colorStops.length - 1, 1);
261
+ const colorPart = posMatch
262
+ ? stop.replace(/([\d.]+)%\s*$/, '').trim()
263
+ : stop.trim();
264
+ try {
265
+ grad.addColorStop(Math.max(0, Math.min(1, position)), colorPart);
266
+ }
267
+ catch {
268
+ // Invalid color string — skip this stop
269
+ }
270
+ }
271
+ ctx.fillStyle = grad;
272
+ ctx.fillRect(0, 0, w, h);
273
+ }
274
+ /**
275
+ * Render a complex repeating CSS gradient pattern as a rasterized PNG image.
276
+ *
277
+ * Some CSS backgrounds use multiple linear-gradient layers combined with
278
+ * background-size / background-position to create tiled patterns (hex grids,
279
+ * noise textures, etc.) that PowerPoint gradients cannot represent. This
280
+ * function rasterises them faithfully using Canvas 2D:
281
+ *
282
+ * 1. Parse background-size to get the tile dimensions
283
+ * 2. Parse background-position offsets per gradient layer
284
+ * 3. For each gradient layer, draw it on a tile-sized canvas
285
+ * 4. Use createPattern() to tile each layer across the full element
286
+ * 5. Return the composited result as a PNG data URI
287
+ *
288
+ * @returns PNG data URI or null on failure
289
+ */
290
+ function renderRepeatingGradientPatternAsImage(bgImage, bgSize, bgPosition, width, height, doc) {
291
+ try {
292
+ const gradParts = splitCssBackgroundGradients(bgImage);
293
+ if (gradParts.length === 0)
294
+ return null;
295
+ // Parse background-size → tile dimensions.
296
+ // `computed.backgroundSize` may be comma-separated (one per layer),
297
+ // e.g. "80px 140px, 80px 140px, ...". We use the first entry.
298
+ let tileW = width;
299
+ let tileH = height;
300
+ if (bgSize && bgSize !== 'auto') {
301
+ const firstLayerSize = bgSize.split(',')[0].trim();
302
+ const sizeParts = firstLayerSize.split(/\s+/);
303
+ const pw = parseFloat(sizeParts[0]);
304
+ if (!isNaN(pw) && pw > 0)
305
+ tileW = pw;
306
+ if (sizeParts.length >= 2) {
307
+ const ph = parseFloat(sizeParts[1]);
308
+ if (!isNaN(ph) && ph > 0)
309
+ tileH = ph;
310
+ }
311
+ else {
312
+ tileH = tileW; // single value → square tiles
313
+ }
314
+ }
315
+ // Parse background-position (comma-separated, one per layer)
316
+ const positionEntries = bgPosition.split(',').map((p) => p.trim());
317
+ const canvas = doc.createElement('canvas');
318
+ canvas.width = Math.round(width);
319
+ canvas.height = Math.round(height);
320
+ const ctx = canvas.getContext('2d');
321
+ if (!ctx)
322
+ return null;
323
+ for (let i = 0; i < gradParts.length; i++) {
324
+ const gradStr = gradParts[i].trim();
325
+ if (!gradStr.includes('linear-gradient'))
326
+ continue; // only handle linear for now
327
+ // Per-layer position offset (falls back to first entry, then 0 0)
328
+ const pos = positionEntries[i] || positionEntries[0] || '0px 0px';
329
+ const posParts = pos.trim().split(/\s+/);
330
+ const offX = parseFloat(posParts[0]) || 0;
331
+ const offY = parseFloat(posParts[1]) || 0;
332
+ // Draw gradient onto a tile-sized canvas
333
+ const tileCanvas = doc.createElement('canvas');
334
+ tileCanvas.width = Math.round(tileW);
335
+ tileCanvas.height = Math.round(tileH);
336
+ const tileCtx = tileCanvas.getContext('2d');
337
+ if (!tileCtx)
338
+ continue;
339
+ drawCssLinearGradientOnCanvas(tileCtx, gradStr, tileW, tileH);
340
+ // Tile the gradient across the full element with the offset
341
+ const pattern = ctx.createPattern(tileCanvas, 'repeat');
342
+ if (!pattern)
343
+ continue;
344
+ ctx.save();
345
+ ctx.translate(offX, offY);
346
+ ctx.fillStyle = pattern;
347
+ ctx.fillRect(-offX, -offY, canvas.width, canvas.height);
348
+ ctx.restore();
349
+ }
350
+ return canvas.toDataURL('image/png');
351
+ }
352
+ catch {
353
+ return null;
354
+ }
355
+ }
356
+ // ---------------------------------------------------------------------------
90
357
  // parseCssGradient (exported, used by other modules)
91
358
  // ---------------------------------------------------------------------------
92
359
  /**
@@ -1173,6 +1440,164 @@ function extractPseudoElements(el, win) {
1173
1440
  pLeft = 0;
1174
1441
  if (isNaN(pTop))
1175
1442
  pTop = 0;
1443
+ // ---- Flex / Grid alignment correction for flow-positioned pseudos ----
1444
+ //
1445
+ // Problem: getComputedStyle on pseudo-elements returns 'auto' for left/top
1446
+ // when their position is static. The browser's flex/grid engine computes
1447
+ // offsets from alignment properties (align-items, justify-content, gap)
1448
+ // but does NOT expose them through the CSSOM for pseudo-elements.
1449
+ //
1450
+ // Strategy:
1451
+ // Cross-axis → derive from align-items (or align-self override)
1452
+ // Main-axis → anchor off the nearest real child's rendered
1453
+ // getBoundingClientRect; fall back to justify-content
1454
+ // when no real children exist.
1455
+ //
1456
+ const pseudoPos = pComputed.position;
1457
+ if (!pseudoPos || pseudoPos === 'static') {
1458
+ const parentComputed = win.getComputedStyle(el);
1459
+ const parentDisplay = parentComputed.display;
1460
+ const isFlex = parentDisplay === 'flex' || parentDisplay === 'inline-flex';
1461
+ const isGrid = parentDisplay === 'grid' || parentDisplay === 'inline-grid';
1462
+ if (isFlex) {
1463
+ const flexDir = parentComputed.flexDirection || 'row';
1464
+ const isRow = flexDir === 'row' || flexDir === 'row-reverse';
1465
+ const isReverse = flexDir.endsWith('-reverse');
1466
+ // -- Effective cross-axis alignment --
1467
+ // align-self on the pseudo overrides the container's align-items
1468
+ const alignSelf = pComputed.alignSelf;
1469
+ const effectiveAlign = (alignSelf && alignSelf !== 'auto')
1470
+ ? alignSelf
1471
+ : (parentComputed.alignItems || 'stretch');
1472
+ // -- Pseudo-element margins --
1473
+ const mTop = parseFloat(pComputed.marginTop) || 0;
1474
+ const mRight = parseFloat(pComputed.marginRight) || 0;
1475
+ const mBottom = parseFloat(pComputed.marginBottom) || 0;
1476
+ const mLeft = parseFloat(pComputed.marginLeft) || 0;
1477
+ // -- Cross-axis offset --
1478
+ // The cross-axis is vertical for row, horizontal for column.
1479
+ const crossSize = isRow ? parentRect.height : parentRect.width;
1480
+ const pseudoCrossOuter = isRow
1481
+ ? (pHeight + mTop + mBottom)
1482
+ : (pWidth + mLeft + mRight);
1483
+ const crossMarginBefore = isRow ? mTop : mLeft;
1484
+ let crossOffset;
1485
+ switch (effectiveAlign) {
1486
+ case 'center':
1487
+ crossOffset = (crossSize - pseudoCrossOuter) / 2 + crossMarginBefore;
1488
+ break;
1489
+ case 'flex-end':
1490
+ case 'end':
1491
+ crossOffset = crossSize - pseudoCrossOuter + crossMarginBefore;
1492
+ break;
1493
+ case 'stretch':
1494
+ case 'baseline':
1495
+ default: // flex-start, start, normal
1496
+ crossOffset = crossMarginBefore;
1497
+ break;
1498
+ }
1499
+ // -- Main-axis offset --
1500
+ // Parse the flex gap along the main axis
1501
+ const gapProp = isRow ? parentComputed.columnGap : parentComputed.rowGap;
1502
+ const mainGap = parseFloat(gapProp) || parseFloat(parentComputed.gap) || 0;
1503
+ // Collect visible, in-flow child elements to use as position anchors.
1504
+ // Pseudo-elements are first (::before) or last (::after) in flex order,
1505
+ // so we anchor off the adjacent real child to compute the main-axis offset.
1506
+ const realChildren = Array.from(el.children).filter(c => {
1507
+ const s = win.getComputedStyle(c);
1508
+ return s.display !== 'none' && s.position !== 'absolute' && s.position !== 'fixed';
1509
+ });
1510
+ let mainOffset;
1511
+ if (realChildren.length > 0) {
1512
+ // Determine which child to anchor against and in which direction.
1513
+ // ::before + normal → sits before first child (screen-left / screen-top)
1514
+ // ::before + reverse → sits after first child (screen-right / screen-bottom)
1515
+ // ::after + normal → sits after last child
1516
+ // ::after + reverse → sits before last child
1517
+ const anchor = pseudo === '::before'
1518
+ ? realChildren[0]
1519
+ : realChildren[realChildren.length - 1];
1520
+ const anchorRect = anchor.getBoundingClientRect();
1521
+ const pseudoSitsAfterAnchor = (pseudo === '::before' && isReverse) ||
1522
+ (pseudo === '::after' && !isReverse);
1523
+ if (pseudoSitsAfterAnchor) {
1524
+ // Pseudo is toward main-end (screen-right in row, screen-bottom in column)
1525
+ const anchorEnd = isRow
1526
+ ? (anchorRect.right - parentRect.left)
1527
+ : (anchorRect.bottom - parentRect.top);
1528
+ const marginBefore = isRow ? mLeft : mTop;
1529
+ mainOffset = anchorEnd + mainGap + marginBefore;
1530
+ }
1531
+ else {
1532
+ // Pseudo is toward main-start (screen-left in row, screen-top in column)
1533
+ const anchorStart = isRow
1534
+ ? (anchorRect.left - parentRect.left)
1535
+ : (anchorRect.top - parentRect.top);
1536
+ const pseudoMainSize = isRow ? pWidth : pHeight;
1537
+ const marginAfter = isRow ? mRight : mBottom;
1538
+ const marginBefore = isRow ? mLeft : mTop;
1539
+ mainOffset = anchorStart - mainGap - marginAfter - pseudoMainSize;
1540
+ // Clamp: don't go before the start of the parent content area
1541
+ if (mainOffset < marginBefore)
1542
+ mainOffset = marginBefore;
1543
+ }
1544
+ }
1545
+ else {
1546
+ // No visible real children — position based on justify-content.
1547
+ // This covers text-only flex containers where the pseudo is the
1548
+ // only true flex box item (text nodes form anonymous items but
1549
+ // have no getBoundingClientRect we can query).
1550
+ const justify = parentComputed.justifyContent || 'normal';
1551
+ const mainSize = isRow ? parentRect.width : parentRect.height;
1552
+ const pseudoMainOuter = isRow
1553
+ ? (pWidth + mLeft + mRight)
1554
+ : (pHeight + mTop + mBottom);
1555
+ const marginBefore = isRow ? mLeft : mTop;
1556
+ if (justify === 'center') {
1557
+ mainOffset = (mainSize - pseudoMainOuter) / 2 + marginBefore;
1558
+ }
1559
+ else if (justify === 'flex-end' || justify === 'end') {
1560
+ mainOffset = mainSize - pseudoMainOuter + marginBefore;
1561
+ }
1562
+ else {
1563
+ // flex-start, start, normal, space-between/around/evenly
1564
+ // (with a single item these behave like flex-start)
1565
+ mainOffset = marginBefore;
1566
+ }
1567
+ }
1568
+ // Assign to the correct axes
1569
+ pLeft = isRow ? mainOffset : crossOffset;
1570
+ pTop = isRow ? crossOffset : mainOffset;
1571
+ }
1572
+ else if (isGrid) {
1573
+ // Basic grid cross-axis alignment.
1574
+ // Grid pseudo-elements participate as grid items; their alignment
1575
+ // is governed by align-items / justify-items (or their -self variants).
1576
+ const alignSelf = pComputed.alignSelf;
1577
+ const alignItems = (alignSelf && alignSelf !== 'auto')
1578
+ ? alignSelf
1579
+ : (parentComputed.alignItems || 'stretch');
1580
+ const justifySelf = pComputed.justifySelf;
1581
+ const justifyItems = (justifySelf && justifySelf !== 'auto')
1582
+ ? justifySelf
1583
+ : (parentComputed.justifyItems || 'stretch');
1584
+ if (alignItems === 'center') {
1585
+ pTop = (parentRect.height - pHeight) / 2;
1586
+ }
1587
+ else if (alignItems === 'end') {
1588
+ pTop = parentRect.height - pHeight;
1589
+ }
1590
+ if (justifyItems === 'center') {
1591
+ pLeft = (parentRect.width - pWidth) / 2;
1592
+ }
1593
+ else if (justifyItems === 'end') {
1594
+ pLeft = parentRect.width - pWidth;
1595
+ }
1596
+ }
1597
+ // For non-flex/grid containers (block/inline), pLeft=0, pTop=0 is a
1598
+ // reasonable approximation: ::before generates content at the start of
1599
+ // the element's content area.
1600
+ }
1176
1601
  // Convert to absolute page coordinates
1177
1602
  const absLeft = parentRect.left + pLeft;
1178
1603
  const absTop = parentRect.top + pTop;
@@ -1184,14 +1609,24 @@ function extractPseudoElements(el, win) {
1184
1609
  const hasBgImage = bgImage && bgImage !== 'none' && bgImage.includes('url(');
1185
1610
  if (!hasBg && !hasGradient && !hasBgImage)
1186
1611
  continue;
1187
- // Parse gradient if present
1612
+ // Parse gradient(s) a single background-image property may contain multiple gradients
1188
1613
  let gradient = null;
1614
+ let extraPseudoGradients = [];
1189
1615
  if (hasGradient) {
1190
- gradient = parseCssGradient(bgImage);
1616
+ const gradParts = splitCssBackgroundGradients(bgImage);
1617
+ if (gradParts.length > 0)
1618
+ gradient = parseCssGradient(gradParts[0]);
1619
+ for (let gi = 1; gi < Math.min(gradParts.length, 4); gi++) {
1620
+ const g = parseCssGradient(gradParts[gi]);
1621
+ if (g)
1622
+ extraPseudoGradients.push(g);
1623
+ }
1191
1624
  }
1192
- // Parse opacity
1193
- const elementOpacity = parseFloat(pComputed.opacity);
1194
- const hasOpacity = !isNaN(elementOpacity) && elementOpacity < 1;
1625
+ // Parse effective opacity (including ancestor chain)
1626
+ const parentEffectiveOpacity = getEffectiveOpacity(el, win);
1627
+ const pseudoOwnOpacity = parseFloat(pComputed.opacity);
1628
+ const elementOpacity = parentEffectiveOpacity * (isNaN(pseudoOwnOpacity) ? 1 : pseudoOwnOpacity);
1629
+ const hasOpacity = elementOpacity < 1;
1195
1630
  // Parse border-radius and detect ellipse shape
1196
1631
  let rectRadius = 0;
1197
1632
  const borderRadius = pComputed.borderRadius;
@@ -1213,6 +1648,42 @@ function extractPseudoElements(el, win) {
1213
1648
  }
1214
1649
  // Parse box-shadow
1215
1650
  const shadow = parseBoxShadow(pComputed.boxShadow);
1651
+ // Inherit parent border-radius when pseudo-element is flush at the edge of a
1652
+ // parent with overflow:hidden. In CSS, the parent clips the pseudo-element;
1653
+ // in PPTX there's no clipping group, so we must round the pseudo-element's
1654
+ // corners to match.
1655
+ let effectiveRectRadius = isEllipse ? 0 : rectRadius;
1656
+ if (rectRadius === 0 && !isEllipse) {
1657
+ const parentComputed = win.getComputedStyle(el);
1658
+ const parentOverflow = parentComputed.overflow;
1659
+ if (parentOverflow === 'hidden' || parentOverflow === 'clip') {
1660
+ const parentRadius = parseFloat(parentComputed.borderRadius);
1661
+ if (parentRadius > 0) {
1662
+ // Account for parent border widths — absolutely positioned pseudo-elements
1663
+ // with left:0/right:0 resolve inside the padding box, not the border box.
1664
+ const parentBorderLeft = parseFloat(parentComputed.borderLeftWidth) || 0;
1665
+ const parentBorderRight = parseFloat(parentComputed.borderRightWidth) || 0;
1666
+ const parentBorderTop = parseFloat(parentComputed.borderTopWidth) || 0;
1667
+ const parentBorderBottom = parseFloat(parentComputed.borderBottomWidth) || 0;
1668
+ const parentInnerWidth = parentRect.width - parentBorderLeft - parentBorderRight;
1669
+ const parentInnerHeight = parentRect.height - parentBorderTop - parentBorderBottom;
1670
+ // Check which edges are flush (within 2px tolerance to handle subpixel rounding)
1671
+ const flushTop = Math.abs(pTop) < 2;
1672
+ const flushLeft = Math.abs(pLeft) < 2;
1673
+ const flushRight = Math.abs(pLeft + pWidth - parentRect.width) < 2 ||
1674
+ Math.abs(pLeft + pWidth - parentInnerWidth) < 2;
1675
+ const flushBottom = Math.abs(pTop + pHeight - parentRect.height) < 2 ||
1676
+ Math.abs(pTop + pHeight - parentInnerHeight) < 2;
1677
+ // Only apply if flush at a corner (top+spanning width, or side+spanning height)
1678
+ const spansFullWidth = flushLeft && flushRight;
1679
+ const spansFullHeight = flushTop && flushBottom;
1680
+ if ((flushTop && spansFullWidth) || (flushBottom && spansFullWidth) ||
1681
+ (flushLeft && spansFullHeight) || (flushRight && spansFullHeight)) {
1682
+ effectiveRectRadius = pxToInch(parentRadius);
1683
+ }
1684
+ }
1685
+ }
1686
+ }
1216
1687
  // Create shape element
1217
1688
  const shapeElement = {
1218
1689
  type: 'shape',
@@ -1230,15 +1701,40 @@ function extractPseudoElements(el, win) {
1230
1701
  gradient: gradient,
1231
1702
  transparency: hasBg ? extractAlpha(pComputed.backgroundColor) : null,
1232
1703
  line: null,
1233
- rectRadius: isEllipse ? 0 : rectRadius,
1704
+ rectRadius: effectiveRectRadius,
1234
1705
  shadow: shadow,
1235
1706
  opacity: hasOpacity ? elementOpacity : null,
1236
1707
  isEllipse: isEllipse,
1237
1708
  softEdge: null,
1709
+ rotate: null,
1238
1710
  customGeometry: null,
1239
1711
  },
1240
1712
  };
1241
1713
  results.push(shapeElement);
1714
+ // Add additional shape elements for extra gradient layers
1715
+ for (const extraGrad of extraPseudoGradients) {
1716
+ const extraShape = {
1717
+ type: 'shape',
1718
+ text: '',
1719
+ textRuns: null,
1720
+ style: null,
1721
+ position: { ...shapeElement.position },
1722
+ shape: {
1723
+ fill: null,
1724
+ gradient: extraGrad,
1725
+ transparency: null,
1726
+ line: null,
1727
+ rectRadius: 0,
1728
+ shadow: null,
1729
+ opacity: shapeElement.shape.opacity,
1730
+ isEllipse: false,
1731
+ softEdge: null,
1732
+ rotate: null,
1733
+ customGeometry: null,
1734
+ },
1735
+ };
1736
+ results.push(extraShape);
1737
+ }
1242
1738
  }
1243
1739
  return results;
1244
1740
  }
@@ -1408,8 +1904,10 @@ export function parseSlideHtml(doc) {
1408
1904
  return;
1409
1905
  }
1410
1906
  }
1411
- // Handle SPAN elements with backgrounds/borders as shapes
1412
- if (el.tagName === 'SPAN') {
1907
+ // Handle SPAN and A elements with backgrounds/borders as shapes
1908
+ // <a> tags styled as buttons (e.g. CTA buttons with gradient backgrounds,
1909
+ // border-radius, box-shadow) need the same shape treatment as SPANs.
1910
+ if (el.tagName === 'SPAN' || el.tagName === 'A') {
1413
1911
  const computed = win.getComputedStyle(el);
1414
1912
  const hasBg = computed.backgroundColor && computed.backgroundColor !== 'rgba(0, 0, 0, 0)';
1415
1913
  const hasBorder = (computed.borderWidth && parseFloat(computed.borderWidth) > 0) ||
@@ -1466,9 +1964,9 @@ export function parseSlideHtml(doc) {
1466
1964
  borderTop === borderRight &&
1467
1965
  borderRight === borderBottom &&
1468
1966
  borderBottom === borderLeft;
1469
- // Extract opacity
1470
- const spanOpacity = parseFloat(computed.opacity);
1471
- const hasSpanOpacity = !isNaN(spanOpacity) && spanOpacity < 1;
1967
+ // Extract effective opacity (including ancestor chain)
1968
+ const spanOpacity = getEffectiveOpacity(el, win);
1969
+ const hasSpanOpacity = spanOpacity < 1;
1472
1970
  // Extract box-shadow
1473
1971
  const spanShadow = parseBoxShadow(computed.boxShadow);
1474
1972
  // For small elements (badges, labels), disable text wrapping to prevent overflow
@@ -1513,6 +2011,7 @@ export function parseSlideHtml(doc) {
1513
2011
  opacity: hasSpanOpacity ? spanOpacity : null,
1514
2012
  isEllipse: spanIsEllipse,
1515
2013
  softEdge: null,
2014
+ rotate: null,
1516
2015
  customGeometry: null,
1517
2016
  },
1518
2017
  };
@@ -1676,9 +2175,9 @@ export function parseSlideHtml(doc) {
1676
2175
  imgComputed.webkitMaskImage ||
1677
2176
  imgComputed.getPropertyValue('mask-image') ||
1678
2177
  imgComputed.getPropertyValue('-webkit-mask-image');
1679
- // Extract CSS opacity early so we can bake it into the mask if present
1680
- const imgOpacity = parseFloat(imgComputed.opacity);
1681
- const hasOpacity = !isNaN(imgOpacity) && imgOpacity < 1 && imgOpacity >= 0;
2178
+ // Extract effective CSS opacity (including ancestor chain) so we can bake it into the mask if present
2179
+ const imgOpacity = getEffectiveOpacity(el, win);
2180
+ const hasOpacity = imgOpacity < 1 && imgOpacity >= 0;
1682
2181
  // Pre-bake CSS mask-image gradient into the image using canvas.
1683
2182
  // This creates a transparency gradient effect (PNG with alpha) that PPTX displays natively.
1684
2183
  // When a mask is applied, we also bake in the CSS opacity to ensure correct alpha blending.
@@ -1754,11 +2253,13 @@ export function parseSlideHtml(doc) {
1754
2253
  if (el.tagName.toLowerCase() === 'svg') {
1755
2254
  const rect = htmlEl.getBoundingClientRect();
1756
2255
  if (rect.width > 0 && rect.height > 0) {
1757
- // Skip SVGs with very low opacity - these are usually decorative overlays
1758
- // (e.g., grain textures, noise patterns) that won't render well in PPTX
2256
+ // Skip SVGs with very low own opacity - these are usually decorative overlays
2257
+ // (e.g., grain textures, noise patterns) that won't render well in PPTX.
2258
+ // Only check the element's own opacity for the skip decision, not ancestors.
2259
+ // Ancestor opacity is applied as transparency on the ImageElement below.
1759
2260
  const computedStyle = win.getComputedStyle(htmlEl);
1760
- const opacity = parseFloat(computedStyle.opacity || '1');
1761
- if (opacity < 0.1) {
2261
+ const ownOpacity = parseFloat(computedStyle.opacity || '1');
2262
+ if (ownOpacity < 0.1) {
1762
2263
  processed.add(el);
1763
2264
  el.querySelectorAll('*').forEach((child) => processed.add(child));
1764
2265
  return;
@@ -1907,6 +2408,10 @@ export function parseSlideHtml(doc) {
1907
2408
  }
1908
2409
  }
1909
2410
  }
2411
+ // Apply effective opacity (including ancestor chain) as image transparency.
2412
+ // PPTX doesn't have opacity containers, so the visual opacity must be
2413
+ // applied directly to each element.
2414
+ const svgEffectiveOpacity = getEffectiveOpacity(el, win);
1910
2415
  const imageElement = {
1911
2416
  type: 'image',
1912
2417
  src: dataUri,
@@ -1917,6 +2422,7 @@ export function parseSlideHtml(doc) {
1917
2422
  h: pxToInch(rect.height),
1918
2423
  },
1919
2424
  sizing: null,
2425
+ transparency: svgEffectiveOpacity < 1 ? Math.round((1 - svgEffectiveOpacity) * 100) : undefined,
1920
2426
  };
1921
2427
  elements.push(imageElement);
1922
2428
  processed.add(el);
@@ -2058,6 +2564,7 @@ export function parseSlideHtml(doc) {
2058
2564
  }
2059
2565
  // === CONIC GRADIENT: render as image ===
2060
2566
  // PPTX has no native conic-gradient support. Render as canvas image.
2567
+ let hasConicGradientImage = false;
2061
2568
  const divBgImageForConic = computed.backgroundImage;
2062
2569
  if (divBgImageForConic && divBgImageForConic.includes('conic-gradient')) {
2063
2570
  const rect = htmlEl.getBoundingClientRect();
@@ -2082,6 +2589,7 @@ export function parseSlideHtml(doc) {
2082
2589
  rectRadius: 0,
2083
2590
  };
2084
2591
  elements.push(imgElement);
2592
+ hasConicGradientImage = true;
2085
2593
  // Don't mark as fully processed — children (text, pseudo-elements) still need extraction
2086
2594
  // Just skip the shape creation for this element's background
2087
2595
  }
@@ -2097,10 +2605,54 @@ export function parseSlideHtml(doc) {
2097
2605
  let bgImageSize = null;
2098
2606
  let bgImagePosition = null;
2099
2607
  let bgGradient = null;
2608
+ let extraDivGradients = [];
2100
2609
  if (elBgImage && elBgImage !== 'none') {
2101
2610
  if (elBgImage.includes('linear-gradient') ||
2102
2611
  elBgImage.includes('radial-gradient')) {
2103
- bgGradient = parseCssGradient(elBgImage);
2612
+ const gradParts = splitCssBackgroundGradients(elBgImage);
2613
+ // Detect complex repeating-pattern backgrounds (4+ gradient layers,
2614
+ // often combined with a small background-size for tiling). These CSS
2615
+ // patterns (hex grids, noise textures, etc.) cannot be faithfully
2616
+ // reproduced as PPTX gradients, so we rasterize them to a canvas image.
2617
+ const bgSize = computed.backgroundSize;
2618
+ const isRepeatingPattern = gradParts.length >= 4 &&
2619
+ bgSize &&
2620
+ bgSize !== 'auto' &&
2621
+ !bgSize.includes('100%');
2622
+ if (isRepeatingPattern) {
2623
+ // Complex repeating CSS pattern — rasterize to canvas image.
2624
+ // PPTX gradients cannot represent tiled multi-layer patterns like
2625
+ // hex grids, but the Canvas 2D API can draw them faithfully.
2626
+ const rect = htmlEl.getBoundingClientRect();
2627
+ const patternDataUri = renderRepeatingGradientPatternAsImage(elBgImage, bgSize, computed.backgroundPosition || '0px 0px', rect.width, rect.height, win.document);
2628
+ if (patternDataUri) {
2629
+ const effOp = getEffectiveOpacity(htmlEl, win);
2630
+ const imgElement = {
2631
+ type: 'image',
2632
+ src: patternDataUri,
2633
+ position: {
2634
+ x: pxToInch(rect.left),
2635
+ y: pxToInch(rect.top),
2636
+ w: pxToInch(rect.width),
2637
+ h: pxToInch(rect.height),
2638
+ },
2639
+ sizing: null,
2640
+ rectRadius: 0,
2641
+ transparency: effOp < 1 ? Math.round((1 - effOp) * 100) : undefined,
2642
+ };
2643
+ elements.push(imgElement);
2644
+ }
2645
+ bgGradient = null;
2646
+ }
2647
+ else if (gradParts.length > 0) {
2648
+ bgGradient = parseCssGradient(gradParts[0]);
2649
+ // Collect additional gradient layers as separate shapes (up to 3 extra)
2650
+ for (let gi = 1; gi < Math.min(gradParts.length, 4); gi++) {
2651
+ const g = parseCssGradient(gradParts[gi]);
2652
+ if (g)
2653
+ extraDivGradients.push(g);
2654
+ }
2655
+ }
2104
2656
  }
2105
2657
  else {
2106
2658
  const urlMatch = elBgImage.match(/url\(["']?([^"')]+)["']?\)/);
@@ -2183,7 +2735,7 @@ export function parseSlideHtml(doc) {
2183
2735
  });
2184
2736
  }
2185
2737
  }
2186
- if (hasBg || hasBorder || bgImageUrl || bgGradient) {
2738
+ if (hasBg || hasBorder || bgImageUrl || bgGradient || hasConicGradientImage) {
2187
2739
  const rect = htmlEl.getBoundingClientRect();
2188
2740
  if (rect.width > 0 && rect.height > 0) {
2189
2741
  const shadow = parseBoxShadow(computed.boxShadow);
@@ -2557,9 +3109,9 @@ export function parseSlideHtml(doc) {
2557
3109
  }
2558
3110
  }
2559
3111
  if (hasBg || hasUniformBorder || bgGradient) {
2560
- // Detect element-level opacity
2561
- const elementOpacity = parseFloat(computed.opacity);
2562
- const hasOpacity = !isNaN(elementOpacity) && elementOpacity < 1;
3112
+ // Detect effective opacity (including ancestor chain)
3113
+ const elementOpacity = getEffectiveOpacity(htmlEl, win);
3114
+ const hasOpacity = elementOpacity < 1;
2563
3115
  // Detect CSS filter: blur() for soft-edge effect
2564
3116
  let softEdgePt = null;
2565
3117
  const filterStr = computed.filter;
@@ -2593,16 +3145,39 @@ export function parseSlideHtml(doc) {
2593
3145
  const paddingBottom = parseFloat(computed.paddingBottom) || 0;
2594
3146
  const paddingLeft = parseFloat(computed.paddingLeft) || 0;
2595
3147
  const hasPadding = paddingTop > 2 || paddingRight > 2 || paddingBottom > 2 || paddingLeft > 2;
3148
+ // --- CSS transform rotation handling ---
3149
+ // If the element has a CSS rotation, use the pre-rotation dimensions
3150
+ // and store the rotation angle for the PPTX shape. This prevents
3151
+ // thin rotated elements (e.g., decorative 1px gradient lines) from
3152
+ // being blown up to their bounding-box size.
3153
+ const rotationAngle = extractRotationAngle(computed);
3154
+ let shapeX = rect.left;
3155
+ let shapeY = rect.top;
3156
+ let shapeW = rect.width;
3157
+ let shapeH = rect.height;
3158
+ if (rotationAngle !== null) {
3159
+ const origW = parseFloat(computed.width);
3160
+ const origH = parseFloat(computed.height);
3161
+ if (!isNaN(origW) && origW > 0 && !isNaN(origH) && origH > 0) {
3162
+ // Use bounding-rect center as shape center
3163
+ const cx = (rect.left + rect.right) / 2;
3164
+ const cy = (rect.top + rect.bottom) / 2;
3165
+ shapeW = origW;
3166
+ shapeH = origH;
3167
+ shapeX = cx - origW / 2;
3168
+ shapeY = cy - origH / 2;
3169
+ }
3170
+ }
2596
3171
  const shapeElement = {
2597
3172
  type: 'shape',
2598
3173
  text: shapeText,
2599
3174
  textRuns: shapeTextRuns,
2600
3175
  style: shapeStyle,
2601
3176
  position: {
2602
- x: pxToInch(rect.left),
2603
- y: pxToInch(rect.top),
2604
- w: pxToInch(rect.width),
2605
- h: pxToInch(rect.height),
3177
+ x: pxToInch(shapeX),
3178
+ y: pxToInch(shapeY),
3179
+ w: pxToInch(shapeW),
3180
+ h: pxToInch(shapeH),
2606
3181
  },
2607
3182
  shape: {
2608
3183
  fill: hasBg ? rgbToHex(computed.backgroundColor) : null,
@@ -2637,13 +3212,14 @@ export function parseSlideHtml(doc) {
2637
3212
  opacity: hasOpacity ? elementOpacity : null,
2638
3213
  isEllipse: isEllipse,
2639
3214
  softEdge: softEdgePt,
3215
+ rotate: rotationAngle,
2640
3216
  customGeometry: clipPathPolygon ? (() => {
2641
3217
  // Convert percentage-based polygon points to EMU coordinates
2642
3218
  // relative to the shape's bounding box.
2643
3219
  // PptxGenJS custGeom uses the cx/cy (extent) as the path coordinate space.
2644
3220
  const EMU = 914400; // EMUs per inch
2645
- const pathW = Math.round(rect.width / PX_PER_IN * EMU);
2646
- const pathH = Math.round(rect.height / PX_PER_IN * EMU);
3221
+ const pathW = Math.round(shapeW / PX_PER_IN * EMU);
3222
+ const pathH = Math.round(shapeH / PX_PER_IN * EMU);
2647
3223
  const points = [];
2648
3224
  clipPathPolygon.forEach((pt, i) => {
2649
3225
  points.push({
@@ -2688,6 +3264,32 @@ export function parseSlideHtml(doc) {
2688
3264
  ];
2689
3265
  }
2690
3266
  elements.push(shapeElement);
3267
+ // Add additional shape elements for extra gradient layers (multiple CSS background gradients)
3268
+ if (extraDivGradients.length > 0) {
3269
+ for (const extraGrad of extraDivGradients) {
3270
+ const extraShape = {
3271
+ type: 'shape',
3272
+ text: '',
3273
+ textRuns: null,
3274
+ style: null,
3275
+ position: { ...shapeElement.position },
3276
+ shape: {
3277
+ fill: null,
3278
+ gradient: extraGrad,
3279
+ transparency: null,
3280
+ line: null,
3281
+ rectRadius: 0,
3282
+ shadow: null,
3283
+ opacity: shapeElement.shape.opacity,
3284
+ isEllipse: false,
3285
+ softEdge: null,
3286
+ rotate: null,
3287
+ customGeometry: null,
3288
+ },
3289
+ };
3290
+ elements.push(extraShape);
3291
+ }
3292
+ }
2691
3293
  }
2692
3294
  else if (shapeStyle && shapeStyle.fontFill) {
2693
3295
  const fontFillTextElement = {
@@ -2703,6 +3305,37 @@ export function parseSlideHtml(doc) {
2703
3305
  };
2704
3306
  elements.push(fontFillTextElement);
2705
3307
  }
3308
+ else if (hasConicGradientImage && shapeStyle && (shapeText || (shapeTextRuns && shapeTextRuns.length > 0))) {
3309
+ // Conic gradient div has text content but no bg/border — create a transparent
3310
+ // text-only shape to preserve flex alignment (center/middle) on top of the
3311
+ // conic gradient image.
3312
+ const conicTextElement = {
3313
+ type: 'shape',
3314
+ text: shapeText,
3315
+ textRuns: shapeTextRuns,
3316
+ style: shapeStyle,
3317
+ position: {
3318
+ x: pxToInch(rect.left),
3319
+ y: pxToInch(rect.top),
3320
+ w: pxToInch(rect.width),
3321
+ h: pxToInch(rect.height),
3322
+ },
3323
+ shape: {
3324
+ fill: null,
3325
+ gradient: null,
3326
+ transparency: null,
3327
+ line: null,
3328
+ rectRadius: 0,
3329
+ shadow: null,
3330
+ opacity: null,
3331
+ isEllipse: false,
3332
+ softEdge: null,
3333
+ rotate: null,
3334
+ customGeometry: null,
3335
+ },
3336
+ };
3337
+ elements.push(conicTextElement);
3338
+ }
2706
3339
  else if (isSingleTextChild) {
2707
3340
  processed.delete(textChildren[0]);
2708
3341
  }
@@ -3010,6 +3643,10 @@ export function parseSlideHtml(doc) {
3010
3643
  // find the actual text position using a Range on the text nodes.
3011
3644
  // This ensures text is positioned at its visual location, not the container's.
3012
3645
  const isFlexContainer = computed.display === 'flex' || computed.display === 'inline-flex';
3646
+ // Track the content child element if a specific child was selected for text
3647
+ // extraction (used later to scope parseInlineFormatting to the content child
3648
+ // instead of the full parent, avoiding duplicate text from shape-children).
3649
+ let flexContentChild = null;
3013
3650
  if (isFlexContainer && el.children.length > 0) {
3014
3651
  // Collect direct text nodes (not in child elements)
3015
3652
  const textNodes = [];
@@ -3033,16 +3670,50 @@ export function parseSlideHtml(doc) {
3033
3670
  }
3034
3671
  }
3035
3672
  else {
3036
- // No direct text nodes — check for text inside child SPAN elements
3037
- // This handles flex LI items like: <li><span class="icon"></span><span class="text">...</span></li>
3673
+ // No direct text nodes — check for text inside child SPAN/elements.
3674
+ // This handles flex LI items like:
3675
+ // <li><span class="icon">1</span><span class="text">Content...</span></li>
3676
+ //
3677
+ // Skip children that have a background or border — those will be
3678
+ // extracted as independent shapes (e.g. numbered circle badges).
3679
+ // Use the first text-bearing "content" child for the rect and text
3680
+ // so the text element is sized to the content area, not the badge.
3038
3681
  for (const child of Array.from(el.children)) {
3039
- if (child.tagName === 'SPAN' && child.textContent?.trim()) {
3040
- const childRect = child.getBoundingClientRect();
3041
- if (childRect.width > 0 && childRect.height > 0) {
3042
- rect = childRect;
3043
- const childComputed = win.getComputedStyle(child);
3044
- text = getTransformedText(child, childComputed);
3045
- break;
3682
+ if (!child.textContent?.trim())
3683
+ continue;
3684
+ const childEl = child;
3685
+ const childComp = win.getComputedStyle(childEl);
3686
+ const childHasBg = childComp.backgroundColor && childComp.backgroundColor !== 'rgba(0, 0, 0, 0)';
3687
+ const childHasBorder = (parseFloat(childComp.borderTopWidth) || 0) > 0 ||
3688
+ (parseFloat(childComp.borderRightWidth) || 0) > 0 ||
3689
+ (parseFloat(childComp.borderBottomWidth) || 0) > 0 ||
3690
+ (parseFloat(childComp.borderLeftWidth) || 0) > 0;
3691
+ const childHasGradient = childComp.backgroundImage && childComp.backgroundImage !== 'none' &&
3692
+ (childComp.backgroundImage.includes('linear-gradient') || childComp.backgroundImage.includes('radial-gradient'));
3693
+ // Skip shape-like children (bg, border, gradient) — they become
3694
+ // independent shapes and their text is already included there.
3695
+ if (childHasBg || childHasBorder || childHasGradient)
3696
+ continue;
3697
+ const childRect = childEl.getBoundingClientRect();
3698
+ if (childRect.width > 0 && childRect.height > 0) {
3699
+ flexContentChild = child;
3700
+ rect = childRect;
3701
+ text = getTransformedText(childEl, childComp);
3702
+ break;
3703
+ }
3704
+ }
3705
+ // If no content child found (all children are shapes), fall back
3706
+ // to the first child with text for backwards compatibility.
3707
+ if (!flexContentChild) {
3708
+ for (const child of Array.from(el.children)) {
3709
+ if (child.textContent?.trim()) {
3710
+ const childRect = child.getBoundingClientRect();
3711
+ if (childRect.width > 0 && childRect.height > 0) {
3712
+ rect = childRect;
3713
+ const childComputed = win.getComputedStyle(child);
3714
+ text = getTransformedText(child, childComputed);
3715
+ break;
3716
+ }
3046
3717
  }
3047
3718
  }
3048
3719
  }
@@ -3135,10 +3806,14 @@ export function parseSlideHtml(doc) {
3135
3806
  }
3136
3807
  }
3137
3808
  }
3138
- const hasFormatting = el.querySelector('b, i, u, strong, em, span, br, code, a, mark, sub, sup, small, s, del, ins, abbr, time, cite, q, dfn, kbd, samp, var');
3809
+ // When a specific content child was selected from a flex container,
3810
+ // scope formatting checks & parseInlineFormatting to that child so
3811
+ // text from shape-children (e.g. numbered circles) isn't duplicated.
3812
+ const formattingRoot = flexContentChild ?? el;
3813
+ 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');
3139
3814
  if (hasFormatting) {
3140
3815
  const transformStr = computed.textTransform;
3141
- const runs = parseInlineFormatting(el, {}, [], (str) => applyTextTransform(str, transformStr), win);
3816
+ const runs = parseInlineFormatting(formattingRoot, {}, [], (str) => applyTextTransform(str, transformStr), win);
3142
3817
  const textElement = {
3143
3818
  type: el.tagName.toLowerCase(),
3144
3819
  text: runs,
@@ -3165,6 +3840,14 @@ export function parseSlideHtml(doc) {
3165
3840
  elements.push(textElement);
3166
3841
  }
3167
3842
  processed.add(el);
3843
+ // When a flex content child was selected for text extraction (e.g., a DIV
3844
+ // inside a flex LI), mark it and all its descendants as processed so the
3845
+ // DOM walk doesn't create duplicate text elements from child <p> tags
3846
+ // (injected by transform.ts text wrapping) or other text-bearing children.
3847
+ if (flexContentChild) {
3848
+ processed.add(flexContentChild);
3849
+ flexContentChild.querySelectorAll('*').forEach(desc => processed.add(desc));
3850
+ }
3168
3851
  });
3169
3852
  // Second pass: extract pseudo-elements for all processed elements
3170
3853
  // This must be done after the main pass so we know which elements have been processed