domotion-svg 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/FEATURES.md +102 -0
  2. package/LICENSE +21 -0
  3. package/README.md +66 -0
  4. package/dist/animator.d.ts +158 -0
  5. package/dist/animator.js +424 -0
  6. package/dist/animator.test.d.ts +5 -0
  7. package/dist/animator.test.js +169 -0
  8. package/dist/border-radius.test.d.ts +1 -0
  9. package/dist/border-radius.test.js +148 -0
  10. package/dist/capture.d.ts +193 -0
  11. package/dist/capture.js +786 -0
  12. package/dist/chrome.d.ts +45 -0
  13. package/dist/chrome.js +107 -0
  14. package/dist/cli.d.ts +16 -0
  15. package/dist/cli.js +512 -0
  16. package/dist/client/dom.d.ts +10 -0
  17. package/dist/client/dom.js +17 -0
  18. package/dist/conic-raster.d.ts +58 -0
  19. package/dist/conic-raster.js +292 -0
  20. package/dist/conic-raster.test.d.ts +1 -0
  21. package/dist/conic-raster.test.js +187 -0
  22. package/dist/coretext-extractor.test.d.ts +1 -0
  23. package/dist/coretext-extractor.test.js +94 -0
  24. package/dist/coretext-helper.d.ts +60 -0
  25. package/dist/coretext-helper.js +205 -0
  26. package/dist/cross-origin-font-face.test.d.ts +1 -0
  27. package/dist/cross-origin-font-face.test.js +107 -0
  28. package/dist/cursor-overlay.d.ts +123 -0
  29. package/dist/cursor-overlay.js +207 -0
  30. package/dist/cursor-overlay.test.d.ts +1 -0
  31. package/dist/cursor-overlay.test.js +88 -0
  32. package/dist/dark-mode-capture.test.d.ts +1 -0
  33. package/dist/dark-mode-capture.test.js +158 -0
  34. package/dist/dark-mode-form-controls.test.d.ts +1 -0
  35. package/dist/dark-mode-form-controls.test.js +218 -0
  36. package/dist/dom-to-svg.d.ts +1016 -0
  37. package/dist/dom-to-svg.js +7717 -0
  38. package/dist/embed-remote-images.test.d.ts +1 -0
  39. package/dist/embed-remote-images.test.js +424 -0
  40. package/dist/form-controls.d.ts +70 -0
  41. package/dist/form-controls.js +1151 -0
  42. package/dist/frame-merge.d.ts +95 -0
  43. package/dist/frame-merge.js +374 -0
  44. package/dist/frame-merge.test.d.ts +6 -0
  45. package/dist/frame-merge.test.js +144 -0
  46. package/dist/gradients.d.ts +184 -0
  47. package/dist/gradients.js +937 -0
  48. package/dist/gradients.test.d.ts +1 -0
  49. package/dist/gradients.test.js +150 -0
  50. package/dist/index.d.ts +12 -0
  51. package/dist/index.js +7 -0
  52. package/dist/jsx-runtime.d.ts +27 -0
  53. package/dist/jsx-runtime.js +96 -0
  54. package/dist/jsx-runtime.test.d.ts +1 -0
  55. package/dist/jsx-runtime.test.js +41 -0
  56. package/dist/kerfjs-imports.test.d.ts +1 -0
  57. package/dist/kerfjs-imports.test.js +36 -0
  58. package/dist/mask.test.d.ts +1 -0
  59. package/dist/mask.test.js +206 -0
  60. package/dist/optimize.d.ts +12 -0
  61. package/dist/optimize.js +32 -0
  62. package/dist/preserve-aspect-ratio.test.d.ts +1 -0
  63. package/dist/preserve-aspect-ratio.test.js +38 -0
  64. package/dist/resize-embedded-images.d.ts +33 -0
  65. package/dist/resize-embedded-images.js +164 -0
  66. package/dist/resize-embedded-images.test.d.ts +9 -0
  67. package/dist/resize-embedded-images.test.js +255 -0
  68. package/dist/stacking-context.test.d.ts +1 -0
  69. package/dist/stacking-context.test.js +927 -0
  70. package/dist/text-renderer.d.ts +42 -0
  71. package/dist/text-renderer.js +608 -0
  72. package/dist/text-renderer.test.d.ts +1 -0
  73. package/dist/text-renderer.test.js +150 -0
  74. package/dist/text-to-path.d.ts +265 -0
  75. package/dist/text-to-path.js +1800 -0
  76. package/dist/text-to-path.test.d.ts +1 -0
  77. package/dist/text-to-path.test.js +570 -0
  78. package/dist/utils/escapeHtml.d.ts +2 -0
  79. package/dist/utils/escapeHtml.js +15 -0
  80. package/dist/webfont-unicode-range.test.d.ts +1 -0
  81. package/dist/webfont-unicode-range.test.js +174 -0
  82. package/package.json +55 -0
  83. package/src/animator.test.ts +179 -0
  84. package/src/animator.ts +660 -0
  85. package/src/border-radius.test.ts +160 -0
  86. package/src/capture.ts +810 -0
  87. package/src/cli.ts +582 -0
  88. package/src/conic-raster.test.ts +213 -0
  89. package/src/conic-raster.ts +309 -0
  90. package/src/coretext-extractor.test.ts +130 -0
  91. package/src/coretext-helper.ts +256 -0
  92. package/src/cross-origin-font-face.test.ts +119 -0
  93. package/src/cursor-overlay.test.ts +95 -0
  94. package/src/cursor-overlay.ts +297 -0
  95. package/src/dark-mode-capture.test.ts +177 -0
  96. package/src/dark-mode-form-controls.test.ts +228 -0
  97. package/src/dom-to-svg.ts +8376 -0
  98. package/src/embed-remote-images.test.ts +461 -0
  99. package/src/form-controls.ts +1174 -0
  100. package/src/frame-merge.test.ts +157 -0
  101. package/src/frame-merge.ts +447 -0
  102. package/src/globals.d.ts +2 -0
  103. package/src/gradients.test.ts +175 -0
  104. package/src/gradients.ts +955 -0
  105. package/src/index.ts +12 -0
  106. package/src/kerf-jsx-augmentation.d.ts +36 -0
  107. package/src/kerfjs-imports.test.tsx +45 -0
  108. package/src/mask.test.ts +274 -0
  109. package/src/optimize.ts +34 -0
  110. package/src/preserve-aspect-ratio.test.ts +49 -0
  111. package/src/resize-embedded-images.test.ts +292 -0
  112. package/src/resize-embedded-images.ts +180 -0
  113. package/src/stacking-context.test.ts +967 -0
  114. package/src/text-renderer.test.ts +162 -0
  115. package/src/text-renderer.ts +623 -0
  116. package/src/text-to-path.test.ts +639 -0
  117. package/src/text-to-path.ts +1810 -0
  118. package/src/utils/escapeHtml.ts +16 -0
  119. package/src/webfont-unicode-range.test.ts +207 -0
@@ -0,0 +1,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
+ });