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