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/dist/render/text.js
CHANGED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); }
|
|
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, "&").replace(/</g, "<").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
|
|
131
|
+
return normalized;
|
|
22
132
|
const suppress = seg.rasterGlyphs.filter((g) => g.suppressGlyph === true);
|
|
23
133
|
if (suppress.length === 0)
|
|
24
|
-
return
|
|
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 <
|
|
139
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
31
140
|
const drop = suppress.some((g) => g.charIndex === i);
|
|
32
|
-
out += drop ? ZWSP :
|
|
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
|
-
|
|
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
|
|
106
|
-
// (`decoration_line_painter.cc::Paint
|
|
107
|
-
//
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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})">${
|
|
734
|
+
return anisotropicCorrectionWrap(el, `<g clip-path="url(#${clipId})">${transformed}</g>`);
|
|
476
735
|
}
|
|
477
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|