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.
- package/FEATURES.md +1 -0
- package/README.md +29 -0
- package/dist/animation/animator.js +25 -14
- package/dist/animation/animator.test.js +54 -21
- package/dist/animation/cursor-overlay.js +0 -2
- package/dist/capture/emoji.js +29 -18
- package/dist/capture/index.js +5 -4
- package/dist/capture/script/color-norm.d.ts +1 -0
- package/dist/capture/script/color-norm.js +43 -1
- package/dist/capture/script/emoji-detect.js +14 -0
- package/dist/capture/script/index.js +593 -65
- package/dist/capture/script/walker/borders-backgrounds.d.ts +24 -17
- package/dist/capture/script/walker/borders-backgrounds.js +123 -7
- package/dist/capture/script/walker/counter-style-resolver.d.ts +7 -0
- package/dist/capture/script/walker/counter-style-resolver.js +218 -0
- package/dist/capture/script/walker/input-value.js +14 -1
- package/dist/capture/script/walker/lists-counters.d.ts +3 -1
- package/dist/capture/script/walker/lists-counters.js +22 -2
- package/dist/capture/script/walker/masks-clips.d.ts +2 -0
- package/dist/capture/script/walker/masks-clips.js +41 -1
- package/dist/capture/script/walker/pseudo-content.d.ts +14 -1
- package/dist/capture/script/walker/pseudo-content.js +301 -61
- package/dist/capture/script/walker/pseudo-inject.js +20 -0
- package/dist/capture/script/walker/text-segments.js +98 -4
- package/dist/capture/script/walker/transforms.d.ts +1 -0
- package/dist/capture/script/walker/transforms.js +16 -0
- package/dist/capture/script.generated.js +1 -1
- package/dist/capture/types.d.ts +213 -2
- package/dist/cli/animate.js +151 -15
- package/dist/mask.test.js +12 -7
- package/dist/render/borders.d.ts +9 -13
- package/dist/render/borders.js +379 -14
- package/dist/render/element-tree-to-svg.d.ts +11 -12
- package/dist/render/element-tree-to-svg.js +2046 -241
- package/dist/render/embedded-font-builder.d.ts +49 -0
- package/dist/render/embedded-font-builder.js +149 -0
- package/dist/render/form-controls.js +45 -24
- package/dist/render/gradients.d.ts +15 -0
- package/dist/render/gradients.js +103 -2
- package/dist/render/gradients.test.js +34 -0
- package/dist/render/text-to-path.d.ts +38 -1
- package/dist/render/text-to-path.js +654 -29
- package/dist/render/text-to-path.test.js +230 -9
- package/dist/render/text.d.ts +14 -0
- package/dist/render/text.js +344 -40
- package/dist/scroll/composer.d.ts +26 -0
- package/dist/scroll/composer.js +199 -11
- package/dist/scroll/composer.test.js +293 -16
- package/dist/scroll/executor.d.ts +3 -1
- package/dist/scroll/executor.js +15 -6
- package/dist/scroll/executor.test.js +25 -0
- package/dist/scroll/hoist-fixed.d.ts +48 -0
- package/dist/scroll/hoist-fixed.js +85 -0
- package/dist/scroll/hoist-fixed.test.d.ts +1 -0
- package/dist/scroll/hoist-fixed.test.js +103 -0
- package/dist/scroll/hoist-sticky.d.ts +45 -0
- package/dist/scroll/hoist-sticky.js +157 -0
- package/dist/scroll/hoist-sticky.test.d.ts +1 -0
- package/dist/scroll/hoist-sticky.test.js +154 -0
- package/dist/scroll/pattern.d.ts +22 -5
- package/dist/scroll/pattern.js +55 -7
- package/dist/scroll/pattern.test.js +48 -1
- package/dist/tree-ops/frame-merge.d.ts +10 -0
- package/dist/tree-ops/frame-merge.js +23 -5
- package/dist/tree-ops/frame-merge.test.js +45 -0
- package/dist/tree-ops/tree-diff.js +1 -1
- package/dist/tree-ops/viewbox-culling.js +32 -18
- package/dist/tree-ops/viewbox-culling.test.js +40 -6
- package/package.json +8 -2
- package/src/animation/animator.test.ts +56 -21
- package/src/animation/animator.ts +25 -14
- package/src/animation/cursor-overlay.ts +0 -2
- package/src/capture/emoji.ts +28 -18
- package/src/capture/index.ts +15 -14
- package/src/capture/script/color-norm.ts +38 -1
- package/src/capture/script/emoji-detect.ts +14 -0
- package/src/capture/script/index.ts +555 -48
- package/src/capture/script/walker/borders-backgrounds.ts +114 -7
- package/src/capture/script/walker/counter-style-resolver.ts +184 -0
- package/src/capture/script/walker/input-value.ts +14 -1
- package/src/capture/script/walker/lists-counters.ts +24 -2
- package/src/capture/script/walker/masks-clips.ts +40 -1
- package/src/capture/script/walker/pseudo-content.ts +297 -55
- package/src/capture/script/walker/pseudo-inject.ts +20 -0
- package/src/capture/script/walker/text-segments.ts +93 -4
- package/src/capture/script/walker/transforms.ts +14 -0
- package/src/capture/script.generated.ts +1 -1
- package/src/capture/types.ts +202 -2
- package/src/cli/animate.ts +135 -15
- package/src/mask.test.ts +12 -7
- package/src/render/borders.ts +383 -17
- package/src/render/element-tree-to-svg.ts +2051 -238
- package/src/render/embedded-font-builder.ts +221 -0
- package/src/render/form-controls.ts +45 -24
- package/src/render/gradients.test.ts +46 -0
- package/src/render/gradients.ts +94 -2
- package/src/render/opentype.js.d.ts +7 -0
- package/src/render/text-to-path.test.ts +246 -9
- package/src/render/text-to-path.ts +702 -31
- package/src/render/text.ts +344 -40
- package/src/scroll/composer.test.ts +322 -16
- package/src/scroll/composer.ts +246 -13
- package/src/scroll/executor.test.ts +27 -0
- package/src/scroll/executor.ts +19 -10
- package/src/scroll/hoist-fixed.test.ts +117 -0
- package/src/scroll/hoist-fixed.ts +95 -0
- package/src/scroll/hoist-sticky.test.ts +173 -0
- package/src/scroll/hoist-sticky.ts +193 -0
- package/src/scroll/pattern.test.ts +58 -1
- package/src/scroll/pattern.ts +71 -8
- package/src/tree-ops/frame-merge.test.ts +51 -0
- package/src/tree-ops/frame-merge.ts +24 -6
- package/src/tree-ops/tree-diff.ts +3 -1
- package/src/tree-ops/viewbox-culling.test.ts +42 -6
- package/src/tree-ops/viewbox-culling.ts +32 -18
package/src/render/text.ts
CHANGED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); }
|
|
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, "&").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
|
-
|
|
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
|
|
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 <
|
|
141
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
34
142
|
const drop = suppress.some((g) => g.charIndex === i);
|
|
35
|
-
out += drop ? ZWSP :
|
|
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
|
-
|
|
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
|
|
114
|
-
// (`decoration_line_painter.cc::Paint
|
|
115
|
-
//
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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})">${
|
|
737
|
+
return anisotropicCorrectionWrap(el, `<g clip-path="url(#${clipId})">${transformed}</g>`);
|
|
477
738
|
}
|
|
478
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
581
|
-
if (result != null) {
|
|
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
|
-
|
|
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 !== "")
|
|
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 !== "")
|
|
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,
|
|
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
|
}
|