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
@@ -8,6 +8,74 @@ import { computeSkipInkGaps, getDecorationMetrics, renderTextAsPath } from "./te
8
8
  // ── Rendering helpers ──
9
9
  function r(n) { return Number(n.toFixed(1)).toString(); }
10
10
  function esc(s) { return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); }
11
+ /**
12
+ * Emit `<line>` markup for each non-zero side border on a pseudo-element box.
13
+ * Used for non-uniform pseudo borders (e.g. Slashdot's `.carouselHeading::after`
14
+ * with a bare `border-bottom`) where the surrounding `<rect>` already painted
15
+ * the box's fill/radius but its `stroke` shorthand can't represent a single-
16
+ * side border. Returns "" when the box has no per-side borders.
17
+ */
18
+ function renderPseudoBoxPerSideBorders(pb) {
19
+ const lines = [];
20
+ // Stroke at the centre of the border-side, so half-width insets are
21
+ // applied to the rect's edges to keep the stroke pixel-aligned with what
22
+ // CSS paints (CSS paints borders inset to the box's outer edges, with the
23
+ // stroke centre offset by half the border width from the rect edge).
24
+ const x2 = pb.x + pb.width;
25
+ const y2 = pb.y + pb.height;
26
+ if (pb.borT != null && pb.borT > 0 && pb.borderTopColor != null) {
27
+ const cy = pb.y + pb.borT / 2;
28
+ lines.push(`<line x1="${r(pb.x)}" y1="${r(cy)}" x2="${r(x2)}" y2="${r(cy)}" stroke="${esc(pb.borderTopColor)}" stroke-width="${r(pb.borT)}"/>`);
29
+ }
30
+ if (pb.borR != null && pb.borR > 0 && pb.borderRightColor != null) {
31
+ const cx = x2 - pb.borR / 2;
32
+ lines.push(`<line x1="${r(cx)}" y1="${r(pb.y)}" x2="${r(cx)}" y2="${r(y2)}" stroke="${esc(pb.borderRightColor)}" stroke-width="${r(pb.borR)}"/>`);
33
+ }
34
+ if (pb.borB != null && pb.borB > 0 && pb.borderBottomColor != null) {
35
+ const cy = y2 - pb.borB / 2;
36
+ lines.push(`<line x1="${r(pb.x)}" y1="${r(cy)}" x2="${r(x2)}" y2="${r(cy)}" stroke="${esc(pb.borderBottomColor)}" stroke-width="${r(pb.borB)}"/>`);
37
+ }
38
+ if (pb.borL != null && pb.borL > 0 && pb.borderLeftColor != null) {
39
+ const cx = pb.x + pb.borL / 2;
40
+ lines.push(`<line x1="${r(cx)}" y1="${r(pb.y)}" x2="${r(cx)}" y2="${r(y2)}" stroke="${esc(pb.borderLeftColor)}" stroke-width="${r(pb.borL)}"/>`);
41
+ }
42
+ return lines.join("");
43
+ }
44
+ /**
45
+ * DM-783: parse a resolved `transform-origin` string (`"50px 50px"`,
46
+ * `"50px 50px 0px"`) into an `(ox, oy)` pair in px relative to the pseudoBox's
47
+ * top-left. Chrome's getComputedStyle always returns px values (never
48
+ * keywords like "left top" or "%"), so we just split + parseFloat. The
49
+ * 3rd Z component is ignored — we only paint 2D. Falls back to the box
50
+ * center when the value is missing or unparseable, matching Chrome's
51
+ * `50% 50%` default.
52
+ */
53
+ function parsePseudoTransformOrigin(originCss, width, height) {
54
+ const center = { ox: width / 2, oy: height / 2 };
55
+ if (originCss == null || originCss === "")
56
+ return center;
57
+ const parts = originCss.split(/\s+/).map((p) => parseFloat(p));
58
+ if (parts.length < 2 || !Number.isFinite(parts[0]) || !Number.isFinite(parts[1]))
59
+ return center;
60
+ return { ox: parts[0], oy: parts[1] };
61
+ }
62
+ /**
63
+ * DM-783: when the pseudoBox carries a `transform`, wrap `inner` in a
64
+ * `<g transform="…">` that pre-bakes the rotation/scale around the captured
65
+ * `transform-origin` — `translate(tx,ty) <css-transform> translate(-tx,-ty)`
66
+ * where `(tx, ty)` is the origin in viewport coords. SVG accepts the CSS
67
+ * matrix() / rotate() / scale() / translate() / skew() forms unchanged
68
+ * (column-major convention matches), so `pb.transform` pastes in verbatim.
69
+ * Returns `inner` unwrapped when no transform was captured.
70
+ */
71
+ function pseudoBoxTransformWrap(pb, inner) {
72
+ if (pb.transform == null || pb.transform === "" || pb.transform === "none")
73
+ return inner;
74
+ const { ox, oy } = parsePseudoTransformOrigin(pb.transformOrigin, pb.width, pb.height);
75
+ const tx = pb.x + ox;
76
+ const ty = pb.y + oy;
77
+ return `<g transform="translate(${r(tx)} ${r(ty)}) ${pb.transform} translate(${r(-tx)} ${r(-ty)})">${inner}</g>`;
78
+ }
11
79
  /**
12
80
  * Replace any UTF-16 code units flagged with `suppressGlyph` in the segment's
13
81
  * raster overlays with U+200B (zero-width space). The path renderer emits no
@@ -16,20 +84,61 @@ function esc(s) { return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(
16
84
  * Used for ::first-letter drop caps (DM-439) where the body-size path glyph
17
85
  * would otherwise show through behind the styled rasterized big letter.
18
86
  */
87
+ // DM-719: pull `-webkit-text-stroke-width / -color` + `paint-order` off the
88
+ // element styles into the trio of args `renderTextAsPath` expects. Returns
89
+ // `{ width: 0 }` when no stroke is set so the renderer keeps the unstroked
90
+ // fast path.
91
+ function textStrokeParams(styles) {
92
+ const widthCss = styles.webkitTextStrokeWidth;
93
+ if (widthCss == null || widthCss === "" || widthCss === "0px" || widthCss === "0") {
94
+ return { width: 0, color: "", paintOrder: "" };
95
+ }
96
+ const width = parseFloat(widthCss);
97
+ if (!Number.isFinite(width) || width <= 0) {
98
+ return { width: 0, color: "", paintOrder: "" };
99
+ }
100
+ return {
101
+ width,
102
+ color: styles.webkitTextStrokeColor ?? "currentColor",
103
+ paintOrder: styles.paintOrder ?? "",
104
+ };
105
+ }
19
106
  function suppressGlyphChars(text, seg) {
107
+ // DM-692: Chrome paints a visible hyphen at line-break points marked by
108
+ // a soft-hyphen (U+00AD); SHYs not at the break paint nothing. Our
109
+ // capture's per-char Range loop keeps EVERY SHY in the captured line
110
+ // text (the height check at the line-break-pos doesn't zero them out),
111
+ // so we have to disambiguate at render time: ONLY the trailing SHY of a
112
+ // line is the visible hyphen — substitute with U+002D. Every other SHY
113
+ // gets U+200B (zero-width space) so it preserves UTF-16 indexing for
114
+ // xOffsets/rasterGlyphs but produces no glyph and no advance.
115
+ const SHY = String.fromCharCode(0x00AD);
116
+ const ZWSP = String.fromCharCode(0x200B);
117
+ let normalized = text;
118
+ if (text.indexOf(SHY) >= 0) {
119
+ const lastNonWs = normalized.replace(/\s+$/, "").length - 1;
120
+ let out = "";
121
+ for (let i = 0; i < normalized.length; i++) {
122
+ const ch = normalized[i];
123
+ if (ch === SHY)
124
+ out += (i === lastNonWs ? "-" : ZWSP);
125
+ else
126
+ out += ch;
127
+ }
128
+ normalized = out;
129
+ }
20
130
  if (seg?.rasterGlyphs == null)
21
- return text;
131
+ return normalized;
22
132
  const suppress = seg.rasterGlyphs.filter((g) => g.suppressGlyph === true);
23
133
  if (suppress.length === 0)
24
- return text;
134
+ return normalized;
25
135
  // text is a UTF-16 string; charIndex is a UTF-16 position. U+200B is one
26
136
  // UTF-16 unit so the substitution preserves text length and xOffsets
27
137
  // alignment.
28
- const ZWSP = String.fromCharCode(0x200B);
29
138
  let out = "";
30
- for (let i = 0; i < text.length; i++) {
139
+ for (let i = 0; i < normalized.length; i++) {
31
140
  const drop = suppress.some((g) => g.charIndex === i);
32
- out += drop ? ZWSP : text[i];
141
+ out += drop ? ZWSP : normalized[i];
33
142
  }
34
143
  return out;
35
144
  }
@@ -65,7 +174,19 @@ export function rasterGlyphOverlays(seg, fallbackFontSize, clipId) {
65
174
  // squished tall line-box rects horizontally — flag emoji and other
66
175
  // raster glyphs rendered visibly larger than Chrome's actual paint
67
176
  // (DM-401 / DM-411 / DM-414).
68
- out.push(`<image href="${g.dataUri}" x="${r(g.rect.x)}" y="${r(g.rect.y)}" width="${r(g.rect.width)}" height="${r(g.rect.height)}" preserveAspectRatio="none" clip-path="url(#${clipId})"/>`);
177
+ // DM-823: floated `::first-letter` drop caps (`float: left; font-size: Nem;
178
+ // initial-letter: N M`) paint OUTSIDE their parent paragraph's box —
179
+ // Chrome's float layout naturally extends the W / B / T above and to the
180
+ // left of the `<p>` border-box. Capture marks these per-char rasters with
181
+ // `suppressGlyph: true` so the underlying path glyph isn't double-emitted
182
+ // (DM-439). Use that same flag here as a signal to OMIT the parent's
183
+ // overflow-clip on the raster `<image>`: the parent's clip rect is the
184
+ // `<p>`'s own bounds, which clips off the top portion of the drop cap
185
+ // exactly where the float overflows. For non-drop-cap raster glyphs
186
+ // (emoji in the middle of text) the parent clip is still desirable.
187
+ const skipClip = g.suppressGlyph === true;
188
+ const clipAttr = skipClip ? "" : ` clip-path="url(#${clipId})"`;
189
+ out.push(`<image href="${g.dataUri}" x="${r(g.rect.x)}" y="${r(g.rect.y)}" width="${r(g.rect.width)}" height="${r(g.rect.height)}" preserveAspectRatio="none"${clipAttr}/>`);
69
190
  }
70
191
  return out.join("");
71
192
  }
@@ -102,11 +223,13 @@ features) {
102
223
  const has = (k) => textDecorationLine.includes(k);
103
224
  const dash = (thick) => style === "dashed" ? ` stroke-dasharray="${thick * 2} ${thick * 2}"`
104
225
  : style === "dotted" ? ` stroke-dasharray="${thick} ${thick}"` : "";
105
- // Skip-ink applies only to solid + double underlines per Chromium
106
- // (`decoration_line_painter.cc::Paint` short-circuits on dashed / dotted /
107
- // wavy). We compute gaps once if any underline emit needs them.
226
+ // Skip-ink applies to solid + double + wavy underlines per Chromium's
227
+ // current behaviour (`decoration_line_painter.cc::Paint`; verified against
228
+ // Chrome's painted output for the 20-deep-wavy-underline-descenders
229
+ // fixture — DM-814). Dashed / dotted still short-circuit. We compute gaps
230
+ // once if any underline emit needs them.
108
231
  const skipInkActive = (skipInk == null || skipInk === "auto") && runText != null && runText !== ""
109
- && (style == null || style === "solid" || style === "double" || style === "");
232
+ && (style == null || style === "solid" || style === "double" || style === "wavy" || style === "");
110
233
  // Compute X-range gaps where the underline rect crosses glyph ink. Returned
111
234
  // gaps are run-relative (0 = segX); subSegments() splits the underline span
112
235
  // around them.
@@ -168,15 +291,64 @@ features) {
168
291
  const tc = explicitThickness ? Math.max(1, t) : Math.max(1, fontSize / 10);
169
292
  const wavelength = 1 + 2 * Math.round(2 * tc + 0.5);
170
293
  const cpDist = 0.5 + Math.round(3 * tc + 0.5);
171
- let d = `M ${r(segX)} ${r(y)}`;
172
- let x = segX;
173
- while (x < segX + segWidth) {
174
- const nx = Math.min(x + wavelength, segX + segWidth);
175
- const cpX = x + wavelength / 2;
176
- d += ` C ${r(cpX)} ${r(y + cpDist)} ${r(cpX)} ${r(y - cpDist)} ${r(nx)} ${r(y)}`;
177
- x = nx;
294
+ // DM-830: re-probed against native Chromium (`tools/probe-wavy-geom5.mjs`)
295
+ // at fs={12, 16, 24, 36} × thickness={1, 2, 3, 4, 6}, measuring wave
296
+ // centre-y and peak amplitude against the descender-less 'm' baseline.
297
+ // Two findings differed from the earlier DM-446 calibration:
298
+ //
299
+ // (1) Chrome paints amplitude `~0.278 × cpDist` (was 0.289). Across the
300
+ // sample matrix: t=1→1.25, t=2→2.00, t=3→3.00, t=4→3.75, t=6→5.50
301
+ // — Chrome's `cpDist`-to-amplitude ratio is consistently 0.27-0.28,
302
+ // NOT the cubic-Bezier-geometric 0.289 = √3/(2π/n) factor we'd
303
+ // derived analytically. Either Chrome uses a different
304
+ // bezier-flatness setting or its wavy is actually a different
305
+ // curve family at the painted scale.
306
+ //
307
+ // (2) Wave centre-y is INDEPENDENT of fontSize (the previous formula
308
+ // `y + 2 * amplitude` produced wave-y identical across font sizes
309
+ // because `y = baseline + 1.5×t` itself was thickness-only; the
310
+ // empirical pattern just confirms this). Centre-y DOES depend on
311
+ // thickness: yCenter - baseline ≈ 2 + t/2 + amplitude. This is
312
+ // consistent with "the wave's TOP edge sits exactly at the solid-
313
+ // underline BOTTOM edge, leaving descender region clear" — where
314
+ // Chrome's auto solid underline at thickness `t` paints at
315
+ // baseline + 2 - t/2 to baseline + 2 + t/2.
316
+ //
317
+ // `y` passed into `emitLine` already equals `baseline + 1.5×t + extra`
318
+ // (the extra is the author's text-underline-offset). The new formula
319
+ // `yWave = y + amp + 2 - t` algebraically reduces to
320
+ // `baseline + 2 + 0.5×t + amp + extra`, matching the probed wave centre.
321
+ // For uniform text-underline-offset = 0, errors stay ≤ 0.4 px across
322
+ // the probed thickness range.
323
+ const waveAmplitude = 0.278 * cpDist;
324
+ const yWave = y + waveAmplitude + 2 - tc;
325
+ // DM-814: skip-ink for wavy. Compute gaps using the wave's full
326
+ // vertical extent (2*amplitude + stroke thickness) so a descender that
327
+ // pokes into the wave's PEAK or TROUGH zones breaks the wave, not just
328
+ // descenders that cross the centerline. Then emit one wave path per
329
+ // non-gap sub-segment. Each sub-segment's wave starts at phase 0 at
330
+ // its own x0 — adjacent segments aren't strictly phase-coherent with a
331
+ // hypothetical continuous wave, but the descender gap is usually wider
332
+ // than the discontinuity which makes the visual indistinguishable from
333
+ // Chrome's per-glyph break style.
334
+ const bandThickness = 2 * waveAmplitude + tc;
335
+ const wavyGaps = isUnderline ? computeGapsAt(yWave - baselineY, bandThickness) : [];
336
+ const subs = subSegments(wavyGaps);
337
+ const parts = [];
338
+ for (const { x0: sx0, x1: sx1 } of subs) {
339
+ if (sx1 - sx0 < 0.5)
340
+ continue;
341
+ let d = `M ${r(sx0)} ${r(yWave)}`;
342
+ let x = sx0;
343
+ while (x < sx1) {
344
+ const nx = Math.min(x + wavelength, sx1);
345
+ const cpX = x + wavelength / 2;
346
+ d += ` C ${r(cpX)} ${r(yWave + cpDist)} ${r(cpX)} ${r(yWave - cpDist)} ${r(nx)} ${r(yWave)}`;
347
+ x = nx;
348
+ }
349
+ parts.push(`<path d="${d}" fill="none" stroke="${decorationColor}" stroke-width="${r(tc)}"/>`);
178
350
  }
179
- return `<path d="${d}" fill="none" stroke="${decorationColor}" stroke-width="${r(tc)}"/>`;
351
+ return parts.join("");
180
352
  }
181
353
  if (style === "double") {
182
354
  // Double: two parallel lines. Per Chromium's `decoration_line_painter
@@ -389,10 +561,67 @@ function mergeFeatureLists(a, b) {
389
561
  }
390
562
  return out;
391
563
  }
564
+ /**
565
+ * DM-680: anisotropic-ancestor scale correction. When the element sits inside
566
+ * a `transform: scale(sx, sy)` with sx ≠ sy, the capture script already
567
+ * folded the geometric mean of (sx, sy) into fontSize / fontAscent / fontDescent
568
+ * — that produces correct glyph metrics for the uniform / isotropic case but
569
+ * leaves an axis ratio still to apply (Chrome paints glyphs into post-transform
570
+ * device space, where width scales by sx and height by sy independently). Wrap
571
+ * the text emission in a per-axis correction `<g transform=...>` pivoted around
572
+ * the text origin so the net visual scale is exactly (sx, sy).
573
+ *
574
+ * DM-822: callers that emit per-char positions FROM CAPTURED xOffsets must
575
+ * also call `anisotropicCorrectionXOffsets` to pre-divide those xOffsets by
576
+ * (cx, cy). The captured xOffsets are post-transform (= native_x × sx)
577
+ * already; without the pre-division, the wrap's outer scale multiplies them
578
+ * a second time and inter-glyph spacing comes out 1.5×–2× too wide on
579
+ * fixtures like `21-deep-anisotropic-scale`'s `scale(1.6, 0.7)` box. The
580
+ * per-axis (cx, cy) factors are `sx/geo` and `sy/geo` so that geo×cx == sx
581
+ * exactly; dividing xOffsetsRel by cx (which is what the wrap is about to
582
+ * multiply by) is the exact inverse and leaves the post-wrap glyph
583
+ * positions equal to the captured xOffsets.
584
+ */
585
+ function getAnisotropicCorrectionFactors(el) {
586
+ const sx = el.cumScaleX;
587
+ const sy = el.cumScaleY;
588
+ if (sx == null || sy == null || sx === sy)
589
+ return null;
590
+ const geo = Math.sqrt(sx * sy);
591
+ if (geo === 0)
592
+ return null;
593
+ const cx = sx / geo;
594
+ const cy = sy / geo;
595
+ if (Math.abs(cx - 1) < 1e-4 && Math.abs(cy - 1) < 1e-4)
596
+ return null;
597
+ return { cx, cy };
598
+ }
599
+ function anisotropicCorrectionXOffsets(el, xOffsetsRel) {
600
+ if (xOffsetsRel == null)
601
+ return xOffsetsRel;
602
+ const f = getAnisotropicCorrectionFactors(el);
603
+ if (f == null)
604
+ return xOffsetsRel;
605
+ // xOffsetsRel is in user-space, relative to the text origin (the wrap's
606
+ // pivot). Dividing by cx pre-shrinks the inter-glyph spacing so that the
607
+ // wrap's scale(cx, cy) multiplies it back to the captured xOffsets.
608
+ return xOffsetsRel.map((v) => v / f.cx);
609
+ }
610
+ function anisotropicCorrectionWrap(el, body) {
611
+ const f = getAnisotropicCorrectionFactors(el);
612
+ if (f == null)
613
+ return body;
614
+ // Pivot around the text origin so the correction stretches glyphs in place
615
+ // rather than translating the whole block.
616
+ const px = el.textLeft ?? el.x;
617
+ const py = el.textTop ?? el.y;
618
+ return `<g transform="translate(${r(px)} ${r(py)}) scale(${r(f.cx)} ${r(f.cy)}) translate(${r(-px)} ${r(-py)})">${body}</g>`;
619
+ }
392
620
  /**
393
621
  * Render a single-line text element.
394
622
  */
395
623
  export function renderSingleLineText(opts) {
624
+ const _ts = textStrokeParams(opts.el.styles);
396
625
  const { el, clipId, fillColor } = opts;
397
626
  // Raster fallback (DM-626 follow-up to DM-583): when the only segment
398
627
  // is a pseudo whose codepoints fontkit can't shape (e.g. icon-font
@@ -424,7 +653,9 @@ export function renderSingleLineText(opts) {
424
653
  const dir = el.styles.direction === "rtl" ? "rtl" : "ltr";
425
654
  const reordered = applyBidi(pathTextRaw, xOffsetsRelRaw, dir);
426
655
  const pathText = reordered.text;
427
- const xOffsetsRel = reordered.xOffsets;
656
+ // DM-822: pre-divide xOffsetsRel by cx when an anisotropic correction
657
+ // wrap will multiply positions on emit. No-op for uniform-scale text.
658
+ const xOffsetsRel = anisotropicCorrectionXOffsets(el, reordered.xOffsets);
428
659
  const features = mergeFeatureLists(resolveCapsFeatures(singleSeg?.fontVariant, el.styles.fontVariantCaps), parseFontFeatureSettings(el.styles.fontFeatureSettings));
429
660
  const variationSettings = parseFontVariationSettings(el.styles.fontVariationSettings);
430
661
  // DM-495: when the only segment is a pseudo with its own typography
@@ -440,6 +671,12 @@ export function renderSingleLineText(opts) {
440
671
  // DM-513: pseudo-element font-family override (e.g. icon font on
441
672
  // `[class^="icon-"]:before { font-family: "sdicon" }`).
442
673
  const segFontFamily = singleSeg?.fontFamily ?? fontFamily;
674
+ // Pseudo-element font-style override (Slashdot's `.carouselHeading::after`
675
+ // is italic on a non-italic host). The multi-segment path already did the
676
+ // `seg.fontStyle ?? el.styles.fontStyle` fallback below; the single-segment
677
+ // path was reading host fontStyle exclusively, so a pseudo's italic was
678
+ // silently swallowed.
679
+ const segFontStyle = singleSeg?.fontStyle ?? el.styles.fontStyle;
443
680
  // DM-507: when the single segment is a pseudo with its own paint box
444
681
  // (background-color / border-radius / border), emit a <rect> behind the
445
682
  // glyphs. Same as the multi-segment path; without this the badge / pill
@@ -447,12 +684,26 @@ export function renderSingleLineText(opts) {
447
684
  const singleSegBoxMarkup = (singleSeg?.pseudoBox != null) ? (() => {
448
685
  const pb = singleSeg.pseudoBox;
449
686
  const fillAttr = pb.backgroundColor != null ? ` fill="${esc(pb.backgroundColor)}"` : ` fill="none"`;
450
- const rxAttr = pb.borderRadius != null && pb.borderRadius > 0 ? ` rx="${r(pb.borderRadius)}" ry="${r(pb.borderRadius)}"` : "";
687
+ // Clamp the pseudo's border-radius to half the SHORTER side so a pill
688
+ // (e.g. `border-radius: 100px` on a 90×40 button) renders as a capsule
689
+ // — flat top/bottom + fully-rounded ends — instead of an ellipse with
690
+ // rx and ry capped independently. Mirrors the inset() clip-path fix
691
+ // (CSS Backgrounds 3 §5.5 uniform-scale rule) for the pseudo-box path.
692
+ const clampedBR = pb.borderRadius != null && pb.borderRadius > 0
693
+ ? Math.min(pb.borderRadius, pb.width / 2, pb.height / 2) : 0;
694
+ const rxAttr = clampedBR > 0 ? ` rx="${r(clampedBR)}" ry="${r(clampedBR)}"` : "";
451
695
  const strokeAttr = pb.borderWidth != null && pb.borderWidth > 0 && pb.borderColor != null
452
696
  ? ` stroke="${esc(pb.borderColor)}" stroke-width="${r(pb.borderWidth)}"` : "";
453
- return `<rect x="${r(pb.x)}" y="${r(pb.y)}" width="${r(pb.width)}" height="${r(pb.height)}"${rxAttr}${fillAttr}${strokeAttr}/>`;
697
+ // DM-782: gradient/url() background-image layers paint BETWEEN the flat
698
+ // bg-color (bottom) and the text glyphs (top). Caller threads defsParts
699
+ // + clipIdx through `emitPseudoBoxBgLayers`; when that closure is absent
700
+ // (standalone callers / unit tests) we just skip the gradient layers.
701
+ const bgImageMarkup = (pb.backgroundImage != null && pb.backgroundImage !== "none" && pb.backgroundImage !== "" && opts.emitPseudoBoxBgLayers != null)
702
+ ? opts.emitPseudoBoxBgLayers({ x: pb.x, y: pb.y, width: pb.width, height: pb.height, backgroundImage: pb.backgroundImage, borderRadius: clampedBR > 0 ? clampedBR : undefined })
703
+ : "";
704
+ return `<rect x="${r(pb.x)}" y="${r(pb.y)}" width="${r(pb.width)}" height="${r(pb.height)}"${rxAttr}${fillAttr}${strokeAttr}/>${bgImageMarkup}${renderPseudoBoxPerSideBorders(pb)}`;
454
705
  })() : "";
455
- const result = renderTextAsPath(pathText, tl, tt, segFontSize, segFontFamily, segFontWeight, segColor, undefined, el.textWidth, xOffsetsRel, el.styles.fontStyle, segAscent, features, el.styles.lang, variationSettings);
706
+ const result = renderTextAsPath(pathText, tl, tt, segFontSize, segFontFamily, segFontWeight, segColor, undefined, el.textWidth, xOffsetsRel, segFontStyle, segAscent, features, el.styles.lang, variationSettings, _ts.width, _ts.color, _ts.paintOrder);
456
707
  if (result != null) {
457
708
  const decoColor = (el.styles.textDecorationColor && el.styles.textDecorationColor !== "currentcolor")
458
709
  ? el.styles.textDecorationColor : segColor;
@@ -471,10 +722,18 @@ export function renderSingleLineText(opts) {
471
722
  // lets text extend past the box edge, so the unconditional clip from
472
723
  // an earlier draft over-cut text on `word-wrap: break-word` paragraphs
473
724
  // whose last char measured a fraction of a px past `el.x + el.width`.
725
+ //
726
+ // DM-783: when the pseudo carries a CSS `transform`, wrap box + glyphs +
727
+ // decoration + raster overlay together so the rotation/scale pivots
728
+ // around the captured `transform-origin` and the text rotates WITH the
729
+ // box (e.g. a `::after { transform: rotate(-15deg) }` rotated pill keeps
730
+ // its label aligned to the pill, not the host's baseline).
731
+ const inner = `${singleSegBoxMarkup}${result}${decoMarkup}${rasterOverlay}`;
732
+ const transformed = (singleSeg?.pseudoBox != null) ? pseudoBoxTransformWrap(singleSeg.pseudoBox, inner) : inner;
474
733
  if (opts.overflowClip) {
475
- return `<g clip-path="url(#${clipId})">${singleSegBoxMarkup}${result}${decoMarkup}${rasterOverlay}</g>`;
734
+ return anisotropicCorrectionWrap(el, `<g clip-path="url(#${clipId})">${transformed}</g>`);
476
735
  }
477
- return `${singleSegBoxMarkup}${result}${decoMarkup}${rasterOverlay}`;
736
+ return anisotropicCorrectionWrap(el, transformed);
478
737
  }
479
738
  // DM-490 / DM-500: when the text is entirely Private Use Area codepoints
480
739
  // and the path-mode renderer returned null (no glyph emitted because every
@@ -508,6 +767,7 @@ export function renderSingleLineText(opts) {
508
767
  * Render multi-segment text (mixed content like: <p>Text <code>x</code> more</p>).
509
768
  */
510
769
  export function renderMultiSegmentText(opts, segments) {
770
+ const _ts = textStrokeParams(opts.el.styles);
511
771
  const { el, clipId, fillColor } = opts;
512
772
  const elFontSize = parseFloat(el.styles.fontSize) || 14;
513
773
  const fontFamily = el.styles.fontFamily;
@@ -535,13 +795,33 @@ export function renderMultiSegmentText(opts, segments) {
535
795
  // background-color or border-radius (badges / pills / chips) need a
536
796
  // <rect> behind the text glyphs. Captured at CAPTURE_SCRIPT time once
537
797
  // seg.x/y is in its final viewport-relative position; we just emit it.
798
+ //
799
+ // DM-783: per-segment buffer so a pseudo's `transform` wraps box + glyphs
800
+ // + decoration + raster overlay together (the rotation/scale must pivot
801
+ // around the pseudo's box, not the host's baseline). Non-pseudo segments
802
+ // — and pseudos without a transform — flush straight into `parts` with no
803
+ // wrapping, preserving the prior emit order byte-for-byte.
804
+ const segParts = [];
538
805
  if (seg.pseudoBox != null) {
539
806
  const pb = seg.pseudoBox;
540
807
  const fillAttr = pb.backgroundColor != null ? ` fill="${esc(pb.backgroundColor)}"` : ` fill="none"`;
541
- const rxAttr = pb.borderRadius != null && pb.borderRadius > 0 ? ` rx="${r(pb.borderRadius)}" ry="${r(pb.borderRadius)}"` : "";
808
+ // Clamp the pseudo's border-radius to half the SHORTER side so a pill
809
+ // (e.g. `border-radius: 100px` on a 90×40 button) renders as a capsule
810
+ // — flat top/bottom + fully-rounded ends — instead of an ellipse with
811
+ // rx and ry capped independently. Mirrors the inset() clip-path fix
812
+ // (CSS Backgrounds 3 §5.5 uniform-scale rule) for the pseudo-box path.
813
+ const clampedBR = pb.borderRadius != null && pb.borderRadius > 0
814
+ ? Math.min(pb.borderRadius, pb.width / 2, pb.height / 2) : 0;
815
+ const rxAttr = clampedBR > 0 ? ` rx="${r(clampedBR)}" ry="${r(clampedBR)}"` : "";
542
816
  const strokeAttr = pb.borderWidth != null && pb.borderWidth > 0 && pb.borderColor != null
543
817
  ? ` stroke="${esc(pb.borderColor)}" stroke-width="${r(pb.borderWidth)}"` : "";
544
- parts.push(`<rect x="${r(pb.x)}" y="${r(pb.y)}" width="${r(pb.width)}" height="${r(pb.height)}"${rxAttr}${fillAttr}${strokeAttr}/>`);
818
+ // DM-782: gradient/url() background-image layers paint between flat
819
+ // bg-color (bottom) and text glyphs (top). See `RenderTextOpts.
820
+ // emitPseudoBoxBgLayers` for the closure-injection rationale.
821
+ const bgImageMarkup = (pb.backgroundImage != null && pb.backgroundImage !== "none" && pb.backgroundImage !== "" && opts.emitPseudoBoxBgLayers != null)
822
+ ? opts.emitPseudoBoxBgLayers({ x: pb.x, y: pb.y, width: pb.width, height: pb.height, backgroundImage: pb.backgroundImage, borderRadius: clampedBR > 0 ? clampedBR : undefined })
823
+ : "";
824
+ segParts.push(`<rect x="${r(pb.x)}" y="${r(pb.y)}" width="${r(pb.width)}" height="${r(pb.height)}"${rxAttr}${fillAttr}${strokeAttr}/>${bgImageMarkup}${renderPseudoBoxPerSideBorders(pb)}`);
545
825
  }
546
826
  // Per-segment overrides from ::before / ::after pseudos (color, fontSize,
547
827
  // fontWeight). Fall back to the element's styles when the segment has no
@@ -566,44 +846,65 @@ export function renderMultiSegmentText(opts, segments) {
566
846
  // text anchors glyphs at the exact Chromium-measured positions.
567
847
  const xOffsetsRelRaw = seg.xOffsets != null ? seg.xOffsets.map((v) => v - seg.x) : undefined;
568
848
  const reordered = applyBidi(suppressGlyphChars(seg.text, seg), xOffsetsRelRaw, dir);
849
+ // DM-822: anisotropic correction — see `anisotropicCorrectionXOffsets`.
850
+ const segXOffsets = anisotropicCorrectionXOffsets(el, reordered.xOffsets);
569
851
  const segAscent = seg.fontAscent ?? el.fontAscent;
570
- const result = renderTextAsPath(reordered.text, seg.x, seg.y, segFontSize, segFontFamily, segFontWeight, segColor, undefined, undefined, reordered.xOffsets, segFontStyle, segAscent, segFeatures, el.styles.lang, elVariationSettings);
852
+ const result = renderTextAsPath(reordered.text, seg.x, seg.y, segFontSize, segFontFamily, segFontWeight, segColor, undefined, undefined, segXOffsets, segFontStyle, segAscent, segFeatures, el.styles.lang, elVariationSettings, _ts.width, _ts.color, _ts.paintOrder);
571
853
  if (result != null) {
572
- parts.push(result);
854
+ segParts.push(result);
573
855
  }
574
- else if (!isAllPrivateUseArea(seg.text)) {
856
+ else if (!isAllPrivateUseArea(seg.text) && reordered.text.replace(/[\s​]/g, "") !== "") {
575
857
  // Fallback to CSS <text> if path rendering fails. DM-490 / DM-500: when
576
858
  // the segment text is entirely Private Use Area (icon-font codepoints
577
859
  // we couldn't resolve to a real glyph), suppress the <text> fallback
578
860
  // too — Chromium's UA fallback paints the same notdef tofu we already
579
861
  // suppressed at the path level, defeating the point.
862
+ // DM-779: same logic for `::first-letter` drop caps — when every glyph
863
+ // in the segment was a `suppressGlyph` rasterGlyph target (e.g. the
864
+ // floated drop-cap letter sitting on its own line), `reordered.text`
865
+ // collapses to all-ZWSP and the raster overlay paints the visible
866
+ // glyph; emitting `seg.text` in the `<text>` fallback would paint a
867
+ // duplicate body-size copy of the letter behind the raster.
580
868
  const ff = segFontFamily.replace(/"/g, "'");
581
869
  const baseStyle = `font-family:${ff};font-size:${r(segFontSize)}px;font-weight:${segFontWeight};font-kerning:normal;font-optical-sizing:auto;`;
582
870
  const sy = seg.y + seg.height / 2;
583
- parts.push(`<text x="${r(seg.x)}" y="${r(sy)}" dominant-baseline="central" fill="${segColor}" style="${baseStyle}" clip-path="url(#${clipId})">${esc(seg.text)}</text>`);
871
+ segParts.push(`<text x="${r(seg.x)}" y="${r(sy)}" dominant-baseline="central" fill="${segColor}" style="${baseStyle}" clip-path="url(#${clipId})">${esc(seg.text)}</text>`);
584
872
  }
585
873
  const segDecoBaselineY = Math.round(seg.y + (segAscent ?? segFontSize));
586
874
  const decoMarkup = renderTextDecoration(decoLine, decoColor, decoStyle, seg.x, segDecoBaselineY, seg.width, segFontSize, segFontFamily, segFontWeight, el.styles.fontStyle, el.styles.textDecorationThickness, el.styles.textUnderlineOffset, reordered.text, el.styles.textDecorationSkipInk, segFeatures);
587
875
  if (decoMarkup !== "")
588
- parts.push(decoMarkup);
876
+ segParts.push(decoMarkup);
589
877
  // Per-char raster overlays (SK-1090). Emoji inline with path-rendered
590
878
  // text get their actual Chrome-painted pixels stamped over the position.
591
879
  const rasterOverlay = rasterGlyphOverlays(seg, segFontSize, clipId);
592
880
  if (rasterOverlay !== "")
593
- parts.push(rasterOverlay);
881
+ segParts.push(rasterOverlay);
882
+ // DM-783: when the segment's pseudo carries a CSS transform, wrap the
883
+ // accumulated box + glyphs + decoration + raster overlay so all four
884
+ // rotate together around the captured transform-origin. No-op for non-
885
+ // pseudo segments (`seg.pseudoBox == null`) and for pseudos without a
886
+ // transform — both flush through unchanged.
887
+ if (seg.pseudoBox != null && seg.pseudoBox.transform != null && seg.pseudoBox.transform !== "" && seg.pseudoBox.transform !== "none") {
888
+ parts.push(pseudoBoxTransformWrap(seg.pseudoBox, segParts.join("")));
889
+ }
890
+ else {
891
+ for (const sp of segParts)
892
+ parts.push(sp);
893
+ }
594
894
  }
595
895
  // Wrap the multi-segment output in the element's clip-path only when the
596
896
  // element actually overflow-clips (DM-305) — see comment in
597
897
  // renderSingleLineText for why an unconditional clip is wrong.
598
898
  if (opts.overflowClip) {
599
- return `<g clip-path="url(#${clipId})">${parts.join("\n")}</g>`;
899
+ return anisotropicCorrectionWrap(el, `<g clip-path="url(#${clipId})">${parts.join("\n")}</g>`);
600
900
  }
601
- return parts.join("\n");
901
+ return anisotropicCorrectionWrap(el, parts.join("\n"));
602
902
  }
603
903
  /**
604
904
  * Render multi-line text (pre blocks).
605
905
  */
606
906
  export function renderMultiLineText(opts) {
907
+ const _ts = textStrokeParams(opts.el.styles);
607
908
  const { el, clipId, fillColor } = opts;
608
909
  const fontSize = parseFloat(el.styles.fontSize) || 14;
609
910
  const fontFamily = el.styles.fontFamily;
@@ -630,11 +931,13 @@ export function renderMultiLineText(opts) {
630
931
  for (const seg of el.textSegments) {
631
932
  const xOffsetsRelRaw = seg.xOffsets != null ? seg.xOffsets.map((v) => v - seg.x) : undefined;
632
933
  const reordered = applyBidi(suppressGlyphChars(seg.text, seg), xOffsetsRelRaw, dir);
934
+ // DM-822: anisotropic correction — see `anisotropicCorrectionXOffsets`.
935
+ const segXOffsets = anisotropicCorrectionXOffsets(el, reordered.xOffsets);
633
936
  const segFontSize = seg.fontSize ?? fontSize;
634
937
  const segFontWeight = seg.fontWeight ?? fontWeight;
635
938
  const segColor = seg.color ?? fillColor;
636
939
  const segAscent = seg.fontAscent ?? el.fontAscent;
637
- const result = renderTextAsPath(reordered.text, seg.x, seg.y, segFontSize, fontFamily, segFontWeight, segColor, undefined, undefined, reordered.xOffsets, el.styles.fontStyle, segAscent, ffsFeatures, el.styles.lang, fvsAxes);
940
+ const result = renderTextAsPath(reordered.text, seg.x, seg.y, segFontSize, fontFamily, segFontWeight, segColor, undefined, undefined, segXOffsets, el.styles.fontStyle, segAscent, ffsFeatures, el.styles.lang, fvsAxes, _ts.width, _ts.color, _ts.paintOrder);
638
941
  if (result != null)
639
942
  parts.push(` ${result}`);
640
943
  }
@@ -646,18 +949,19 @@ export function renderMultiLineText(opts) {
646
949
  if (line === "")
647
950
  continue;
648
951
  const lineY = startY + li * lineHeight;
649
- const result = renderTextAsPath(line, startX, lineY, fontSize, fontFamily, fontWeight, fillColor, undefined, undefined, undefined, el.styles.fontStyle, el.fontAscent, ffsFeatures, el.styles.lang, fvsAxes);
952
+ const result = renderTextAsPath(line, startX, lineY, fontSize, fontFamily, fontWeight, fillColor, undefined, undefined, undefined, el.styles.fontStyle, el.fontAscent, ffsFeatures, el.styles.lang, fvsAxes, _ts.width, _ts.color, _ts.paintOrder);
650
953
  if (result != null)
651
954
  parts.push(` ${result}`);
652
955
  }
653
956
  }
654
957
  parts.push("</g>");
655
- return parts.join("\n");
958
+ return anisotropicCorrectionWrap(el, parts.join("\n"));
656
959
  }
657
960
  /**
658
961
  * Render input/textarea text.
659
962
  */
660
963
  export function renderInputText(opts) {
964
+ const _ts = textStrokeParams(opts.el.styles);
661
965
  const { el, clipId, fillColor } = opts;
662
966
  // Textarea content was rasterized via page.screenshot (SK-1108) — stamp the
663
967
  // PNG at the content rect and skip the path pipeline. This bypasses our
@@ -688,17 +992,17 @@ export function renderInputText(opts) {
688
992
  ? el.inputXOffsets.map((v) => v - textX) : undefined;
689
993
  const inputFeatures = parseFontFeatureSettings(el.styles.fontFeatureSettings);
690
994
  const inputAxes = parseFontVariationSettings(el.styles.fontVariationSettings);
691
- const result = renderTextAsPath(el.text, textX, tt, fontSize, fontFamily, textFontWeight, textColor, undefined, undefined, xOffsetsRel, textFontStyle, el.fontAscent, inputFeatures, el.styles.lang, inputAxes);
995
+ const result = renderTextAsPath(el.text, textX, tt, fontSize, fontFamily, textFontWeight, textColor, undefined, undefined, xOffsetsRel, textFontStyle, el.fontAscent, inputFeatures, el.styles.lang, inputAxes, _ts.width, _ts.color, _ts.paintOrder);
692
996
  // Clip the path-rendered text to the input's content rect so values that
693
997
  // overflow the visible width (common on readonly inputs with long text or
694
998
  // any input narrower than its value) are truncated like Chrome paints
695
999
  // them, not extending past the right border. DM-245.
696
1000
  if (result != null)
697
- return `<g clip-path="url(#${clipId})">${result}</g>`;
1001
+ return anisotropicCorrectionWrap(el, `<g clip-path="url(#${clipId})">${result}</g>`);
698
1002
  // Fallback to CSS <text> if path rendering fails
699
1003
  const textY = (el.textTop != null && el.textHeight != null && el.textHeight > 0)
700
1004
  ? el.textTop + el.textHeight / 2 : el.y + el.height / 2;
701
1005
  const ff = fontFamily.replace(/"/g, "'");
702
1006
  const baseStyle = `font-family:${ff};font-size:${r(fontSize)}px;font-weight:${fontWeight};font-kerning:normal;font-optical-sizing:auto;`;
703
- return `<text x="${r(textX)}" y="${r(textY)}" dominant-baseline="central" fill="${textColor}" style="${baseStyle}" clip-path="url(#${clipId})">${esc(el.text)}</text>`;
1007
+ return anisotropicCorrectionWrap(el, `<text x="${r(textX)}" y="${r(textY)}" dominant-baseline="central" fill="${textColor}" style="${baseStyle}" clip-path="url(#${clipId})">${esc(el.text)}</text>`);
704
1008
  }
@@ -21,6 +21,7 @@
21
21
  * shrinking output size on mostly-static-content pages.
22
22
  */
23
23
  import type { ScrollSegmentCapture } from "./executor.js";
24
+ import { type RenderTextMode } from "../render/text-to-path.js";
24
25
  export interface ScrollComposerOptions {
25
26
  /** Visible viewport width (output SVG width). */
26
27
  viewportW: number;
@@ -37,6 +38,31 @@ export interface ScrollComposerOptions {
37
38
  * to source-resolution data URIs. Default 2 (matches `elementTreeToSvg`).
38
39
  */
39
40
  hiDPIFactor?: number;
41
+ /**
42
+ * DM-648: number of consecutive segments to group inside one
43
+ * `<g style="will-change: transform">` chunk wrapper. Each chunk gets its
44
+ * own GPU backing store so tall composites don't blow Chromium's
45
+ * per-layer raster budget (the 1280 × 6015 px apple-desktop-scroll capture
46
+ * at hi-DPI 2 was ~30 MB of raster on a single layer). Default `2` —
47
+ * meaning every two segments share one layer; a 16-segment scroll yields
48
+ * 8 layers. `1` puts each segment on its own layer (more layers, smaller
49
+ * each); higher values trade layer count for layer size. Must be ≥ 1.
50
+ * See `docs/36-scroll-composite-layer-chunking.md`.
51
+ */
52
+ chunkSize?: number;
53
+ /**
54
+ * DM-652: text rendering mode. Default `"embedded-font"` emits `<text>`
55
+ * elements with a single `@font-face` per used webfont — much faster
56
+ * in WebKit (≈1.84× perf gain + 4.5× smaller file on text-heavy
57
+ * fixtures per DM-651) and visually equivalent in Chromium. `"paths"`
58
+ * is the legacy Chromium-faithful mode that emits `<use href="#gN">`
59
+ * references to glyph path defs, useful when the consumer needs
60
+ * per-pixel parity with the Chromium capture (e.g. visual-regression
61
+ * diffing against the live page). System-font runs and bidi /
62
+ * fallback-chain runs always stay in `"paths"` mode regardless of
63
+ * this flag (MVP scope).
64
+ */
65
+ renderText?: RenderTextMode;
40
66
  }
41
67
  /**
42
68
  * Compose a sequence of segment-captures into one animated SVG. The output