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,1800 @@
1
+ /**
2
+ * Text-to-Path Converter
3
+ *
4
+ * Uses fontkit to convert text strings into SVG <path> outlines using
5
+ * the actual macOS system fonts. Glyphs are deduplicated using SVG
6
+ * <defs>/<use> — each unique glyph shape is defined once and referenced
7
+ * everywhere it appears.
8
+ */
9
+ import * as fontkit from "fontkit";
10
+ import { createCoretextFont, isCoretextHelperAvailable } from "./coretext-helper.js";
11
+ const fontInstanceCache = new Map();
12
+ const webfontRegistry = new Map();
13
+ /**
14
+ * Open a webfont buffer with fontkit and register it under the given family
15
+ * name (case-insensitive). `weight` is a CSS numeric weight (100-900); 400
16
+ * when omitted. `style` is "normal" / "italic" / "oblique"; treated as italic
17
+ * for any non-normal value.
18
+ *
19
+ * `unicodeRange` mirrors the `@font-face { unicode-range: ... }` descriptor as
20
+ * a list of inclusive `[from, to]` codepoint intervals. Google-Fonts-style
21
+ * partitioning declares the same `(family, weight)` pair across multiple
22
+ * `@font-face` rules, each with a distinct `unicode-range` (Latin, Latin Ext,
23
+ * Cyrillic, Greek, Vietnamese, …). Without honoring the descriptor,
24
+ * `pickWebfontVariant` may return the Cyrillic-only partition for a Latin
25
+ * text run — the run lays out as .notdef tofu (DM-517).
26
+ *
27
+ * Buffers must be decompressed already — fontkit's `create()` reads TTF/OTF
28
+ * directly. WOFF2/WOFF bytes are decompressed in `loadWebfont()` (capture.ts)
29
+ * before they reach this function.
30
+ */
31
+ export function registerWebfont(family, weight, style, buffer, unicodeRange) {
32
+ const key = family.toLowerCase().replace(/^["']|["']$/g, "");
33
+ let font;
34
+ try {
35
+ font = fontkit.create(buffer);
36
+ }
37
+ catch {
38
+ return; // unparseable — silently skip; capture-side warning happens elsewhere
39
+ }
40
+ const italic = style != null && style !== "" && style.toLowerCase() !== "normal";
41
+ const list = webfontRegistry.get(key) ?? [];
42
+ list.push({ weight, italic, font, unicodeRange });
43
+ webfontRegistry.set(key, list);
44
+ }
45
+ /** True iff `cp` falls in any of the inclusive `[from, to]` intervals. */
46
+ export function unicodeRangeCovers(ranges, cp) {
47
+ if (ranges == null)
48
+ return true; // no range = U+0..U+10FFFF (CSS default)
49
+ for (const [from, to] of ranges) {
50
+ if (cp >= from && cp <= to)
51
+ return true;
52
+ }
53
+ return false;
54
+ }
55
+ /**
56
+ * DM-557: codepoint-aware variant pick for partitioned webfonts. Filters
57
+ * registered variants by whether their `unicode-range` covers `codepoint`
58
+ * (per CSS Fonts 4 §11.5 — a partition only declares it can shape glyphs
59
+ * within its declared range), then scores by (italic, weight) like
60
+ * `pickWebfontVariant`. Returns null when no registered variant covers the
61
+ * codepoint — the caller is expected to walk the system fallback chain in
62
+ * that case.
63
+ *
64
+ * Used by the run-splitter in `textToPathMarkup` to route per-codepoint
65
+ * within a Google-Fonts-style partitioned family (Geist@400 split across
66
+ * Latin/Latin-Ext/Cyrillic/etc.). Without this, the Latin-biased
67
+ * `pickWebfontVariant` is the single primary font for the whole text and
68
+ * codepoints outside its range fall straight to system fonts — losing the
69
+ * matching Cyrillic/Greek/Latin-Ext partition that's registered but
70
+ * unselected.
71
+ */
72
+ export function pickWebfontVariantForCodepoint(family, weight, fontSize, slant, codepoint) {
73
+ const variants = webfontRegistry.get(family.toLowerCase());
74
+ if (variants == null || variants.length === 0)
75
+ return null;
76
+ const wantItalic = slant !== 0;
77
+ let best = null;
78
+ let bestScore = Infinity;
79
+ for (const v of variants) {
80
+ if (!unicodeRangeCovers(v.unicodeRange, codepoint))
81
+ continue;
82
+ const styleMismatch = v.italic === wantItalic ? 0 : 1000;
83
+ const score = styleMismatch + Math.abs(v.weight - weight);
84
+ if (score < bestScore) {
85
+ bestScore = score;
86
+ best = v;
87
+ }
88
+ }
89
+ if (best == null)
90
+ return null;
91
+ return applyVariationAxes(best.font, weight, fontSize, slant);
92
+ }
93
+ /**
94
+ * Test-only: return metadata for the variant `pickWebfontVariant` would
95
+ * choose, without resolving variation axes / returning a FontInstance. Lets
96
+ * unit tests verify scoring (weight, italic, unicode-range) without needing
97
+ * to introspect glyph paths.
98
+ */
99
+ export function __pickWebfontVariantMetaForTest(family, weight, italic) {
100
+ const variants = webfontRegistry.get(family.toLowerCase());
101
+ if (variants == null || variants.length === 0)
102
+ return null;
103
+ const LATIN_PROBE = 0x0041;
104
+ let best = null;
105
+ let bestScore = Infinity;
106
+ for (const v of variants) {
107
+ const styleMismatch = v.italic === italic ? 0 : 1000;
108
+ const rangeMismatch = unicodeRangeCovers(v.unicodeRange, LATIN_PROBE) ? 0 : 2000;
109
+ const score = styleMismatch + rangeMismatch + Math.abs(v.weight - weight);
110
+ if (score < bestScore) {
111
+ bestScore = score;
112
+ best = v;
113
+ }
114
+ }
115
+ if (best == null)
116
+ return null;
117
+ return { weight: best.weight, italic: best.italic, unicodeRange: best.unicodeRange };
118
+ }
119
+ /** Test-only meta variant for `pickWebfontVariantForCodepoint` (DM-557). */
120
+ export function __pickWebfontVariantMetaForCodepointForTest(family, weight, italic, codepoint) {
121
+ const variants = webfontRegistry.get(family.toLowerCase());
122
+ if (variants == null || variants.length === 0)
123
+ return null;
124
+ let best = null;
125
+ let bestScore = Infinity;
126
+ for (const v of variants) {
127
+ if (!unicodeRangeCovers(v.unicodeRange, codepoint))
128
+ continue;
129
+ const styleMismatch = v.italic === italic ? 0 : 1000;
130
+ const score = styleMismatch + Math.abs(v.weight - weight);
131
+ if (score < bestScore) {
132
+ bestScore = score;
133
+ best = v;
134
+ }
135
+ }
136
+ if (best == null)
137
+ return null;
138
+ return { weight: best.weight, italic: best.italic, unicodeRange: best.unicodeRange };
139
+ }
140
+ /** Drop all registered webfonts. Call at the start of a fresh capture run. */
141
+ export function clearWebfonts() {
142
+ webfontRegistry.clear();
143
+ localFontAliasRegistry.clear();
144
+ }
145
+ const localFontAliasRegistry = new Map();
146
+ export function registerLocalFontAlias(family, resolvedKey, weight = 400, italic = false) {
147
+ const key = family.toLowerCase().replace(/^["']|["']$/g, "").trim();
148
+ if (key === "" || resolvedKey === "")
149
+ return;
150
+ const list = localFontAliasRegistry.get(key) ?? [];
151
+ list.push({ weight, italic, baseKey: resolvedKey });
152
+ localFontAliasRegistry.set(key, list);
153
+ }
154
+ /** Pick the declared (weight, style) variant closest to the requested combo —
155
+ * mirrors `pickWebfontVariant` scoring (italic match dominates). Returns the
156
+ * matched variant's resolved base key (e.g. `"georgia"`), or null when no
157
+ * variants are registered for the family. */
158
+ function pickLocalFontAliasVariant(family, weight, italic) {
159
+ const variants = localFontAliasRegistry.get(family);
160
+ if (variants == null || variants.length === 0)
161
+ return null;
162
+ let best = null;
163
+ let bestScore = Infinity;
164
+ for (const v of variants) {
165
+ const styleMismatch = v.italic === italic ? 0 : 1000;
166
+ const score = styleMismatch + Math.abs(v.weight - weight);
167
+ if (score < bestScore) {
168
+ bestScore = score;
169
+ best = v;
170
+ }
171
+ }
172
+ return best;
173
+ }
174
+ /**
175
+ * Pick the closest matching registered variant for the given family +
176
+ * weight/style, then drive any variation axes the file exposes (so a single
177
+ * variable webfont — Inter Variable, Roboto Flex, Recursive — can serve
178
+ * multiple weights / sizes / slants from one buffer instead of substituting
179
+ * the registered base instance for every request).
180
+ *
181
+ * Used internally by `getFontInstance` for `webfont:<name>` keys; italic
182
+ * match dominates the score so italic+regular beats upright+italic-mismatch.
183
+ */
184
+ function pickWebfontVariant(family, weight, fontSize, slant) {
185
+ const variants = webfontRegistry.get(family);
186
+ if (variants == null || variants.length === 0)
187
+ return null;
188
+ const wantItalic = slant !== 0;
189
+ // Tertiary preference: when multiple variants tie on (italic, weight) the
190
+ // one whose `unicode-range` covers Basic Latin (U+0020..U+007F) wins. Google-
191
+ // Fonts-style partitioning registers e.g. Geist@400 across 3 woff2 files
192
+ // (Cyrillic, Latin Ext, Latin Basic) — without this, the first registered
193
+ // partition wins regardless of whether it has glyphs for the rendered text,
194
+ // and Latin runs lay out as .notdef tofu (DM-517).
195
+ //
196
+ // We can't yet route per-codepoint (would require run-splitting upstream),
197
+ // so we bias toward the partition that covers the overwhelmingly common
198
+ // case: Latin text. Variants with no `unicode-range` declared (CSS default
199
+ // covers everything) match here trivially, so non-partitioned fonts are
200
+ // unaffected.
201
+ const LATIN_PROBE = 0x0041; // 'A'
202
+ let best = null;
203
+ let bestScore = Infinity;
204
+ for (const v of variants) {
205
+ const styleMismatch = v.italic === wantItalic ? 0 : 1000;
206
+ // Range mismatch must outweigh italic mismatch: rendering tofu (no glyph)
207
+ // is far worse than rendering upright glyphs for an italic request, where
208
+ // the renderer can fall back to synthesized italic via `slant`.
209
+ const rangeMismatch = unicodeRangeCovers(v.unicodeRange, LATIN_PROBE) ? 0 : 2000;
210
+ const score = styleMismatch + rangeMismatch + Math.abs(v.weight - weight);
211
+ if (score < bestScore) {
212
+ bestScore = score;
213
+ best = v;
214
+ }
215
+ }
216
+ if (best == null)
217
+ return null;
218
+ return applyVariationAxes(best.font, weight, fontSize, slant);
219
+ }
220
+ /**
221
+ * Italic slant for SF Pro's `slnt` variation axis. SF Pro supports slnt ∈
222
+ * roughly [-10, 0] and exposes no separate italic family, so we drive the
223
+ * axis directly when CSS font-style is italic/oblique. Matches Chrome's
224
+ * synthesis of italic from the variable font. Used as a cache-key component
225
+ * so italic and upright glyphs dedupe separately. See SK-1105.
226
+ */
227
+ const ITALIC_SLNT = -9.99;
228
+ function slantForStyle(style) {
229
+ if (style == null)
230
+ return 0;
231
+ const s = style.toLowerCase();
232
+ return (s === "italic" || s.startsWith("oblique")) ? ITALIC_SLNT : 0;
233
+ }
234
+ const FONT_PATHS = {
235
+ "sf-pro": { path: "/System/Library/Fonts/SFNS.ttf" },
236
+ // SF Pro ships its italic as a sibling file, not as a variable `slnt` axis
237
+ // on SFNS.ttf — so for CSS font-style:italic / oblique we switch to this
238
+ // font instead of trying to drive a nonexistent axis. See SK-1105.
239
+ "sf-pro-italic": { path: "/System/Library/Fonts/SFNSItalic.ttf" },
240
+ "sf-mono": { path: "/System/Library/Fonts/SFNSMono.ttf" },
241
+ "sf-mono-italic": { path: "/System/Library/Fonts/SFNSMonoItalic.ttf" },
242
+ // Chrome on macOS resolves the CSS `monospace` generic keyword to Courier
243
+ // (per Blink's third_party/blink/renderer/platform/fonts/mac
244
+ // font_cache_mac.mm — kMonospaceFamily → kCourier), NOT SF Mono or Menlo.
245
+ // SF Mono is ~3% wider than Courier at the same em size and has a 2px
246
+ // taller ascent at 13px (rounded), so substituting it for `monospace`
247
+ // misaligns `<code>` baselines against the surrounding sans-serif text.
248
+ // Courier.ttc is a collection: weight × slant variants picked by
249
+ // postscriptName in getFontInstance.
250
+ "courier": { path: "/System/Library/Fonts/Courier.ttc", postscriptName: "Courier" },
251
+ "courier-bold": { path: "/System/Library/Fonts/Courier.ttc", postscriptName: "Courier-Bold" },
252
+ "courier-italic": { path: "/System/Library/Fonts/Courier.ttc", postscriptName: "Courier-Oblique" },
253
+ "courier-bold-italic": { path: "/System/Library/Fonts/Courier.ttc", postscriptName: "Courier-BoldOblique" },
254
+ // Author-named monospace families. Menlo and Monaco both ship as system
255
+ // fonts with their own metrics — different from Courier and SF Mono — so
256
+ // when an author explicitly requests them we should honor that rather than
257
+ // substitute one mono for another.
258
+ "menlo": { path: "/System/Library/Fonts/Menlo.ttc", postscriptName: "Menlo-Regular" },
259
+ "menlo-bold": { path: "/System/Library/Fonts/Menlo.ttc", postscriptName: "Menlo-Bold" },
260
+ "menlo-italic": { path: "/System/Library/Fonts/Menlo.ttc", postscriptName: "Menlo-Italic" },
261
+ "menlo-bold-italic": { path: "/System/Library/Fonts/Menlo.ttc", postscriptName: "Menlo-BoldItalic" },
262
+ "monaco": { path: "/System/Library/Fonts/Monaco.ttf" },
263
+ // Chrome on macOS uses Geeza Pro for the Arabic block, NOT SF Arabic. SF
264
+ // Arabic glyphs are wider (~29.7px for بحرم at 16px) while Geeza Pro
265
+ // matches Chrome's painted width (~27.6px) — DM-270 probe. SF Arabic was
266
+ // designed for Apple system UI and isn't what Chrome's CoreText fallback
267
+ // picks for `Times` body text.
268
+ "sf-arabic": { path: "/System/Library/Fonts/GeezaPro.ttc", postscriptName: "GeezaPro" },
269
+ "sf-hebrew": { path: "/System/Library/Fonts/SFHebrew.ttf" },
270
+ // Hiragino Sans GB ships W3 (regular) and W6 (bold) as separate sub-fonts in
271
+ // the same TTC; the file doesn't expose a usable wght axis (DM-256), so the
272
+ // bold variant is selected by postscriptName at the spec level — same
273
+ // pattern as helvetica/times/georgia. The advance widths are identical
274
+ // between W3/W6 (24px @24px font-size for em-square glyphs) but the stem
275
+ // thickness differs, so headings using cjk-block fallback chars (← → ▲ ☀)
276
+ // need W6 to match Chrome's painted weight.
277
+ //
278
+ // PingFang SC: what Chrome on macOS actually paints unmarked Han ideographs
279
+ // (漢 字 北 京 東 明 日 …) through, NOT HiraginoSansGB. Verified via CDP
280
+ // `CSS.getPlatformFontsForNode` against the 02-text-ruby fixture: every Han
281
+ // codepoint resolves to "蘋方-簡" (PingFang SC). PingFang stores its outlines
282
+ // in Apple's proprietary `hvgl` table — fontkit's outline parser doesn't
283
+ // read that, so we route extraction through the CoreText helper
284
+ // (`tools/macos-glyph-extractor/`). HiraginoSansGB stays as the secondary
285
+ // route via `cjk` for any glyph PingFang lacks. DM-382 / DM-364 / DM-385 /
286
+ // DM-388.
287
+ "pingfang-sc": { path: "/System/Library/Fonts/PingFang.ttc", postscriptName: "PingFangSC-Regular", extractor: "coretext" },
288
+ "pingfang-sc-bold": { path: "/System/Library/Fonts/PingFang.ttc", postscriptName: "PingFangSC-Medium", extractor: "coretext" },
289
+ // Per-locale PingFang variants (DM-394). Apple ships the same `hvgl`-only
290
+ // PingFang.ttc with regional faces for Traditional Chinese, Hong Kong, and
291
+ // Macau. Chrome routes by computed `lang`: zh-TW / zh-Hant → TC, zh-HK → HK,
292
+ // zh-MO → MO. There is no `PingFangJP-Regular` postscriptName on macOS;
293
+ // Japanese text routes through `hiragino-jp` (HiraKakuProN) instead.
294
+ "pingfang-tc": { path: "/System/Library/Fonts/PingFang.ttc", postscriptName: "PingFangTC-Regular", extractor: "coretext" },
295
+ "pingfang-tc-bold": { path: "/System/Library/Fonts/PingFang.ttc", postscriptName: "PingFangTC-Medium", extractor: "coretext" },
296
+ "pingfang-hk": { path: "/System/Library/Fonts/PingFang.ttc", postscriptName: "PingFangHK-Regular", extractor: "coretext" },
297
+ "pingfang-hk-bold": { path: "/System/Library/Fonts/PingFang.ttc", postscriptName: "PingFangHK-Medium", extractor: "coretext" },
298
+ "pingfang-mo": { path: "/System/Library/Fonts/PingFang.ttc", postscriptName: "PingFangMO-Regular", extractor: "coretext" },
299
+ "pingfang-mo-bold": { path: "/System/Library/Fonts/PingFang.ttc", postscriptName: "PingFangMO-Medium", extractor: "coretext" },
300
+ "cjk": { path: "/System/Library/Fonts/Hiragino Sans GB.ttc", postscriptName: "HiraginoSansGB-W3" },
301
+ "cjk-bold": { path: "/System/Library/Fonts/Hiragino Sans GB.ttc", postscriptName: "HiraginoSansGB-W6" },
302
+ // Songti SC Light (postscriptName STSongti-SC-Light) is what Chrome on
303
+ // macOS picks for CJK chars when the primary is a SERIF family
304
+ // (`font-family: serif` / `Times` / `ui-serif` / `fangsong` / `math` /
305
+ // bare UA default body). Empirical pixel probe at 16px against `font-
306
+ // family: serif` rendering "你好世界" shows STSongti-SC-Light produces
307
+ // a 100.000% pixel match — neither HiraginoSansGB-W3 (90.20%) nor
308
+ // Songti SC Regular (90.23%) matches. DM-333. Same em-square advance
309
+ // (16px @16px) as the sans-serif `cjk` route, so layout is unaffected;
310
+ // only the visible glyph shape (stroke contrast / Mincho-style shapes)
311
+ // changes when the primary is serif.
312
+ "cjk-serif": { path: "/System/Library/Fonts/Supplemental/Songti.ttc", postscriptName: "STSongti-SC-Light" },
313
+ "cjk-serif-bold": { path: "/System/Library/Fonts/Supplemental/Songti.ttc", postscriptName: "STSongti-SC-Bold" },
314
+ // Hiragino Sans (the Japanese family, not GB) covers a much wider set of
315
+ // Geometric Shapes and Misc Symbols at em-square width — ◉◌◐◑ ☀☁☂☃ etc. —
316
+ // that the GB family lacks. Chrome on macOS routes these chars here when
317
+ // the primary Helvetica/Times/etc. doesn't have them and HiraginoSansGB
318
+ // doesn't either, painting at 18px em-square; Apple Symbols' versions are
319
+ // proportional 11-15px so falling all the way through to "symbols" left
320
+ // them visibly narrower than Chrome (DM-324 / DM-326). The TTC ships W3..W9
321
+ // sub-fonts; W3 is the regular weight, W6 is the bold pair to match the
322
+ // existing cjk → cjk-bold weight swap.
323
+ "hiragino-jp": { path: "/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc", postscriptName: "HiraKakuProN-W3" },
324
+ "hiragino-jp-bold": { path: "/System/Library/Fonts/ヒラギノ角ゴシック W6.ttc", postscriptName: "HiraKakuProN-W6" },
325
+ "thai": { path: "/System/Library/Fonts/ThonburiUI.ttc", postscriptName: ".ThonburiUI-Regular" },
326
+ "devanagari": { path: "/System/Library/Fonts/Kohinoor.ttc", postscriptName: "KohinoorDevanagari-Regular" },
327
+ "symbols": { path: "/System/Library/Fonts/Apple Symbols.ttf" },
328
+ // Chrome on macOS routes a handful of arrow codepoints (↑ ↓) to LucidaGrande
329
+ // rather than Apple Symbols — Apple Symbols' ↑ ↓ are 9.86/10.28px wide
330
+ // @22px while LucidaGrande's are 14.19/14.19px, and Chrome's captured
331
+ // bounding box matches LucidaGrande to within 0.01px. DM-369. Other arrows
332
+ // (↔ ⇒ ⇔ etc.) stay on Apple Symbols because LucidaGrande lacks those
333
+ // glyphs.
334
+ "lucida-grande": { path: "/System/Library/Fonts/LucidaGrande.ttc", postscriptName: "LucidaGrande" },
335
+ // Chrome on macOS routes Dingbats (U+2700-27BF: ✂✈✏✔✘✚✦❄❤❶ etc.) to
336
+ // Zapf Dingbats, NOT Apple Symbols. Apple Symbols' glyphs at the same
337
+ // codepoints exist but have different (narrower, often slightly different
338
+ // shape) widths — verified empirically per DM-241 follow-up: every dingbat
339
+ // tested matched Zapf Dingbats' natural advance, none matched Apple Symbols'.
340
+ "zapf-dingbats": { path: "/System/Library/Fonts/ZapfDingbats.ttf" },
341
+ // Mathematical Alphanumeric Symbols (U+1D400-1D7FF: 𝐀 𝒜 𝕊 𝟬 𝔄 𝛼 etc.)
342
+ // — Chrome paints these via STIX Two Math, the math-coverage font Apple
343
+ // ships in Supplemental. Verified empirically (DM-257): every Math Alpha
344
+ // char tested matched STIXTwoMath's natural advance to within 0.05px,
345
+ // while Apple Symbols and Helvetica lack these glyphs entirely (would
346
+ // render as .notdef tofu).
347
+ "stix-math": { path: "/System/Library/Fonts/Supplemental/STIXTwoMath.otf" },
348
+ // Chrome on macOS resolves the CSS `sans-serif` generic keyword to
349
+ // Helvetica (per Blink's third_party/blink/renderer/platform/fonts/mac
350
+ // font_cache_mac.mm). This is critical for fidelity — SF Pro has different
351
+ // glyph shapes and metrics, so substituting it for `sans-serif` produces
352
+ // visible drift on every page that uses the default. Helvetica.ttc is a
353
+ // collection: pick weight × slant variants by postscriptName in
354
+ // getFontInstance.
355
+ "helvetica": { path: "/System/Library/Fonts/Helvetica.ttc", postscriptName: "Helvetica" },
356
+ "helvetica-bold": { path: "/System/Library/Fonts/Helvetica.ttc", postscriptName: "Helvetica-Bold" },
357
+ "helvetica-italic": { path: "/System/Library/Fonts/Helvetica.ttc", postscriptName: "Helvetica-Oblique" },
358
+ "helvetica-bold-italic": { path: "/System/Library/Fonts/Helvetica.ttc", postscriptName: "Helvetica-BoldOblique" },
359
+ // Arial ships as separate weight/style files in macOS Supplemental.
360
+ "arial": { path: "/System/Library/Fonts/Supplemental/Arial.ttf" },
361
+ "arial-bold": { path: "/System/Library/Fonts/Supplemental/Arial Bold.ttf" },
362
+ "arial-italic": { path: "/System/Library/Fonts/Supplemental/Arial Italic.ttf" },
363
+ "arial-bold-italic": { path: "/System/Library/Fonts/Supplemental/Arial Bold Italic.ttf" },
364
+ // Generic serif. Chrome on macOS resolves `font-family: serif`, bare
365
+ // `Times`, `ui-serif`, and the UA-default body/h1 (when no font-family is
366
+ // set) to Apple's `Times.ttc` — NOT to Times New Roman. The two faces have
367
+ // identical advance widths for every glyph tested (so layout is unchanged)
368
+ // but visibly different outlines: Apple Times has bolder em-dash / en-dash
369
+ // bars (H=185 units in Bold vs TNR's 122) and slightly taller caps. The
370
+ // h1 default font-weight: bold made the em-dash mismatch the most visible
371
+ // case (DM-330). Author-named "Times New Roman" still routes to the
372
+ // separate `times-new-roman*` keys below so explicit requests are honored.
373
+ "times": { path: "/System/Library/Fonts/Times.ttc", postscriptName: "Times-Roman" },
374
+ "times-bold": { path: "/System/Library/Fonts/Times.ttc", postscriptName: "Times-Bold" },
375
+ "times-italic": { path: "/System/Library/Fonts/Times.ttc", postscriptName: "Times-Italic" },
376
+ "times-bold-italic": { path: "/System/Library/Fonts/Times.ttc", postscriptName: "Times-BoldItalic" },
377
+ // Times New Roman (the Microsoft face shipped in Supplemental on macOS) is
378
+ // what Chrome picks when CSS specifies `font-family: "Times New Roman"`
379
+ // explicitly — same advance metrics as Apple's Times above but a thinner
380
+ // em-dash / en-dash and shorter caps.
381
+ "times-new-roman": { path: "/System/Library/Fonts/Supplemental/Times New Roman.ttf" },
382
+ "times-new-roman-bold": { path: "/System/Library/Fonts/Supplemental/Times New Roman Bold.ttf" },
383
+ "times-new-roman-italic": { path: "/System/Library/Fonts/Supplemental/Times New Roman Italic.ttf" },
384
+ "times-new-roman-bold-italic": { path: "/System/Library/Fonts/Supplemental/Times New Roman Bold Italic.ttf" },
385
+ "georgia": { path: "/System/Library/Fonts/Supplemental/Georgia.ttf" },
386
+ "georgia-bold": { path: "/System/Library/Fonts/Supplemental/Georgia Bold.ttf" },
387
+ "georgia-italic": { path: "/System/Library/Fonts/Supplemental/Georgia Italic.ttf" },
388
+ "georgia-bold-italic": { path: "/System/Library/Fonts/Supplemental/Georgia Bold Italic.ttf" },
389
+ // Generic cursive — Chrome on macOS resolves `cursive` to Apple Chancery
390
+ // (NOT Snell Roundhand). Empirical probe at 16px on the sample "The quick
391
+ // brown fox jumps over the lazy dog": Chrome cursive = 290.08px, Apple
392
+ // Chancery = 290.08px, Snell Roundhand = 263.84px. SnellRoundhand stays in
393
+ // FONT_PATHS for `font-family: "Snell Roundhand"` author requests.
394
+ "snell": { path: "/System/Library/Fonts/Supplemental/SnellRoundhand.ttc", postscriptName: "SnellRoundhand" },
395
+ "apple-chancery": { path: "/System/Library/Fonts/Supplemental/Apple Chancery.ttf" },
396
+ // Generic fantasy — Chrome on macOS resolves `fantasy` to Papyrus.
397
+ // Empirical probe at 16px: Chrome fantasy = 313.94px, Papyrus = 313.94px,
398
+ // Impact = 286.03px (a common other "fantasy" candidate, but not what
399
+ // Chrome picks). Papyrus.ttc ships W3 + Condensed sub-fonts; the default
400
+ // (no postscriptName) picks the Regular member.
401
+ "papyrus": { path: "/System/Library/Fonts/Supplemental/Papyrus.ttc", postscriptName: "Papyrus" },
402
+ };
403
+ /**
404
+ * Ordered list of fallback font keys to try when the primary font lacks a
405
+ * glyph for `codepoint`. Caller iterates the chain and picks the first font
406
+ * whose `glyphForCodePoint(cp).id !== 0`. Returns an empty array when no
407
+ * fallback is needed (caller should keep using primary).
408
+ *
409
+ * Order matches Chrome's macOS CoreText fallback per Unicode block, verified
410
+ * empirically by probing Chrome's painted width vs each candidate font's
411
+ * natural advance (DM-241 follow-up audit). Apple Symbols stays as the final
412
+ * safety net so we never end up with a .notdef tofu — better to draw a
413
+ * slightly-wrong glyph than nothing.
414
+ */
415
+ /**
416
+ * Pick the PingFang regional variant key (or `hiragino-jp` for Japanese)
417
+ * that matches the element's computed `lang`. Returns null when the lang
418
+ * is empty / unknown — caller should fall through to the default `pingfang-sc`
419
+ * route in that case. DM-394.
420
+ *
421
+ * Matches BCP-47 language tags: the primary subtag wins, with a Han-script
422
+ * subtag (`Hans` / `Hant`) overriding region for the simplified-vs-traditional
423
+ * split. Examples:
424
+ * "zh-TW" → pingfang-tc
425
+ * "zh-Hant" → pingfang-tc
426
+ * "zh-Hant-HK" → pingfang-hk (region is more specific than script)
427
+ * "zh-HK" → pingfang-hk
428
+ * "zh-MO" → pingfang-mo
429
+ * "zh-CN" / "zh-Hans" / "zh" / "" → null (caller picks pingfang-sc)
430
+ * "ja" / "ja-JP" → hiragino-jp (PingFang has no JP subfont on macOS)
431
+ */
432
+ export function pingfangKeyForLang(lang) {
433
+ if (lang == null || lang === "")
434
+ return null;
435
+ const lower = lang.toLowerCase();
436
+ // Japanese: not a PingFang variant — Apple's PingFang.ttc has no PingFangJP
437
+ // postscriptName. Route Japanese Han through Hiragino Kaku (HiraKakuProN).
438
+ if (lower === "ja" || lower.startsWith("ja-"))
439
+ return "hiragino-jp";
440
+ // Match `zh-*` (or any tag that opts into a Chinese region/script).
441
+ if (lower !== "zh" && !lower.startsWith("zh-") && !lower.includes("-zh-"))
442
+ return null;
443
+ // Region subtags win over script subtags when both appear (zh-Hant-HK = HK).
444
+ if (lower.includes("-hk"))
445
+ return "pingfang-hk";
446
+ if (lower.includes("-mo"))
447
+ return "pingfang-mo";
448
+ if (lower.includes("-tw"))
449
+ return "pingfang-tc";
450
+ if (lower.includes("-cn") || lower.includes("-sg"))
451
+ return null; // SC default
452
+ if (lower.includes("hant"))
453
+ return "pingfang-tc";
454
+ if (lower.includes("hans"))
455
+ return null; // SC default
456
+ return null;
457
+ }
458
+ export function fallbackFontChain(codepoint, primaryKey, lang) {
459
+ // When the primary family is a serif (Apple Times / Times New Roman /
460
+ // Georgia, or fangsong/math/serif/ui-serif which all resolve to `times`),
461
+ // CJK fallback should produce SERIF CJK glyphs (Songti SC Light) instead
462
+ // of the default sans-serif Hiragino Sans GB. DM-333. The check is just
463
+ // the resolved key — `times` includes serif/fangsong/math/ui-serif/UA-
464
+ // default since they all collapse to that key in `resolveFontKey`.
465
+ const serifPrimary = primaryKey === "times" || primaryKey === "times-new-roman" || primaryKey === "georgia";
466
+ // Hebrew (U+0590..05FF) + presentation forms (U+FB1D..FB4F).
467
+ // sf-hebrew before lucida-grande as a probe: SFHebrew layouts "שלום עולם"
468
+ // at 68.62px @16px while LucidaGrande layouts at 75.85px. Captured xs from
469
+ // Chrome's `font-family: sans-serif` paint in 02-text-bidi land at 63.766
470
+ // for ש's ink-left, suggesting a run width around 75 (closer to LucidaGrande
471
+ // — Chrome's Helvetica → Hebrew CoreText fallback). Track DM-347 follow-up.
472
+ if ((codepoint >= 0x0590 && codepoint <= 0x05FF)
473
+ || (codepoint >= 0xFB1D && codepoint <= 0xFB4F)) {
474
+ return ["lucida-grande", "sf-hebrew"];
475
+ }
476
+ // Arabic core block + presentation forms A and B.
477
+ if ((codepoint >= 0x0600 && codepoint <= 0x06FF)
478
+ || (codepoint >= 0xFB50 && codepoint <= 0xFDFF)
479
+ || (codepoint >= 0xFE70 && codepoint <= 0xFEFF)) {
480
+ return ["sf-arabic"];
481
+ }
482
+ // Devanagari (U+0900..097F).
483
+ if (codepoint >= 0x0900 && codepoint <= 0x097F)
484
+ return ["devanagari"];
485
+ // Thai (U+0E00..0E7F).
486
+ if (codepoint >= 0x0E00 && codepoint <= 0x0E7F)
487
+ return ["thai"];
488
+ // CJK: Unified Ideographs + Ext A, Hiragana, Katakana (+ phonetic exts),
489
+ // Hangul Syllables + Jamo, CJK Symbols & Punctuation.
490
+ if ((codepoint >= 0x3000 && codepoint <= 0x303F)
491
+ || (codepoint >= 0x3040 && codepoint <= 0x309F)
492
+ || (codepoint >= 0x30A0 && codepoint <= 0x30FF)
493
+ || (codepoint >= 0x31F0 && codepoint <= 0x31FF)
494
+ || (codepoint >= 0x3400 && codepoint <= 0x4DBF)
495
+ || (codepoint >= 0x4E00 && codepoint <= 0x9FFF)
496
+ || (codepoint >= 0xAC00 && codepoint <= 0xD7AF)
497
+ || (codepoint >= 0x1100 && codepoint <= 0x11FF)
498
+ || (codepoint >= 0xF900 && codepoint <= 0xFAFF)) {
499
+ // Serif primary → SERIF CJK font first (DM-333). Keep `cjk`
500
+ // (HiraginoSansGB) as a secondary so chars Songti SC Light lacks (a
501
+ // small set in the rare extension blocks) still resolve.
502
+ // For sans-serif primary, route Han Unified Ideographs (and Ext A) through
503
+ // PingFang SC via CoreText first — that's what Chrome actually paints
504
+ // (DM-382). The `cjk` HiraginoSansGB chain stays as the fallback for
505
+ // any codepoint PingFang lacks AND for the Hiragana/Katakana/Hangul/
506
+ // CJK Symbols ranges where Hiragino is what Chrome picks. Bold scope is
507
+ // resolved at `getFontInstance` time: weight ≥ 600 → pingfang-sc-bold.
508
+ if (serifPrimary)
509
+ return ["cjk-serif", "cjk"];
510
+ const isHan = (codepoint >= 0x4E00 && codepoint <= 0x9FFF)
511
+ || (codepoint >= 0x3400 && codepoint <= 0x4DBF)
512
+ || (codepoint >= 0xF900 && codepoint <= 0xFAFF);
513
+ if (!isHan)
514
+ return ["cjk"];
515
+ // For Han: prefer the lang-matching PingFang variant (or hiragino-jp for
516
+ // Japanese) when lang is set, otherwise fall through to PingFang SC. The
517
+ // bare `cjk` (HiraginoSansGB) stays as the safety net for any glyph
518
+ // PingFang lacks in the rare extension blocks.
519
+ const localeKey = pingfangKeyForLang(lang);
520
+ if (localeKey === "hiragino-jp")
521
+ return ["hiragino-jp", "cjk"];
522
+ if (localeKey != null)
523
+ return [localeKey, "pingfang-sc", "cjk"];
524
+ return ["pingfang-sc", "cjk"];
525
+ }
526
+ // Box Drawing / Block Elements → Hiragino Kaku Gothic, then Menlo. Hiragino
527
+ // has all 128 box-drawing glyphs at em-width (`adv = 1000` at `em = 1000`),
528
+ // so they fill the full painted cell and connect seamlessly. Helvetica has
529
+ // some of them (─ ┌ ┐ │) but is missing ┬ ┴ ┼ — when those fall through
530
+ // to Menlo, Menlo's narrower advance (1233 / 2048 ≈ 0.6 em) leaves a gap
531
+ // and Menlo's horizontal stroke sits at a different y than Helvetica's, so
532
+ // the box edges don't connect to the corner pieces. Routing the entire
533
+ // block to a single font with em-wide glyphs eliminates both mismatches.
534
+ // Menlo stays as a final fallback for installations missing Hiragino.
535
+ // DM-442. (Earlier `["menlo"]` per DM-241 — addressed Apple Symbols's
536
+ // proportional glyphs but didn't account for the Helvetica/Menlo split.)
537
+ if (codepoint >= 0x2500 && codepoint <= 0x259F)
538
+ return ["hiragino-jp", "menlo"];
539
+ // Dingbats → Zapf Dingbats. macOS Chrome paints ✂✈✏✔✘✚✦❄❤❶ via Zapf
540
+ // Dingbats; Apple Symbols has the same codepoints but at different (often
541
+ // narrower) widths — empirical match shows Chrome consistently picks Zapf.
542
+ if (codepoint >= 0x2700 && codepoint <= 0x27BF)
543
+ return ["zapf-dingbats", "symbols"];
544
+ // Geometric Shapes (▲△▽★☆♀♂…) and Misc Symbols (☀☁☂♠♥♦…) — Chrome on
545
+ // macOS paints many of these at the CJK em-square width (16px @16px font-
546
+ // size) via Hiragino Sans GB, NOT Apple Symbols (which has them at
547
+ // proportional 9-14px). Try CJK first; fall through to Apple Symbols for
548
+ // the chars Hiragino lacks (☘ ☑ ◇ etc.). DM-256. Insert Japanese Hiragino
549
+ // Sans (HiraKakuProN-W3) between cjk-GB and Apple Symbols — it covers
550
+ // ◉◌◐◑ (DM-324) and ☀☁☂☃ (DM-326) at em-square width when GB doesn't,
551
+ // matching Chrome's 18px paint instead of falling through to Apple
552
+ // Symbols' narrower 11-15px advance.
553
+ // Within Geometric Shapes, the small filled / outline primitives that
554
+ // LucidaGrande carries at narrow proportional advance — ■ □ ● ○ ◆ ◇ —
555
+ // are what Chrome's CoreText cascade for `font-family: sans-serif`
556
+ // (Helvetica) actually picks for those individual codepoints, NOT the
557
+ // CJK/Hiragino em-square glyph that the rest of the block uses. Probed
558
+ // against captured xOffsets in 02-text-symbols (DM-349):
559
+ // ■ □ : LucidaGrande 9.76px @18px (Hiragino paints 18px → 8px too wide)
560
+ // ● ○ : LucidaGrande 10.41px @18px (Hiragino 18px too wide)
561
+ // ◆ : LucidaGrande 13.01px @18px
562
+ // ◇ : LucidaGrande 11.07px @18px
563
+ // Everything else in 0x25A0..25FF (▲▽◉◌◐◑★…) Chrome paints at em-square
564
+ // via Hiragino — keep those on the existing chain.
565
+ // DM-415 / DM-429: tried routing patterned squares (U+25A3..A8 + U+25C8)
566
+ // to AppleSDGothicNeo and SF NS for the open-shape primitives, but the
567
+ // painted-ink size came out larger than Chrome's actual paint despite the
568
+ // advance widths matching — Chrome uses a font with smaller-ink-in-wider-
569
+ // advance for these. Reverted; the LucidaGrande route remains the closest
570
+ // visible match in our available font set. Tracked further in DM-429.
571
+ if (codepoint === 0x25A0 || codepoint === 0x25A1
572
+ || codepoint === 0x25CF || codepoint === 0x25CB
573
+ || codepoint === 0x25C6 || codepoint === 0x25C7) {
574
+ return ["lucida-grande", "symbols"];
575
+ }
576
+ // Chess pieces ♔..♟ (U+2654..U+265F) — Chrome routes these through Menlo,
577
+ // not Apple Symbols. Verified via CDP CSS.getPlatformFontsForNode at 22px
578
+ // sans-serif: Chrome reports the font as "Menlo" and the captured advance
579
+ // (13.234px @22px) matches Menlo's 13.245px exactly, while Apple Symbols
580
+ // paints them at 17.188/17.284 — ~4px too wide, causing ♚ to overlap ♔
581
+ // in domotion's render. (DM-380)
582
+ if (codepoint >= 0x2654 && codepoint <= 0x265F) {
583
+ return ["menlo", "symbols"];
584
+ }
585
+ if ((codepoint >= 0x25A0 && codepoint <= 0x25FF)
586
+ || (codepoint >= 0x2600 && codepoint <= 0x26FF)) {
587
+ return ["cjk", "hiragino-jp", "symbols"];
588
+ }
589
+ // Arrows: most of the Arrows block (↔↦⇒⇔ …) routes to Apple Symbols
590
+ // below, but specific codepoints split off:
591
+ // ← → ↗ ↙ — Hiragino W6 at the CJK em-square width (24px @24px), which
592
+ // is what Chrome paints; Apple Symbols has them at 15-17px,
593
+ // rendering visibly thinner (DM-296).
594
+ // ↑ ↓ — LucidaGrande at 14.19px @22px, which matches Chrome's
595
+ // captured bounding box; Apple Symbols paints them at
596
+ // 9.86/10.28px and Hiragino paints at 22/24px, both wrong
597
+ // (DM-369).
598
+ // ← → ↑ ↓ — Lucida Grande at every size (12 → 32 px), per CDP
599
+ // `CSS.getPlatformFontsForNode` (DM-405). The painted glyph is the
600
+ // chunkier LucidaGrande arrow; CJK Hiragino's thin outline visibly
601
+ // diverges (DM-296 reverted by DM-405).
602
+ if (codepoint === 0x2190 || codepoint === 0x2192
603
+ || codepoint === 0x2191 || codepoint === 0x2193) {
604
+ return ["lucida-grande", "symbols"];
605
+ }
606
+ // ↗ ↙ — Lucida Grande LACKS these codepoints (verified via fontkit
607
+ // `glyphForCodePoint(0x2197).id === 0` on the system .ttc, all four
608
+ // faces). The earlier consolidation onto "lucida-grande" silently fell
609
+ // through to Apple Symbols at ~10 px advance — visibly half the width
610
+ // Chrome paints (16 px at 16 px font). Hiragino Sans GB has them at
611
+ // em-width (adv=1000 / em=1000 → 16 px), matching Chrome's painted
612
+ // advance. (DM-441.)
613
+ if (codepoint === 0x2197 || codepoint === 0x2199) {
614
+ return ["cjk", "hiragino-jp", "symbols"];
615
+ }
616
+ // Mathematical Alphanumeric Symbols (𝐀 𝒜 𝕊 𝟬 𝔄 𝛼 etc.) — Chrome paints
617
+ // via STIX Two Math (the system math-coverage font); Apple Symbols
618
+ // and Hiragino lack these glyphs entirely. DM-257.
619
+ if (codepoint >= 0x1D400 && codepoint <= 0x1D7FF) {
620
+ return ["stix-math", "symbols"];
621
+ }
622
+ // Letterlike (ℝℕℤℂℚ™), Arrows residue, Math Operators, Pictographs, Transport.
623
+ // The caller's primary-first check already routes chars Helvetica/Times
624
+ // have (∑∏∫≠≤≥, ™, ●) to the primary; what reaches this fallback is the
625
+ // residue (∀∃∈ ↑↓↔ ⇒⇔ etc.) for which Apple Symbols is the right macOS
626
+ // source. (← → ↗ ↙ branch above to CJK because Hiragino's em-wide glyph
627
+ // matches Chrome and Apple Symbols' is too narrow — DM-296.)
628
+ if ((codepoint >= 0x2100 && codepoint <= 0x214F)
629
+ || (codepoint >= 0x2190 && codepoint <= 0x21FF)
630
+ || (codepoint >= 0x2200 && codepoint <= 0x22FF)
631
+ || (codepoint >= 0x1F300 && codepoint <= 0x1F5FF)
632
+ || (codepoint >= 0x1F680 && codepoint <= 0x1F6FF)) {
633
+ return ["symbols"];
634
+ }
635
+ return [];
636
+ }
637
+ /** @deprecated Single-key wrapper for back-compat — prefer `fallbackFontChain`. */
638
+ export function fallbackFontKey(codepoint) {
639
+ const chain = fallbackFontChain(codepoint);
640
+ return chain.length > 0 ? chain[0] : null;
641
+ }
642
+ /**
643
+ * Codepoints Chrome on macOS paints via the color-emoji font (Apple Color
644
+ * Emoji), regardless of any path-font's coverage. Mirrors the predicate in
645
+ * `src/dom-to-svg.ts` (CAPTURE_SCRIPT's `needsRaster`) so the path pipeline
646
+ * can skip emitting the .notdef tofu rectangle for these codepoints — the
647
+ * raster <image> overlay added by the capture layer already covers the
648
+ * visible glyph, and emitting the tofu underneath produces a visible
649
+ * black rectangle around the edges of the emoji where the raster has
650
+ * sub-pixel transparency. (DM-334.)
651
+ */
652
+ /**
653
+ * Codepoints in the Unicode Private Use Areas — these are author-assigned
654
+ * (typically icon-font) glyphs. When the host system fonts don't cover the
655
+ * codepoint, fontkit returns a `.notdef` tofu (a striated rectangle). We
656
+ * suppress that emission rather than paint the tofu — a missing icon should
657
+ * read as "nothing" not as a glyph-shaped black blob over surrounding text
658
+ * (apple.com country dropdown checkmark covering the leading 'P' of
659
+ * 'Philippines' — DM-490 / DM-500).
660
+ */
661
+ function isPrivateUseCodepoint(cp) {
662
+ // BMP PUA
663
+ if (cp >= 0xE000 && cp <= 0xF8FF)
664
+ return true;
665
+ // Supplementary PUA-A
666
+ if (cp >= 0xF0000 && cp <= 0xFFFFD)
667
+ return true;
668
+ // Supplementary PUA-B
669
+ if (cp >= 0x100000 && cp <= 0x10FFFD)
670
+ return true;
671
+ return false;
672
+ }
673
+ function isEmojiCodepoint(cp, nextCp) {
674
+ // Misc Symbols block (U+2600..26FF) chars with default emoji presentation.
675
+ if (cp === 0x2614 || cp === 0x2615 || (cp >= 0x2648 && cp <= 0x2653)
676
+ || cp === 0x267F || cp === 0x2693 || cp === 0x26A1 || cp === 0x26AA || cp === 0x26AB
677
+ || cp === 0x26BD || cp === 0x26BE || cp === 0x26C4 || cp === 0x26C5 || cp === 0x26CE
678
+ || cp === 0x26D4 || cp === 0x26EA || cp === 0x26F2 || cp === 0x26F3 || cp === 0x26F5
679
+ || cp === 0x26FA || cp === 0x26FD)
680
+ return true;
681
+ // Dingbats Chrome routes to Apple Color Emoji (✨ ❌ ❎ ❓ ❔ ❕ ❗ ➕ ➖ ➗ ➡ ➰ ➿ etc.).
682
+ if (cp === 0x2728 || cp === 0x2753 || cp === 0x2754 || cp === 0x2755 || cp === 0x2757
683
+ || cp === 0x274C || cp === 0x274E || cp === 0x2795 || cp === 0x2796 || cp === 0x2797
684
+ || cp === 0x27A1 || cp === 0x27B0 || cp === 0x27BF)
685
+ return true;
686
+ // VS-16 (U+FE0F) after a base emoji codepoint requests color presentation.
687
+ if (nextCp === 0xFE0F && cp >= 0x2600 && cp <= 0x26FF)
688
+ return true;
689
+ // Regional-indicator flags (pairs are joined into country flag emoji).
690
+ if (cp >= 0x1F1E6 && cp <= 0x1F1FF)
691
+ return true;
692
+ // Main emoji blocks: Misc Symbols & Pictographs, Emoticons, Transport,
693
+ // Alchemical, Supplemental Symbols, Pictographs Extended-A/B.
694
+ if (cp >= 0x1F300 && cp <= 0x1FAFF)
695
+ return true;
696
+ return false;
697
+ }
698
+ function getFontInstance(key, weight, fontSize, slant = 0) {
699
+ // Webfont keys (`webfont:<lowercased family>`) resolve through the runtime
700
+ // registry rather than the on-disk FONT_PATHS table.
701
+ if (key.startsWith("webfont:")) {
702
+ return pickWebfontVariant(key.slice("webfont:".length), weight, fontSize, slant);
703
+ }
704
+ // `localalias:<family>` — the family was declared via @font-face local() and
705
+ // we tracked one or more declared (weight, italic) variants pointing at base
706
+ // FONT_PATHS keys. Pick the closest declared variant and use ITS weight /
707
+ // italic to drive the sibling-file selection below — NOT the requested
708
+ // weight/italic — so Chrome's "no bold-italic declared → use italic 400"
709
+ // behavior is preserved instead of silently substituting the on-disk
710
+ // bold-italic sibling. DM-360.
711
+ if (key.startsWith("localalias:")) {
712
+ const family = key.slice("localalias:".length);
713
+ const variant = pickLocalFontAliasVariant(family, weight, slant !== 0);
714
+ if (variant == null)
715
+ return null;
716
+ return getFontInstance(variant.baseKey, variant.weight, fontSize, variant.italic ? slant : 0);
717
+ }
718
+ // SF Pro / SF Mono ship their italics as separate .ttf files rather than
719
+ // exposing a `slnt` variable-axis on the upright file, so route italic
720
+ // requests at the spec level instead of trying to drive an axis. Fallback
721
+ // fonts (sf-arabic / cjk / thai / devanagari / symbols) have no italic
722
+ // sibling — the slnt argument is quietly ignored there.
723
+ let effectiveKey = key;
724
+ if (slant !== 0) {
725
+ if (key === "sf-pro")
726
+ effectiveKey = "sf-pro-italic";
727
+ else if (key === "sf-mono")
728
+ effectiveKey = "sf-mono-italic";
729
+ }
730
+ // Helvetica/Arial/Courier/Menlo/Times/Georgia don't expose a variable wght
731
+ // axis — pick the right sub-font (or sibling file) based on weight × slant.
732
+ // Boundary at 600 matches CSS font-weight: bold (700) and the typical
733
+ // "semibold or above is bold" rule Chrome uses when an exact weight isn't
734
+ // installed. Times/Georgia ship four sibling files (regular/bold/italic/
735
+ // bold-italic) for headings + emphasis in serif content (DM-269).
736
+ if (key === "helvetica" || key === "arial" || key === "courier" || key === "menlo"
737
+ || key === "times" || key === "times-new-roman" || key === "georgia") {
738
+ const isBold = weight >= 600;
739
+ const isItalic = slant !== 0;
740
+ if (isBold && isItalic)
741
+ effectiveKey = `${key}-bold-italic`;
742
+ else if (isBold)
743
+ effectiveKey = `${key}-bold`;
744
+ else if (isItalic)
745
+ effectiveKey = `${key}-italic`;
746
+ }
747
+ // CJK has only regular + bold variants (no italic); pick W6 for bold contexts
748
+ // so fallback characters in headings (← → ▲ ☀) inherit the heading weight.
749
+ if (key === "cjk" && weight >= 600) {
750
+ effectiveKey = "cjk-bold";
751
+ }
752
+ if (key === "cjk-serif" && weight >= 600) {
753
+ effectiveKey = "cjk-serif-bold";
754
+ }
755
+ if (key === "hiragino-jp" && weight >= 600) {
756
+ effectiveKey = "hiragino-jp-bold";
757
+ }
758
+ // PingFang ships separate weight subfonts in PingFang.ttc — Regular for
759
+ // body weight, Medium for semibold+. No italic. Same pattern across all
760
+ // regional variants (SC / TC / HK / MO).
761
+ if ((key === "pingfang-sc" || key === "pingfang-tc"
762
+ || key === "pingfang-hk" || key === "pingfang-mo")
763
+ && weight >= 600) {
764
+ effectiveKey = `${key}-bold`;
765
+ }
766
+ const cacheKey = `${effectiveKey}-${weight}-${fontSize}-${slant}`;
767
+ if (fontInstanceCache.has(cacheKey))
768
+ return fontInstanceCache.get(cacheKey);
769
+ const spec = FONT_PATHS[effectiveKey];
770
+ if (spec == null)
771
+ return null;
772
+ // CoreText-extractor route: route to the macOS Swift helper (DM-385 / DM-388).
773
+ // When the helper isn't present (non-darwin host, dev hasn't built it,
774
+ // DOMOTION_DISABLE_HELPER set), fall through to fontkit — the renderer's
775
+ // chain logic skips fontkit-empty paths and walks to the next candidate
776
+ // (`cjk` / HiraginoSansGB for the PingFang case), preserving the pre-DM-385
777
+ // baseline.
778
+ if (spec.extractor === "coretext" && isCoretextHelperAvailable()) {
779
+ const coretextFont = createCoretextFont({ postscriptName: spec.postscriptName, fontPath: spec.path });
780
+ if (coretextFont != null) {
781
+ const instance = coretextFont;
782
+ fontInstanceCache.set(cacheKey, instance);
783
+ return instance;
784
+ }
785
+ }
786
+ if (spec.extractor === "coretext")
787
+ return null;
788
+ try {
789
+ const opened = fontkit.openSync(spec.path);
790
+ // TTC collections expose .fonts + .getFont(postscriptName). Pick the
791
+ // requested member; fall back to the first sub-font if the requested
792
+ // one is missing (defensive against OS font updates renaming members).
793
+ let font = opened;
794
+ if (opened.fonts != null && Array.isArray(opened.fonts)) {
795
+ if (spec.postscriptName != null && opened.getFont != null) {
796
+ font = opened.getFont(spec.postscriptName) ?? opened.fonts[0];
797
+ }
798
+ else {
799
+ font = opened.fonts[0];
800
+ }
801
+ }
802
+ const instance = applyVariationAxes(font, weight, fontSize, slant);
803
+ fontInstanceCache.set(cacheKey, instance);
804
+ return instance;
805
+ }
806
+ catch {
807
+ return null;
808
+ }
809
+ }
810
+ /**
811
+ * Drive a variable font's exposed variation axes from the requested CSS
812
+ * weight / font-size / slant:
813
+ *
814
+ * - `wght` ← `weight` (CSS numeric weight, 100-900)
815
+ * - `opsz` ← `fontSize` (px) when the font exposes the axis
816
+ * - `slnt` ← `slant` when non-zero AND the axis exists (SF Pro / Recursive
817
+ * synthesize italic/oblique from this; ignored otherwise)
818
+ *
819
+ * Returns the original font when the file isn't variable or `getVariation`
820
+ * is missing. Some fonts (Hiragino Sans GB) expose `getVariation` but lack
821
+ * the required `fvar`/`gvar`/`CFF2` tables, so the call is wrapped in
822
+ * try/catch — failure falls back to the unvariated font rather than
823
+ * cascading up.
824
+ *
825
+ * Used by both the system-installed font path (`getFontInstance`) and the
826
+ * runtime webfont path (`pickWebfontVariant`) so variable webfonts like
827
+ * Inter Variable or Roboto Flex render at the requested weight/size instead
828
+ * of always producing the registered base instance.
829
+ */
830
+ function applyVariationAxes(font, weight, fontSize, slant) {
831
+ if (font.variationAxes == null || Object.keys(font.variationAxes).length === 0 || font.getVariation == null) {
832
+ return font;
833
+ }
834
+ const axes = {};
835
+ if (font.variationAxes.wght != null)
836
+ axes.wght = weight;
837
+ if (font.variationAxes.opsz != null)
838
+ axes.opsz = fontSize;
839
+ if (slant !== 0 && font.variationAxes.slnt != null)
840
+ axes.slnt = slant;
841
+ if (Object.keys(axes).length === 0)
842
+ return font;
843
+ let v;
844
+ try {
845
+ v = font.getVariation(axes);
846
+ }
847
+ catch {
848
+ return font;
849
+ }
850
+ // Fontkit's WOFF2 variation path returns an instance whose internal stream
851
+ // doesn't expose the parent's tables — accessing `unitsPerEm` /
852
+ // `layout(...)` throws "Cannot read properties of undefined". Probe for
853
+ // that and fall back to the original font when the variation is broken.
854
+ // For TTF/OTF parents the probe succeeds and we use the variation as-is.
855
+ try {
856
+ if (v.unitsPerEm == null)
857
+ return font;
858
+ }
859
+ catch {
860
+ return font;
861
+ }
862
+ return v;
863
+ }
864
+ export function resolveFontKey(fontFamily) {
865
+ // Walk the comma-separated stack — Chrome's getComputedStyle returns the
866
+ // unresolved list (e.g. `"DoesNotExist", Georgia, "Times New Roman", serif`)
867
+ // not the matched font. Pick the first name we recognize, mirroring how
868
+ // Chrome falls through the stack until something loads.
869
+ const names = fontFamily.split(",").map((s) => s.trim().replace(/^["']|["']$/g, "").toLowerCase());
870
+ for (const name of names) {
871
+ if (name === "" || name === "doesnotexist")
872
+ continue;
873
+ // Registered webfonts win — the page declared this family AND we hold
874
+ // its bytes. `getFontInstance` dispatches the webfont: prefix to the
875
+ // runtime registry instead of the on-disk FONT_PATHS table.
876
+ if (webfontRegistry.has(name))
877
+ return `webfont:${name}`;
878
+ // `@font-face { src: local(...) }` alias — the page declared one or more
879
+ // @font-face rules whose first local() source resolves to a system font
880
+ // we already know about (Georgia / Menlo / Times / etc.). Return a
881
+ // `localalias:` prefixed key so getFontInstance can score the requested
882
+ // weight/italic against the registered variants — important when the page
883
+ // declared regular + italic + bold but NOT bold-italic (DM-360 / DM-303).
884
+ if (localFontAliasRegistry.has(name))
885
+ return `localalias:${name}`;
886
+ // Chrome on macOS resolves the CSS `monospace` generic to Courier (per
887
+ // Blink's font_cache_mac.mm — kMonospaceFamily → kCourier). For author-
888
+ // named monospaces we map to whatever the author asked for if we have
889
+ // it on disk; SF Mono is only used when explicitly requested. Consolas
890
+ // isn't installed on macOS — Chrome falls back to Times metrics there,
891
+ // but for fidelity-of-intent we route it to Courier.
892
+ //
893
+ // `ui-monospace` is NOT recognized by Chrome on macOS (DM-269 probe:
894
+ // painted T width = 9.77, q = 8.0 — same as Times, not Courier or SF
895
+ // Mono). Chrome falls through to the standard-font default (Times). It
896
+ // intentionally falls through here so the last-resort `times` mapping
897
+ // at the bottom catches it.
898
+ if (name === "monospace" || name === "courier" || name === "courier new"
899
+ || name === "consolas")
900
+ return "courier";
901
+ if (name === "menlo")
902
+ return "menlo";
903
+ if (name === "monaco")
904
+ return "monaco";
905
+ if (name === "sf mono" || name === "sfmono-regular" || name === "sf-mono")
906
+ return "sf-mono";
907
+ // `Times New Roman` resolves to the Microsoft TNR face (separate file from
908
+ // Apple's Times.ttc); bare `Times` / `serif` / `ui-serif` / the UA default
909
+ // resolve to Apple Times (DM-330). The two have identical metrics but
910
+ // visibly different em-dash glyphs in bold weights.
911
+ if (name === "times new roman")
912
+ return "times-new-roman";
913
+ if (name === "serif" || name === "ui-serif" || name === "times")
914
+ return "times";
915
+ if (name === "georgia")
916
+ return "georgia";
917
+ // Chrome on macOS resolves the CSS `cursive` generic keyword to Apple
918
+ // Chancery (per the empirical probe — bare `cursive` paints at exactly
919
+ // Apple Chancery's advance, NOT Snell Roundhand's, on macOS Sonoma+).
920
+ // Author-named "Snell Roundhand" / "Brush Script MT" still get their
921
+ // explicit families.
922
+ if (name === "cursive" || name === "apple chancery")
923
+ return "apple-chancery";
924
+ if (name === "snell roundhand" || name === "brush script mt")
925
+ return "snell";
926
+ // Chrome on macOS resolves the CSS `fantasy` generic to Papyrus
927
+ // (empirical probe: 313.94px = Papyrus's exact advance on the sample).
928
+ if (name === "fantasy" || name === "papyrus")
929
+ return "papyrus";
930
+ // Chrome on macOS resolves `sans-serif`, `helvetica`, and `helvetica neue`
931
+ // to Helvetica (Blink: font_cache_mac.mm + font_fallback_list.cc — the
932
+ // generic `sans-serif` keyword is hardcoded to Helvetica on macOS, not
933
+ // SF Pro). Matching this exactly is critical: SF Pro has different
934
+ // glyph shapes (notably the `1`, `R`, `g`) and ~2% wider metrics than
935
+ // Helvetica at the same em size, so substituting it produces visible
936
+ // drift on every page that uses the default sans-serif.
937
+ if (name === "sans-serif" || name === "helvetica"
938
+ || name === "helvetica neue")
939
+ return "helvetica";
940
+ if (name === "arial")
941
+ return "arial";
942
+ // system-ui / BlinkMacSystemFont / "SF Pro" → SF Pro.
943
+ // These keywords mean "the platform UI font", which on modern macOS is
944
+ // San Francisco. NOTE: `-apple-system` is INTENTIONALLY excluded —
945
+ // empirical probe (DM-291) on the current Chromium build shows bare
946
+ // `-apple-system` resolves to the UA standard font (Times, 35.98px on
947
+ // the "greet" sample at 18px) rather than SF Pro (42.20px), and as a
948
+ // first family in a stack like `-apple-system, sans-serif` Chrome falls
949
+ // through to `sans-serif` → Helvetica (41.03px). Mapping it to SF Pro
950
+ // here paints the Latin glyphs ~3% wider than Chrome on every test that
951
+ // uses the historically-canonical -apple-system stack, including the
952
+ // text-mixed-script feature fixture's "greet" / "Hello" runs which
953
+ // jammed against the adjacent Arabic/CJK glyphs because SF Pro's "t"
954
+ // and "o" advances are ~1px wider than Helvetica's at 18px. Let
955
+ // `-apple-system` fall through via the `continue` clause below.
956
+ if (name === "system-ui" || name === "blinkmacsystemfont"
957
+ || name === "sf pro" || name === "sf pro text" || name === "sf pro display")
958
+ return "sf-pro";
959
+ // Other generic keywords Chrome on macOS does NOT recognize as system
960
+ // fonts: `ui-monospace`, `ui-rounded`, `fantasy`, `math`, `emoji`,
961
+ // `fangsong`. Chrome treats them as missing and walks past them to the
962
+ // next name in the stack. Only when nothing else matches does Chrome
963
+ // fall through to the standard-font default (Times). DM-269 probe
964
+ // confirmed bare `ui-monospace` paints with Times metrics (q=8.0,
965
+ // T=9.77), but `ui-monospace, Menlo, monospace` paints in Menlo —
966
+ // proving Chrome doesn't pin these keywords, it skips them. We must do
967
+ // the same: `continue` past them so the rest of the stack (Menlo,
968
+ // Consolas, monospace, …) gets a chance to match. The last-resort
969
+ // `times` at the bottom of this function catches the no-match case.
970
+ // (DM-302: textarea code editor used `font: ui-monospace, Menlo, …`
971
+ // and we wrongly pinned to Times, painting code in a serif face.)
972
+ if (name === "ui-monospace" || name === "ui-rounded" || name === "ui-sans-serif"
973
+ || name === "math" || name === "emoji" || name === "fangsong"
974
+ || name === "-apple-system")
975
+ continue;
976
+ }
977
+ // Last-resort fallback when no family in the stack matched. Chrome's
978
+ // ultimate fallback on macOS for an unrecognized name is the user's
979
+ // configured "Standard Font" preference, which defaults to Times.
980
+ return "times";
981
+ }
982
+ function resolveFont(fontFamily, fontWeight, fontSize, slant = 0) {
983
+ return getFontInstance(resolveFontKey(fontFamily), fontWeight, fontSize, slant);
984
+ }
985
+ // ── Glyph Registry (for <defs>/<use> deduplication) ──
986
+ /** Stores unique glyph path definitions. Uses short sequential IDs for compact output. */
987
+ const glyphDefs = new Map();
988
+ const glyphKeyToId = new Map();
989
+ let glyphIdCounter = 0;
990
+ function ensureGlyphDef(fontKey, weight, fontSize, slant, glyphId, commands) {
991
+ const key = `${fontKey}-${weight}-${fontSize}-${slant}-${glyphId}`;
992
+ const existing = glyphKeyToId.get(key);
993
+ if (existing != null)
994
+ return existing;
995
+ // Short sequential ID for compact output
996
+ const defId = `g${glyphIdCounter++}`;
997
+ glyphKeyToId.set(key, defId);
998
+ // Convert glyph commands to SVG path data at font-unit scale.
999
+ // Use integer coordinates (font units are integers) and shorthand commands.
1000
+ let d = "";
1001
+ let prevX = 0, prevY = 0;
1002
+ for (const cmd of commands) {
1003
+ const a = cmd.args;
1004
+ switch (cmd.command) {
1005
+ case "moveTo":
1006
+ d += `M${a[0]} ${a[1]}`;
1007
+ prevX = a[0];
1008
+ prevY = a[1];
1009
+ break;
1010
+ case "lineTo":
1011
+ if (a[1] === prevY) {
1012
+ d += `H${a[0]}`;
1013
+ }
1014
+ else if (a[0] === prevX) {
1015
+ d += `V${a[1]}`;
1016
+ }
1017
+ else {
1018
+ d += `L${a[0]} ${a[1]}`;
1019
+ }
1020
+ prevX = a[0];
1021
+ prevY = a[1];
1022
+ break;
1023
+ case "quadraticCurveTo":
1024
+ d += `Q${a[0]} ${a[1]} ${a[2]} ${a[3]}`;
1025
+ prevX = a[2];
1026
+ prevY = a[3];
1027
+ break;
1028
+ case "bezierCurveTo":
1029
+ d += `C${a[0]} ${a[1]} ${a[2]} ${a[3]} ${a[4]} ${a[5]}`;
1030
+ prevX = a[4];
1031
+ prevY = a[5];
1032
+ break;
1033
+ case "closePath":
1034
+ d += "Z";
1035
+ break;
1036
+ }
1037
+ }
1038
+ glyphDefs.set(defId, `<path id="${defId}" d="${d}"/>`);
1039
+ return defId;
1040
+ }
1041
+ /**
1042
+ * Get all glyph <defs> accumulated so far. Call this once when building the final SVG.
1043
+ * Returns SVG markup to place inside a <defs> block.
1044
+ */
1045
+ export function getGlyphDefs() {
1046
+ return [...glyphDefs.values()].join("");
1047
+ }
1048
+ /** Clear the glyph registry (call between independent SVG generations). */
1049
+ export function clearGlyphDefs() {
1050
+ glyphDefs.clear();
1051
+ glyphKeyToId.clear();
1052
+ glyphIdCounter = 0;
1053
+ }
1054
+ /**
1055
+ * Convert a text string to SVG markup using <use> references to glyph defs.
1056
+ *
1057
+ * Positioning modes (in order of preference):
1058
+ * 1. xOffsets (per-char x in CSS pixels, relative to text origin) — used
1059
+ * when the capture layer measured each character's actual rect.left.
1060
+ * This eliminates per-character drift because glyph placement matches
1061
+ * exactly what the browser painted (including kerning, letter-spacing,
1062
+ * optical-size effects, etc.).
1063
+ * 2. targetWidth — scales native fontkit advances uniformly so the total
1064
+ * width matches Chrome. Good for single-line text where per-char drift
1065
+ * is small. Kept as a fallback for inputs/textarea values (no per-char
1066
+ * rect data) and legacy callers.
1067
+ * 3. Native fontkit advances — if neither is provided.
1068
+ */
1069
+ export function textToPathMarkup(text, fontSize, fontFamily, fontWeight, targetWidth,
1070
+ /** CSS-pixel x offsets per visible char, relative to the text origin. */
1071
+ xOffsets,
1072
+ /** CSS font-style ('italic' / 'oblique' / 'normal'). Drives SF Pro's slnt. */
1073
+ fontStyle,
1074
+ /**
1075
+ * OpenType feature tags to enable for shaping (e.g. ['smcp'] for
1076
+ * `font-variant: small-caps`). Threaded through to every fontkit
1077
+ * `font.layout()` call so single-char and multi-char shaping both pick the
1078
+ * substituted glyph. Empty / undefined means default shaping. (DM-294)
1079
+ */
1080
+ features,
1081
+ /** BCP-47 language tag from the element's computed `lang` attribute. Routes
1082
+ * Han fallbacks to the matching PingFang regional variant — `zh-TW` / `zh-Hant`
1083
+ * → PingFang TC, `zh-HK` → PingFang HK, `zh-MO` → PingFang MO, `ja` →
1084
+ * Hiragino Kaku, otherwise PingFang SC. (DM-394) */
1085
+ lang) {
1086
+ const weight = parseInt(fontWeight) || 400;
1087
+ const slant = slantForStyle(fontStyle);
1088
+ const primaryFont = resolveFont(fontFamily, weight, fontSize, slant);
1089
+ if (primaryFont == null)
1090
+ return null;
1091
+ const primaryFontKey = resolveFontKey(fontFamily);
1092
+ const runs = [];
1093
+ {
1094
+ let curKey = primaryFontKey;
1095
+ let curFontOverride = null; // DM-557: per-codepoint webfont variant
1096
+ let curText = "";
1097
+ let curStart = 0;
1098
+ let i = 0;
1099
+ while (i < text.length) {
1100
+ const cp = text.codePointAt(i);
1101
+ const ch = String.fromCodePoint(cp);
1102
+ // Primary-first: many of the chars `fallbackFontChain` routes (∑ ∏ ≠ ●
1103
+ // ™ ←) are present in the requested primary font (Helvetica/Times/SF
1104
+ // Pro) at metrics that match Chrome's painted width. Use primary
1105
+ // whenever it has the glyph; only walk the fallback chain otherwise.
1106
+ let useKey = primaryFontKey;
1107
+ let useFontOverride = null;
1108
+ if (primaryFont.glyphForCodePoint(cp).id === 0) {
1109
+ // DM-557: for a partitioned webfont family (Geist split across
1110
+ // Latin / Latin-Ext / Cyrillic / etc. by `unicode-range`), the
1111
+ // Latin-biased `pickWebfontVariant` returns the Latin partition as
1112
+ // primary. Codepoints outside that partition's range have no glyph
1113
+ // in the primary, but they MAY be covered by another registered
1114
+ // variant of the same family. Try a codepoint-aware variant lookup
1115
+ // BEFORE walking the system fallback chain — picking the matching
1116
+ // partition matches Chrome's @font-face cascade and preserves the
1117
+ // family's typographic identity instead of falling to a body system
1118
+ // font.
1119
+ if (primaryFontKey.startsWith("webfont:")) {
1120
+ const family = primaryFontKey.slice("webfont:".length);
1121
+ const cpVariant = pickWebfontVariantForCodepoint(family, weight, fontSize, slant, cp);
1122
+ if (cpVariant != null && cpVariant.glyphForCodePoint(cp).id !== 0) {
1123
+ // Use the codepoint-aware variant. Keep `useKey` = primary so the
1124
+ // run discriminator still groups with primary at the key level;
1125
+ // the font instance override propagates through the run grouping
1126
+ // below to give this codepoint its own (or coalesced) run.
1127
+ useFontOverride = cpVariant;
1128
+ }
1129
+ }
1130
+ if (useFontOverride == null) {
1131
+ // Walk the chain in order, pick the first font that actually has
1132
+ // the glyph. If nothing in the chain has it (e.g. an exotic emoji
1133
+ // that even Apple Symbols lacks), fall through to the LAST chain
1134
+ // entry anyway — its .notdef has a stable advance the rasterGlyph
1135
+ // overlay can pin a captured emoji PNG against, where switching
1136
+ // to primary's .notdef would shift glyph positions and drift the
1137
+ // rest of the line.
1138
+ const chain = fallbackFontChain(cp, primaryFontKey, lang);
1139
+ let picked = null;
1140
+ for (const candidate of chain) {
1141
+ const cf = getFontInstance(candidate, weight, fontSize, slant);
1142
+ if (cf != null && cf.glyphForCodePoint != null
1143
+ && cf.glyphForCodePoint(cp).id !== 0) {
1144
+ picked = candidate;
1145
+ break;
1146
+ }
1147
+ }
1148
+ useKey = picked ?? (chain.length > 0 ? chain[chain.length - 1] : primaryFontKey);
1149
+ }
1150
+ }
1151
+ // DM-557: a per-codepoint webfont variant is a different FontInstance
1152
+ // even when its `useKey` matches `curKey` (both are the primary
1153
+ // family's webfont:<key>). Discriminate runs by the (key, override)
1154
+ // pair so a Latin-partition run and a Cyrillic-partition run within
1155
+ // the same Geist family stay separate even though they share the key.
1156
+ const runChanged = useKey !== curKey || useFontOverride !== curFontOverride;
1157
+ if (runChanged && curText.length > 0) {
1158
+ const f = curFontOverride ?? getFontInstance(curKey, weight, fontSize, slant);
1159
+ if (f != null)
1160
+ runs.push({ fontKey: curKey, font: f, text: curText, startIdx: curStart, endIdx: i });
1161
+ curText = "";
1162
+ curStart = i;
1163
+ }
1164
+ curKey = useKey;
1165
+ curFontOverride = useFontOverride;
1166
+ curText += ch;
1167
+ i += ch.length;
1168
+ }
1169
+ if (curText.length > 0) {
1170
+ const f = curFontOverride ?? getFontInstance(curKey, weight, fontSize, slant) ?? primaryFont;
1171
+ runs.push({ fontKey: curKey === primaryFontKey ? primaryFontKey : (f === primaryFont ? primaryFontKey : curKey), font: f, text: curText, startIdx: curStart, endIdx: text.length });
1172
+ }
1173
+ }
1174
+ // Synthesized small-caps detection (DM-294). When `font-variant: small-caps`
1175
+ // resolves to the OpenType `smcp` feature but the active font lacks `smcp`
1176
+ // (Helvetica, Arial, SF Pro, Georgia, Times — all the body fonts we hit on
1177
+ // macOS), Chrome falls back to *synthesized* small-caps: lowercase letters
1178
+ // are rendered as uppercase glyphs at a smaller font size while uppercase
1179
+ // letters stay full size. Empirically the scale Chromium uses on Helvetica
1180
+ // at 16px is 11/16 ≈ 0.6875; at 32px it's 22/32 = 0.6875; at 20px it's
1181
+ // 14/20 = 0.700. We approximate with a flat 0.7 — the per-char xOffsets
1182
+ // already encode Chrome's painted positions, so the only thing we choose
1183
+ // is the glyph (uppercase vs lowercase) and its scale; small width drift
1184
+ // per glyph is acceptable.
1185
+ // Synthesis covers all six font-variant-caps modes (DM-294 + DM-444):
1186
+ // small-caps → smcp (lowercase → small-cap scale)
1187
+ // all-small-caps → smcp + c2sc (lowercase + uppercase → small-cap scale)
1188
+ // petite-caps → pcap (lowercase → petite-cap scale)
1189
+ // all-petite-caps → pcap + c2pc (lowercase + uppercase → petite-cap scale)
1190
+ // unicase → unic (uppercase → small-cap scale; lowercase → small-cap)
1191
+ // titling-caps → titl (no synthesis fallback; rely on OT feature or no-op)
1192
+ // The body-text fonts on macOS (Helvetica / Arial / SF Pro / Georgia /
1193
+ // Times / Menlo) lack pcap, c2pc, c2sc, unic, and titl entirely, so the
1194
+ // synthesis path runs whenever any of these is requested.
1195
+ const features_ = features ?? [];
1196
+ const wantSmcp = features_.includes("smcp");
1197
+ const wantC2sc = features_.includes("c2sc");
1198
+ const wantPcap = features_.includes("pcap");
1199
+ const wantC2pc = features_.includes("c2pc");
1200
+ const wantUnic = features_.includes("unic");
1201
+ const availableFeatures = Array.isArray(primaryFont.availableFeatures)
1202
+ ? primaryFont.availableFeatures : [];
1203
+ const hasFeature = (f) => availableFeatures.includes(f);
1204
+ // Determine the synthesized scale for lowercase / uppercase letters under
1205
+ // each variant. `null` means do not transform (keep native glyph at 1.0).
1206
+ // Chromium uses a single synthesis multiplier for ALL caps variants:
1207
+ // `kSmallCapsFontSizeMultiplier = 0.7f` in
1208
+ // third_party/blink/renderer/platform/fonts/simple_font_data.cc. Per CSS
1209
+ // Fonts 4 §7.4, petite-caps falls back to small-caps when the font lacks
1210
+ // pcap / c2pc, and Chrome uses the same 0.7 scale for the synthesized
1211
+ // form (no separate kPetiteCapsFontSizeMultiplier exists). Our macOS body
1212
+ // fonts (Helvetica/Arial/SF Pro/Georgia/Times/Menlo) ship neither pcap
1213
+ // nor c2pc, so the petite path always synthesizes at 0.7 to match Chrome's
1214
+ // painted output. (DM-444 follow-up.)
1215
+ const SMALL_CAP_SCALE = 0.7;
1216
+ let synthLowerScale = null;
1217
+ let synthUpperScale = null;
1218
+ if (wantSmcp && !hasFeature("smcp"))
1219
+ synthLowerScale = SMALL_CAP_SCALE;
1220
+ if (wantC2sc && !hasFeature("c2sc"))
1221
+ synthUpperScale = SMALL_CAP_SCALE;
1222
+ if (wantPcap && !hasFeature("pcap"))
1223
+ synthLowerScale = SMALL_CAP_SCALE;
1224
+ if (wantC2pc && !hasFeature("c2pc"))
1225
+ synthUpperScale = SMALL_CAP_SCALE;
1226
+ if (wantUnic && !hasFeature("unic")) {
1227
+ // unicase: both cases render at small-cap height.
1228
+ synthLowerScale = SMALL_CAP_SCALE;
1229
+ synthUpperScale = SMALL_CAP_SCALE;
1230
+ }
1231
+ const synthSmallCaps = synthLowerScale != null || synthUpperScale != null;
1232
+ const smcpRequested = wantSmcp;
1233
+ void smcpRequested;
1234
+ // Single-run, primary-font path keeps the existing fast path with xOffsets
1235
+ // support and per-char fidelity. Multi-run path falls back to native advances.
1236
+ // When synthesizing small-caps we need per-char rendering at variable scales,
1237
+ // so we route around singleFontMarkup which emits one fixed-scale group.
1238
+ if (runs.length === 1 && runs[0].fontKey === primaryFontKey && !synthSmallCaps) {
1239
+ return singleFontMarkup(runs[0].font, runs[0].fontKey, runs[0].text, weight, fontSize, slant, targetWidth, xOffsets, features);
1240
+ }
1241
+ // Content with captured per-char xOffsets. Primary runs and non-shaping
1242
+ // fallback runs (CJK, hiragana/katakana via cjk, Hebrew, symbols, Menlo)
1243
+ // anchor each glyph at its captured x to preserve subpixel positioning
1244
+ // (SK-1234) AND honor per-char layout decisions Chrome made that fontkit's
1245
+ // native advances would miss (notably ruby-align: space-around distributing
1246
+ // a single rt char to fill its base column — DM-239). Shaping-required
1247
+ // fallbacks (Arabic, Devanagari, Thai) still go through font.layout(runText)
1248
+ // as a unit so contextual joining (init/medi/fina), cluster reordering
1249
+ // (Devanagari i-matra), and ligature substitution (क्ष) survive — fontkit's
1250
+ // shaping for these scripts agrees with Chromium's HarfBuzz to within ~1px
1251
+ // (SK-1237 investigation).
1252
+ if (xOffsets != null && xOffsets.length === text.length) {
1253
+ const groups = [];
1254
+ let rightEdge = 0;
1255
+ for (const run of runs) {
1256
+ const runScale = fontSize / run.font.unitsPerEm;
1257
+ const sc = Number(runScale.toFixed(5));
1258
+ const isShapingRequired = run.fontKey === "sf-arabic"
1259
+ || run.fontKey === "devanagari"
1260
+ || run.fontKey === "thai";
1261
+ if (!isShapingRequired) {
1262
+ // Per-char anchoring — primary runs and any fallback that's 1:1 char→
1263
+ // glyph (no shaping reordering or contextual joining). Each codepoint
1264
+ // shapes individually; placement uses the captured xOffset so we
1265
+ // inherit Chrome's spacing decisions including ruby column-fitting.
1266
+ // When `synthSmallCaps` is on (DM-294 + DM-444), case-fold and
1267
+ // re-scale glyphs per the variant-caps spec. Lowercase letters are
1268
+ // up-cased and rendered at synthLowerScale; uppercase letters
1269
+ // (under c2sc / c2pc / unic) are rendered at synthUpperScale. The
1270
+ // fonts on macOS we hit here all lack the OT features for these
1271
+ // variants, so synthesis is the path Chrome takes too.
1272
+ let i = run.startIdx;
1273
+ while (i < run.endIdx) {
1274
+ const cp = text.codePointAt(i);
1275
+ let ch = String.fromCodePoint(cp);
1276
+ let chScale = sc;
1277
+ if (synthSmallCaps) {
1278
+ const upper = ch.toUpperCase();
1279
+ const isLower = upper !== ch && upper.length === ch.length;
1280
+ const isUpper = !isLower && ch.toLowerCase() !== ch && ch.toLowerCase().length === ch.length;
1281
+ if (isLower && synthLowerScale != null) {
1282
+ ch = upper;
1283
+ chScale = Number((runScale * synthLowerScale).toFixed(5));
1284
+ }
1285
+ else if (isUpper && synthUpperScale != null) {
1286
+ chScale = Number((runScale * synthUpperScale).toFixed(5));
1287
+ }
1288
+ }
1289
+ const layout = features != null && features.length > 0 && !synthSmallCaps
1290
+ ? run.font.layout(ch, features)
1291
+ : run.font.layout(ch);
1292
+ // For emoji codepoints whose layout returns a .notdef tofu (id=0,
1293
+ // hollow rectangle outline), suppress path emission. The capture
1294
+ // layer attached a raster <image> overlay that fills the visual;
1295
+ // emitting the tofu underneath leaves visible black edges around
1296
+ // the emoji where the raster's sub-pixel transparency exposes the
1297
+ // tofu's outline. (DM-334.)
1298
+ const nextI = i + ch.length;
1299
+ const nextCp = nextI < text.length ? text.codePointAt(nextI) : 0;
1300
+ const skipNotdef = isEmojiCodepoint(cp, nextCp) || isPrivateUseCodepoint(cp);
1301
+ const uses = [];
1302
+ for (const g of layout.glyphs) {
1303
+ if (g.path.commands.length > 0 && !(skipNotdef && g.id === 0)) {
1304
+ const defId = ensureGlyphDef(run.fontKey, weight, fontSize, slant, g.id, g.path.commands);
1305
+ uses.push(`<use href="#${defId}" x="0" y="0"/>`);
1306
+ }
1307
+ }
1308
+ if (uses.length > 0) {
1309
+ const cssX = Number(xOffsets[i].toFixed(3));
1310
+ groups.push(`<g transform="translate(${cssX},0) scale(${chScale},${-chScale})">${uses.join("")}</g>`);
1311
+ if (cssX > rightEdge)
1312
+ rightEdge = cssX;
1313
+ }
1314
+ i += ch.length;
1315
+ }
1316
+ }
1317
+ else {
1318
+ // Shaping fallback — shape the whole run together. Anchor at the
1319
+ // visual-leftmost captured x: for LTR that's xOffsets[startIdx], for
1320
+ // RTL that's xOffsets[endIdx-1] (last logical char paints leftmost).
1321
+ // Math.min covers both directions and any embedded BiDi.
1322
+ let runMinX = Infinity;
1323
+ for (let i = run.startIdx; i < run.endIdx; i++) {
1324
+ if (xOffsets[i] < runMinX)
1325
+ runMinX = xOffsets[i];
1326
+ }
1327
+ const layout = features != null && features.length > 0
1328
+ ? run.font.layout(run.text, features)
1329
+ : run.font.layout(run.text);
1330
+ const uses = [];
1331
+ let runFontUnits = 0;
1332
+ for (let gi = 0; gi < layout.glyphs.length; gi++) {
1333
+ const glyph = layout.glyphs[gi];
1334
+ const pos = layout.positions[gi];
1335
+ if (glyph.path.commands.length > 0) {
1336
+ const defId = ensureGlyphDef(run.fontKey, weight, fontSize, slant, glyph.id, glyph.path.commands);
1337
+ const tx = runFontUnits + pos.xOffset;
1338
+ const ty = -pos.yOffset;
1339
+ uses.push(`<use href="#${defId}" x="${r(tx)}" y="${r(ty)}"/>`);
1340
+ }
1341
+ runFontUnits += pos.xAdvance;
1342
+ }
1343
+ if (uses.length > 0) {
1344
+ const cssX = Number(runMinX.toFixed(3));
1345
+ groups.push(`<g transform="translate(${cssX},0) scale(${sc},${-sc})">${uses.join("")}</g>`);
1346
+ const runRight = runMinX + runFontUnits * runScale;
1347
+ if (runRight > rightEdge)
1348
+ rightEdge = runRight;
1349
+ }
1350
+ }
1351
+ }
1352
+ return { markup: groups.join(""), width: rightEdge };
1353
+ }
1354
+ // Multi-font path: emit one <g scale> per run, each at its accumulated CSS-x.
1355
+ const groups = [];
1356
+ let xCss = 0;
1357
+ for (const run of runs) {
1358
+ const runScale = fontSize / run.font.unitsPerEm;
1359
+ const layout = features != null && features.length > 0
1360
+ ? run.font.layout(run.text, features)
1361
+ : run.font.layout(run.text);
1362
+ const uses = [];
1363
+ let runX = 0;
1364
+ for (let i = 0; i < layout.glyphs.length; i++) {
1365
+ const glyph = layout.glyphs[i];
1366
+ const pos = layout.positions[i];
1367
+ if (glyph.path.commands.length > 0) {
1368
+ const defId = ensureGlyphDef(run.fontKey, weight, fontSize, slant, glyph.id, glyph.path.commands);
1369
+ const tx = runX + pos.xOffset;
1370
+ const ty = -pos.yOffset;
1371
+ uses.push(`<use href="#${defId}" x="${r(tx)}" y="${r(ty)}"/>`);
1372
+ }
1373
+ runX += pos.xAdvance;
1374
+ }
1375
+ if (uses.length > 0) {
1376
+ const sc = Number(runScale.toFixed(5));
1377
+ groups.push(`<g transform="translate(${r(xCss)},0) scale(${sc},${-sc})">${uses.join("")}</g>`);
1378
+ }
1379
+ xCss += runX * runScale;
1380
+ }
1381
+ return {
1382
+ markup: groups.length > 0 ? groups.join("") : "",
1383
+ width: xCss,
1384
+ };
1385
+ }
1386
+ /** Original single-font path (unchanged behavior — preserves xOffsets / targetWidth). */
1387
+ function singleFontMarkup(font, fontKey, text, weight, fontSize, slant, targetWidth, xOffsets, features) {
1388
+ const scale = fontSize / font.unitsPerEm;
1389
+ const run = features != null && features.length > 0
1390
+ ? font.layout(text, features)
1391
+ : font.layout(text);
1392
+ let totalAdvance = 0;
1393
+ for (const pos of run.positions)
1394
+ totalAdvance += pos.xAdvance;
1395
+ const nativeWidth = totalAdvance * scale;
1396
+ const xScale = (targetWidth != null && targetWidth > 0 && nativeWidth > 0)
1397
+ ? targetWidth / nativeWidth : 1;
1398
+ // When xOffsets are provided but the layout's glyph count doesn't match the
1399
+ // text length (Helvetica's `liga` feature collapsed `fi`/`fl` into single
1400
+ // glyphs, Apple Chancery's `Th`/`th` ligatures, etc.), the simple per-char
1401
+ // anchoring path can't run. (DM-287 / DM-331). Use the layout's actual
1402
+ // glyph stream — which includes any ligatures the font fired — and anchor
1403
+ // each glyph cluster at its FIRST codepoint's xOffset. Chrome paints
1404
+ // ligature glyphs at the position of the cluster's first char, so this
1405
+ // matches Chrome both for justified text (DM-287, fi/fl) and for
1406
+ // stylistic-ligature fonts where the per-char glyphs differ visibly from
1407
+ // the ligature glyph (DM-331, Apple Chancery's connected Th/th forms).
1408
+ if (xOffsets != null && xOffsets.length !== run.glyphs.length) {
1409
+ const sc = Number(scale.toFixed(5));
1410
+ const uses = [];
1411
+ let textIdx = 0;
1412
+ for (let gi = 0; gi < run.glyphs.length; gi++) {
1413
+ const glyph = run.glyphs[gi];
1414
+ const pos = run.positions[gi];
1415
+ const skipNotdefHere = glyph.id === 0 && glyph.codePoints != null && glyph.codePoints.length > 0
1416
+ && glyph.codePoints.every((cp) => isPrivateUseCodepoint(cp));
1417
+ if (textIdx < xOffsets.length && glyph.path.commands.length > 0 && !skipNotdefHere) {
1418
+ const defId = ensureGlyphDef(fontKey, weight, fontSize, slant, glyph.id, glyph.path.commands);
1419
+ const tx = xOffsets[textIdx] / scale + pos.xOffset;
1420
+ const ty = -pos.yOffset;
1421
+ uses.push(`<use href="#${defId}" x="${r(tx)}" y="${r(ty)}"/>`);
1422
+ }
1423
+ // Advance the text-index cursor by the cluster's char span: each BMP
1424
+ // codepoint in the cluster consumes 1 text index, each astral codepoint
1425
+ // consumes 2 (surrogate pair). Empty codePoints (decomposed glyphs)
1426
+ // count as 1 to keep the cursor moving — good enough for the Latin
1427
+ // ligature cases we hit; Arabic/Devanagari/Thai re-ordering goes
1428
+ // through the multi-font run path, not here.
1429
+ const cps = glyph.codePoints;
1430
+ let span = 0;
1431
+ if (cps != null && cps.length > 0) {
1432
+ for (const cp of cps)
1433
+ span += cp > 0xFFFF ? 2 : 1;
1434
+ }
1435
+ else {
1436
+ span = 1;
1437
+ }
1438
+ textIdx += span;
1439
+ }
1440
+ return {
1441
+ markup: uses.length > 0 ? `<g transform="scale(${sc},${-sc})">${uses.join("")}</g>` : "",
1442
+ width: xOffsets[xOffsets.length - 1] + nativeWidth / Math.max(1, text.length),
1443
+ };
1444
+ }
1445
+ const usePerChar = xOffsets != null && xOffsets.length === run.glyphs.length;
1446
+ const sc = Number(scale.toFixed(5));
1447
+ const uses = [];
1448
+ let x = 0;
1449
+ for (let i = 0; i < run.glyphs.length; i++) {
1450
+ const glyph = run.glyphs[i];
1451
+ const pos = run.positions[i];
1452
+ const skipNotdefHere = glyph.id === 0 && glyph.codePoints != null && glyph.codePoints.length > 0
1453
+ && glyph.codePoints.every((cp) => isPrivateUseCodepoint(cp));
1454
+ if (glyph.path.commands.length > 0 && !skipNotdefHere) {
1455
+ const defId = ensureGlyphDef(fontKey, weight, fontSize, slant, glyph.id, glyph.path.commands);
1456
+ let tx;
1457
+ if (usePerChar) {
1458
+ // Use the fractional CSS x straight from getBoundingClientRect — do
1459
+ // NOT round to integer pixels (SK-1234). Chromium uses subpixel
1460
+ // positioning (positions can be at any fraction like 24.7188px),
1461
+ // and rounding accumulates drift across a line of text — a 43-char
1462
+ // body line averages ~10px of cumulative drift just from rounding.
1463
+ // SVG honors fractional coordinates natively; rasterization is the
1464
+ // SVG renderer's concern. Convert CSS pixels → font units by
1465
+ // dividing by `scale`. pos.xOffset (the font's per-glyph subpixel
1466
+ // offset, in font units) is added in font-unit space.
1467
+ tx = xOffsets[i] / scale + pos.xOffset;
1468
+ }
1469
+ else {
1470
+ tx = (x + pos.xOffset) * xScale;
1471
+ }
1472
+ const ty = -pos.yOffset;
1473
+ uses.push(`<use href="#${defId}" x="${r(tx)}" y="${r(ty)}"/>`);
1474
+ }
1475
+ x += pos.xAdvance;
1476
+ }
1477
+ return {
1478
+ markup: uses.length > 0 ? `<g transform="scale(${sc},${-sc})">${uses.join("")}</g>` : "",
1479
+ width: usePerChar ? (xOffsets[xOffsets.length - 1] + nativeWidth / run.glyphs.length) : (targetWidth ?? nativeWidth),
1480
+ };
1481
+ }
1482
+ /**
1483
+ * Render text as SVG markup using path outlines with <defs>/<use> deduplication.
1484
+ * Returns a <g> element containing <use> references, positioned at (x, y) top.
1485
+ */
1486
+ export function renderTextAsPath(text, x, y, fontSize, fontFamily, fontWeight, fill, _clipPath,
1487
+ /** Chrome's measured text width — used to scale glyph positions for accurate layout */
1488
+ targetWidth,
1489
+ /** Per-char x offsets relative to this text's origin (CSS pixels). */
1490
+ xOffsets,
1491
+ /** CSS font-style; 'italic' / 'oblique' activate SF Pro's slnt axis. */
1492
+ fontStyle,
1493
+ /**
1494
+ * Captured `canvas.measureText().fontBoundingBoxAscent` (px) — distance
1495
+ * from line-box top to baseline as Chrome will paint it. Overrides the
1496
+ * fontkit-derived ascent below, which is HHEA-based and disagrees with
1497
+ * Chrome on macOS for Helvetica/Arial/Times/Georgia/Menlo/Courier (Chrome
1498
+ * uses winAscent there). Per-font metric-selection rules are fragile to
1499
+ * derive but trivial to read from the browser. See SK-1267 / DM-237.
1500
+ */
1501
+ ascentOverride,
1502
+ /**
1503
+ * OpenType feature tags forwarded to fontkit (e.g. ['smcp'] when CSS
1504
+ * `font-variant: small-caps` is in effect on this run). DM-294.
1505
+ */
1506
+ features,
1507
+ /** BCP-47 language tag for locale-aware Han fallback variant routing
1508
+ * (PingFang TC / HK / MO, or Hiragino Kaku for `ja`). DM-394. */
1509
+ lang) {
1510
+ const result = textToPathMarkup(text, fontSize, fontFamily, fontWeight, targetWidth, xOffsets, fontStyle, features, lang);
1511
+ if (result == null || result.markup === "")
1512
+ return null;
1513
+ const weight = parseInt(fontWeight) || 400;
1514
+ const slant = slantForStyle(fontStyle);
1515
+ const font = resolveFont(fontFamily, weight, fontSize, slant);
1516
+ if (font == null)
1517
+ return null;
1518
+ const scale = fontSize / font.unitsPerEm;
1519
+ // Use the captured fontBoundingBoxAscent when available — that's the exact
1520
+ // value Chrome used to position the baseline within the line box. fontkit's
1521
+ // font.ascent (HHEA) is the right answer for SF Pro / SF Mono (where HHEA
1522
+ // = winAscent) but ~5 px too small at fontSize=32 for Helvetica and other
1523
+ // legacy MS fonts on macOS, where Chrome reads winAscent.
1524
+ const ascent = ascentOverride != null ? ascentOverride : Math.round(font.ascent * scale);
1525
+ const baselineY = y + ascent;
1526
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1527
+ return `<g transform="translate(${r(x)},${r(baselineY)})" fill="${fill}" role="img" aria-label="${esc(text)}"><title>${esc(text)}</title>${result.markup}</g>`;
1528
+ }
1529
+ /**
1530
+ * Check if text-to-path conversion is available for a font family.
1531
+ */
1532
+ export function isTextToPathAvailable(fontFamily) {
1533
+ return resolveFont(fontFamily, 400, 14) != null;
1534
+ }
1535
+ /**
1536
+ * Resolve text-decoration line placement from the font's actual `post`/`OS/2`
1537
+ * tables (SK-1236). Chromium uses these same metric tables, so reading them
1538
+ * directly tightens decoration alignment vs the previous fontSize-fraction
1539
+ * approximation — most visible on SF Mono (underline ~0.75px higher than the
1540
+ * generic 15%-of-fontSize estimate) and on large-fontSize text.
1541
+ *
1542
+ * Returns offsets in px from the baseline (sign convention noted on each
1543
+ * field). Falls back to the legacy 15%/30%/95% approximations when the font
1544
+ * fails to resolve.
1545
+ */
1546
+ export function getDecorationMetrics(fontFamily, fontSize, fontWeight, fontStyle,
1547
+ /** CSS `text-decoration-thickness` — when set to a length value (e.g. "5px"),
1548
+ * overrides the auto thickness. Pass `undefined` or `auto` to use the auto
1549
+ * rule. DM-431. */
1550
+ thicknessOverride,
1551
+ /** CSS `text-underline-offset` — when set to a length value, adds this much
1552
+ * EXTRA distance below the baseline (on top of the auto offset). DM-431. */
1553
+ underlineOffsetCss) {
1554
+ const weight = typeof fontWeight === "number" ? fontWeight : (parseInt(fontWeight) || 400);
1555
+ const slant = slantForStyle(fontStyle);
1556
+ const font = resolveFont(fontFamily, weight, fontSize, slant);
1557
+ // Chromium's text-decoration auto rules (verified vs source — see below):
1558
+ // thickness = max(1, fontSize / 10) [text_decoration_info.cc:ComputeDecorationThickness]
1559
+ // underline_gap = max(1, ceil(thickness / 2)) [text_decoration_offset.cc:ComputeUnderlineOffsetAuto]
1560
+ // line_through = 2 * FloatAscent / 3 - thickness / 2 [text_decoration_info.cc]
1561
+ //
1562
+ // BUT: our SVG output is rasterized by Chrome'\\'s SVG painter at consume
1563
+ // time, which uses a different sub-pixel grid + AA distribution than the
1564
+ // HTML text painter that produced the reference PNG (the DM-418 SVG-vs-
1565
+ // HTML rasterization gap). Emitting the source-verified sub-pixel values
1566
+ // (thickness 1.6 + offset 1 for 16 px) measurably regresses the visual
1567
+ // diff vs Chrome'\\'s HTML output by ~10-30% per fixture compared to the
1568
+ // integer-rounded empirical formulas below — the empirical values were
1569
+ // tuned to compensate for the rasterization mismatch.
1570
+ //
1571
+ // Empirical rule: thickness = max(1, ceil(fontSize / 20)). Auto underline
1572
+ // gap = 1.5 * thickness (puts SVG stroke center half-pixel below an
1573
+ // integer pixel boundary so 1px strokes paint a single solid row at small
1574
+ // sizes). Empirically derived via `scripts/probe-text-decorations.mjs`
1575
+ // against rendered Helvetica from 12-32 px. DM-398 / DM-431.
1576
+ const autoThicknessPx = Math.max(1, Math.ceil(fontSize / 20));
1577
+ let thicknessPx = autoThicknessPx;
1578
+ if (thicknessOverride != null && thicknessOverride !== "" && thicknessOverride !== "auto" && thicknessOverride !== "from-font") {
1579
+ const explicit = parseFloat(thicknessOverride);
1580
+ if (!isNaN(explicit) && explicit > 0)
1581
+ thicknessPx = explicit;
1582
+ }
1583
+ let extraUnderlineOffset = 0;
1584
+ if (underlineOffsetCss != null && underlineOffsetCss !== "" && underlineOffsetCss !== "auto") {
1585
+ const v = parseFloat(underlineOffsetCss);
1586
+ if (!isNaN(v))
1587
+ extraUnderlineOffset = v;
1588
+ }
1589
+ const underlineOffsetY = 1.5 * thicknessPx + extraUnderlineOffset;
1590
+ const underlineThickness = thicknessPx;
1591
+ // Empirical strike: stroke top sits at `round(baseline) - round(fontSize / 3)`
1592
+ // (probed at 14 / 22 / 32 px sans-serif / Times / Menlo). The Chromium-
1593
+ // source formula `2 * FloatAscent / 3 - thickness / 2` produces values
1594
+ // ~1.5 px lower (Chromium uses HHEA ascent ~0.77 of em, vs the empirical
1595
+ // 1/3 of em rule). The empirical formula matches Chrome'\\'s SVG-rasterized
1596
+ // output better despite differing from the source HTML rule. DM-398.
1597
+ const strikeoutOffsetY = Math.round(fontSize / 3) + thicknessPx * 0.5;
1598
+ const strikeoutThickness = thicknessPx;
1599
+ // Chromium paints overline with stroke top at the em-box top — i.e.
1600
+ // `round(baseline) - fontSize`. fontkit's HHEA ascent (used previously)
1601
+ // sits ~3 px below this on Helvetica because Chrome uses winAscent for
1602
+ // legacy MS-style fonts on macOS. DM-398.
1603
+ const overlineOffsetY = fontSize - thicknessPx * 0.5;
1604
+ if (font == null) {
1605
+ return {
1606
+ underlineOffsetY, underlineThickness,
1607
+ strikeoutOffsetY, strikeoutThickness,
1608
+ overlineOffsetY, overlineThickness: thicknessPx,
1609
+ };
1610
+ }
1611
+ return {
1612
+ underlineOffsetY, underlineThickness,
1613
+ strikeoutOffsetY, strikeoutThickness,
1614
+ overlineOffsetY, overlineThickness: underlineThickness,
1615
+ };
1616
+ }
1617
+ /**
1618
+ * Compute X-range gaps where the underline rect [decorationCenterY - thickness/2,
1619
+ * decorationCenterY + thickness/2] crosses glyph ink for `text` rendered in
1620
+ * the given font. Returns gaps in the text's local coordinate system (X=0 at
1621
+ * the run's anchor — caller adds segX to translate). Used to honor
1622
+ * `text-decoration-skip-ink: auto` on solid / double underlines, matching
1623
+ * Chromium's `decoration_line_painter.cc::ComputeUnderlineSkipFromIntercepts`.
1624
+ *
1625
+ * Algorithm: shape via fontkit, walk each glyph's path, flatten quadratic /
1626
+ * cubic Beziers to short polylines, find segment-vs-horizontal-line
1627
+ * intersections at the rect's top and bottom Y. Per glyph, the gap spans
1628
+ * `[minIntersectX - pad, maxIntersectX + pad]` where pad = 0.5 * thickness
1629
+ * (matches Chromium's `kIntersectionExtension`).
1630
+ *
1631
+ * `decorationCenterYRel` is in baseline-relative screen coords (positive =
1632
+ * below baseline). Returns `[]` when font isn't resolvable, no glyphs cross
1633
+ * the rect, or shaping throws. (DM-446.)
1634
+ */
1635
+ export function computeSkipInkGaps(text, fontSize, fontFamily, fontWeight, fontStyle, decorationCenterYRel = 0, decorationThickness = 1, features,
1636
+ /** Chromium-measured run width — when set, intercepts are scaled to match
1637
+ * so gaps line up with the painted glyph positions even when fontkit's
1638
+ * layout disagrees with HarfBuzz at sub-px scale. */
1639
+ targetWidth) {
1640
+ const weight = typeof fontWeight === "number" ? fontWeight : (parseInt(fontWeight) || 400);
1641
+ const slant = slantForStyle(fontStyle);
1642
+ const font = resolveFont(fontFamily, weight, fontSize, slant);
1643
+ if (font == null)
1644
+ return [];
1645
+ let layout;
1646
+ try {
1647
+ layout = font.layout(text, features);
1648
+ }
1649
+ catch {
1650
+ return [];
1651
+ }
1652
+ const scale = fontSize / font.unitsPerEm;
1653
+ const yTop = decorationCenterYRel - decorationThickness / 2;
1654
+ const yBot = decorationCenterYRel + decorationThickness / 2;
1655
+ const pad = Math.max(0.5, decorationThickness * 0.5);
1656
+ const rawGaps = [];
1657
+ let xCursor = 0;
1658
+ for (let i = 0; i < layout.glyphs.length; i++) {
1659
+ const glyph = layout.glyphs[i];
1660
+ const pos = layout.positions[i];
1661
+ const glyphX = xCursor + (pos.xOffset || 0) * scale;
1662
+ const range = glyphPathIntercepts(glyph.path, glyphX, scale, yTop, yBot);
1663
+ if (range != null)
1664
+ rawGaps.push([range.minX - pad, range.maxX + pad]);
1665
+ xCursor += pos.xAdvance * scale;
1666
+ }
1667
+ if (rawGaps.length === 0)
1668
+ return [];
1669
+ if (targetWidth != null && xCursor > 0.5 && Math.abs(xCursor - targetWidth) > 0.5) {
1670
+ const factor = targetWidth / xCursor;
1671
+ for (const g of rawGaps) {
1672
+ g[0] *= factor;
1673
+ g[1] *= factor;
1674
+ }
1675
+ }
1676
+ return mergeGaps(rawGaps);
1677
+ }
1678
+ function mergeGaps(gaps) {
1679
+ gaps.sort((a, b) => a[0] - b[0]);
1680
+ const out = [];
1681
+ for (const g of gaps) {
1682
+ const top = out[out.length - 1];
1683
+ if (top != null && g[0] <= top[1])
1684
+ top[1] = Math.max(top[1], g[1]);
1685
+ else
1686
+ out.push([g[0], g[1]]);
1687
+ }
1688
+ return out;
1689
+ }
1690
+ function glyphPathIntercepts(path, glyphX, scale, yTop, yBot) {
1691
+ // fontkit y is up-positive in glyph space; screen y is down-positive. We
1692
+ // express screen y relative to baseline so screenY = -fy * scale and yTop /
1693
+ // yBot come in as baseline-relative (positive = below baseline).
1694
+ let prev = null;
1695
+ let subStart = null;
1696
+ let minX = Infinity;
1697
+ let maxX = -Infinity;
1698
+ function pt(fx, fy) {
1699
+ return { x: glyphX + fx * scale, y: -fy * scale };
1700
+ }
1701
+ function update(x) {
1702
+ if (x < minX)
1703
+ minX = x;
1704
+ if (x > maxX)
1705
+ maxX = x;
1706
+ }
1707
+ function segCheck(a, b) {
1708
+ const ymin = Math.min(a.y, b.y);
1709
+ const ymax = Math.max(a.y, b.y);
1710
+ if (ymax < yTop || ymin > yBot)
1711
+ return;
1712
+ if (a.y >= yTop && a.y <= yBot)
1713
+ update(a.x);
1714
+ if (b.y >= yTop && b.y <= yBot)
1715
+ update(b.x);
1716
+ const dy = b.y - a.y;
1717
+ if (Math.abs(dy) > 1e-9) {
1718
+ const dx = b.x - a.x;
1719
+ const t1 = (yTop - a.y) / dy;
1720
+ if (t1 > 0 && t1 < 1)
1721
+ update(a.x + t1 * dx);
1722
+ const t2 = (yBot - a.y) / dy;
1723
+ if (t2 > 0 && t2 < 1)
1724
+ update(a.x + t2 * dx);
1725
+ }
1726
+ }
1727
+ function quadAt(p0, p1, p2, t) {
1728
+ const u = 1 - t;
1729
+ return { x: u * u * p0.x + 2 * u * t * p1.x + t * t * p2.x,
1730
+ y: u * u * p0.y + 2 * u * t * p1.y + t * t * p2.y };
1731
+ }
1732
+ function cubAt(p0, p1, p2, p3, t) {
1733
+ const u = 1 - t;
1734
+ return { x: u * u * u * p0.x + 3 * u * u * t * p1.x + 3 * u * t * t * p2.x + t * t * t * p3.x,
1735
+ y: u * u * u * p0.y + 3 * u * u * t * p1.y + 3 * u * t * t * p2.y + t * t * t * p3.y };
1736
+ }
1737
+ function flattenQuad(p0, p1, p2) {
1738
+ const STEPS = 8;
1739
+ let last = p0;
1740
+ for (let i = 1; i <= STEPS; i++) {
1741
+ const cur = quadAt(p0, p1, p2, i / STEPS);
1742
+ segCheck(last, cur);
1743
+ last = cur;
1744
+ }
1745
+ }
1746
+ function flattenCubic(p0, p1, p2, p3) {
1747
+ const STEPS = 12;
1748
+ let last = p0;
1749
+ for (let i = 1; i <= STEPS; i++) {
1750
+ const cur = cubAt(p0, p1, p2, p3, i / STEPS);
1751
+ segCheck(last, cur);
1752
+ last = cur;
1753
+ }
1754
+ }
1755
+ for (const cmd of path.commands) {
1756
+ const a = cmd.args;
1757
+ switch (cmd.command) {
1758
+ case "moveTo": {
1759
+ const p = pt(a[0], a[1]);
1760
+ prev = p;
1761
+ subStart = p;
1762
+ break;
1763
+ }
1764
+ case "lineTo": {
1765
+ const p = pt(a[0], a[1]);
1766
+ if (prev)
1767
+ segCheck(prev, p);
1768
+ prev = p;
1769
+ break;
1770
+ }
1771
+ case "quadraticCurveTo": {
1772
+ const c1 = pt(a[0], a[1]);
1773
+ const p = pt(a[2], a[3]);
1774
+ if (prev)
1775
+ flattenQuad(prev, c1, p);
1776
+ prev = p;
1777
+ break;
1778
+ }
1779
+ case "bezierCurveTo": {
1780
+ const c1 = pt(a[0], a[1]);
1781
+ const c2 = pt(a[2], a[3]);
1782
+ const p = pt(a[4], a[5]);
1783
+ if (prev)
1784
+ flattenCubic(prev, c1, c2, p);
1785
+ prev = p;
1786
+ break;
1787
+ }
1788
+ case "closePath": {
1789
+ if (prev != null && subStart != null)
1790
+ segCheck(prev, subStart);
1791
+ prev = subStart;
1792
+ break;
1793
+ }
1794
+ }
1795
+ }
1796
+ if (minX === Infinity)
1797
+ return null;
1798
+ return { minX, maxX };
1799
+ }
1800
+ function r(n) { return Number(n.toFixed(2)).toString(); }