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,639 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { computeSkipInkGaps, fallbackFontChain, getDecorationMetrics, pingfangKeyForLang, renderTextAsPath, resolveFontKey } from "./text-to-path.js";
|
|
3
|
+
|
|
4
|
+
// Pinned mappings for the CSS generic-family keywords. These exist to lock
|
|
5
|
+
// the fidelity-critical resolutions Chrome on macOS performs (per Blink's
|
|
6
|
+
// font_cache_mac.mm) — substituting any of these silently shifts every
|
|
7
|
+
// page's text metrics. See DM-236 (monospace was wrongly routed to SF Mono)
|
|
8
|
+
// and SK-1124 (sans-serif was wrongly routed to SF Pro).
|
|
9
|
+
describe("resolveFontKey: generic-family resolution", () => {
|
|
10
|
+
it("routes sans-serif to Helvetica, not SF Pro", () => {
|
|
11
|
+
expect(resolveFontKey("sans-serif")).toBe("helvetica");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("skips bare ui-sans-serif so it falls through to Times (DM-290)", () => {
|
|
15
|
+
// Empirical Chromium probe at 16px: `ui-sans-serif` paints at 376.38px
|
|
16
|
+
// (UA-default Times metrics), not 410.03px (Helvetica). Like the other
|
|
17
|
+
// ui-* keywords, Chromium-on-macOS doesn't recognise this generic and
|
|
18
|
+
// walks past it to the next family in the stack — or falls through to
|
|
19
|
+
// the Standard Font default if it's the only one. Mapping it to
|
|
20
|
+
// Helvetica painted the 20-font-family fixture's `ui-sans-serif` row
|
|
21
|
+
// with sans-serif glyphs while Chrome paints serifs (DM-290 user note).
|
|
22
|
+
expect(resolveFontKey("ui-sans-serif")).toBe("times");
|
|
23
|
+
expect(resolveFontKey("ui-sans-serif, sans-serif")).toBe("helvetica");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("routes monospace to Courier, not SF Mono or Menlo", () => {
|
|
27
|
+
expect(resolveFontKey("monospace")).toBe("courier");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("routes bare ui-monospace / ui-rounded / ui-sans-serif to Times (last-resort fallback)", () => {
|
|
31
|
+
// DM-269: macOS Chrome doesn't recognize ui-monospace / ui-rounded as
|
|
32
|
+
// system fonts — painted T width is 9.77px (Times) and q is 8.0px (Times),
|
|
33
|
+
// not Courier or SF Mono. Chrome falls through to the Standard Font default.
|
|
34
|
+
expect(resolveFontKey("ui-monospace")).toBe("times");
|
|
35
|
+
expect(resolveFontKey("ui-rounded")).toBe("times");
|
|
36
|
+
expect(resolveFontKey("ui-sans-serif")).toBe("times");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("falls through ui-monospace when later names in the chain are valid (DM-302)", () => {
|
|
40
|
+
// CSS like `font: ui-monospace, Menlo, Consolas, monospace` is common —
|
|
41
|
+
// the leading ui-monospace is a hint Chrome doesn't recognize on macOS,
|
|
42
|
+
// and Chrome paints Menlo (the next valid name). Pinning to Times on the
|
|
43
|
+
// ui-monospace keyword would make code editors render in a serif face.
|
|
44
|
+
expect(resolveFontKey("ui-monospace, Menlo, Consolas, monospace")).toBe("menlo");
|
|
45
|
+
expect(resolveFontKey("ui-rounded, Helvetica")).toBe("helvetica");
|
|
46
|
+
expect(resolveFontKey("emoji, sans-serif")).toBe("helvetica");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("routes serif to Times, not Georgia", () => {
|
|
50
|
+
expect(resolveFontKey("serif")).toBe("times");
|
|
51
|
+
expect(resolveFontKey("ui-serif")).toBe("times");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("routes system-ui / BlinkMacSystemFont to SF Pro", () => {
|
|
55
|
+
expect(resolveFontKey("system-ui")).toBe("sf-pro");
|
|
56
|
+
expect(resolveFontKey("BlinkMacSystemFont")).toBe("sf-pro");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("skips bare -apple-system so the next family in the stack matches (DM-291)", () => {
|
|
60
|
+
// Chromium probe at 18px on "greet": `font-family: -apple-system` alone
|
|
61
|
+
// paints at 35.98px (UA-default Times metrics), `font-family: sans-serif`
|
|
62
|
+
// paints at 41.03px (Helvetica), and `font-family: -apple-system,
|
|
63
|
+
// sans-serif` paints at 41.03px — proving Chrome doesn't recognise
|
|
64
|
+
// -apple-system in this build and falls through to the next family. We
|
|
65
|
+
// mirror that by skipping it; the test fixture pinned this stack and the
|
|
66
|
+
// SF Pro glyphs were ~1px wider than Chrome's Helvetica painted output.
|
|
67
|
+
expect(resolveFontKey("-apple-system")).toBe("times");
|
|
68
|
+
expect(resolveFontKey("-apple-system, sans-serif")).toBe("helvetica");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("routes cursive to Apple Chancery (DM-290)", () => {
|
|
72
|
+
// Empirical probe at 16px: Chrome cursive paints at 290.08px which
|
|
73
|
+
// matches Apple Chancery exactly (Snell Roundhand is 263.84px — a
|
|
74
|
+
// ~10% drift if we picked Snell). Author-named "Snell Roundhand" /
|
|
75
|
+
// "Brush Script MT" still get the snell key since those are explicit.
|
|
76
|
+
expect(resolveFontKey("cursive")).toBe("apple-chancery");
|
|
77
|
+
expect(resolveFontKey("Apple Chancery")).toBe("apple-chancery");
|
|
78
|
+
expect(resolveFontKey("Snell Roundhand")).toBe("snell");
|
|
79
|
+
expect(resolveFontKey("Brush Script MT")).toBe("snell");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("routes fantasy to Papyrus (DM-290)", () => {
|
|
83
|
+
// Empirical probe at 16px: Chrome fantasy paints at 313.94px which
|
|
84
|
+
// matches Papyrus exactly. Without this mapping the keyword fell
|
|
85
|
+
// through to Times metrics (292.38px), which is ~7% narrower.
|
|
86
|
+
expect(resolveFontKey("fantasy")).toBe("papyrus");
|
|
87
|
+
expect(resolveFontKey("Papyrus")).toBe("papyrus");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("resolveFontKey: explicit-name resolution", () => {
|
|
92
|
+
it("honors author-named monospace families separately", () => {
|
|
93
|
+
expect(resolveFontKey("Menlo")).toBe("menlo");
|
|
94
|
+
expect(resolveFontKey("Monaco")).toBe("monaco");
|
|
95
|
+
expect(resolveFontKey("Courier")).toBe("courier");
|
|
96
|
+
expect(resolveFontKey("Courier New")).toBe("courier");
|
|
97
|
+
expect(resolveFontKey("SF Mono")).toBe("sf-mono");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("honors author-named sans families separately", () => {
|
|
101
|
+
expect(resolveFontKey("Helvetica")).toBe("helvetica");
|
|
102
|
+
expect(resolveFontKey("Helvetica Neue")).toBe("helvetica");
|
|
103
|
+
expect(resolveFontKey("Arial")).toBe("arial");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("honors author-named serif families separately", () => {
|
|
107
|
+
expect(resolveFontKey("Georgia")).toBe("georgia");
|
|
108
|
+
// DM-330: explicit `Times New Roman` → the Microsoft TNR face (thinner
|
|
109
|
+
// em-dash bar, H=122 in Bold), distinct from `Times`/`serif` which
|
|
110
|
+
// resolve to Apple's `Times.ttc` (H=185 in Bold).
|
|
111
|
+
expect(resolveFontKey("Times New Roman")).toBe("times-new-roman");
|
|
112
|
+
expect(resolveFontKey('"Times New Roman"')).toBe("times-new-roman");
|
|
113
|
+
expect(resolveFontKey("Times")).toBe("times");
|
|
114
|
+
expect(resolveFontKey("serif")).toBe("times");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("is case-insensitive and strips quotes", () => {
|
|
118
|
+
expect(resolveFontKey("MONOSPACE")).toBe("courier");
|
|
119
|
+
expect(resolveFontKey('"Helvetica Neue"')).toBe("helvetica");
|
|
120
|
+
expect(resolveFontKey("'SF Mono'")).toBe("sf-mono");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("routes Chrome-unrecognized generics (math / emoji / fangsong) to Times", () => {
|
|
124
|
+
// DM-269: probed Chrome on macOS — these paint with Times metrics
|
|
125
|
+
// (q=8.0, T=9.77) when used as the only family. The Standard Font default
|
|
126
|
+
// is Times; per-codepoint fallback then routes the glyphs Times lacks
|
|
127
|
+
// (CJK, math alpha, color emoji) to the right block-specific font.
|
|
128
|
+
// `fantasy` was previously in this list but is mapped to Papyrus
|
|
129
|
+
// (DM-290) — see the dedicated test above.
|
|
130
|
+
expect(resolveFontKey("math")).toBe("times");
|
|
131
|
+
expect(resolveFontKey("emoji")).toBe("times");
|
|
132
|
+
expect(resolveFontKey("fangsong")).toBe("times");
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Baseline placement: when CAPTURE_SCRIPT records the browser's
|
|
137
|
+
// canvas.measureText().fontBoundingBoxAscent on the element (DM-237), the
|
|
138
|
+
// renderer must use that value verbatim instead of computing ascent from
|
|
139
|
+
// fontkit's HHEA `font.ascent`. fontkit's HHEA is correct for SF Pro / SF Mono
|
|
140
|
+
// (where HHEA = winAscent) but ~5px too small at fontSize=32 for Helvetica
|
|
141
|
+
// and the other macOS legacy MS fonts (Arial, Times, Georgia, Menlo, Courier),
|
|
142
|
+
// where Chrome reads winAscent. Without the override, headings drift up by an
|
|
143
|
+
// amount proportional to font size.
|
|
144
|
+
describe("renderTextAsPath: ascentOverride threading", () => {
|
|
145
|
+
// Extract the y-coordinate from the outer translate(x,y) on the returned
|
|
146
|
+
// <g> markup. That y is the baseline anchor — exactly the value affected
|
|
147
|
+
// by the override.
|
|
148
|
+
const baselineY = (markup: string | null): number | null => {
|
|
149
|
+
if (markup == null) return null;
|
|
150
|
+
const m = /transform="translate\([^,]+,([^)]+)\)"/.exec(markup);
|
|
151
|
+
return m != null ? parseFloat(m[1]) : null;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
it("uses ascentOverride verbatim for baselineY when provided", () => {
|
|
155
|
+
const top = 100;
|
|
156
|
+
const ascent = 30; // simulates Chrome's fontBoundingBoxAscent for fs=32 Helvetica bold
|
|
157
|
+
const out = renderTextAsPath("Hi", 0, top, 32, "Helvetica", "700", "#000",
|
|
158
|
+
undefined, undefined, undefined, undefined, ascent);
|
|
159
|
+
expect(baselineY(out)).toBe(top + ascent);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("falls back to fontkit ascent when no override given", () => {
|
|
163
|
+
const top = 100;
|
|
164
|
+
// No override — falls back to round(font.ascent * scale). The exact value
|
|
165
|
+
// depends on the resolved font; we just assert the answer is *different*
|
|
166
|
+
// from a clearly-wrong override, so the test fails if both branches end
|
|
167
|
+
// up using the same code.
|
|
168
|
+
const native = renderTextAsPath("Hi", 0, top, 32, "Helvetica", "700", "#000");
|
|
169
|
+
const overridden = renderTextAsPath("Hi", 0, top, 32, "Helvetica", "700", "#000",
|
|
170
|
+
undefined, undefined, undefined, undefined, 30);
|
|
171
|
+
expect(baselineY(native)).not.toBe(baselineY(overridden));
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("scales the override correctly across font sizes", () => {
|
|
175
|
+
// Same font, different sizes → override is applied verbatim, no extra math.
|
|
176
|
+
const a = renderTextAsPath("Hi", 0, 0, 14, "Helvetica", "400", "#000",
|
|
177
|
+
undefined, undefined, undefined, undefined, 13);
|
|
178
|
+
const b = renderTextAsPath("Hi", 0, 0, 50, "Helvetica", "400", "#000",
|
|
179
|
+
undefined, undefined, undefined, undefined, 47);
|
|
180
|
+
expect(baselineY(a)).toBe(13);
|
|
181
|
+
expect(baselineY(b)).toBe(47);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("fallbackFontChain: Geometric/Misc Symbols routing (DM-324 / DM-326)", () => {
|
|
186
|
+
// Chrome on macOS paints chars like ◉◌◐◑ (U+25C9..D1) and ☀☁☂☃ (U+2600..03)
|
|
187
|
+
// at em-square width (18px @18px font-size). HiraginoSansGB-W3 (the "cjk"
|
|
188
|
+
// key) lacks these glyphs entirely; HiraKakuProN-W3 (the "hiragino-jp"
|
|
189
|
+
// key, regular Japanese Hiragino Sans) covers them at em-square width.
|
|
190
|
+
// Without hiragino-jp in the chain the renderer falls all the way through
|
|
191
|
+
// to Apple Symbols whose advances are 11-15px — visibly narrower than
|
|
192
|
+
// Chrome's painted output.
|
|
193
|
+
it("routes the U+25A0..25FF and U+2600..26FF blocks through hiragino-jp before symbols", () => {
|
|
194
|
+
// Geometric Shapes block (U+25A0..25FF) — chars Chrome paints at em-square.
|
|
195
|
+
// Note: ■ □ ● ○ ◆ ◇ are individually carved out to LucidaGrande first
|
|
196
|
+
// (DM-349) because Chrome paints those at proportional 9-13px, not em-square.
|
|
197
|
+
expect(fallbackFontChain(0x25C9)).toEqual(["cjk", "hiragino-jp", "symbols"]);
|
|
198
|
+
expect(fallbackFontChain(0x25CC)).toEqual(["cjk", "hiragino-jp", "symbols"]);
|
|
199
|
+
expect(fallbackFontChain(0x25D0)).toEqual(["cjk", "hiragino-jp", "symbols"]);
|
|
200
|
+
expect(fallbackFontChain(0x25D1)).toEqual(["cjk", "hiragino-jp", "symbols"]);
|
|
201
|
+
// Misc Symbols block (U+2600..26FF).
|
|
202
|
+
expect(fallbackFontChain(0x2600)).toEqual(["cjk", "hiragino-jp", "symbols"]);
|
|
203
|
+
expect(fallbackFontChain(0x2601)).toEqual(["cjk", "hiragino-jp", "symbols"]);
|
|
204
|
+
expect(fallbackFontChain(0x2602)).toEqual(["cjk", "hiragino-jp", "symbols"]);
|
|
205
|
+
expect(fallbackFontChain(0x2603)).toEqual(["cjk", "hiragino-jp", "symbols"]);
|
|
206
|
+
expect(fallbackFontChain(0x2640)).toEqual(["cjk", "hiragino-jp", "symbols"]);
|
|
207
|
+
expect(fallbackFontChain(0x26A5)).toEqual(["cjk", "hiragino-jp", "symbols"]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("routes ■ □ ● ○ ◆ ◇ through LucidaGrande (matches Chrome's narrow paint)", () => {
|
|
211
|
+
// DM-349: empirical xOffset capture in 02-text-symbols showed Chrome
|
|
212
|
+
// paints these at LucidaGrande's proportional advance (9.76 / 10.41 /
|
|
213
|
+
// 13.01 / 11.07 px @18px), not at the em-square 18px Hiragino renders.
|
|
214
|
+
// DM-415 / DM-429 verified this is still the closest visible-shape
|
|
215
|
+
// match in our font set (tried SF NS / AppleSDGothicNeo, both produced
|
|
216
|
+
// visibly larger glyphs than Chrome's painted ink).
|
|
217
|
+
expect(fallbackFontChain(0x25A0)).toEqual(["lucida-grande", "symbols"]); // ■
|
|
218
|
+
expect(fallbackFontChain(0x25A1)).toEqual(["lucida-grande", "symbols"]); // □
|
|
219
|
+
expect(fallbackFontChain(0x25CF)).toEqual(["lucida-grande", "symbols"]); // ●
|
|
220
|
+
expect(fallbackFontChain(0x25CB)).toEqual(["lucida-grande", "symbols"]); // ○
|
|
221
|
+
expect(fallbackFontChain(0x25C6)).toEqual(["lucida-grande", "symbols"]); // ◆
|
|
222
|
+
expect(fallbackFontChain(0x25C7)).toEqual(["lucida-grande", "symbols"]); // ◇
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("Primary-aware CJK fallback (DM-333)", () => {
|
|
227
|
+
// CJK characters routing depends on the primary font's broad style: serif
|
|
228
|
+
// primaries (Apple Times / Times New Roman / Georgia, plus the bare
|
|
229
|
+
// generics that resolve to `times`) get serif CJK glyphs (Songti SC Light)
|
|
230
|
+
// matching Chrome's painted output 100% pixel-exact at 16px on `font-
|
|
231
|
+
// family: serif/fangsong/ui-serif`. Non-serif primaries keep the existing
|
|
232
|
+
// HiraginoSansGB-W3 sans CJK route.
|
|
233
|
+
it("returns ['cjk-serif', 'cjk'] when primary is times / times-new-roman / georgia", () => {
|
|
234
|
+
expect(fallbackFontChain(0x4E00, "times")).toEqual(["cjk-serif", "cjk"]);
|
|
235
|
+
expect(fallbackFontChain(0x4F60, "times-new-roman")).toEqual(["cjk-serif", "cjk"]);
|
|
236
|
+
expect(fallbackFontChain(0x4F60, "georgia")).toEqual(["cjk-serif", "cjk"]);
|
|
237
|
+
// Hiragana / Katakana / Hangul also go through the serif route.
|
|
238
|
+
expect(fallbackFontChain(0x3042, "times")).toEqual(["cjk-serif", "cjk"]);
|
|
239
|
+
expect(fallbackFontChain(0x30A2, "times")).toEqual(["cjk-serif", "cjk"]);
|
|
240
|
+
expect(fallbackFontChain(0xAC00, "times")).toEqual(["cjk-serif", "cjk"]);
|
|
241
|
+
});
|
|
242
|
+
it("routes Han Unified Ideographs through pingfang-sc → cjk for non-serif primaries (DM-388)", () => {
|
|
243
|
+
// U+4F60 is in CJK Unified Ideographs (the 你 in 你好). Sans-serif primary
|
|
244
|
+
// routes through PingFang SC (CoreText extractor) first to match what
|
|
245
|
+
// Chrome paints, with HiraginoSansGB-W3 retained as the fontkit-readable
|
|
246
|
+
// safety net for any glyph PingFang lacks. DM-382 / DM-364 / DM-388.
|
|
247
|
+
expect(fallbackFontChain(0x4F60, "helvetica")).toEqual(["pingfang-sc", "cjk"]);
|
|
248
|
+
expect(fallbackFontChain(0x4F60, "sf-pro")).toEqual(["pingfang-sc", "cjk"]);
|
|
249
|
+
expect(fallbackFontChain(0x4F60, "menlo")).toEqual(["pingfang-sc", "cjk"]);
|
|
250
|
+
// No primaryKey arg → default sans behaviour.
|
|
251
|
+
expect(fallbackFontChain(0x4F60)).toEqual(["pingfang-sc", "cjk"]);
|
|
252
|
+
});
|
|
253
|
+
it("keeps the bare ['cjk'] route for non-Han CJK ranges (Hiragana / Katakana / Hangul)", () => {
|
|
254
|
+
// PingFang routing applies only to Han Unified Ideographs + Ext A + CJK
|
|
255
|
+
// Compatibility Ideographs. Hiragana (3040..309F), Katakana (30A0..30FF),
|
|
256
|
+
// and Hangul (AC00..D7AF) are what HiraginoSansGB / Apple's Hiragino
|
|
257
|
+
// chain paints; they don't go through PingFang.
|
|
258
|
+
expect(fallbackFontChain(0x3042, "helvetica")).toEqual(["cjk"]); // ぁ
|
|
259
|
+
expect(fallbackFontChain(0x30A2, "helvetica")).toEqual(["cjk"]); // ア
|
|
260
|
+
expect(fallbackFontChain(0xAC00, "helvetica")).toEqual(["cjk"]); // 가
|
|
261
|
+
});
|
|
262
|
+
it("routes Han through the lang-matching PingFang variant when lang is set (DM-394)", () => {
|
|
263
|
+
// 你 is U+4F60 — Han ideograph.
|
|
264
|
+
expect(fallbackFontChain(0x4F60, "helvetica", "zh-TW")).toEqual(["pingfang-tc", "pingfang-sc", "cjk"]);
|
|
265
|
+
expect(fallbackFontChain(0x4F60, "helvetica", "zh-Hant")).toEqual(["pingfang-tc", "pingfang-sc", "cjk"]);
|
|
266
|
+
expect(fallbackFontChain(0x4F60, "helvetica", "zh-HK")).toEqual(["pingfang-hk", "pingfang-sc", "cjk"]);
|
|
267
|
+
expect(fallbackFontChain(0x4F60, "helvetica", "zh-MO")).toEqual(["pingfang-mo", "pingfang-sc", "cjk"]);
|
|
268
|
+
// zh-Hant-HK: region wins over script.
|
|
269
|
+
expect(fallbackFontChain(0x4F60, "helvetica", "zh-Hant-HK")).toEqual(["pingfang-hk", "pingfang-sc", "cjk"]);
|
|
270
|
+
// Japanese: there's no PingFang JP — routes through Hiragino Kaku.
|
|
271
|
+
expect(fallbackFontChain(0x4F60, "helvetica", "ja")).toEqual(["hiragino-jp", "cjk"]);
|
|
272
|
+
expect(fallbackFontChain(0x4F60, "helvetica", "ja-JP")).toEqual(["hiragino-jp", "cjk"]);
|
|
273
|
+
// SC / unspecified / non-CJK lang → default PingFang SC.
|
|
274
|
+
expect(fallbackFontChain(0x4F60, "helvetica", "zh-CN")).toEqual(["pingfang-sc", "cjk"]);
|
|
275
|
+
expect(fallbackFontChain(0x4F60, "helvetica", "zh-Hans")).toEqual(["pingfang-sc", "cjk"]);
|
|
276
|
+
expect(fallbackFontChain(0x4F60, "helvetica", "en-US")).toEqual(["pingfang-sc", "cjk"]);
|
|
277
|
+
expect(fallbackFontChain(0x4F60, "helvetica", "")).toEqual(["pingfang-sc", "cjk"]);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe("pingfangKeyForLang BCP-47 mapping (DM-394)", () => {
|
|
282
|
+
it("maps Traditional Chinese region tags to TC", () => {
|
|
283
|
+
expect(pingfangKeyForLang("zh-TW")).toBe("pingfang-tc");
|
|
284
|
+
expect(pingfangKeyForLang("zh-tw")).toBe("pingfang-tc");
|
|
285
|
+
expect(pingfangKeyForLang("zh-Hant")).toBe("pingfang-tc");
|
|
286
|
+
expect(pingfangKeyForLang("zh-Hant-TW")).toBe("pingfang-tc"); // -tw region
|
|
287
|
+
});
|
|
288
|
+
it("maps Hong Kong / Macau region tags to HK / MO", () => {
|
|
289
|
+
expect(pingfangKeyForLang("zh-HK")).toBe("pingfang-hk");
|
|
290
|
+
expect(pingfangKeyForLang("zh-Hant-HK")).toBe("pingfang-hk"); // region beats script
|
|
291
|
+
expect(pingfangKeyForLang("zh-MO")).toBe("pingfang-mo");
|
|
292
|
+
});
|
|
293
|
+
it("maps Japanese tags to hiragino-jp (no PingFang JP exists on macOS)", () => {
|
|
294
|
+
expect(pingfangKeyForLang("ja")).toBe("hiragino-jp");
|
|
295
|
+
expect(pingfangKeyForLang("ja-JP")).toBe("hiragino-jp");
|
|
296
|
+
});
|
|
297
|
+
it("returns null for SC / unspecified / non-CJK / empty (caller falls back to pingfang-sc)", () => {
|
|
298
|
+
expect(pingfangKeyForLang("zh")).toBeNull();
|
|
299
|
+
expect(pingfangKeyForLang("zh-CN")).toBeNull();
|
|
300
|
+
expect(pingfangKeyForLang("zh-Hans")).toBeNull();
|
|
301
|
+
expect(pingfangKeyForLang("zh-SG")).toBeNull(); // Singapore uses simplified
|
|
302
|
+
expect(pingfangKeyForLang("en-US")).toBeNull();
|
|
303
|
+
expect(pingfangKeyForLang("")).toBeNull();
|
|
304
|
+
expect(pingfangKeyForLang(undefined)).toBeNull();
|
|
305
|
+
});
|
|
306
|
+
it("does NOT swap the symbol blocks for serif primaries (only CJK ranges)", () => {
|
|
307
|
+
// Geometric Shapes / Misc Symbols still route through their dedicated
|
|
308
|
+
// chains regardless of primary — those blocks aren't affected by the
|
|
309
|
+
// serif/sans CJK distinction. ■ is one of the LucidaGrande-narrow chars
|
|
310
|
+
// (DM-349), so it stays on its dedicated chain even with a serif primary.
|
|
311
|
+
expect(fallbackFontChain(0x25A0, "times")).toEqual(["lucida-grande", "symbols"]);
|
|
312
|
+
expect(fallbackFontChain(0x25C9, "times")).toEqual(["cjk", "hiragino-jp", "symbols"]);
|
|
313
|
+
expect(fallbackFontChain(0x2600, "times")).toEqual(["cjk", "hiragino-jp", "symbols"]);
|
|
314
|
+
// Arrows ← → ↗ ↙ now route to LucidaGrande regardless of primary
|
|
315
|
+
// (DM-405 — re-probed via CDP, Chrome paints these via LucidaGrande
|
|
316
|
+
// at every size 12 → 32 px, not Hiragino).
|
|
317
|
+
expect(fallbackFontChain(0x2190, "times")).toEqual(["lucida-grande", "symbols"]);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
describe("Math Operators primary-font handling (DM-332)", () => {
|
|
322
|
+
// U+2200..22FF math operators: Chrome on macOS paints chars Apple Times has
|
|
323
|
+
// (≥ ≤ ≠ ≈ ± ÷ × − ∑ √ ∫ ∞) AT TIMES'S advance, NOT at Apple Symbols's. The
|
|
324
|
+
// user's reported difference on ≥ traced to our renderer painting Apple
|
|
325
|
+
// Symbols's ≥ glyph (id=599, advance=10.27px, ascending arrows-style shape)
|
|
326
|
+
// while Chrome paints Apple Times's ≥ glyph (id=149, advance=8.78px, flat
|
|
327
|
+
// baseline). Both glyphs share the same codepoint but the visual forms are
|
|
328
|
+
// very different. STIX Two Math (the obvious candidate for `font-family:
|
|
329
|
+
// math`) is NOT what Chrome uses for any of these operators — STIX advances
|
|
330
|
+
// are 11.52px+ across the board, way wider than Chrome's 8.78px painted ≥.
|
|
331
|
+
//
|
|
332
|
+
// The fix is structural: `times` resolves to Apple Times.ttc (DM-330), which
|
|
333
|
+
// has all of these operator glyphs. The renderer's primary-font-first logic
|
|
334
|
+
// then picks them from Apple Times instead of falling through to the symbols
|
|
335
|
+
// chain. So the `fallbackFontChain` for U+2200..22FF stays empty / unchanged
|
|
336
|
+
// — it only fires when the primary lacks the codepoint (∀ ∇ ∂ ∈ ⊂ ∧ etc.).
|
|
337
|
+
it("ui-serif / math / serif resolves to times (Apple Times has the common operators)", () => {
|
|
338
|
+
// `font-family: math` falls through to the Times default (DM-269 +
|
|
339
|
+
// DM-291), so the math-row primary is `times` which is Apple Times.
|
|
340
|
+
expect(resolveFontKey("math")).toBe("times");
|
|
341
|
+
expect(resolveFontKey("serif")).toBe("times");
|
|
342
|
+
expect(resolveFontKey("ui-serif")).toBe("times");
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
describe("fallbackFontChain: Arrows-block routing (DM-296 / DM-369 / DM-405 / DM-441)", () => {
|
|
347
|
+
// ← → ↑ ↓ — Lucida Grande, per CDP `CSS.getPlatformFontsForNode` (DM-405).
|
|
348
|
+
// Chrome paints these chunky filled arrows; Hiragino's thin outline visibly
|
|
349
|
+
// diverges (DM-296 reverted by DM-405).
|
|
350
|
+
it("routes ← → ↑ ↓ to LucidaGrande (matches Chrome's painted glyph shape)", () => {
|
|
351
|
+
expect(fallbackFontChain(0x2190)).toEqual(["lucida-grande", "symbols"]); // ←
|
|
352
|
+
expect(fallbackFontChain(0x2192)).toEqual(["lucida-grande", "symbols"]); // →
|
|
353
|
+
expect(fallbackFontChain(0x2191)).toEqual(["lucida-grande", "symbols"]); // ↑
|
|
354
|
+
expect(fallbackFontChain(0x2193)).toEqual(["lucida-grande", "symbols"]); // ↓
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// ↗ ↙ — Lucida Grande LACKS these codepoints (verified via fontkit on all
|
|
358
|
+
// four faces of LucidaGrande.ttc). Routing them to "lucida-grande" silently
|
|
359
|
+
// fell through to Apple Symbols at ~10 px advance, half the width Chrome
|
|
360
|
+
// paints. Hiragino Sans GB has them at em-width (16 px @ 16 px font),
|
|
361
|
+
// matching Chrome. (DM-441.)
|
|
362
|
+
it("routes ↗ ↙ to Hiragino — Lucida Grande lacks the diagonal-arrow glyphs", () => {
|
|
363
|
+
expect(fallbackFontChain(0x2197)).toEqual(["cjk", "hiragino-jp", "symbols"]); // ↗
|
|
364
|
+
expect(fallbackFontChain(0x2199)).toEqual(["cjk", "hiragino-jp", "symbols"]); // ↙
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// ↑ ↓ are not at CJK em-square width and not at Apple Symbols' narrow
|
|
368
|
+
// width either — Chrome paints them via LucidaGrande at 14.19px @22px.
|
|
369
|
+
// DM-369: confirmed via fontkit advance probe (LucidaGrande U+2191 id=926
|
|
370
|
+
// = 14.19px, U+2193 id=928 = 14.19px) matching the bounding box that
|
|
371
|
+
// Range.getBoundingClientRect captures from Chrome.
|
|
372
|
+
it("routes ↑ ↓ to LucidaGrande (matches Chrome's painted width)", () => {
|
|
373
|
+
expect(fallbackFontChain(0x2191)).toEqual(["lucida-grande", "symbols"]);
|
|
374
|
+
expect(fallbackFontChain(0x2193)).toEqual(["lucida-grande", "symbols"]);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("keeps the rest of the Arrows block on Apple Symbols", () => {
|
|
378
|
+
expect(fallbackFontChain(0x2194)).toEqual(["symbols"]);
|
|
379
|
+
expect(fallbackFontChain(0x21D2)).toEqual(["symbols"]);
|
|
380
|
+
expect(fallbackFontChain(0x21D4)).toEqual(["symbols"]);
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
describe("ligature handling with captured xOffsets (DM-287 / DM-331)", () => {
|
|
385
|
+
// When font.layout fires ligatures (Helvetica fi/fl, Apple Chancery Th/th),
|
|
386
|
+
// the layout glyph count is shorter than the input text length. The
|
|
387
|
+
// renderer must walk the layout's actual glyph stream — anchoring each
|
|
388
|
+
// cluster at its first codepoint's xOffset — instead of either re-shaping
|
|
389
|
+
// per-char (which loses the ligature glyph) or falling back to native
|
|
390
|
+
// advances (which loses Chrome's captured xOffsets). DM-287 was the
|
|
391
|
+
// original justify-spacing bug; DM-331 was Apple Chancery painting
|
|
392
|
+
// disconnected per-char Th/th instead of the connected ligature glyphs.
|
|
393
|
+
it("emits ligature glyphs when font.layout collapses chars (Apple Chancery Th/th)", () => {
|
|
394
|
+
// 43-char text with two Apple Chancery ligatures: Th at start, th in
|
|
395
|
+
// "the lazy". Chrome captures 43 per-char xOffsets but font.layout
|
|
396
|
+
// returns 41 glyphs. Each of the 2 ligature clusters covers 2
|
|
397
|
+
// codepoints; per Chrome each is anchored at the first char's xOffset.
|
|
398
|
+
const text = "The quick brown fox jumps over the lazy dog";
|
|
399
|
+
const xOffsets: number[] = [];
|
|
400
|
+
// Spread chars at 8px each — exact values don't matter for this test, we
|
|
401
|
+
// just need length === text.length so the ligature path activates.
|
|
402
|
+
for (let i = 0; i < text.length; i++) xOffsets.push(i * 8);
|
|
403
|
+
const out = renderTextAsPath(
|
|
404
|
+
text, 0, 0, 16, "cursive", "400", "#000",
|
|
405
|
+
undefined, undefined, xOffsets,
|
|
406
|
+
);
|
|
407
|
+
expect(out).not.toBeNull();
|
|
408
|
+
// Apple Chancery's Th ligature is glyph id=343, th ligature id=338,
|
|
409
|
+
// and per-char e is id=72. We expect to see exactly one <use> referencing
|
|
410
|
+
// each ligature glyph (anchored at xOffsets[0] = 0 and xOffsets[31] =
|
|
411
|
+
// 248 / scale respectively, but we don't pin the exact tx — just that
|
|
412
|
+
// the ligature glyph defs are present).
|
|
413
|
+
const useCount = (out!.match(/<use href="#g\d+"/g) ?? []).length;
|
|
414
|
+
// 43 chars - 8 spaces - 2 ligature collapses (Th, th) = 33 emitted uses.
|
|
415
|
+
expect(useCount).toBe(33);
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
describe("Emoji codepoints suppress .notdef tofu emission (DM-334)", () => {
|
|
420
|
+
// When a codepoint is one Chrome paints via Apple Color Emoji (✨ 😀 🚀
|
|
421
|
+
// 🌟 🎉 etc.), neither Times nor Apple Symbols nor Zapf Dingbats has a
|
|
422
|
+
// glyph in their path tables — they all return id=0 (the hollow-rectangle
|
|
423
|
+
// .notdef tofu). The capture layer screenshots the page and stamps a
|
|
424
|
+
// raster <image> overlay at the emoji's painted rect, so the path
|
|
425
|
+
// pipeline's tofu rectangle is redundant; emitting it leaves a black
|
|
426
|
+
// silhouette around the edges of the emoji where the raster has
|
|
427
|
+
// sub-pixel transparency. Verify that for emoji codepoints the path
|
|
428
|
+
// pipeline emits NO `<use>` element (the only renderable would be the
|
|
429
|
+
// tofu, and that's now suppressed).
|
|
430
|
+
it("emits no <use> for U+2728 ✨ (Dingbats emoji-presentation)", () => {
|
|
431
|
+
// Render just "✨" with a captured xOffset. Primary=Times → no glyph.
|
|
432
|
+
// Chain is ["zapf-dingbats", "symbols"] — neither has ✨, so picked
|
|
433
|
+
// would be the chain's last entry (symbols) producing tofu. With the
|
|
434
|
+
// emoji-codepoint suppression, the markup is empty and
|
|
435
|
+
// renderTextAsPath returns null (no <g> wrapper for empty content).
|
|
436
|
+
const out = renderTextAsPath(
|
|
437
|
+
"✨", 0, 0, 16, "Times", "400", "#000",
|
|
438
|
+
undefined, undefined, [0],
|
|
439
|
+
);
|
|
440
|
+
expect(out).toBeNull();
|
|
441
|
+
});
|
|
442
|
+
it("emits no <use> for U+1F600 😀 / U+1F680 🚀 (main emoji blocks)", () => {
|
|
443
|
+
const out = renderTextAsPath(
|
|
444
|
+
"😀🚀", 0, 0, 16, "Times", "400", "#000",
|
|
445
|
+
undefined, undefined, [0, 0, 18, 18],
|
|
446
|
+
);
|
|
447
|
+
expect(out).toBeNull();
|
|
448
|
+
});
|
|
449
|
+
it("emits text-but-no-emoji-tofu in mixed runs (Smile 😀)", () => {
|
|
450
|
+
// Mixed text: "Smile 😀" — the "Smile " chars emit Times glyphs, the
|
|
451
|
+
// 😀 codepoint suppresses its tofu. Without the suppression we'd see
|
|
452
|
+
// 7 <use>s (S, m, i, l, e, space, tofu); with it we see 6 (no tofu).
|
|
453
|
+
const out = renderTextAsPath(
|
|
454
|
+
"Smile 😀", 0, 0, 16, "Times", "400", "#000",
|
|
455
|
+
undefined, undefined, [0, 9, 18, 22, 26, 30, 34, 34],
|
|
456
|
+
);
|
|
457
|
+
expect(out).not.toBeNull();
|
|
458
|
+
const useCount = (out!.match(/<use href="#g\d+"/g) ?? []).length;
|
|
459
|
+
expect(useCount).toBe(6);
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
describe("synthesized small-caps (DM-294)", () => {
|
|
464
|
+
// Helvetica/Arial/SF Pro/Times/Georgia all lack the OpenType `smcp` feature,
|
|
465
|
+
// so `font-variant: small-caps` triggers Chrome's synthesized-small-caps
|
|
466
|
+
// path: lowercase letters render as uppercase glyphs at ~0.7× the font
|
|
467
|
+
// size, while uppercase letters stay at full size. The renderer mirrors
|
|
468
|
+
// this when it sees `features: ['smcp']` and the font lacks the feature.
|
|
469
|
+
it("renders lowercase letters as uppercase glyphs at the small-cap scale", () => {
|
|
470
|
+
// Render "abc" at 16px Helvetica with smcp.
|
|
471
|
+
const out = renderTextAsPath(
|
|
472
|
+
"abc", 0, 0, 16, "Helvetica", "400", "#000",
|
|
473
|
+
undefined, undefined, [0, 8, 16], undefined, undefined, ["smcp"],
|
|
474
|
+
);
|
|
475
|
+
expect(out).not.toBeNull();
|
|
476
|
+
// Synth path emits one <g transform="translate(x,0) scale(s,-s)"> per
|
|
477
|
+
// char. With SMALL_CAP_SCALE = 0.7 and 16/2048 unit scale, the per-char
|
|
478
|
+
// scale is 16/2048 * 0.7 ≈ 0.00547. Confirm that we see the small-cap
|
|
479
|
+
// scale on each <g> (not the full-size 0.00781).
|
|
480
|
+
const matches = out!.match(/scale\(([^,]+),/g) ?? [];
|
|
481
|
+
expect(matches.length).toBeGreaterThanOrEqual(3);
|
|
482
|
+
// Outer scale on the wrapper <g transform="translate(x,baselineY)"> is
|
|
483
|
+
// 1, so we look at the inner per-char scales (4 total: 1 outer + 3 char).
|
|
484
|
+
// Each should be ≈ 0.00547 (small-cap), not 0.00781 (full).
|
|
485
|
+
const charScales = matches.slice(1, 4).map((m) => parseFloat(m.replace(/scale\(/, "")));
|
|
486
|
+
for (const s of charScales) {
|
|
487
|
+
expect(s).toBeCloseTo(16 / 2048 * 0.7, 3);
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("keeps uppercase letters at full size in a smcp run", () => {
|
|
492
|
+
// "ABC" all uppercase — synth path must NOT shrink them.
|
|
493
|
+
const out = renderTextAsPath(
|
|
494
|
+
"ABC", 0, 0, 16, "Helvetica", "400", "#000",
|
|
495
|
+
undefined, undefined, [0, 10, 20], undefined, undefined, ["smcp"],
|
|
496
|
+
);
|
|
497
|
+
expect(out).not.toBeNull();
|
|
498
|
+
const matches = out!.match(/scale\(([^,]+),/g) ?? [];
|
|
499
|
+
const charScales = matches.slice(1, 4).map((m) => parseFloat(m.replace(/scale\(/, "")));
|
|
500
|
+
for (const s of charScales) {
|
|
501
|
+
expect(s).toBeCloseTo(16 / 2048, 3);
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
describe("resolveFontKey: chain walking", () => {
|
|
507
|
+
it("picks the first recognized name in the stack", () => {
|
|
508
|
+
expect(resolveFontKey('"DoesNotExist", monospace')).toBe("courier");
|
|
509
|
+
expect(resolveFontKey("DoesNotExist, Helvetica, sans-serif")).toBe("helvetica");
|
|
510
|
+
expect(resolveFontKey("Menlo, Consolas, monospace")).toBe("menlo");
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("falls through to Times when nothing matches (Chrome's macOS Standard Font default)", () => {
|
|
514
|
+
// DM-269: probed Chrome — body with no font-family computes to "Times",
|
|
515
|
+
// and elements declaring an unrecognized family chain fall through to
|
|
516
|
+
// the same Standard Font default. Previously this was Helvetica which
|
|
517
|
+
// was wrong for serif default contexts.
|
|
518
|
+
expect(resolveFontKey("Nothing-Installed-1, Nothing-Installed-2")).toBe("times");
|
|
519
|
+
expect(resolveFontKey("")).toBe("times");
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
describe("getDecorationMetrics: Chrome auto-thickness rule (DM-398)", () => {
|
|
524
|
+
// Empirical formula tuned for SVG rasterization (NOT Chromium's source
|
|
525
|
+
// formula `fontSize / 10` — that one is theoretically correct but produces
|
|
526
|
+
// worse visual match against Chrome'\\'s HTML render due to the SVG-vs-HTML
|
|
527
|
+
// rasterization gap documented in DM-418).
|
|
528
|
+
it("uses 1px stroke for body sizes (≤ 19px)", () => {
|
|
529
|
+
expect(getDecorationMetrics("Helvetica", 12, "400").underlineThickness).toBe(1);
|
|
530
|
+
expect(getDecorationMetrics("Helvetica", 14, "400").underlineThickness).toBe(1);
|
|
531
|
+
expect(getDecorationMetrics("Helvetica", 16, "400").underlineThickness).toBe(1);
|
|
532
|
+
expect(getDecorationMetrics("Helvetica", 18, "400").underlineThickness).toBe(1);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("bumps to 2px stroke at heading sizes (≥ 20px)", () => {
|
|
536
|
+
expect(getDecorationMetrics("Helvetica", 22, "400").underlineThickness).toBe(2);
|
|
537
|
+
expect(getDecorationMetrics("Helvetica", 24, "400").underlineThickness).toBe(2);
|
|
538
|
+
expect(getDecorationMetrics("Helvetica", 32, "400").underlineThickness).toBe(2);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("emits underlineOffsetY = 1.5 × thickness", () => {
|
|
542
|
+
const m14 = getDecorationMetrics("Helvetica", 14, "400");
|
|
543
|
+
expect(m14.underlineOffsetY).toBe(1.5);
|
|
544
|
+
const m22 = getDecorationMetrics("Helvetica", 22, "400");
|
|
545
|
+
expect(m22.underlineOffsetY).toBe(3);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it("emits strikeoutOffsetY ≈ fontSize/3 above baseline", () => {
|
|
549
|
+
const m14 = getDecorationMetrics("Helvetica", 14, "400");
|
|
550
|
+
expect(m14.strikeoutOffsetY).toBe(Math.round(14 / 3) + 0.5);
|
|
551
|
+
const m22 = getDecorationMetrics("Helvetica", 22, "400");
|
|
552
|
+
expect(m22.strikeoutOffsetY).toBe(Math.round(22 / 3) + 1);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it("emits overlineOffsetY ≈ fontSize above baseline (top of em-box)", () => {
|
|
556
|
+
const m14 = getDecorationMetrics("Helvetica", 14, "400");
|
|
557
|
+
expect(m14.overlineOffsetY).toBe(14 - 0.5);
|
|
558
|
+
const m22 = getDecorationMetrics("Helvetica", 22, "400");
|
|
559
|
+
expect(m22.overlineOffsetY).toBe(22 - 1);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it("honors explicit text-decoration-thickness length (DM-431)", () => {
|
|
563
|
+
const m = getDecorationMetrics("Helvetica", 16, "400", undefined, "5px");
|
|
564
|
+
expect(m.underlineThickness).toBe(5);
|
|
565
|
+
expect(m.underlineOffsetY).toBe(7.5);
|
|
566
|
+
expect(m.strikeoutThickness).toBe(5);
|
|
567
|
+
expect(m.overlineOffsetY).toBe(13.5);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it("falls back to auto thickness when text-decoration-thickness is 'auto' or 'from-font' (DM-431)", () => {
|
|
571
|
+
const auto = getDecorationMetrics("Helvetica", 16, "400", undefined, "auto");
|
|
572
|
+
expect(auto.underlineThickness).toBe(1);
|
|
573
|
+
const fromFont = getDecorationMetrics("Helvetica", 16, "400", undefined, "from-font");
|
|
574
|
+
expect(fromFont.underlineThickness).toBe(1);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it("adds explicit text-underline-offset to underlineOffsetY (DM-431)", () => {
|
|
578
|
+
const m = getDecorationMetrics("Helvetica", 16, "400", undefined, undefined, "6px");
|
|
579
|
+
expect(m.underlineOffsetY).toBe(7.5);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it("falls back to auto offset when text-underline-offset is 'auto' (DM-431)", () => {
|
|
583
|
+
const m = getDecorationMetrics("Helvetica", 16, "400", undefined, undefined, "auto");
|
|
584
|
+
expect(m.underlineOffsetY).toBe(1.5);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it("combines explicit thickness + offset overrides (DM-431)", () => {
|
|
588
|
+
const m = getDecorationMetrics("Helvetica", 16, "400", undefined, "5px", "6px");
|
|
589
|
+
expect(m.underlineThickness).toBe(5);
|
|
590
|
+
expect(m.underlineOffsetY).toBe(13.5);
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
describe("computeSkipInkGaps: text-decoration-skip-ink (DM-446)", () => {
|
|
595
|
+
// Underline rect for 18px Helvetica auto thickness sits at +1.5 px from
|
|
596
|
+
// baseline (1.5 * thickness=1). Descender stems on `j p g y` cross this
|
|
597
|
+
// band; ascender-only / x-height-only letters do not.
|
|
598
|
+
const FS = 18;
|
|
599
|
+
const FF = "Helvetica";
|
|
600
|
+
const FW = "400";
|
|
601
|
+
const Y = 1.5;
|
|
602
|
+
const T = 1;
|
|
603
|
+
|
|
604
|
+
it("produces gaps for descender-bearing glyphs", () => {
|
|
605
|
+
const gaps = computeSkipInkGaps("jumping", FS, FF, FW, undefined, Y, T);
|
|
606
|
+
// 'j', 'p', 'g' all have stems crossing the underline band.
|
|
607
|
+
expect(gaps.length).toBeGreaterThanOrEqual(2);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it("produces no gaps for ascender-only / x-height-only text", () => {
|
|
611
|
+
const gaps = computeSkipInkGaps("alone", FS, FF, FW, undefined, Y, T);
|
|
612
|
+
expect(gaps).toEqual([]);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it("merges adjacent / overlapping descender gaps", () => {
|
|
616
|
+
const gaps = computeSkipInkGaps("ggg", FS, FF, FW, undefined, Y, T);
|
|
617
|
+
// Three adjacent 'g' descenders may merge into one gap or stay separate
|
|
618
|
+
// depending on the pad — guarantee non-overlapping output.
|
|
619
|
+
for (let i = 1; i < gaps.length; i++) {
|
|
620
|
+
expect(gaps[i][0]).toBeGreaterThanOrEqual(gaps[i - 1][1]);
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it("returns empty when font cannot be resolved", () => {
|
|
625
|
+
const gaps = computeSkipInkGaps("test", FS, "NotAFontFamily12345", FW, undefined, Y, T);
|
|
626
|
+
expect(gaps).toEqual([]);
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it("scales gaps when targetWidth diverges from fontkit's layout width", () => {
|
|
630
|
+
const baseline = computeSkipInkGaps("jumping", FS, FF, FW, undefined, Y, T);
|
|
631
|
+
if (baseline.length === 0) return;
|
|
632
|
+
const stretched = computeSkipInkGaps("jumping", FS, FF, FW, undefined, Y, T, undefined, 200);
|
|
633
|
+
// With a stretched targetWidth, the gap centers should shift outward
|
|
634
|
+
// proportionally — at minimum, the rightmost gap moves right.
|
|
635
|
+
const lastBaseline = baseline[baseline.length - 1];
|
|
636
|
+
const lastStretched = stretched[stretched.length - 1];
|
|
637
|
+
expect(lastStretched[1]).toBeGreaterThan(lastBaseline[1]);
|
|
638
|
+
});
|
|
639
|
+
});
|