domotion-svg 0.2.2 → 0.3.2

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 (115) hide show
  1. package/FEATURES.md +1 -0
  2. package/README.md +29 -0
  3. package/dist/animation/animator.js +25 -14
  4. package/dist/animation/animator.test.js +54 -21
  5. package/dist/animation/cursor-overlay.js +0 -2
  6. package/dist/capture/emoji.js +29 -18
  7. package/dist/capture/index.js +5 -4
  8. package/dist/capture/script/color-norm.d.ts +1 -0
  9. package/dist/capture/script/color-norm.js +43 -1
  10. package/dist/capture/script/emoji-detect.js +14 -0
  11. package/dist/capture/script/index.js +593 -65
  12. package/dist/capture/script/walker/borders-backgrounds.d.ts +24 -17
  13. package/dist/capture/script/walker/borders-backgrounds.js +123 -7
  14. package/dist/capture/script/walker/counter-style-resolver.d.ts +7 -0
  15. package/dist/capture/script/walker/counter-style-resolver.js +218 -0
  16. package/dist/capture/script/walker/input-value.js +14 -1
  17. package/dist/capture/script/walker/lists-counters.d.ts +3 -1
  18. package/dist/capture/script/walker/lists-counters.js +22 -2
  19. package/dist/capture/script/walker/masks-clips.d.ts +2 -0
  20. package/dist/capture/script/walker/masks-clips.js +41 -1
  21. package/dist/capture/script/walker/pseudo-content.d.ts +14 -1
  22. package/dist/capture/script/walker/pseudo-content.js +301 -61
  23. package/dist/capture/script/walker/pseudo-inject.js +20 -0
  24. package/dist/capture/script/walker/text-segments.js +98 -4
  25. package/dist/capture/script/walker/transforms.d.ts +1 -0
  26. package/dist/capture/script/walker/transforms.js +16 -0
  27. package/dist/capture/script.generated.js +1 -1
  28. package/dist/capture/types.d.ts +213 -2
  29. package/dist/cli/animate.js +151 -15
  30. package/dist/mask.test.js +12 -7
  31. package/dist/render/borders.d.ts +9 -13
  32. package/dist/render/borders.js +379 -14
  33. package/dist/render/element-tree-to-svg.d.ts +11 -12
  34. package/dist/render/element-tree-to-svg.js +2046 -241
  35. package/dist/render/embedded-font-builder.d.ts +49 -0
  36. package/dist/render/embedded-font-builder.js +149 -0
  37. package/dist/render/form-controls.js +45 -24
  38. package/dist/render/gradients.d.ts +15 -0
  39. package/dist/render/gradients.js +103 -2
  40. package/dist/render/gradients.test.js +34 -0
  41. package/dist/render/text-to-path.d.ts +38 -1
  42. package/dist/render/text-to-path.js +654 -29
  43. package/dist/render/text-to-path.test.js +230 -9
  44. package/dist/render/text.d.ts +14 -0
  45. package/dist/render/text.js +344 -40
  46. package/dist/scroll/composer.d.ts +26 -0
  47. package/dist/scroll/composer.js +199 -11
  48. package/dist/scroll/composer.test.js +293 -16
  49. package/dist/scroll/executor.d.ts +3 -1
  50. package/dist/scroll/executor.js +15 -6
  51. package/dist/scroll/executor.test.js +25 -0
  52. package/dist/scroll/hoist-fixed.d.ts +48 -0
  53. package/dist/scroll/hoist-fixed.js +85 -0
  54. package/dist/scroll/hoist-fixed.test.d.ts +1 -0
  55. package/dist/scroll/hoist-fixed.test.js +103 -0
  56. package/dist/scroll/hoist-sticky.d.ts +45 -0
  57. package/dist/scroll/hoist-sticky.js +157 -0
  58. package/dist/scroll/hoist-sticky.test.d.ts +1 -0
  59. package/dist/scroll/hoist-sticky.test.js +154 -0
  60. package/dist/scroll/pattern.d.ts +22 -5
  61. package/dist/scroll/pattern.js +55 -7
  62. package/dist/scroll/pattern.test.js +48 -1
  63. package/dist/tree-ops/frame-merge.d.ts +10 -0
  64. package/dist/tree-ops/frame-merge.js +23 -5
  65. package/dist/tree-ops/frame-merge.test.js +45 -0
  66. package/dist/tree-ops/tree-diff.js +1 -1
  67. package/dist/tree-ops/viewbox-culling.js +32 -18
  68. package/dist/tree-ops/viewbox-culling.test.js +40 -6
  69. package/package.json +8 -2
  70. package/src/animation/animator.test.ts +56 -21
  71. package/src/animation/animator.ts +25 -14
  72. package/src/animation/cursor-overlay.ts +0 -2
  73. package/src/capture/emoji.ts +28 -18
  74. package/src/capture/index.ts +15 -14
  75. package/src/capture/script/color-norm.ts +38 -1
  76. package/src/capture/script/emoji-detect.ts +14 -0
  77. package/src/capture/script/index.ts +555 -48
  78. package/src/capture/script/walker/borders-backgrounds.ts +114 -7
  79. package/src/capture/script/walker/counter-style-resolver.ts +184 -0
  80. package/src/capture/script/walker/input-value.ts +14 -1
  81. package/src/capture/script/walker/lists-counters.ts +24 -2
  82. package/src/capture/script/walker/masks-clips.ts +40 -1
  83. package/src/capture/script/walker/pseudo-content.ts +297 -55
  84. package/src/capture/script/walker/pseudo-inject.ts +20 -0
  85. package/src/capture/script/walker/text-segments.ts +93 -4
  86. package/src/capture/script/walker/transforms.ts +14 -0
  87. package/src/capture/script.generated.ts +1 -1
  88. package/src/capture/types.ts +202 -2
  89. package/src/cli/animate.ts +135 -15
  90. package/src/mask.test.ts +12 -7
  91. package/src/render/borders.ts +383 -17
  92. package/src/render/element-tree-to-svg.ts +2051 -238
  93. package/src/render/embedded-font-builder.ts +221 -0
  94. package/src/render/form-controls.ts +45 -24
  95. package/src/render/gradients.test.ts +46 -0
  96. package/src/render/gradients.ts +94 -2
  97. package/src/render/opentype.js.d.ts +7 -0
  98. package/src/render/text-to-path.test.ts +246 -9
  99. package/src/render/text-to-path.ts +702 -31
  100. package/src/render/text.ts +344 -40
  101. package/src/scroll/composer.test.ts +322 -16
  102. package/src/scroll/composer.ts +246 -13
  103. package/src/scroll/executor.test.ts +27 -0
  104. package/src/scroll/executor.ts +19 -10
  105. package/src/scroll/hoist-fixed.test.ts +117 -0
  106. package/src/scroll/hoist-fixed.ts +95 -0
  107. package/src/scroll/hoist-sticky.test.ts +173 -0
  108. package/src/scroll/hoist-sticky.ts +193 -0
  109. package/src/scroll/pattern.test.ts +58 -1
  110. package/src/scroll/pattern.ts +71 -8
  111. package/src/tree-ops/frame-merge.test.ts +51 -0
  112. package/src/tree-ops/frame-merge.ts +24 -6
  113. package/src/tree-ops/tree-diff.ts +3 -1
  114. package/src/tree-ops/viewbox-culling.test.ts +42 -6
  115. package/src/tree-ops/viewbox-culling.ts +32 -18
@@ -53,7 +53,124 @@
53
53
  // element's own textSegments. Those depend on text shaping state that
54
54
  // hasn't been pulled out of captureInner yet — follow-up.
55
55
 
56
- export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics, textNeedsRaster }) => {
56
+ export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics, textNeedsRaster, resolveCounterValue, isCustomCounterStyle }) => {
57
+ // DM-785: Chrome's HarfBuzz-shaped layout width differs from
58
+ // `canvas.measureText` by ~1-3px on bold uppercase short strings (the
59
+ // gradient-pill / MOST POPULAR / NEW badge pattern). Measuring via an
60
+ // off-screen <span> with the pseudo's resolved font properties and reading
61
+ // `getBoundingClientRect().width` matches the painted width exactly because
62
+ // it goes through the same shaping pipeline Chrome uses for layout. Only
63
+ // matters for `width: auto` absolute pseudos — the DM-507 numeric `pcs.width`
64
+ // path is still authoritative when present.
65
+ const probePseudoTextWidth = (text, pcs) => {
66
+ const span = document.createElement('span');
67
+ span.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none;left:-99999px;top:-99999px;white-space:pre;line-height:normal;margin:0;padding:0;border:0;text-indent:0';
68
+ span.style.fontFamily = pcs.fontFamily || '';
69
+ span.style.fontSize = pcs.fontSize || '';
70
+ span.style.fontWeight = pcs.fontWeight || '';
71
+ span.style.fontStyle = pcs.fontStyle || '';
72
+ span.style.fontStretch = pcs.fontStretch || '';
73
+ span.style.fontVariant = pcs.fontVariant || '';
74
+ span.style.fontFeatureSettings = pcs.fontFeatureSettings || '';
75
+ span.style.fontVariationSettings = pcs.fontVariationSettings || '';
76
+ span.style.letterSpacing = pcs.letterSpacing || '';
77
+ span.style.wordSpacing = pcs.wordSpacing || '';
78
+ span.textContent = text;
79
+ document.body.appendChild(span);
80
+ const w = span.getBoundingClientRect().width;
81
+ document.body.removeChild(span);
82
+ return w;
83
+ };
84
+
85
+ // DM-768: when a static-flow pseudo declares `display: inline-block` (or
86
+ // inline-flex / inline-grid / inline-table) the box participates in Chrome's
87
+ // inline vertical-align math — `vertical-align: middle` aligns the pseudo's
88
+ // mid-point with the parent's baseline + 0.5 × x-height, `baseline` aligns
89
+ // the pseudo's bottom to the parent baseline, etc. The earlier formula
90
+ // (`rect.top + hostBorT + hostPadT + pMarT`) ignores that and places the
91
+ // pseudo at the host's content-area top — i.e. the line-box top — so an
92
+ // inline-block down-caret with `border-top: 5px solid` paints 6-7 px too
93
+ // high inside its parent button. Probe instead: insert a real sentinel
94
+ // mirroring the pseudo's box (display / size / borders / padding / margin /
95
+ // vertical-align) at the pseudo's logical position in the host and read its
96
+ // `getBoundingClientRect()`. Chrome lays out the sentinel exactly where the
97
+ // pseudo would have gone, so we get the correct x/y without re-deriving
98
+ // font metrics + vertical-align semantics ourselves.
99
+ const probePseudoStaticBoxRect = (el, pseudo, pcs) => {
100
+ const probe = document.createElement('span');
101
+ probe.style.cssText = 'pointer-events:none;visibility:hidden;box-sizing:content-box';
102
+ probe.style.display = pcs.display;
103
+ probe.style.width = pcs.width;
104
+ probe.style.height = pcs.height;
105
+ probe.style.paddingTop = pcs.paddingTop;
106
+ probe.style.paddingRight = pcs.paddingRight;
107
+ probe.style.paddingBottom = pcs.paddingBottom;
108
+ probe.style.paddingLeft = pcs.paddingLeft;
109
+ probe.style.borderTopWidth = pcs.borderTopWidth;
110
+ probe.style.borderRightWidth = pcs.borderRightWidth;
111
+ probe.style.borderBottomWidth = pcs.borderBottomWidth;
112
+ probe.style.borderLeftWidth = pcs.borderLeftWidth;
113
+ probe.style.borderStyle = 'solid';
114
+ probe.style.borderColor = 'transparent';
115
+ probe.style.marginTop = pcs.marginTop;
116
+ probe.style.marginRight = pcs.marginRight;
117
+ probe.style.marginBottom = pcs.marginBottom;
118
+ probe.style.marginLeft = pcs.marginLeft;
119
+ probe.style.verticalAlign = pcs.verticalAlign;
120
+ probe.style.font = ''; // inherit so line-box metrics match the pseudo's parent
121
+ if (pseudo === '::before') el.insertBefore(probe, el.firstChild);
122
+ else el.appendChild(probe);
123
+ const r = probe.getBoundingClientRect();
124
+ probe.remove();
125
+ return r;
126
+ };
127
+
128
+ // For `position: absolute` / `position: fixed` pseudos, the containing block
129
+ // is the nearest positioned ancestor of the host (NOT the host itself when
130
+ // the host is `position: static`). NYT's mobile nav `.css-sdhjrl::after`
131
+ // fade-out is `position: absolute; right: 0; top:0; width: 24px; height: 40px`
132
+ // on a `position: static; display: flex; overflow: scroll` NAV — the pseudo's
133
+ // computed `top` / `left` resolve against a far-up ancestor, so naïvely adding
134
+ // them to the host's padding-box origin places the gradient ~3088px below the
135
+ // NAV (where there's no NAV to fade over). Instead, inject a real absolutely-
136
+ // positioned sentinel as a child of the host: it inherits the same containing
137
+ // block the pseudo would have, and Chrome lays it out at the exact rect the
138
+ // pseudo paints to. Read its `getBoundingClientRect` directly.
139
+ const probePseudoAbsoluteBoxRect = (el, pseudo, pcs) => {
140
+ const probe = document.createElement('div');
141
+ probe.style.cssText = 'pointer-events:none;visibility:hidden;box-sizing:content-box;margin:0';
142
+ probe.style.position = pcs.position;
143
+ probe.style.top = pcs.top;
144
+ probe.style.right = pcs.right;
145
+ probe.style.bottom = pcs.bottom;
146
+ probe.style.left = pcs.left;
147
+ probe.style.width = pcs.width;
148
+ probe.style.height = pcs.height;
149
+ probe.style.paddingTop = pcs.paddingTop;
150
+ probe.style.paddingRight = pcs.paddingRight;
151
+ probe.style.paddingBottom = pcs.paddingBottom;
152
+ probe.style.paddingLeft = pcs.paddingLeft;
153
+ probe.style.borderTopWidth = pcs.borderTopWidth;
154
+ probe.style.borderRightWidth = pcs.borderRightWidth;
155
+ probe.style.borderBottomWidth = pcs.borderBottomWidth;
156
+ probe.style.borderLeftWidth = pcs.borderLeftWidth;
157
+ probe.style.borderStyle = 'solid';
158
+ probe.style.borderColor = 'transparent';
159
+ probe.style.marginTop = pcs.marginTop;
160
+ probe.style.marginRight = pcs.marginRight;
161
+ probe.style.marginBottom = pcs.marginBottom;
162
+ probe.style.marginLeft = pcs.marginLeft;
163
+ probe.style.transform = pcs.transform && pcs.transform !== 'none' ? pcs.transform : '';
164
+ probe.style.transformOrigin = pcs.transformOrigin || '';
165
+ // Pseudo lives logically inside the host; an absolute child of the host
166
+ // inherits the same containing-block lookup.
167
+ if (pseudo === '::before') el.insertBefore(probe, el.firstChild);
168
+ else el.appendChild(probe);
169
+ const r = probe.getBoundingClientRect();
170
+ probe.remove();
171
+ return r;
172
+ };
173
+
57
174
  const pickQuoteChar = (forEl, isOpen) => {
58
175
  // Count q-element ancestors above this element (depth=0 = the first q
59
176
  // not inside another q). The pseudo lives ON forEl so when forEl IS a
@@ -105,6 +222,16 @@ export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics,
105
222
  const pcs = window.getComputedStyle(el, pseudo);
106
223
  const content = pcs.content;
107
224
  if (content == null || content === 'none' || content === 'normal' || content === '') continue;
225
+ // DM-665 / DM-677: pseudos with computed `opacity: 0` paint nothing in
226
+ // Chrome (Material-style ripple / hover overlays use this — Google's
227
+ // `a.gb_C::before` is the empty-content variant we already skipped;
228
+ // `a.gb_A::before` on the mobile "Sign in" pill is the same idea but
229
+ // with `content: " "` (a single space, non-empty) so it slipped past
230
+ // the previous gate). Skip ALL opacity-zero pseudos before doing any
231
+ // measurement / box-rect work; capturing them anyway would paint an
232
+ // opaque box over the host's actual content.
233
+ const opacityNum = parseFloat(pcs.opacity);
234
+ if (Number.isFinite(opacityNum) && opacityNum === 0) continue;
108
235
 
109
236
  let text = '';
110
237
  let imageUrl = '';
@@ -145,15 +272,26 @@ export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics,
145
272
  });
146
273
  const cname = args[0];
147
274
  const sep = isCounters ? (args[1] ?? '') : '';
148
- // counter-style argument (third arg of counters, second of
149
- // counter) is currently ignored decimal-only. Most fixtures
150
- // use the default style; non-decimal can be added later.
275
+ // DM-788: third arg of counters() / second arg of counter() is a
276
+ // `<counter-style>` name. When that name matches a custom
277
+ // `@counter-style` rule captured in the pre-walk, run each value
278
+ // through the resolver so prefix / suffix / pad / negative / range
279
+ // / fallback descriptors apply — e.g. `counter(step, prefixed)`
280
+ // produces "Step 01: " instead of plain decimal "1".
281
+ const styleArg = isCounters ? args[2] : args[1];
282
+ const useCustomStyle = styleArg != null && styleArg !== ''
283
+ && isCustomCounterStyle != null && isCustomCounterStyle(styleArg);
284
+ const format = (v) => {
285
+ if (!useCustomStyle) return String(v);
286
+ const out = resolveCounterValue(styleArg, v);
287
+ return out != null ? out : String(v);
288
+ };
151
289
  const snapshot = counterSnapshot.get(el) || [];
152
- const matches = snapshot.filter((s) => s.name === cname).map((s) => String(s.value));
290
+ const matches = snapshot.filter((s) => s.name === cname).map((s) => format(s.value));
153
291
  if (isCounters) {
154
- text += matches.length > 0 ? matches.join(sep) : '0';
292
+ text += matches.length > 0 ? matches.join(sep) : format(0);
155
293
  } else {
156
- text += matches.length > 0 ? matches[matches.length - 1] : '0';
294
+ text += matches.length > 0 ? matches[matches.length - 1] : format(0);
157
295
  }
158
296
  i = closeIdx + 1;
159
297
  } else if (content.startsWith('open-quote', i)) {
@@ -180,13 +318,28 @@ export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics,
180
318
  // side or a visible background.
181
319
  const bgRaw = pcs.backgroundColor;
182
320
  const hasBg = bgRaw && bgRaw !== '' && bgRaw !== 'rgba(0, 0, 0, 0)' && bgRaw !== 'transparent';
321
+ // DM-767: capture background-image (linear-gradient / radial-gradient /
322
+ // url) on empty-content pseudos too. The `.corner::after` accent stripe
323
+ // pattern in `24-deep-pseudo-shapes` is an absolutely-positioned 4 px
324
+ // strip with a `linear-gradient` background and no color / border —
325
+ // without this check the pseudoBox emit was skipped entirely.
326
+ const bgImgRaw = pcs.backgroundImage;
327
+ const hasBgImg = bgImgRaw != null && bgImgRaw !== '' && bgImgRaw !== 'none';
183
328
  const bwT = parseFloat(pcs.borderTopWidth) || 0;
184
329
  const bwR = parseFloat(pcs.borderRightWidth) || 0;
185
330
  const bwB = parseFloat(pcs.borderBottomWidth) || 0;
186
331
  const bwL = parseFloat(pcs.borderLeftWidth) || 0;
187
332
  const hasBorder = bwT > 0 || bwR > 0 || bwB > 0 || bwL > 0;
188
333
  const isBlockLike = pcs.display === 'block' || pcs.display === 'inline-block' || pcs.display === 'flex';
189
- if (isBlockLike && (hasBg || hasBorder)) {
334
+ // DM-665: `opacity: 0` pseudos paint nothing (Material-style ripple
335
+ // / hover overlays use this pattern — Google's `a.gb_C::before` is
336
+ // a 40×40 dark-grey absolute box that's invisible at rest). Capturing
337
+ // them anyway would paint an opaque box over the host's content
338
+ // (the apps-grid SVG underneath the anchor). Skip empty-content
339
+ // pseudos whose `opacity: 0` makes them visually a no-op.
340
+ const opacityNum = parseFloat(pcs.opacity);
341
+ if (Number.isFinite(opacityNum) && opacityNum === 0) continue;
342
+ if (isBlockLike && (hasBg || hasBgImg || hasBorder)) {
190
343
  const hostPadL = parseFloat(cs.paddingLeft) || 0;
191
344
  const hostPadT = parseFloat(cs.paddingTop) || 0;
192
345
  const hostBorL = parseFloat(cs.borderLeftWidth) || 0;
@@ -214,37 +367,97 @@ export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics,
214
367
  let borderBoxX;
215
368
  let borderBoxY;
216
369
  if (pcs.position === 'absolute' || pcs.position === 'fixed') {
217
- const pcsLeft = parseFloat(pcs.left);
218
- const pcsTop = parseFloat(pcs.top);
219
- const pcsRight = parseFloat(pcs.right);
220
- const pcsBottom = parseFloat(pcs.bottom);
221
- const paddingBoxL = rect.left - vp.x + hostBorL;
222
- const paddingBoxT = rect.top - vp.y + hostBorT;
223
- const paddingBoxR = rect.right - vp.x - hostBorR;
224
- const paddingBoxB = rect.bottom - vp.y - (parseFloat(cs.borderBottomWidth) || 0);
225
- if (!isNaN(pcsLeft)) borderBoxX = paddingBoxL + pcsLeft;
226
- else if (!isNaN(pcsRight)) borderBoxX = paddingBoxR - pcsRight - borderBoxW;
227
- else borderBoxX = paddingBoxL;
228
- if (!isNaN(pcsTop)) borderBoxY = paddingBoxT + pcsTop;
229
- else if (!isNaN(pcsBottom)) borderBoxY = paddingBoxB - pcsBottom - borderBoxH;
230
- else borderBoxY = paddingBoxT;
370
+ // Use a real positioned sentinel to find the pseudo's true painted
371
+ // rect. Chrome's containing-block lookup walks up from the host
372
+ // looking for a positioned ancestor (or a transformed / filtered
373
+ // / contained ancestor); the same lookup applies to a real child
374
+ // of the host. Probing avoids re-implementing that walk + all the
375
+ // containing-block-establishing properties. NYT mobile's nav fade-
376
+ // out `.css-sdhjrl::after` (`position: absolute; right: 0`) on a
377
+ // `position: static` NAV is the trigger case the pseudo's
378
+ // resolved `top` / `left` are relative to a far-up positioned
379
+ // ancestor, not the NAV, so the prior additive math placed the
380
+ // gradient thousands of pixels off the NAV.
381
+ const pr = probePseudoAbsoluteBoxRect(el, pseudo, pcs);
382
+ borderBoxX = pr.left - vp.x;
383
+ borderBoxY = pr.top - vp.y;
231
384
  } else {
232
385
  const pMarT = parseFloat(pcs.marginTop) || 0;
233
386
  borderBoxX = rect.left - vp.x + hostBorL + hostPadL + pMarL;
234
387
  borderBoxY = rect.top - vp.y + hostBorT + hostPadT + pMarT;
388
+ // DM-768: static `display: inline-block` (and inline-flex / inline-grid /
389
+ // inline-table) pseudos participate in Chrome's inline vertical-align
390
+ // math — the formula above ignores `vertical-align` and pins the box to
391
+ // the host's content-area top, which is 6-7 px too high for a typical
392
+ // `vertical-align: middle` down-caret. Probe with a real sentinel that
393
+ // mirrors the pseudo's box properties.
394
+ //
395
+ // CSS render order on the line is: ::before → real children → ::after.
396
+ // The sentinel is a real child:
397
+ // - For ::before, the sentinel renders AFTER the pseudo. The
398
+ // pseudo's own position is unchanged; the sentinel just shifts
399
+ // subsequent content. So the pseudo's border-box left =
400
+ // probe.left − pMarR − borderBoxW − pMarL (back out the sentinel
401
+ // gap), and the pseudo's top equals probe.top (both lay out
402
+ // on the same line with matching `vertical-align`).
403
+ // - For ::after, the sentinel renders BEFORE the pseudo. Without
404
+ // the sentinel, the pseudo would take the slot the sentinel
405
+ // now occupies, so the pseudo's border-box left = probe.left
406
+ // and top = probe.top.
407
+ const dispIsInline = pcs.display === 'inline-block' || pcs.display === 'inline-flex' || pcs.display === 'inline-grid' || pcs.display === 'inline-table';
408
+ if (dispIsInline) {
409
+ const pr = probePseudoStaticBoxRect(el, pseudo, pcs);
410
+ borderBoxY = pr.top - vp.y;
411
+ if (pseudo === '::after') {
412
+ borderBoxX = pr.left - vp.x;
413
+ } else {
414
+ const pMarR = parseFloat(pcs.marginRight) || 0;
415
+ borderBoxX = pr.left - vp.x - pMarR - borderBoxW - pMarL;
416
+ }
417
+ }
418
+ }
419
+ // DM-710: if the host has a CSS transform whose 2D submatrix is
420
+ // singular (zero determinant), the host's painted area collapses
421
+ // to a point / line and the pseudo paints nothing visible —
422
+ // Apple's `.globalnav-bag-badge` carries `transform: matrix(0, 0,
423
+ // 0, 0, 0, 0)` as the "no items in cart" state, and the empty
424
+ // ::before with `width: 13px; background: black; border-radius:
425
+ // 13px` would otherwise emit as a visible dot. Skip the pseudoBox
426
+ // in that case; the live-rect model already drops the host itself.
427
+ let degenerateHostTransform = false;
428
+ if (cs.transform && cs.transform !== 'none') {
429
+ const m2 = /^matrix\(\s*([-\d.eE]+)\s*,\s*([-\d.eE]+)\s*,\s*([-\d.eE]+)\s*,\s*([-\d.eE]+)/.exec(cs.transform);
430
+ if (m2) {
431
+ const a = parseFloat(m2[1]);
432
+ const b = parseFloat(m2[2]);
433
+ const c = parseFloat(m2[3]);
434
+ const d = parseFloat(m2[4]);
435
+ if (Math.abs(a * d - b * c) < 1e-9) degenerateHostTransform = true;
436
+ }
235
437
  }
236
- if (borderBoxW > 0 && borderBoxH > 0) {
438
+ if (borderBoxW > 0 && borderBoxH > 0 && !degenerateHostTransform) {
439
+ // DM-783: the pseudo's own `transform` (rotate/scale/translate/
440
+ // matrix) wraps the pseudoBox at render time. getComputedStyle
441
+ // returns the resolved matrix() form, and transformOrigin returns
442
+ // resolved px values relative to the pseudo's box top-left — both
443
+ // can be pasted directly into an SVG `<g>` wrapper. Captured only
444
+ // when non-`none` to keep the captured tree compact.
445
+ const pcsTransform = pcs.transform && pcs.transform !== 'none' ? pcs.transform : undefined;
446
+ const pcsTransformOrigin = pcsTransform != null ? (pcs.transformOrigin || undefined) : undefined;
237
447
  pseudoBoxes.push({
238
448
  x: borderBoxX,
239
449
  y: borderBoxY,
240
450
  width: borderBoxW,
241
451
  height: borderBoxH,
242
452
  backgroundColor: hasBg ? normColor(bgRaw) : undefined,
453
+ backgroundImage: hasBgImg ? bgImgRaw : undefined,
243
454
  borderTopWidth: bwT, borderTopColor: bwT > 0 ? normColor(pcs.borderTopColor) : undefined, borderTopStyle: pcs.borderTopStyle,
244
455
  borderRightWidth: bwR, borderRightColor: bwR > 0 ? normColor(pcs.borderRightColor) : undefined, borderRightStyle: pcs.borderRightStyle,
245
456
  borderBottomWidth: bwB, borderBottomColor: bwB > 0 ? normColor(pcs.borderBottomColor) : undefined, borderBottomStyle: pcs.borderBottomStyle,
246
457
  borderLeftWidth: bwL, borderLeftColor: bwL > 0 ? normColor(pcs.borderLeftColor) : undefined, borderLeftStyle: pcs.borderLeftStyle,
247
458
  borderRadius: parseFloat(pcs.borderRadius) || 0,
459
+ transform: pcsTransform,
460
+ transformOrigin: pcsTransformOrigin,
248
461
  });
249
462
  }
250
463
  }
@@ -303,22 +516,12 @@ export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics,
303
516
  }
304
517
  if (text === '') continue;
305
518
 
306
- // Measure via canvas using the pseudo's computed font.
307
- const fontSpec = pcs.font || (pcs.fontWeight + ' ' + pcs.fontSize + ' ' + pcs.fontFamily);
308
- const measureCanvas = document.createElement('canvas');
309
- const mctx = measureCanvas.getContext('2d');
310
- mctx.font = fontSpec;
311
- // DM-507: prefer Chrome's resolved layout width over
312
- // canvas.measureText when available. For position:absolute pseudos
313
- // with auto-width Chrome shrink-to-fits the box and
314
- // getComputedStyle returns the resolved content-box width.
315
- // canvas.measureText drifts ~1-2px from Chrome's actual layout in
316
- // common bold / symbol-mix fixtures because the canvas font-
317
- // shaping path differs slightly from Chrome's HarfBuzz paint
318
- // pipeline. Falling back to canvas measurement when pcs.width is
319
- // unavailable (typical for non-positioned inline pseudos) keeps
320
- // the existing path.
321
- let pseudoWidth = mctx.measureText(text).width;
519
+ // DM-785: probe-span measurement matches Chrome's HarfBuzz-shaped
520
+ // layout width canvas.measureText drifted ~1-3px on bold uppercase
521
+ // short strings (visible on rotated gradient pills as the text
522
+ // overflowing the badge). DM-507 numeric-pcs.width override still
523
+ // wins when the pseudo's box has an authored fixed width.
524
+ let pseudoWidth = probePseudoTextWidth(text, pcs);
322
525
  if (pcs.position === 'absolute' || pcs.position === 'fixed') {
323
526
  const pcsW = parseFloat(pcs.width);
324
527
  if (!isNaN(pcsW) && pcsW > 0) pseudoWidth = pcsW;
@@ -414,12 +617,15 @@ export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics,
414
617
  width: pseudoWidth,
415
618
  height: elFontSize,
416
619
  // Carry pseudo-specific typography so the renderer can respect
417
- // per-pseudo color, font-size, font-weight, font-family (CSS
418
- // lets pseudos style independently of their parent).
620
+ // per-pseudo color, font-size, font-weight, font-family, font-style
621
+ // (CSS lets pseudos style independently of their parent — Slashdot's
622
+ // "Most Discussed" carousel heading is a ::after that's italic+bordered
623
+ // on a non-italic host div).
419
624
  color: pcs.color,
420
625
  fontSize: elFontSize,
421
626
  fontWeight: pcs.fontWeight,
422
627
  fontFamily: pcs.fontFamily,
628
+ fontStyle: pcs.fontStyle,
423
629
  fontAscent: pseudoMetrics.ascent,
424
630
  };
425
631
  // DM-497: stash pseudo's own background / border-radius on the
@@ -431,26 +637,51 @@ export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics,
431
637
  const pseudoBgColor = pseudoBgRaw && pseudoBgRaw !== '' && pseudoBgRaw !== 'rgba(0, 0, 0, 0)' && pseudoBgRaw !== 'transparent'
432
638
  ? normColor(pseudoBgRaw) : '';
433
639
  const pseudoBR = parseFloat(pcs.borderRadius) || 0;
434
- // Capture a uniform border when all four sides match. Mixed-side
435
- // styling is rare on pseudos in real-world fixtures and falls
436
- // through.
437
- const bw = parseFloat(pcs.borderTopWidth) || 0;
438
- const bwUniform = bw > 0
439
- && (parseFloat(pcs.borderRightWidth) || 0) === bw
440
- && (parseFloat(pcs.borderBottomWidth) || 0) === bw
441
- && (parseFloat(pcs.borderLeftWidth) || 0) === bw;
640
+ // Capture a uniform border when all four sides match (renders as
641
+ // `<rect stroke=…>`). When a single side carries a border (e.g.
642
+ // `border-bottom: 1px solid rgba(255,255,255,0.5)` on Slashdot's
643
+ // `.carouselHeading::after`) we still capture per-side widths +
644
+ // colors so the renderer can emit a `<line>` for the visible side.
645
+ const bwTop = parseFloat(pcs.borderTopWidth) || 0;
646
+ const bwRight = parseFloat(pcs.borderRightWidth) || 0;
647
+ const bwBottom = parseFloat(pcs.borderBottomWidth) || 0;
648
+ const bwLeft = parseFloat(pcs.borderLeftWidth) || 0;
649
+ const bwUniform = bwTop > 0 && bwRight === bwTop && bwBottom === bwTop && bwLeft === bwTop;
442
650
  const pseudoBC = bwUniform ? normColor(pcs.borderTopColor) : '';
651
+ const colorIsPaintable = (raw: string): boolean => raw !== '' && raw !== 'rgba(0, 0, 0, 0)' && raw !== 'transparent';
652
+ const sideBorderTopColor = bwTop > 0 ? normColor(pcs.borderTopColor) : '';
653
+ const sideBorderRightColor = bwRight > 0 ? normColor(pcs.borderRightColor) : '';
654
+ const sideBorderBottomColor = bwBottom > 0 ? normColor(pcs.borderBottomColor) : '';
655
+ const sideBorderLeftColor = bwLeft > 0 ? normColor(pcs.borderLeftColor) : '';
656
+ const hasPerSideBorder = !bwUniform && (
657
+ (bwTop > 0 && colorIsPaintable(sideBorderTopColor))
658
+ || (bwRight > 0 && colorIsPaintable(sideBorderRightColor))
659
+ || (bwBottom > 0 && colorIsPaintable(sideBorderBottomColor))
660
+ || (bwLeft > 0 && colorIsPaintable(sideBorderLeftColor))
661
+ );
662
+ // DM-782: background-image (linear-gradient / radial-gradient / url())
663
+ // on text-content pseudos. The empty-content path already plumbs this
664
+ // (DM-767); the text-content path was dropping it, so "gradient badge"
665
+ // patterns (`.tier.popular::before { content: "MOST POPULAR"; background:
666
+ // linear-gradient(135deg, ...) }`) lost the pill bg behind the white
667
+ // glyphs.
668
+ const pseudoBgImgRaw = pcs.backgroundImage;
669
+ const hasPseudoBgImg = pseudoBgImgRaw != null && pseudoBgImgRaw !== '' && pseudoBgImgRaw !== 'none';
670
+ // DM-783: pseudo's own `transform` (rotate/scale/translate/matrix)
671
+ // wraps both the paint box AND the glyph emit at render time.
672
+ const pseudoTransform = pcs.transform && pcs.transform !== 'none' ? pcs.transform : undefined;
673
+ const pseudoTransformOrigin = pseudoTransform != null ? (pcs.transformOrigin || undefined) : undefined;
443
674
  let pseudoBoxStyles = null;
444
- if (pseudoBgColor !== '' || pseudoBR > 0 || (bwUniform && pseudoBC !== '' && pseudoBC !== 'rgba(0, 0, 0, 0)')) {
675
+ if (pseudoBgColor !== '' || hasPseudoBgImg || pseudoBR > 0 || (bwUniform && pseudoBC !== '' && pseudoBC !== 'rgba(0, 0, 0, 0)') || hasPerSideBorder || pseudoTransform != null) {
445
676
  pseudoBoxStyles = {
446
677
  padL: parseFloat(pcs.paddingLeft) || 0,
447
678
  padR: parseFloat(pcs.paddingRight) || 0,
448
679
  padT: parseFloat(pcs.paddingTop) || 0,
449
680
  padB: parseFloat(pcs.paddingBottom) || 0,
450
- borL: parseFloat(pcs.borderLeftWidth) || 0,
451
- borR: parseFloat(pcs.borderRightWidth) || 0,
452
- borT: parseFloat(pcs.borderTopWidth) || 0,
453
- borB: parseFloat(pcs.borderBottomWidth) || 0,
681
+ borL: bwLeft,
682
+ borR: bwRight,
683
+ borT: bwTop,
684
+ borB: bwBottom,
454
685
  // Inline-box bg paints at line-height, not at font-size — so
455
686
  // the box's vertical extent is lineH + padding + border (not
456
687
  // fontSize). Capture lineH alongside the metrics; the post-
@@ -459,9 +690,20 @@ export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics,
459
690
  lineH,
460
691
  fontSize: elFontSize,
461
692
  backgroundColor: pseudoBgColor !== '' ? pseudoBgColor : undefined,
693
+ backgroundImage: hasPseudoBgImg ? pseudoBgImgRaw : undefined,
462
694
  borderRadius: pseudoBR > 0 ? pseudoBR : undefined,
463
- borderWidth: bwUniform ? bw : undefined,
695
+ borderWidth: bwUniform ? bwTop : undefined,
464
696
  borderColor: bwUniform && pseudoBC !== '' && pseudoBC !== 'rgba(0, 0, 0, 0)' ? pseudoBC : undefined,
697
+ transform: pseudoTransform,
698
+ transformOrigin: pseudoTransformOrigin,
699
+ // Per-side colors. Renderer reads these when no uniform border
700
+ // is set and emits a `<line>` for each side whose width > 0 and
701
+ // color is paintable. Undefined when the side has no visible
702
+ // border, keeping the captured tree compact in the common case.
703
+ borderTopColor: hasPerSideBorder && bwTop > 0 && colorIsPaintable(sideBorderTopColor) ? sideBorderTopColor : undefined,
704
+ borderRightColor: hasPerSideBorder && bwRight > 0 && colorIsPaintable(sideBorderRightColor) ? sideBorderRightColor : undefined,
705
+ borderBottomColor: hasPerSideBorder && bwBottom > 0 && colorIsPaintable(sideBorderBottomColor) ? sideBorderBottomColor : undefined,
706
+ borderLeftColor: hasPerSideBorder && bwLeft > 0 && colorIsPaintable(sideBorderLeftColor) ? sideBorderLeftColor : undefined,
465
707
  };
466
708
  }
467
709
  // If the pseudo contains any codepoint Chrome paints via a color-
@@ -167,9 +167,29 @@ export const createPseudoInjectHandler = () => {
167
167
  p.seg.pseudoBox = {
168
168
  x: bx, y: boxTop, width: bw, height: bh,
169
169
  backgroundColor: bs.backgroundColor,
170
+ // DM-782: gradient/url() bg-image plumbing — renderer threads
171
+ // each comma-separated layer through `buildBackgroundLayerDef`
172
+ // and paints rect(s) behind the glyphs (mirrors the empty-
173
+ // content pseudoBox path in `element-tree-to-svg.ts`).
174
+ backgroundImage: bs.backgroundImage,
170
175
  borderRadius: bs.borderRadius,
171
176
  borderWidth: bs.borderWidth,
172
177
  borderColor: bs.borderColor,
178
+ // Per-side widths + colors for non-uniform borders (e.g. a
179
+ // bare `border-bottom` on a pseudo). Width fields are always
180
+ // emitted so the renderer doesn't have to fall back to zero
181
+ // when a `borderWidth` (uniform) shorthand is absent.
182
+ borL: bs.borL, borR: bs.borR, borT: bs.borT, borB: bs.borB,
183
+ borderTopColor: bs.borderTopColor,
184
+ borderRightColor: bs.borderRightColor,
185
+ borderBottomColor: bs.borderBottomColor,
186
+ borderLeftColor: bs.borderLeftColor,
187
+ // DM-783: pseudo's `transform` + `transformOrigin`. Renderer
188
+ // wraps the box + glyphs in a pre-baked
189
+ // translate-(transform)-translate matrix so the rotation/scale
190
+ // pivots around the box-relative origin instead of (0,0).
191
+ transform: bs.transform,
192
+ transformOrigin: bs.transformOrigin,
173
193
  };
174
194
  }
175
195
  }
@@ -144,6 +144,53 @@ export const createTextSegmentsHandler = ({ vp, measureFontMetrics, needsRaster
144
144
  const firstLetterStyled = flFsRaw > 0 && Math.abs(flFsRaw - elFsRaw) > 0.5;
145
145
  let firstCharSeen = false;
146
146
 
147
+ // DM-747 / DM-791: MathML `<mi>` with a single token character is
148
+ // automatically painted with the Mathematical Italic alphabet (the
149
+ // mathvariant=italic mapping from MathML 4 / Core). Chrome paints
150
+ // `<mi>a</mi>` using U+1D44E (𝑎), `<mi>α</mi>` using U+1D6FC (𝛼), etc.
151
+ // The computed `font-style` stays `normal` and `font-family` stays
152
+ // `math`, so we can't detect this through CSS — we apply the mapping
153
+ // ourselves at capture time so the downstream text-shaping pipeline
154
+ // picks up the right glyphs from whatever math font the system has.
155
+ const elTag = el.tagName != null ? el.tagName.toLowerCase() : '';
156
+ const _miText = (el.textContent || '').trim();
157
+ const mathItalicizeMi = elTag === 'mi'
158
+ && [..._miText].length === 1
159
+ && /^[a-zA-ZΑ-ΩΆΈΉΊΌΎΏα-ωϐϑϕϖϗϰϱϵ∂∇]$/u.test(_miText);
160
+ const mathItalicChar = (ch) => {
161
+ const code = ch.codePointAt(0);
162
+ if (code == null) return ch;
163
+ // Latin Mathematical Italic Capital A..Z = U+1D434..U+1D44D.
164
+ if (code >= 0x41 && code <= 0x5A) return String.fromCodePoint(0x1D434 + (code - 0x41));
165
+ // Latin Mathematical Italic Small a..z = U+1D44E..U+1D467, except
166
+ // U+1D455 ("h") is reserved — Chrome paints U+210E (ℎ, PLANCK CONSTANT).
167
+ if (code >= 0x61 && code <= 0x7A) {
168
+ if (code === 0x68) return 'ℎ';
169
+ return String.fromCodePoint(0x1D44E + (code - 0x61));
170
+ }
171
+ // Greek Mathematical Italic Capital Α..Ω = U+1D6E2..U+1D6FA.
172
+ // The block is dense: 25 codepoints for Α (U+0391) through Ω (U+03A9),
173
+ // skipping nothing in the source range.
174
+ if (code >= 0x0391 && code <= 0x03A9) return String.fromCodePoint(0x1D6E2 + (code - 0x0391));
175
+ // Greek Mathematical Italic Small α..ω = U+1D6FC..U+1D71B.
176
+ // U+03C2 (final sigma ς) maps to the regular U+1D70D position; the
177
+ // block is contiguous.
178
+ if (code >= 0x03B1 && code <= 0x03C9) return String.fromCodePoint(0x1D6FC + (code - 0x03B1));
179
+ // Greek symbol variants — these alternate forms get their own italic
180
+ // codepoints at the tail of the lowercase Greek block.
181
+ switch (code) {
182
+ case 0x2202: return String.fromCodePoint(0x1D715); // ∂ → italic partial differential
183
+ case 0x03F5: return String.fromCodePoint(0x1D716); // ϵ (lunate) → italic epsilon symbol
184
+ case 0x03D1: return String.fromCodePoint(0x1D717); // ϑ (theta sym) → italic theta symbol
185
+ case 0x03F0: return String.fromCodePoint(0x1D718); // ϰ (kappa sym) → italic kappa symbol
186
+ case 0x03D5: return String.fromCodePoint(0x1D719); // ϕ (phi sym) → italic phi symbol
187
+ case 0x03F1: return String.fromCodePoint(0x1D71A); // ϱ (rho sym) → italic rho symbol
188
+ case 0x03D6: return String.fromCodePoint(0x1D71B); // ϖ (pi sym) → italic pi symbol
189
+ case 0x2207: return String.fromCodePoint(0x1D6FB); // ∇ (nabla) → italic nabla (upper-block tail)
190
+ default: return ch;
191
+ }
192
+ };
193
+
147
194
  for (const node of el.childNodes) {
148
195
  if (node.nodeType !== Node.TEXT_NODE) continue;
149
196
  // text-transform — see header comment.
@@ -153,7 +200,13 @@ export const createTextSegmentsHandler = ({ vp, measureFontMetrics, needsRaster
153
200
  else if (tt === 'lowercase') raw = raw.toLowerCase();
154
201
  else if (tt === 'capitalize') raw = raw.replace(/\b\p{L}/gu, (ch) => ch.toUpperCase());
155
202
  if (!raw.trim()) continue;
156
- text += raw.trim() + ' ';
203
+ // DM-747: when `<mi>` math-italic substitution applies, the element's
204
+ // aggregate `text` field should carry the substituted codepoint too —
205
+ // it's used for aria-label / accessibility and matches the painted
206
+ // glyph. The per-character ranges still measure against the original
207
+ // textContent (see below).
208
+ const rawForText = mathItalicizeMi ? mathItalicChar(raw.trim()) : raw.trim();
209
+ text += rawForText + ' ';
157
210
 
158
211
  // Group characters by their laid-out line (matching rect.top).
159
212
  const lines = [];
@@ -168,7 +221,14 @@ export const createTextSegmentsHandler = ({ vp, measureFontMetrics, needsRaster
168
221
  const cr = r.getBoundingClientRect();
169
222
  const isWs = step === 1 && /\s/.test(raw[i]);
170
223
  if (cr.width === 0 && (cr.height === 0 || isWs)) { i += step - 1; continue; }
171
- const ch = raw.slice(i, i + step);
224
+ let ch = raw.slice(i, i + step);
225
+ // DM-747: see `mathItalicizeMi` block above — apply the mathvariant=
226
+ // italic substitution AFTER the Range-based measurement so the Range
227
+ // offsets stay valid against the original textContent (`a`, 1 code
228
+ // unit) while the captured glyph string carries the surrogate-pair
229
+ // math-italic codepoint (`𝑎`, 2 code units) that the downstream
230
+ // shaping pipeline picks up.
231
+ if (mathItalicizeMi) ch = mathItalicChar(ch);
172
232
  const charRec = { ch, left: cr.left, top: cr.top, right: cr.right, bottom: cr.bottom };
173
233
  if (cur == null || Math.abs(cr.top - cur.top) > 1) {
174
234
  if (cur != null) lines.push(cur);
@@ -239,13 +299,42 @@ export const createTextSegmentsHandler = ({ vp, measureFontMetrics, needsRaster
239
299
  const isFirstLetter = firstLetterStyled && !firstCharSeen && /\S/.test(cRec.ch);
240
300
  if (isFirstLetter) firstCharSeen = true;
241
301
  if ((cp != null && needsRaster(cp, nextCp)) || isFirstLetter) {
302
+ // DM-823: for floated ::first-letter drop caps (initial-letter:
303
+ // N M), `Range.getBoundingClientRect()` returns the line-box of
304
+ // the first character in the NORMAL flow — but Chrome paints the
305
+ // float at a much larger box (~N × parent-line-height tall). The
306
+ // bitmap captured at the Range rect gets vertically truncated,
307
+ // visible as a clipped W / B / T drop cap in the rendered SVG.
308
+ // Expand the rasterRect downward by the difference between
309
+ // (N × parent-line-height) and the natural-flow Range height so
310
+ // the screenshot includes the full painted glyph. Width stays
311
+ // unchanged — Chrome's drop cap matches the natural-flow
312
+ // first-char width.
313
+ let rasterTop = cRec.top - vp.y;
314
+ let rasterHeight = cRec.bottom - cRec.top;
315
+ if (isFirstLetter) {
316
+ const ilRaw = flStyle.initialLetter || flStyle.webkitInitialLetter || '';
317
+ const ilN = parseFloat(ilRaw);
318
+ const parentLineHeight = parseFloat(cs.lineHeight);
319
+ if (Number.isFinite(ilN) && ilN > 1 && Number.isFinite(parentLineHeight) && parentLineHeight > 0) {
320
+ const expectedHeight = ilN * parentLineHeight;
321
+ if (expectedHeight > rasterHeight) {
322
+ // Extend downward only — Chrome aligns the cap-top at the
323
+ // first line's cap-height position; extra space the
324
+ // expansion captures past the actual ink is empty (will
325
+ // raster as transparent, with `omitBackground: true`) and
326
+ // doesn't paint anything visible.
327
+ rasterHeight = expectedHeight;
328
+ }
329
+ }
330
+ }
242
331
  rasterGlyphs.push({
243
332
  charIndex: utf16Idx,
244
333
  rect: {
245
334
  x: cRec.left - vp.x,
246
- y: cRec.top - vp.y,
335
+ y: rasterTop,
247
336
  width: cRec.right - cRec.left,
248
- height: cRec.bottom - cRec.top,
337
+ height: rasterHeight,
249
338
  },
250
339
  // ::first-letter drop caps: suppress the path glyph so
251
340
  // only the rasterized big letter paints (DM-439).
@@ -131,6 +131,20 @@ export const createTransformsHandler = () => {
131
131
  ? frozenTransform
132
132
  : 'none',
133
133
  transformOrigin: cs.transformOrigin,
134
+ // DM-751: extract `matrix3d` translateZ so the paint-order sort can
135
+ // honor 3D Z position when the parent element has
136
+ // `transform-style: preserve-3d` (which sorts children by Z in 3D
137
+ // space, not by z-index per CSS Transforms 2 §6). We can't represent
138
+ // perspective / actual 3D rendering in SVG; this is paint-order only.
139
+ translateZ: (function () {
140
+ const tt = cs.transform;
141
+ if (tt == null || tt === 'none' || tt === '') return undefined;
142
+ const m3 = /^matrix3d\(([^)]+)\)/.exec(tt);
143
+ if (m3 == null) return undefined;
144
+ const parts = m3[1].split(',').map((s) => parseFloat(s.trim()));
145
+ const tz = parts[14];
146
+ return Number.isFinite(tz) && tz !== 0 ? tz : undefined;
147
+ })(),
134
148
  // DM-587: separately flag elements that ORIGINALLY had a non-none
135
149
  // transform — even though we discard the value to suppress the wrap,
136
150
  // CSS Transforms 2 §4 says any non-none transform creates a stacking