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