domotion-svg 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/FEATURES.md +102 -0
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/animator.d.ts +158 -0
- package/dist/animator.js +424 -0
- package/dist/animator.test.d.ts +5 -0
- package/dist/animator.test.js +169 -0
- package/dist/border-radius.test.d.ts +1 -0
- package/dist/border-radius.test.js +148 -0
- package/dist/capture.d.ts +193 -0
- package/dist/capture.js +786 -0
- package/dist/chrome.d.ts +45 -0
- package/dist/chrome.js +107 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +512 -0
- package/dist/client/dom.d.ts +10 -0
- package/dist/client/dom.js +17 -0
- package/dist/conic-raster.d.ts +58 -0
- package/dist/conic-raster.js +292 -0
- package/dist/conic-raster.test.d.ts +1 -0
- package/dist/conic-raster.test.js +187 -0
- package/dist/coretext-extractor.test.d.ts +1 -0
- package/dist/coretext-extractor.test.js +94 -0
- package/dist/coretext-helper.d.ts +60 -0
- package/dist/coretext-helper.js +205 -0
- package/dist/cross-origin-font-face.test.d.ts +1 -0
- package/dist/cross-origin-font-face.test.js +107 -0
- package/dist/cursor-overlay.d.ts +123 -0
- package/dist/cursor-overlay.js +207 -0
- package/dist/cursor-overlay.test.d.ts +1 -0
- package/dist/cursor-overlay.test.js +88 -0
- package/dist/dark-mode-capture.test.d.ts +1 -0
- package/dist/dark-mode-capture.test.js +158 -0
- package/dist/dark-mode-form-controls.test.d.ts +1 -0
- package/dist/dark-mode-form-controls.test.js +218 -0
- package/dist/dom-to-svg.d.ts +1016 -0
- package/dist/dom-to-svg.js +7717 -0
- package/dist/embed-remote-images.test.d.ts +1 -0
- package/dist/embed-remote-images.test.js +424 -0
- package/dist/form-controls.d.ts +70 -0
- package/dist/form-controls.js +1151 -0
- package/dist/frame-merge.d.ts +95 -0
- package/dist/frame-merge.js +374 -0
- package/dist/frame-merge.test.d.ts +6 -0
- package/dist/frame-merge.test.js +144 -0
- package/dist/gradients.d.ts +184 -0
- package/dist/gradients.js +937 -0
- package/dist/gradients.test.d.ts +1 -0
- package/dist/gradients.test.js +150 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +7 -0
- package/dist/jsx-runtime.d.ts +27 -0
- package/dist/jsx-runtime.js +96 -0
- package/dist/jsx-runtime.test.d.ts +1 -0
- package/dist/jsx-runtime.test.js +41 -0
- package/dist/kerfjs-imports.test.d.ts +1 -0
- package/dist/kerfjs-imports.test.js +36 -0
- package/dist/mask.test.d.ts +1 -0
- package/dist/mask.test.js +206 -0
- package/dist/optimize.d.ts +12 -0
- package/dist/optimize.js +32 -0
- package/dist/preserve-aspect-ratio.test.d.ts +1 -0
- package/dist/preserve-aspect-ratio.test.js +38 -0
- package/dist/resize-embedded-images.d.ts +33 -0
- package/dist/resize-embedded-images.js +164 -0
- package/dist/resize-embedded-images.test.d.ts +9 -0
- package/dist/resize-embedded-images.test.js +255 -0
- package/dist/stacking-context.test.d.ts +1 -0
- package/dist/stacking-context.test.js +927 -0
- package/dist/text-renderer.d.ts +42 -0
- package/dist/text-renderer.js +608 -0
- package/dist/text-renderer.test.d.ts +1 -0
- package/dist/text-renderer.test.js +150 -0
- package/dist/text-to-path.d.ts +265 -0
- package/dist/text-to-path.js +1800 -0
- package/dist/text-to-path.test.d.ts +1 -0
- package/dist/text-to-path.test.js +570 -0
- package/dist/utils/escapeHtml.d.ts +2 -0
- package/dist/utils/escapeHtml.js +15 -0
- package/dist/webfont-unicode-range.test.d.ts +1 -0
- package/dist/webfont-unicode-range.test.js +174 -0
- package/package.json +55 -0
- package/src/animator.test.ts +179 -0
- package/src/animator.ts +660 -0
- package/src/border-radius.test.ts +160 -0
- package/src/capture.ts +810 -0
- package/src/cli.ts +582 -0
- package/src/conic-raster.test.ts +213 -0
- package/src/conic-raster.ts +309 -0
- package/src/coretext-extractor.test.ts +130 -0
- package/src/coretext-helper.ts +256 -0
- package/src/cross-origin-font-face.test.ts +119 -0
- package/src/cursor-overlay.test.ts +95 -0
- package/src/cursor-overlay.ts +297 -0
- package/src/dark-mode-capture.test.ts +177 -0
- package/src/dark-mode-form-controls.test.ts +228 -0
- package/src/dom-to-svg.ts +8376 -0
- package/src/embed-remote-images.test.ts +461 -0
- package/src/form-controls.ts +1174 -0
- package/src/frame-merge.test.ts +157 -0
- package/src/frame-merge.ts +447 -0
- package/src/globals.d.ts +2 -0
- package/src/gradients.test.ts +175 -0
- package/src/gradients.ts +955 -0
- package/src/index.ts +12 -0
- package/src/kerf-jsx-augmentation.d.ts +36 -0
- package/src/kerfjs-imports.test.tsx +45 -0
- package/src/mask.test.ts +274 -0
- package/src/optimize.ts +34 -0
- package/src/preserve-aspect-ratio.test.ts +49 -0
- package/src/resize-embedded-images.test.ts +292 -0
- package/src/resize-embedded-images.ts +180 -0
- package/src/stacking-context.test.ts +967 -0
- package/src/text-renderer.test.ts +162 -0
- package/src/text-renderer.ts +623 -0
- package/src/text-to-path.test.ts +639 -0
- package/src/text-to-path.ts +1810 -0
- package/src/utils/escapeHtml.ts +16 -0
- package/src/webfont-unicode-range.test.ts +207 -0
|
@@ -0,0 +1,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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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(); }
|