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.
Files changed (119) hide show
  1. package/FEATURES.md +102 -0
  2. package/LICENSE +21 -0
  3. package/README.md +66 -0
  4. package/dist/animator.d.ts +158 -0
  5. package/dist/animator.js +424 -0
  6. package/dist/animator.test.d.ts +5 -0
  7. package/dist/animator.test.js +169 -0
  8. package/dist/border-radius.test.d.ts +1 -0
  9. package/dist/border-radius.test.js +148 -0
  10. package/dist/capture.d.ts +193 -0
  11. package/dist/capture.js +786 -0
  12. package/dist/chrome.d.ts +45 -0
  13. package/dist/chrome.js +107 -0
  14. package/dist/cli.d.ts +16 -0
  15. package/dist/cli.js +512 -0
  16. package/dist/client/dom.d.ts +10 -0
  17. package/dist/client/dom.js +17 -0
  18. package/dist/conic-raster.d.ts +58 -0
  19. package/dist/conic-raster.js +292 -0
  20. package/dist/conic-raster.test.d.ts +1 -0
  21. package/dist/conic-raster.test.js +187 -0
  22. package/dist/coretext-extractor.test.d.ts +1 -0
  23. package/dist/coretext-extractor.test.js +94 -0
  24. package/dist/coretext-helper.d.ts +60 -0
  25. package/dist/coretext-helper.js +205 -0
  26. package/dist/cross-origin-font-face.test.d.ts +1 -0
  27. package/dist/cross-origin-font-face.test.js +107 -0
  28. package/dist/cursor-overlay.d.ts +123 -0
  29. package/dist/cursor-overlay.js +207 -0
  30. package/dist/cursor-overlay.test.d.ts +1 -0
  31. package/dist/cursor-overlay.test.js +88 -0
  32. package/dist/dark-mode-capture.test.d.ts +1 -0
  33. package/dist/dark-mode-capture.test.js +158 -0
  34. package/dist/dark-mode-form-controls.test.d.ts +1 -0
  35. package/dist/dark-mode-form-controls.test.js +218 -0
  36. package/dist/dom-to-svg.d.ts +1016 -0
  37. package/dist/dom-to-svg.js +7717 -0
  38. package/dist/embed-remote-images.test.d.ts +1 -0
  39. package/dist/embed-remote-images.test.js +424 -0
  40. package/dist/form-controls.d.ts +70 -0
  41. package/dist/form-controls.js +1151 -0
  42. package/dist/frame-merge.d.ts +95 -0
  43. package/dist/frame-merge.js +374 -0
  44. package/dist/frame-merge.test.d.ts +6 -0
  45. package/dist/frame-merge.test.js +144 -0
  46. package/dist/gradients.d.ts +184 -0
  47. package/dist/gradients.js +937 -0
  48. package/dist/gradients.test.d.ts +1 -0
  49. package/dist/gradients.test.js +150 -0
  50. package/dist/index.d.ts +12 -0
  51. package/dist/index.js +7 -0
  52. package/dist/jsx-runtime.d.ts +27 -0
  53. package/dist/jsx-runtime.js +96 -0
  54. package/dist/jsx-runtime.test.d.ts +1 -0
  55. package/dist/jsx-runtime.test.js +41 -0
  56. package/dist/kerfjs-imports.test.d.ts +1 -0
  57. package/dist/kerfjs-imports.test.js +36 -0
  58. package/dist/mask.test.d.ts +1 -0
  59. package/dist/mask.test.js +206 -0
  60. package/dist/optimize.d.ts +12 -0
  61. package/dist/optimize.js +32 -0
  62. package/dist/preserve-aspect-ratio.test.d.ts +1 -0
  63. package/dist/preserve-aspect-ratio.test.js +38 -0
  64. package/dist/resize-embedded-images.d.ts +33 -0
  65. package/dist/resize-embedded-images.js +164 -0
  66. package/dist/resize-embedded-images.test.d.ts +9 -0
  67. package/dist/resize-embedded-images.test.js +255 -0
  68. package/dist/stacking-context.test.d.ts +1 -0
  69. package/dist/stacking-context.test.js +927 -0
  70. package/dist/text-renderer.d.ts +42 -0
  71. package/dist/text-renderer.js +608 -0
  72. package/dist/text-renderer.test.d.ts +1 -0
  73. package/dist/text-renderer.test.js +150 -0
  74. package/dist/text-to-path.d.ts +265 -0
  75. package/dist/text-to-path.js +1800 -0
  76. package/dist/text-to-path.test.d.ts +1 -0
  77. package/dist/text-to-path.test.js +570 -0
  78. package/dist/utils/escapeHtml.d.ts +2 -0
  79. package/dist/utils/escapeHtml.js +15 -0
  80. package/dist/webfont-unicode-range.test.d.ts +1 -0
  81. package/dist/webfont-unicode-range.test.js +174 -0
  82. package/package.json +55 -0
  83. package/src/animator.test.ts +179 -0
  84. package/src/animator.ts +660 -0
  85. package/src/border-radius.test.ts +160 -0
  86. package/src/capture.ts +810 -0
  87. package/src/cli.ts +582 -0
  88. package/src/conic-raster.test.ts +213 -0
  89. package/src/conic-raster.ts +309 -0
  90. package/src/coretext-extractor.test.ts +130 -0
  91. package/src/coretext-helper.ts +256 -0
  92. package/src/cross-origin-font-face.test.ts +119 -0
  93. package/src/cursor-overlay.test.ts +95 -0
  94. package/src/cursor-overlay.ts +297 -0
  95. package/src/dark-mode-capture.test.ts +177 -0
  96. package/src/dark-mode-form-controls.test.ts +228 -0
  97. package/src/dom-to-svg.ts +8376 -0
  98. package/src/embed-remote-images.test.ts +461 -0
  99. package/src/form-controls.ts +1174 -0
  100. package/src/frame-merge.test.ts +157 -0
  101. package/src/frame-merge.ts +447 -0
  102. package/src/globals.d.ts +2 -0
  103. package/src/gradients.test.ts +175 -0
  104. package/src/gradients.ts +955 -0
  105. package/src/index.ts +12 -0
  106. package/src/kerf-jsx-augmentation.d.ts +36 -0
  107. package/src/kerfjs-imports.test.tsx +45 -0
  108. package/src/mask.test.ts +274 -0
  109. package/src/optimize.ts +34 -0
  110. package/src/preserve-aspect-ratio.test.ts +49 -0
  111. package/src/resize-embedded-images.test.ts +292 -0
  112. package/src/resize-embedded-images.ts +180 -0
  113. package/src/stacking-context.test.ts +967 -0
  114. package/src/text-renderer.test.ts +162 -0
  115. package/src/text-renderer.ts +623 -0
  116. package/src/text-to-path.test.ts +639 -0
  117. package/src/text-to-path.ts +1810 -0
  118. package/src/utils/escapeHtml.ts +16 -0
  119. 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); }
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
+ }