domotion-svg 0.1.1
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 +102 -0
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/animator.d.ts +158 -0
- package/dist/animator.js +424 -0
- package/dist/animator.test.d.ts +5 -0
- package/dist/animator.test.js +169 -0
- package/dist/border-radius.test.d.ts +1 -0
- package/dist/border-radius.test.js +148 -0
- package/dist/capture.d.ts +193 -0
- package/dist/capture.js +786 -0
- package/dist/chrome.d.ts +45 -0
- package/dist/chrome.js +107 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +512 -0
- package/dist/client/dom.d.ts +10 -0
- package/dist/client/dom.js +17 -0
- package/dist/conic-raster.d.ts +58 -0
- package/dist/conic-raster.js +292 -0
- package/dist/conic-raster.test.d.ts +1 -0
- package/dist/conic-raster.test.js +187 -0
- package/dist/coretext-extractor.test.d.ts +1 -0
- package/dist/coretext-extractor.test.js +94 -0
- package/dist/coretext-helper.d.ts +60 -0
- package/dist/coretext-helper.js +205 -0
- package/dist/cross-origin-font-face.test.d.ts +1 -0
- package/dist/cross-origin-font-face.test.js +107 -0
- package/dist/cursor-overlay.d.ts +123 -0
- package/dist/cursor-overlay.js +207 -0
- package/dist/cursor-overlay.test.d.ts +1 -0
- package/dist/cursor-overlay.test.js +88 -0
- package/dist/dark-mode-capture.test.d.ts +1 -0
- package/dist/dark-mode-capture.test.js +158 -0
- package/dist/dark-mode-form-controls.test.d.ts +1 -0
- package/dist/dark-mode-form-controls.test.js +218 -0
- package/dist/dom-to-svg.d.ts +1016 -0
- package/dist/dom-to-svg.js +7717 -0
- package/dist/embed-remote-images.test.d.ts +1 -0
- package/dist/embed-remote-images.test.js +424 -0
- package/dist/form-controls.d.ts +70 -0
- package/dist/form-controls.js +1151 -0
- package/dist/frame-merge.d.ts +95 -0
- package/dist/frame-merge.js +374 -0
- package/dist/frame-merge.test.d.ts +6 -0
- package/dist/frame-merge.test.js +144 -0
- package/dist/gradients.d.ts +184 -0
- package/dist/gradients.js +937 -0
- package/dist/gradients.test.d.ts +1 -0
- package/dist/gradients.test.js +150 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +7 -0
- package/dist/jsx-runtime.d.ts +27 -0
- package/dist/jsx-runtime.js +96 -0
- package/dist/jsx-runtime.test.d.ts +1 -0
- package/dist/jsx-runtime.test.js +41 -0
- package/dist/kerfjs-imports.test.d.ts +1 -0
- package/dist/kerfjs-imports.test.js +36 -0
- package/dist/mask.test.d.ts +1 -0
- package/dist/mask.test.js +206 -0
- package/dist/optimize.d.ts +12 -0
- package/dist/optimize.js +32 -0
- package/dist/preserve-aspect-ratio.test.d.ts +1 -0
- package/dist/preserve-aspect-ratio.test.js +38 -0
- package/dist/resize-embedded-images.d.ts +33 -0
- package/dist/resize-embedded-images.js +164 -0
- package/dist/resize-embedded-images.test.d.ts +9 -0
- package/dist/resize-embedded-images.test.js +255 -0
- package/dist/stacking-context.test.d.ts +1 -0
- package/dist/stacking-context.test.js +927 -0
- package/dist/text-renderer.d.ts +42 -0
- package/dist/text-renderer.js +608 -0
- package/dist/text-renderer.test.d.ts +1 -0
- package/dist/text-renderer.test.js +150 -0
- package/dist/text-to-path.d.ts +265 -0
- package/dist/text-to-path.js +1800 -0
- package/dist/text-to-path.test.d.ts +1 -0
- package/dist/text-to-path.test.js +570 -0
- package/dist/utils/escapeHtml.d.ts +2 -0
- package/dist/utils/escapeHtml.js +15 -0
- package/dist/webfont-unicode-range.test.d.ts +1 -0
- package/dist/webfont-unicode-range.test.js +174 -0
- package/package.json +55 -0
- package/src/animator.test.ts +179 -0
- package/src/animator.ts +660 -0
- package/src/border-radius.test.ts +160 -0
- package/src/capture.ts +810 -0
- package/src/cli.ts +582 -0
- package/src/conic-raster.test.ts +213 -0
- package/src/conic-raster.ts +309 -0
- package/src/coretext-extractor.test.ts +130 -0
- package/src/coretext-helper.ts +256 -0
- package/src/cross-origin-font-face.test.ts +119 -0
- package/src/cursor-overlay.test.ts +95 -0
- package/src/cursor-overlay.ts +297 -0
- package/src/dark-mode-capture.test.ts +177 -0
- package/src/dark-mode-form-controls.test.ts +228 -0
- package/src/dom-to-svg.ts +8376 -0
- package/src/embed-remote-images.test.ts +461 -0
- package/src/form-controls.ts +1174 -0
- package/src/frame-merge.test.ts +157 -0
- package/src/frame-merge.ts +447 -0
- package/src/globals.d.ts +2 -0
- package/src/gradients.test.ts +175 -0
- package/src/gradients.ts +955 -0
- package/src/index.ts +12 -0
- package/src/kerf-jsx-augmentation.d.ts +36 -0
- package/src/kerfjs-imports.test.tsx +45 -0
- package/src/mask.test.ts +274 -0
- package/src/optimize.ts +34 -0
- package/src/preserve-aspect-ratio.test.ts +49 -0
- package/src/resize-embedded-images.test.ts +292 -0
- package/src/resize-embedded-images.ts +180 -0
- package/src/stacking-context.test.ts +967 -0
- package/src/text-renderer.test.ts +162 -0
- package/src/text-renderer.ts +623 -0
- package/src/text-to-path.test.ts +639 -0
- package/src/text-to-path.ts +1810 -0
- package/src/utils/escapeHtml.ts +16 -0
- package/src/webfont-unicode-range.test.ts +207 -0
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text Renderer
|
|
3
|
+
*
|
|
4
|
+
* Renders text in SVG using fontkit path outlines for cross-browser identical rendering.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import bidiFactory from "bidi-js";
|
|
8
|
+
import { computeSkipInkGaps, getDecorationMetrics, renderTextAsPath } from "./text-to-path.js";
|
|
9
|
+
import type { CapturedElement, TextSegment } from "./dom-to-svg.js";
|
|
10
|
+
|
|
11
|
+
// ── Rendering helpers ──
|
|
12
|
+
|
|
13
|
+
function r(n: number): string { return Number(n.toFixed(1)).toString(); }
|
|
14
|
+
function esc(s: string): string { return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); }
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Replace any UTF-16 code units flagged with `suppressGlyph` in the segment's
|
|
18
|
+
* raster overlays with U+200B (zero-width space). The path renderer emits no
|
|
19
|
+
* `<use>` for zero-contour glyphs, so this hides the underlying path glyph
|
|
20
|
+
* while leaving the segment's xOffsets / aria-label / raster overlay intact.
|
|
21
|
+
* Used for ::first-letter drop caps (DM-439) where the body-size path glyph
|
|
22
|
+
* would otherwise show through behind the styled rasterized big letter.
|
|
23
|
+
*/
|
|
24
|
+
function suppressGlyphChars(text: string, seg: TextSegment | undefined): string {
|
|
25
|
+
if (seg?.rasterGlyphs == null) return text;
|
|
26
|
+
const suppress = seg.rasterGlyphs.filter((g) => g.suppressGlyph === true);
|
|
27
|
+
if (suppress.length === 0) return text;
|
|
28
|
+
// text is a UTF-16 string; charIndex is a UTF-16 position. U+200B is one
|
|
29
|
+
// UTF-16 unit so the substitution preserves text length and xOffsets
|
|
30
|
+
// alignment.
|
|
31
|
+
const ZWSP = String.fromCharCode(0x200B);
|
|
32
|
+
let out = "";
|
|
33
|
+
for (let i = 0; i < text.length; i++) {
|
|
34
|
+
const drop = suppress.some((g) => g.charIndex === i);
|
|
35
|
+
out += drop ? ZWSP : text[i];
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Emit <image> overlays for any per-char raster glyphs the capture layer
|
|
42
|
+
* attached to this segment (SK-1090). These sit on top of the text-path
|
|
43
|
+
* markup and cover the exact pixel region Chrome painted for the color-
|
|
44
|
+
* bitmap codepoint (emoji, U+2713-family, etc.). Returns "" when the
|
|
45
|
+
* segment has no raster glyphs or none of them have a resolved dataUri.
|
|
46
|
+
*/
|
|
47
|
+
export function rasterGlyphOverlays(seg: TextSegment, fallbackFontSize: number, clipId: string): string {
|
|
48
|
+
if (seg.rasterGlyphs == null || seg.rasterGlyphs.length === 0) return "";
|
|
49
|
+
const out: string[] = [];
|
|
50
|
+
// Apple Color Emoji bitmaps are rendered by Chrome's CoreText at em-square
|
|
51
|
+
// size (fontSize × fontSize), centered horizontally on the glyph advance and
|
|
52
|
+
// baseline-aligned vertically. The captured `g.rect` is Chrome's per-char
|
|
53
|
+
// bbox, which spans the FULL line-box height (~1.2-1.5em) and the advance
|
|
54
|
+
// width (~1.05-1.1em) — bigger than the painted bitmap on both axes. The
|
|
55
|
+
// earlier `xMidYMid meet` strategy fitted a square bitmap into the rect
|
|
56
|
+
// using `min(rect.w, rect.h)` as the side length, which over-shot Chrome's
|
|
57
|
+
// em-square paint by ~1px and shifted the bitmap down by ~1px relative to
|
|
58
|
+
// the baseline — producing the colored fringe DM-381 reported. Emit at
|
|
59
|
+
// em-square size centered in the rect instead. (DM-381)
|
|
60
|
+
for (const g of seg.rasterGlyphs) {
|
|
61
|
+
if (g.dataUri == null) continue;
|
|
62
|
+
// Emit the screenshot at exactly the captured rect coords + dims. The
|
|
63
|
+
// PNG was screenshot from Chrome's actual paint at this rect, so
|
|
64
|
+
// re-embedding it at the same rect preserves the painted geometry
|
|
65
|
+
// pixel-for-pixel. Earlier (DM-381) we stretched to em-square via
|
|
66
|
+
// `width=fontSize height=fontSize preserveAspectRatio=none`, which
|
|
67
|
+
// squished tall line-box rects horizontally — flag emoji and other
|
|
68
|
+
// raster glyphs rendered visibly larger than Chrome's actual paint
|
|
69
|
+
// (DM-401 / DM-411 / DM-414).
|
|
70
|
+
out.push(`<image href="${g.dataUri}" x="${r(g.rect.x)}" y="${r(g.rect.y)}" width="${r(g.rect.width)}" height="${r(g.rect.height)}" preserveAspectRatio="none" clip-path="url(#${clipId})"/>`);
|
|
71
|
+
}
|
|
72
|
+
return out.join("");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Emit SVG lines for CSS text-decoration-line (underline / line-through /
|
|
77
|
+
* overline). Each segment gets its own line anchored to the segment's box —
|
|
78
|
+
* this matches how browsers paint decoration on each inline box instead of
|
|
79
|
+
* one continuous line across gaps. Returns "" when no decoration applies.
|
|
80
|
+
*
|
|
81
|
+
* `baselineY` is the segment's baseline in viewport coords. Position and
|
|
82
|
+
* thickness come from the font's `post.underlinePosition` / `underlineThickness`
|
|
83
|
+
* (underline) and `OS/2.yStrikeoutPosition` / `yStrikeoutSize` (line-through)
|
|
84
|
+
* tables — the same metrics Chromium consults — so placement matches the
|
|
85
|
+
* browser within ~0.5px instead of relying on fontSize fractions (SK-1236).
|
|
86
|
+
*/
|
|
87
|
+
function renderTextDecoration(
|
|
88
|
+
textDecorationLine: string | undefined,
|
|
89
|
+
decorationColor: string,
|
|
90
|
+
style: string | undefined,
|
|
91
|
+
segX: number, baselineY: number, segWidth: number,
|
|
92
|
+
fontSize: number, fontFamily: string, fontWeight: string | number, fontStyle: string | undefined,
|
|
93
|
+
/** CSS `text-decoration-thickness` (e.g. `5px` or `auto`). DM-431. */
|
|
94
|
+
thicknessOverride?: string,
|
|
95
|
+
/** CSS `text-underline-offset` (e.g. `6px` or `auto`). DM-431. */
|
|
96
|
+
underlineOffset?: string,
|
|
97
|
+
/** Run text used to compute `text-decoration-skip-ink: auto` glyph
|
|
98
|
+
* intercepts. Required for skip-ink to apply. DM-446. */
|
|
99
|
+
runText?: string,
|
|
100
|
+
/** CSS `text-decoration-skip-ink` — `auto` (default) or `none`. Only solid /
|
|
101
|
+
* double underlines honor it (matches Chromium). DM-446. */
|
|
102
|
+
skipInk?: string,
|
|
103
|
+
/** OpenType feature tags forwarded to fontkit shaping when computing skip-
|
|
104
|
+
* ink intercepts so small-caps / petite-caps glyphs match the painted text. */
|
|
105
|
+
features?: string[],
|
|
106
|
+
): string {
|
|
107
|
+
if (textDecorationLine == null || textDecorationLine === "none" || textDecorationLine === "") return "";
|
|
108
|
+
const m = getDecorationMetrics(fontFamily, fontSize, fontWeight, fontStyle, thicknessOverride, underlineOffset);
|
|
109
|
+
const lines: string[] = [];
|
|
110
|
+
const has = (k: string) => textDecorationLine.includes(k);
|
|
111
|
+
const dash = (thick: number) => style === "dashed" ? ` stroke-dasharray="${thick * 2} ${thick * 2}"`
|
|
112
|
+
: style === "dotted" ? ` stroke-dasharray="${thick} ${thick}"` : "";
|
|
113
|
+
// Skip-ink applies only to solid + double underlines per Chromium
|
|
114
|
+
// (`decoration_line_painter.cc::Paint` short-circuits on dashed / dotted /
|
|
115
|
+
// wavy). We compute gaps once if any underline emit needs them.
|
|
116
|
+
const skipInkActive = (skipInk == null || skipInk === "auto") && runText != null && runText !== ""
|
|
117
|
+
&& (style == null || style === "solid" || style === "double" || style === "");
|
|
118
|
+
// Compute X-range gaps where the underline rect crosses glyph ink. Returned
|
|
119
|
+
// gaps are run-relative (0 = segX); subSegments() splits the underline span
|
|
120
|
+
// around them.
|
|
121
|
+
function computeGapsAt(yRel: number, thick: number): Array<[number, number]> {
|
|
122
|
+
if (!skipInkActive || runText == null) return [];
|
|
123
|
+
return computeSkipInkGaps(runText, fontSize, fontFamily, fontWeight, fontStyle, yRel, thick, features, segWidth);
|
|
124
|
+
}
|
|
125
|
+
// Split [segX, segX+segWidth] into sub-runs by removing gap intervals
|
|
126
|
+
// (run-relative; gap[0]+segX is absolute screen X).
|
|
127
|
+
function subSegments(gaps: Array<[number, number]>): Array<{ x0: number; x1: number }> {
|
|
128
|
+
const x0 = segX;
|
|
129
|
+
const x1 = segX + segWidth;
|
|
130
|
+
if (gaps.length === 0) return [{ x0, x1 }];
|
|
131
|
+
const out: Array<{ x0: number; x1: number }> = [];
|
|
132
|
+
let cursor = x0;
|
|
133
|
+
for (const [ga, gb] of gaps) {
|
|
134
|
+
const ax = segX + ga;
|
|
135
|
+
const bx = segX + gb;
|
|
136
|
+
if (bx <= cursor) continue;
|
|
137
|
+
if (ax > cursor) out.push({ x0: cursor, x1: Math.min(ax, x1) });
|
|
138
|
+
cursor = Math.max(cursor, bx);
|
|
139
|
+
if (cursor >= x1) break;
|
|
140
|
+
}
|
|
141
|
+
if (cursor < x1) out.push({ x0: cursor, x1 });
|
|
142
|
+
return out.filter((r) => r.x1 - r.x0 > 0.25);
|
|
143
|
+
}
|
|
144
|
+
// Emit one decoration line at (y, thickness) honoring the text-decoration-style.
|
|
145
|
+
// (DM-345.) For wavy: build a sin-wave path. For double: two parallel
|
|
146
|
+
// lines with a 1×thickness gap. For solid/dashed/dotted: a single
|
|
147
|
+
// <line> with optional stroke-dasharray.
|
|
148
|
+
const explicitThickness = thicknessOverride != null && thicknessOverride !== ""
|
|
149
|
+
&& thicknessOverride !== "auto" && thicknessOverride !== "from-font";
|
|
150
|
+
function emitLine(y: number, t: number, isUnderline: boolean = false): string {
|
|
151
|
+
if (style === "wavy") {
|
|
152
|
+
// Match Chromium's `decoration_line_painter.cc::MakeWave` + `WavyPath`:
|
|
153
|
+
// wavelength = 1 + 2 * round(2 * thickness + 0.5)
|
|
154
|
+
// cp_distance = 0.5 + round(3 * thickness + 0.5)
|
|
155
|
+
// Each wavelength is one cubic Bezier with both control points at
|
|
156
|
+
// `wavelength/2` x — `cp1.y = +cp_distance`, `cp2.y = -cp_distance` —
|
|
157
|
+
// producing an S-curve from `(0, 0)` through a peak / trough back to
|
|
158
|
+
// `(wavelength, 0)`. Total visual amplitude ≈ `cp_distance * 0.289`.
|
|
159
|
+
//
|
|
160
|
+
// Earlier we rendered as quadratic Q curves with the control point at
|
|
161
|
+
// `±cp_distance` directly; that paints visual amplitude `cp_distance/2`
|
|
162
|
+
// — about 70% taller than Chrome — making 18 px wavy underlines look
|
|
163
|
+
// exaggerated. Switch to cubic to reproduce Chrome's geometry. (DM-446.)
|
|
164
|
+
//
|
|
165
|
+
// Thickness uses Chromium's auto-rule `max(1, fontSize/10)` rather than
|
|
166
|
+
// `getDecorationMetrics`'s `fontSize/20` empirical formula. The
|
|
167
|
+
// empirical rule compensates for an SVG-vs-HTML pixel-grid mismatch on
|
|
168
|
+
// axis-aligned solid strokes; curves don't hit that artifact, and the
|
|
169
|
+
// smaller value paints a visibly thinner stroke than Chrome.
|
|
170
|
+
const tc = explicitThickness ? Math.max(1, t) : Math.max(1, fontSize / 10);
|
|
171
|
+
const wavelength = 1 + 2 * Math.round(2 * tc + 0.5);
|
|
172
|
+
const cpDist = 0.5 + Math.round(3 * tc + 0.5);
|
|
173
|
+
let d = `M ${r(segX)} ${r(y)}`;
|
|
174
|
+
let x = segX;
|
|
175
|
+
while (x < segX + segWidth) {
|
|
176
|
+
const nx = Math.min(x + wavelength, segX + segWidth);
|
|
177
|
+
const cpX = x + wavelength / 2;
|
|
178
|
+
d += ` C ${r(cpX)} ${r(y + cpDist)} ${r(cpX)} ${r(y - cpDist)} ${r(nx)} ${r(y)}`;
|
|
179
|
+
x = nx;
|
|
180
|
+
}
|
|
181
|
+
return `<path d="${d}" fill="none" stroke="${decorationColor}" stroke-width="${r(tc)}"/>`;
|
|
182
|
+
}
|
|
183
|
+
if (style === "double") {
|
|
184
|
+
// Double: two parallel lines. Per Chromium's `decoration_line_painter
|
|
185
|
+
// .cc::DrawLineAsRect`, kDouble extends the single-underline rect to
|
|
186
|
+
// 3×thickness tall and emits a stroke at each end — i.e., the TOP
|
|
187
|
+
// stroke sits at the single-underline position and the BOTTOM stroke
|
|
188
|
+
// sits 2×thickness below it. Total height = 3×thickness.
|
|
189
|
+
//
|
|
190
|
+
// Earlier we centered the double on the single-underline position,
|
|
191
|
+
// which placed the top of the top stroke AT the baseline. The skip-ink
|
|
192
|
+
// intercept band then began at y_rel=0 and false-triggered on every
|
|
193
|
+
// baseline-resting glyph (d / o / u / b / l / e ...) producing the
|
|
194
|
+
// shredded-line artifact reported in DM-446.
|
|
195
|
+
const stroke = Math.max(1, t);
|
|
196
|
+
const top = y;
|
|
197
|
+
const bot = y + 2 * stroke;
|
|
198
|
+
// Skip-ink band spans both strokes plus their stroke widths:
|
|
199
|
+
// [top - stroke/2, bot + stroke/2] = 3×stroke tall, centered at
|
|
200
|
+
// (top + bot) / 2.
|
|
201
|
+
const bandCenter = (top + bot) / 2;
|
|
202
|
+
const bandThickness = (bot - top) + stroke;
|
|
203
|
+
const dblGaps = isUnderline
|
|
204
|
+
? computeGapsAt(bandCenter - baselineY, bandThickness)
|
|
205
|
+
: [];
|
|
206
|
+
const subs = subSegments(dblGaps);
|
|
207
|
+
return subs.map(({ x0, x1 }) =>
|
|
208
|
+
`<line x1="${r(x0)}" y1="${r(top)}" x2="${r(x1)}" y2="${r(top)}" stroke="${decorationColor}" stroke-width="${r(stroke)}"/>`
|
|
209
|
+
+ `<line x1="${r(x0)}" y1="${r(bot)}" x2="${r(x1)}" y2="${r(bot)}" stroke="${decorationColor}" stroke-width="${r(stroke)}"/>`
|
|
210
|
+
).join("");
|
|
211
|
+
}
|
|
212
|
+
// Solid (or dashed / dotted): single line span with optional dasharray.
|
|
213
|
+
// Skip-ink applies only to solid (Chromium short-circuits dashed / dotted).
|
|
214
|
+
const wantSkip = isUnderline && (style == null || style === "solid" || style === "");
|
|
215
|
+
const solidGaps = wantSkip ? computeGapsAt(y - baselineY, t) : [];
|
|
216
|
+
const subs = subSegments(solidGaps);
|
|
217
|
+
return subs.map(({ x0, x1 }) =>
|
|
218
|
+
`<line x1="${r(x0)}" y1="${r(y)}" x2="${r(x1)}" y2="${r(y)}" stroke="${decorationColor}" stroke-width="${r(t)}"${dash(t)}/>`
|
|
219
|
+
).join("");
|
|
220
|
+
}
|
|
221
|
+
if (has("underline")) {
|
|
222
|
+
lines.push(emitLine(baselineY + m.underlineOffsetY, m.underlineThickness, true));
|
|
223
|
+
}
|
|
224
|
+
if (has("line-through")) {
|
|
225
|
+
lines.push(emitLine(baselineY - m.strikeoutOffsetY, m.strikeoutThickness));
|
|
226
|
+
}
|
|
227
|
+
if (has("overline")) {
|
|
228
|
+
lines.push(emitLine(baselineY - m.overlineOffsetY, m.overlineThickness));
|
|
229
|
+
}
|
|
230
|
+
return lines.join("");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const _bidi = bidiFactory();
|
|
234
|
+
// Quick test for any RTL code point (Hebrew + Arabic + Syriac + Thaana etc.).
|
|
235
|
+
const _RTL_RE = /[-ࣿיִ-ﻼ]/;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Apply BiDi paired-bracket mirroring. Capture preserves chars in DOM/logical
|
|
239
|
+
* order with each char's visual x in xOffsets — positioning is already done
|
|
240
|
+
* by Chrome, so we only need to mirror paired brackets on RTL embedding
|
|
241
|
+
* levels (so a logical "(" inside an RTL run renders as ")"). We do NOT
|
|
242
|
+
* reorder: passing logical-order text to fontkit lets Arabic pick the
|
|
243
|
+
* correct contextual (initial/medial/final/isolated) forms when the renderer
|
|
244
|
+
* can honor shaping, and when it falls back to per-char isolated rendering
|
|
245
|
+
* the visual order still comes out right because per-char xOffsets already
|
|
246
|
+
* reflect Chrome's BiDi visual layout.
|
|
247
|
+
*
|
|
248
|
+
* paragraphDir comes from the element's CSS `direction` (default 'ltr').
|
|
249
|
+
* Returns the input text with mirror substitutions applied; xOffsets pass
|
|
250
|
+
* through unchanged.
|
|
251
|
+
*/
|
|
252
|
+
function applyBidi(text: string, xOffsets: number[] | undefined, paragraphDir: "ltr" | "rtl"): { text: string; xOffsets?: number[] } {
|
|
253
|
+
if (!_RTL_RE.test(text) && paragraphDir !== "rtl") return { text, xOffsets };
|
|
254
|
+
const embeddingLevels = _bidi.getEmbeddingLevels(text, paragraphDir);
|
|
255
|
+
// bidi-js's getMirroredCharactersMap is keyed for a post-reorder pipeline
|
|
256
|
+
// and returns empty when we haven't reordered, so apply mirroring directly:
|
|
257
|
+
// any paired bracket at an odd (RTL) embedding level gets swapped.
|
|
258
|
+
let outText = "";
|
|
259
|
+
let anyMirror = false;
|
|
260
|
+
for (let i = 0; i < text.length; i++) {
|
|
261
|
+
const level = embeddingLevels.levels[i];
|
|
262
|
+
if (level % 2 === 1) {
|
|
263
|
+
const m = _bidi.getMirroredCharacter(text[i]);
|
|
264
|
+
if (m != null) { outText += m; anyMirror = true; continue; }
|
|
265
|
+
}
|
|
266
|
+
outText += text[i];
|
|
267
|
+
}
|
|
268
|
+
if (!anyMirror) return { text, xOffsets };
|
|
269
|
+
return { text: outText, xOffsets };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
interface RenderTextOpts {
|
|
273
|
+
el: CapturedElement;
|
|
274
|
+
idPrefix: string;
|
|
275
|
+
clipId: string;
|
|
276
|
+
fillColor: string;
|
|
277
|
+
/** True when the element has overflow != visible (hidden / clip / scroll /
|
|
278
|
+
* auto on either axis) and text must be clipped to the content rect. When
|
|
279
|
+
* false, path-mode text renders without a clip-path so default
|
|
280
|
+
* `overflow: visible` text can spill past the box edge as Chrome paints. */
|
|
281
|
+
overflowClip?: boolean;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Returns true when every codepoint in `text` is in a Unicode Private Use
|
|
286
|
+
* Area (U+E000–F8FF, U+F0000–FFFFD, U+100000–10FFFD). Used to suppress the
|
|
287
|
+
* `<text>` fallback for icon-font codepoints whose path-mode emission was
|
|
288
|
+
* already suppressed as notdef tofu — letting Chromium repaint the tofu
|
|
289
|
+
* via its UA glyph fallback would defeat the suppression. (DM-490 / DM-500.)
|
|
290
|
+
*/
|
|
291
|
+
function isAllPrivateUseArea(text: string): boolean {
|
|
292
|
+
if (text.length === 0) return false;
|
|
293
|
+
for (let i = 0; i < text.length;) {
|
|
294
|
+
const cp = text.codePointAt(i)!;
|
|
295
|
+
const inPua = (cp >= 0xE000 && cp <= 0xF8FF)
|
|
296
|
+
|| (cp >= 0xF0000 && cp <= 0xFFFFD)
|
|
297
|
+
|| (cp >= 0x100000 && cp <= 0x10FFFD);
|
|
298
|
+
if (!inPua) return false;
|
|
299
|
+
i += cp > 0xFFFF ? 2 : 1;
|
|
300
|
+
}
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Resolve OpenType features from font-variant-caps (DM-361, DM-444).
|
|
305
|
+
// Spec mapping:
|
|
306
|
+
// small-caps → [smcp] (lowercase → small caps)
|
|
307
|
+
// all-small-caps → [smcp, c2sc] (lowercase + uppercase → small caps)
|
|
308
|
+
// petite-caps → [pcap] (lowercase → petite caps)
|
|
309
|
+
// all-petite-caps → [pcap, c2pc] (lowercase + uppercase → petite caps)
|
|
310
|
+
// unicase → [unic] (uppercase → small-cap height; lowercase same)
|
|
311
|
+
// titling-caps → [titl] (no case change; uppercase glyphs adjusted)
|
|
312
|
+
// The path renderer's synthesis layer (src/text-to-path.ts) applies the
|
|
313
|
+
// matching case-fold + scale when the active font lacks these OpenType
|
|
314
|
+
// features (Helvetica / Arial / SF Pro / Georgia / Times all do). Segment-
|
|
315
|
+
// level fontVariant (::first-line override) wins when set.
|
|
316
|
+
function resolveCapsFeatures(segVariant: string | undefined, elCaps: string | undefined): string[] | undefined {
|
|
317
|
+
const v = segVariant != null && segVariant !== "" ? segVariant : (elCaps ?? "");
|
|
318
|
+
if (/\ball-small-caps\b/.test(v)) return ["smcp", "c2sc"];
|
|
319
|
+
if (/\ball-petite-caps\b/.test(v)) return ["pcap", "c2pc"];
|
|
320
|
+
if (/\bsmall-caps\b/.test(v)) return ["smcp"];
|
|
321
|
+
if (/\bpetite-caps\b/.test(v)) return ["pcap"];
|
|
322
|
+
if (/\bunicase\b/.test(v)) return ["unic"];
|
|
323
|
+
if (/\btitling-caps\b/.test(v)) return ["titl"];
|
|
324
|
+
return undefined;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Render a single-line text element.
|
|
329
|
+
*/
|
|
330
|
+
export function renderSingleLineText(opts: RenderTextOpts): string {
|
|
331
|
+
const { el, clipId, fillColor } = opts;
|
|
332
|
+
const fontSize = parseFloat(el.styles.fontSize) || 14;
|
|
333
|
+
const fontFamily = el.styles.fontFamily;
|
|
334
|
+
const fontWeight = el.styles.fontWeight;
|
|
335
|
+
const tl = el.textLeft ?? el.x + 4;
|
|
336
|
+
const tt = el.textTop ?? el.y;
|
|
337
|
+
|
|
338
|
+
// Path mode: convert to <path> outlines. When the capture layer recorded
|
|
339
|
+
// per-character xOffsets for this single-line segment, pass them through so
|
|
340
|
+
// each glyph is anchored at the exact x Chrome painted (closes per-char
|
|
341
|
+
// drift). Falls back to Chrome's measured width for uniform scaling.
|
|
342
|
+
const singleSeg = (el.textSegments != null && el.textSegments.length === 1) ? el.textSegments[0] : undefined;
|
|
343
|
+
const pathTextRawSrc = singleSeg != null ? singleSeg.text : el.text;
|
|
344
|
+
const pathTextRaw = suppressGlyphChars(pathTextRawSrc, singleSeg);
|
|
345
|
+
const xOffsetsRelRaw = singleSeg?.xOffsets != null ? singleSeg.xOffsets.map((v) => v - tl) : undefined;
|
|
346
|
+
const dir = el.styles.direction === "rtl" ? "rtl" : "ltr";
|
|
347
|
+
const reordered = applyBidi(pathTextRaw, xOffsetsRelRaw, dir);
|
|
348
|
+
const pathText = reordered.text;
|
|
349
|
+
const xOffsetsRel = reordered.xOffsets;
|
|
350
|
+
const features = resolveCapsFeatures(singleSeg?.fontVariant, el.styles.fontVariantCaps);
|
|
351
|
+
// DM-495: when the only segment is a pseudo with its own typography
|
|
352
|
+
// overrides (color / fontSize / fontWeight / fontAscent), prefer those
|
|
353
|
+
// over the host element's values. The capture layer carries pseudo-
|
|
354
|
+
// specific overrides on the segment, but the singleSeg path was reading
|
|
355
|
+
// host-level fields exclusively, so a `.marker::after { color: white }`
|
|
356
|
+
// pseudo painted in the marker's own color (typically inherited black).
|
|
357
|
+
const segColor = singleSeg?.color ?? fillColor;
|
|
358
|
+
const segFontSize = singleSeg?.fontSize ?? fontSize;
|
|
359
|
+
const segFontWeight = singleSeg?.fontWeight ?? fontWeight;
|
|
360
|
+
const segAscent = singleSeg?.fontAscent ?? el.fontAscent;
|
|
361
|
+
// DM-513: pseudo-element font-family override (e.g. icon font on
|
|
362
|
+
// `[class^="icon-"]:before { font-family: "sdicon" }`).
|
|
363
|
+
const segFontFamily = singleSeg?.fontFamily ?? fontFamily;
|
|
364
|
+
// DM-507: when the single segment is a pseudo with its own paint box
|
|
365
|
+
// (background-color / border-radius / border), emit a <rect> behind the
|
|
366
|
+
// glyphs. Same as the multi-segment path; without this the badge / pill
|
|
367
|
+
// bg never paints when the pseudo is the only text on the host.
|
|
368
|
+
const singleSegBoxMarkup = (singleSeg?.pseudoBox != null) ? (() => {
|
|
369
|
+
const pb = singleSeg.pseudoBox!;
|
|
370
|
+
const fillAttr = pb.backgroundColor != null ? ` fill="${esc(pb.backgroundColor)}"` : ` fill="none"`;
|
|
371
|
+
const rxAttr = pb.borderRadius != null && pb.borderRadius > 0 ? ` rx="${r(pb.borderRadius)}" ry="${r(pb.borderRadius)}"` : "";
|
|
372
|
+
const strokeAttr = pb.borderWidth != null && pb.borderWidth > 0 && pb.borderColor != null
|
|
373
|
+
? ` stroke="${esc(pb.borderColor)}" stroke-width="${r(pb.borderWidth)}"` : "";
|
|
374
|
+
return `<rect x="${r(pb.x)}" y="${r(pb.y)}" width="${r(pb.width)}" height="${r(pb.height)}"${rxAttr}${fillAttr}${strokeAttr}/>`;
|
|
375
|
+
})() : "";
|
|
376
|
+
const result = renderTextAsPath(pathText, tl, tt, segFontSize, segFontFamily, segFontWeight, segColor, undefined, el.textWidth, xOffsetsRel, el.styles.fontStyle, segAscent, features, el.styles.lang);
|
|
377
|
+
if (result != null) {
|
|
378
|
+
const decoColor = (el.styles.textDecorationColor && el.styles.textDecorationColor !== "currentcolor")
|
|
379
|
+
? el.styles.textDecorationColor : segColor;
|
|
380
|
+
// baselineY = textTop + fontAscent. Using fontSize here would put the
|
|
381
|
+
// underline ~1px too low (fontSize includes descent; baseline sits at
|
|
382
|
+
// ascent above textTop, not at the line-bottom). DM-265.
|
|
383
|
+
// Round to integer px so Chrome's pixel-aligned decoration paint
|
|
384
|
+
// (`round(baseline) + thickness` for underline top) reproduces. DM-398.
|
|
385
|
+
const decoBaselineY = Math.round(tt + (segAscent ?? segFontSize));
|
|
386
|
+
const decoMarkup = renderTextDecoration(el.styles.textDecorationLine, decoColor, el.styles.textDecorationStyle, tl, decoBaselineY, el.textWidth ?? 0, segFontSize, segFontFamily, segFontWeight, el.styles.fontStyle, el.styles.textDecorationThickness, el.styles.textUnderlineOffset, pathText, el.styles.textDecorationSkipInk, features);
|
|
387
|
+
// Per-char raster overlays (SK-1090). Emoji / color-bitmap codepoints in
|
|
388
|
+
// the middle of plain-text runs get stamped on top of the path output.
|
|
389
|
+
const rasterOverlay = singleSeg != null ? rasterGlyphOverlays(singleSeg, fontSize, clipId) : "";
|
|
390
|
+
// Wrap the path-mode output in the element's clip-path only when the
|
|
391
|
+
// element actually overflow-clips (DM-305). Default `overflow: visible`
|
|
392
|
+
// lets text extend past the box edge, so the unconditional clip from
|
|
393
|
+
// an earlier draft over-cut text on `word-wrap: break-word` paragraphs
|
|
394
|
+
// whose last char measured a fraction of a px past `el.x + el.width`.
|
|
395
|
+
if (opts.overflowClip) {
|
|
396
|
+
return `<g clip-path="url(#${clipId})">${singleSegBoxMarkup}${result}${decoMarkup}${rasterOverlay}</g>`;
|
|
397
|
+
}
|
|
398
|
+
return `${singleSegBoxMarkup}${result}${decoMarkup}${rasterOverlay}`;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// DM-490 / DM-500: when the text is entirely Private Use Area codepoints
|
|
402
|
+
// and the path-mode renderer returned null (no glyph emitted because every
|
|
403
|
+
// glyph was a notdef tofu we suppressed), don't fall through to a `<text>`
|
|
404
|
+
// element either — Chromium will paint the same notdef tofu using its own
|
|
405
|
+
// glyph fallback, which is exactly what we suppressed at the path level.
|
|
406
|
+
// A missing icon should read as 'nothing'.
|
|
407
|
+
if (isAllPrivateUseArea(el.text)) return "";
|
|
408
|
+
|
|
409
|
+
// Fallback to CSS <text> if path rendering fails
|
|
410
|
+
const ff = segFontFamily.replace(/"/g, "'");
|
|
411
|
+
const lsCss = el.styles.letterSpacing !== "normal" && el.styles.letterSpacing !== "0px"
|
|
412
|
+
? `letter-spacing:${el.styles.letterSpacing};` : "";
|
|
413
|
+
const baseStyle = `font-family:${ff};font-size:${r(segFontSize)}px;font-weight:${segFontWeight};font-kerning:normal;font-optical-sizing:auto;${lsCss}`;
|
|
414
|
+
|
|
415
|
+
const textY = (el.textTop != null && el.textHeight != null && el.textHeight > 0)
|
|
416
|
+
? el.textTop + el.textHeight / 2 : el.y + el.height / 2;
|
|
417
|
+
|
|
418
|
+
// Detect centered text (badges, buttons with symmetric padding)
|
|
419
|
+
const tw = el.textWidth ?? 0;
|
|
420
|
+
const leftGap = tl - el.x;
|
|
421
|
+
const rightGap = (el.x + el.width) - (tl + tw);
|
|
422
|
+
const minGap = Math.min(leftGap, rightGap);
|
|
423
|
+
const isCentered = tw > 0 && leftGap > 2 && rightGap > 2
|
|
424
|
+
&& Math.abs(leftGap - rightGap) < Math.max(2, minGap * 0.3);
|
|
425
|
+
|
|
426
|
+
if (isCentered) {
|
|
427
|
+
const cx = el.x + el.width / 2;
|
|
428
|
+
return `<text x="${r(cx)}" y="${r(textY)}" text-anchor="middle" dominant-baseline="central" fill="${fillColor}" style="${baseStyle}" clip-path="url(#${clipId})">${esc(el.text)}</text>`;
|
|
429
|
+
}
|
|
430
|
+
return `<text x="${r(tl)}" y="${r(textY)}" dominant-baseline="central" fill="${fillColor}" style="${baseStyle}" clip-path="url(#${clipId})">${esc(el.text)}</text>`;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Render multi-segment text (mixed content like: <p>Text <code>x</code> more</p>).
|
|
435
|
+
*/
|
|
436
|
+
export function renderMultiSegmentText(opts: RenderTextOpts, segments: TextSegment[]): string {
|
|
437
|
+
const { el, clipId, fillColor } = opts;
|
|
438
|
+
const elFontSize = parseFloat(el.styles.fontSize) || 14;
|
|
439
|
+
const fontFamily = el.styles.fontFamily;
|
|
440
|
+
const elFontWeight = el.styles.fontWeight;
|
|
441
|
+
const parts: string[] = [];
|
|
442
|
+
|
|
443
|
+
const dir = el.styles.direction === "rtl" ? "rtl" : "ltr";
|
|
444
|
+
const decoLine = el.styles.textDecorationLine;
|
|
445
|
+
const decoColor = (el.styles.textDecorationColor && el.styles.textDecorationColor !== "currentcolor")
|
|
446
|
+
? el.styles.textDecorationColor : fillColor;
|
|
447
|
+
const decoStyle = el.styles.textDecorationStyle;
|
|
448
|
+
for (const seg of segments) {
|
|
449
|
+
// Color-bitmap glyph fallback (SK-1058): CAPTURE_SCRIPT marked this
|
|
450
|
+
// segment with a Playwright screenshot of Chrome's actual raster (e.g.
|
|
451
|
+
// U+2713 ✓, which Chrome paints via Apple Color Emoji's sbix bitmap that
|
|
452
|
+
// fontkit can't convert to <path>). Stamp the PNG directly at the
|
|
453
|
+
// segment's layout box. Skip the path pipeline — the rasterRect was sized
|
|
454
|
+
// to the full line box, so y/height here use that same rect so the image
|
|
455
|
+
// lands exactly where Chrome painted it.
|
|
456
|
+
if (seg.rasterDataUri != null && seg.rasterRect != null) {
|
|
457
|
+
parts.push(`<image href="${seg.rasterDataUri}" x="${r(seg.rasterRect.x)}" y="${r(seg.rasterRect.y)}" width="${r(seg.rasterRect.width)}" height="${r(seg.rasterRect.height)}" preserveAspectRatio="none" clip-path="url(#${clipId})"/>`);
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
// DM-497: pseudo-element paint box. ::before / ::after with their own
|
|
461
|
+
// background-color or border-radius (badges / pills / chips) need a
|
|
462
|
+
// <rect> behind the text glyphs. Captured at CAPTURE_SCRIPT time once
|
|
463
|
+
// seg.x/y is in its final viewport-relative position; we just emit it.
|
|
464
|
+
if (seg.pseudoBox != null) {
|
|
465
|
+
const pb = seg.pseudoBox;
|
|
466
|
+
const fillAttr = pb.backgroundColor != null ? ` fill="${esc(pb.backgroundColor)}"` : ` fill="none"`;
|
|
467
|
+
const rxAttr = pb.borderRadius != null && pb.borderRadius > 0 ? ` rx="${r(pb.borderRadius)}" ry="${r(pb.borderRadius)}"` : "";
|
|
468
|
+
const strokeAttr = pb.borderWidth != null && pb.borderWidth > 0 && pb.borderColor != null
|
|
469
|
+
? ` stroke="${esc(pb.borderColor)}" stroke-width="${r(pb.borderWidth)}"` : "";
|
|
470
|
+
parts.push(`<rect x="${r(pb.x)}" y="${r(pb.y)}" width="${r(pb.width)}" height="${r(pb.height)}"${rxAttr}${fillAttr}${strokeAttr}/>`);
|
|
471
|
+
}
|
|
472
|
+
// Per-segment overrides from ::before / ::after pseudos (color, fontSize,
|
|
473
|
+
// fontWeight). Fall back to the element's styles when the segment has no
|
|
474
|
+
// override. This is how we render the .flag::before red, the li[data-badge]
|
|
475
|
+
// purple-bold, the abbr[title]::after blue-and-smaller, etc. ::first-line
|
|
476
|
+
// uses the same mechanism — the first segment of a paragraph inherits a
|
|
477
|
+
// pseudo-style override from CAPTURE_SCRIPT (DM-294).
|
|
478
|
+
const segColor = seg.color ?? fillColor;
|
|
479
|
+
const segFontSize = seg.fontSize ?? elFontSize;
|
|
480
|
+
const segFontWeight = seg.fontWeight ?? elFontWeight;
|
|
481
|
+
const segFontStyle = seg.fontStyle ?? el.styles.fontStyle;
|
|
482
|
+
// DM-513: pseudos with `font-family: 'sdicon'` etc. need their icon font
|
|
483
|
+
// routed through the renderer, not the parent element's body font.
|
|
484
|
+
const segFontFamily = seg.fontFamily ?? fontFamily;
|
|
485
|
+
// Honor either segment-level font-variant override (::first-line) or
|
|
486
|
+
// the element-level font-variant-caps. (DM-294, DM-361, DM-444). See
|
|
487
|
+
// resolveCapsFeatures (module scope) for the full spec mapping.
|
|
488
|
+
const segFeatures = resolveCapsFeatures(seg.fontVariant, el.styles.fontVariantCaps);
|
|
489
|
+
// Pass per-char xOffsets through (relative to seg.x) so multi-line wrapped
|
|
490
|
+
// text anchors glyphs at the exact Chromium-measured positions.
|
|
491
|
+
const xOffsetsRelRaw = seg.xOffsets != null ? seg.xOffsets.map((v) => v - seg.x) : undefined;
|
|
492
|
+
const reordered = applyBidi(suppressGlyphChars(seg.text, seg), xOffsetsRelRaw, dir);
|
|
493
|
+
const segAscent = seg.fontAscent ?? el.fontAscent;
|
|
494
|
+
const result = renderTextAsPath(reordered.text, seg.x, seg.y, segFontSize, segFontFamily, segFontWeight, segColor, undefined, undefined, reordered.xOffsets, segFontStyle, segAscent, segFeatures, el.styles.lang);
|
|
495
|
+
if (result != null) { parts.push(result); }
|
|
496
|
+
else if (!isAllPrivateUseArea(seg.text)) {
|
|
497
|
+
// Fallback to CSS <text> if path rendering fails. DM-490 / DM-500: when
|
|
498
|
+
// the segment text is entirely Private Use Area (icon-font codepoints
|
|
499
|
+
// we couldn't resolve to a real glyph), suppress the <text> fallback
|
|
500
|
+
// too — Chromium's UA fallback paints the same notdef tofu we already
|
|
501
|
+
// suppressed at the path level, defeating the point.
|
|
502
|
+
const ff = segFontFamily.replace(/"/g, "'");
|
|
503
|
+
const baseStyle = `font-family:${ff};font-size:${r(segFontSize)}px;font-weight:${segFontWeight};font-kerning:normal;font-optical-sizing:auto;`;
|
|
504
|
+
const sy = seg.y + seg.height / 2;
|
|
505
|
+
parts.push(`<text x="${r(seg.x)}" y="${r(sy)}" dominant-baseline="central" fill="${segColor}" style="${baseStyle}" clip-path="url(#${clipId})">${esc(seg.text)}</text>`);
|
|
506
|
+
}
|
|
507
|
+
const segDecoBaselineY = Math.round(seg.y + (segAscent ?? segFontSize));
|
|
508
|
+
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);
|
|
509
|
+
if (decoMarkup !== "") parts.push(decoMarkup);
|
|
510
|
+
// Per-char raster overlays (SK-1090). Emoji inline with path-rendered
|
|
511
|
+
// text get their actual Chrome-painted pixels stamped over the position.
|
|
512
|
+
const rasterOverlay = rasterGlyphOverlays(seg, segFontSize, clipId);
|
|
513
|
+
if (rasterOverlay !== "") parts.push(rasterOverlay);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Wrap the multi-segment output in the element's clip-path only when the
|
|
517
|
+
// element actually overflow-clips (DM-305) — see comment in
|
|
518
|
+
// renderSingleLineText for why an unconditional clip is wrong.
|
|
519
|
+
if (opts.overflowClip) {
|
|
520
|
+
return `<g clip-path="url(#${clipId})">${parts.join("\n")}</g>`;
|
|
521
|
+
}
|
|
522
|
+
return parts.join("\n");
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Render multi-line text (pre blocks).
|
|
527
|
+
*/
|
|
528
|
+
export function renderMultiLineText(opts: RenderTextOpts): string {
|
|
529
|
+
const { el, clipId, fillColor } = opts;
|
|
530
|
+
const fontSize = parseFloat(el.styles.fontSize) || 14;
|
|
531
|
+
const fontFamily = el.styles.fontFamily;
|
|
532
|
+
const fontWeight = el.styles.fontWeight;
|
|
533
|
+
const lhStr = el.styles.lineHeight;
|
|
534
|
+
const lhParsed = parseFloat(lhStr);
|
|
535
|
+
const lineHeight = (lhStr !== "normal" && !isNaN(lhParsed) && lhParsed > 0) ? lhParsed : fontSize * 1.2;
|
|
536
|
+
const startX = el.textLeft ?? el.x + 4;
|
|
537
|
+
const startY = el.textTop ?? el.y + 4;
|
|
538
|
+
const outerEsc = esc;
|
|
539
|
+
|
|
540
|
+
const parts: string[] = [];
|
|
541
|
+
parts.push(`<g clip-path="url(#${clipId})" role="img" aria-label="${outerEsc(el.text)}"><title>${outerEsc(el.text)}</title>`);
|
|
542
|
+
|
|
543
|
+
// SK-1235: prefer captured text segments — each carries Chromium-measured
|
|
544
|
+
// per-char xOffsets that close the same fontkit-vs-HarfBuzz drift SK-1234
|
|
545
|
+
// closed for inputs. Each segment is one visual line, so iterating segments
|
|
546
|
+
// also matches Chromium's actual line-wrapping decisions for the cases
|
|
547
|
+
// where source HTML has internal `\n` whitespace that the browser collapses
|
|
548
|
+
// (those land here with a single segment and a \n-bearing el.text — splitting
|
|
549
|
+
// on `\n` would emit a phantom second line below the captured one).
|
|
550
|
+
if (el.textSegments != null && el.textSegments.length > 0) {
|
|
551
|
+
const dir = el.styles.direction === "rtl" ? "rtl" : "ltr";
|
|
552
|
+
for (const seg of el.textSegments) {
|
|
553
|
+
const xOffsetsRelRaw = seg.xOffsets != null ? seg.xOffsets.map((v) => v - seg.x) : undefined;
|
|
554
|
+
const reordered = applyBidi(suppressGlyphChars(seg.text, seg), xOffsetsRelRaw, dir);
|
|
555
|
+
const segFontSize = seg.fontSize ?? fontSize;
|
|
556
|
+
const segFontWeight = seg.fontWeight ?? fontWeight;
|
|
557
|
+
const segColor = seg.color ?? fillColor;
|
|
558
|
+
const segAscent = seg.fontAscent ?? el.fontAscent;
|
|
559
|
+
const result = renderTextAsPath(reordered.text, seg.x, seg.y, segFontSize, fontFamily, segFontWeight, segColor, undefined, undefined, reordered.xOffsets, el.styles.fontStyle, segAscent, undefined, el.styles.lang);
|
|
560
|
+
if (result != null) parts.push(` ${result}`);
|
|
561
|
+
}
|
|
562
|
+
} else {
|
|
563
|
+
const lines = el.text.split("\n");
|
|
564
|
+
for (let li = 0; li < lines.length; li++) {
|
|
565
|
+
const line = lines[li];
|
|
566
|
+
if (line === "") continue;
|
|
567
|
+
const lineY = startY + li * lineHeight;
|
|
568
|
+
const result = renderTextAsPath(line, startX, lineY, fontSize, fontFamily, fontWeight, fillColor, undefined, undefined, undefined, el.styles.fontStyle, el.fontAscent, undefined, el.styles.lang);
|
|
569
|
+
if (result != null) parts.push(` ${result}`);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
parts.push("</g>");
|
|
573
|
+
return parts.join("\n");
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Render input/textarea text.
|
|
578
|
+
*/
|
|
579
|
+
export function renderInputText(opts: RenderTextOpts): string {
|
|
580
|
+
const { el, clipId, fillColor } = opts;
|
|
581
|
+
// Textarea content was rasterized via page.screenshot (SK-1108) — stamp the
|
|
582
|
+
// PNG at the content rect and skip the path pipeline. This bypasses our
|
|
583
|
+
// missing word-wrap implementation and delivers pixel-perfect Chrome
|
|
584
|
+
// rendering of the textarea's laid-out value.
|
|
585
|
+
if (el.elementRaster != null && el.elementRaster.dataUri != null) {
|
|
586
|
+
const er = el.elementRaster;
|
|
587
|
+
return `<image href="${er.dataUri}" x="${r(er.x)}" y="${r(er.y)}" width="${r(er.width)}" height="${r(er.height)}" preserveAspectRatio="none" clip-path="url(#${clipId})"/>`;
|
|
588
|
+
}
|
|
589
|
+
const fontSize = parseFloat(el.styles.fontSize) || 14;
|
|
590
|
+
const fontFamily = el.styles.fontFamily;
|
|
591
|
+
const fontWeight = el.styles.fontWeight;
|
|
592
|
+
const textX = el.textLeft ?? el.x + 4;
|
|
593
|
+
const tt = el.textTop ?? el.y;
|
|
594
|
+
|
|
595
|
+
// Placeholder text is painted in the ::placeholder color (muted gray by
|
|
596
|
+
// default), not the user-typed text color. CAPTURE_SCRIPT sets this when
|
|
597
|
+
// the input value is empty and a placeholder= attribute is present. CSS
|
|
598
|
+
// lets ::placeholder also override font-style / font-weight independently
|
|
599
|
+
// (purple italic placeholder, etc.). See SK-1097 / SK-1100 / SK-1099.
|
|
600
|
+
const textColor = el.isPlaceholderText && el.placeholderColor != null ? el.placeholderColor : fillColor;
|
|
601
|
+
const textFontStyle = el.isPlaceholderText && el.placeholderFontStyle != null ? el.placeholderFontStyle : el.styles.fontStyle;
|
|
602
|
+
const textFontWeight = el.isPlaceholderText && el.placeholderFontWeight != null ? el.placeholderFontWeight : fontWeight;
|
|
603
|
+
// Per-char xOffsets captured via DOM probe (SK-1234) — anchors each glyph
|
|
604
|
+
// at the position Chromium's HarfBuzz shaping would paint. Falls back to
|
|
605
|
+
// fontkit native advances when the probe wasn't run (e.g. for textarea,
|
|
606
|
+
// which still uses the SK-1108 element raster path).
|
|
607
|
+
const xOffsetsRel = el.inputXOffsets != null
|
|
608
|
+
? el.inputXOffsets.map((v) => v - textX) : undefined;
|
|
609
|
+
const result = renderTextAsPath(el.text, textX, tt, fontSize, fontFamily, textFontWeight, textColor, undefined, undefined, xOffsetsRel, textFontStyle, el.fontAscent, undefined, el.styles.lang);
|
|
610
|
+
// Clip the path-rendered text to the input's content rect so values that
|
|
611
|
+
// overflow the visible width (common on readonly inputs with long text or
|
|
612
|
+
// any input narrower than its value) are truncated like Chrome paints
|
|
613
|
+
// them, not extending past the right border. DM-245.
|
|
614
|
+
if (result != null) return `<g clip-path="url(#${clipId})">${result}</g>`;
|
|
615
|
+
|
|
616
|
+
// Fallback to CSS <text> if path rendering fails
|
|
617
|
+
const textY = (el.textTop != null && el.textHeight != null && el.textHeight > 0)
|
|
618
|
+
? el.textTop + el.textHeight / 2 : el.y + el.height / 2;
|
|
619
|
+
const ff = fontFamily.replace(/"/g, "'");
|
|
620
|
+
const baseStyle = `font-family:${ff};font-size:${r(fontSize)}px;font-weight:${fontWeight};font-kerning:normal;font-optical-sizing:auto;`;
|
|
621
|
+
|
|
622
|
+
return `<text x="${r(textX)}" y="${r(textY)}" dominant-baseline="central" fill="${textColor}" style="${baseStyle}" clip-path="url(#${clipId})">${esc(el.text)}</text>`;
|
|
623
|
+
}
|