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