@navaramap/font 0.0.0

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.
@@ -0,0 +1,2087 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+
3
+ import { FontManager, createSdfAtlasTexture } from "./FontManager";
4
+ import type {
5
+ BatchPrepareTextResult,
6
+ FontFamily,
7
+ ShapeTextResult,
8
+ } from "./types";
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Mocks
12
+ // ---------------------------------------------------------------------------
13
+
14
+ const mocks = vi.hoisted(() => ({
15
+ mockReady: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
16
+ // `cmapRanges: null` = cmap unknown, so declared face ranges stay as-is.
17
+ mockLoadFont: vi
18
+ .fn<
19
+ (url: string) => Promise<{ ok: boolean; cmapRanges: Uint32Array | null }>
20
+ >()
21
+ .mockResolvedValue({ ok: true, cmapRanges: null }),
22
+ mockUnloadFont: vi
23
+ .fn<() => Promise<{ ok: boolean }>>()
24
+ .mockResolvedValue({ ok: true }),
25
+ mockPrepareTextBatch: vi.fn(),
26
+ mockClientDispose: vi.fn(),
27
+ FontWorkerClient: vi.fn(),
28
+ }));
29
+
30
+ vi.mock("./FontWorkerClient", () => ({
31
+ FontWorkerClient: mocks.FontWorkerClient.mockImplementation(function () {
32
+ return {
33
+ ready: mocks.mockReady,
34
+ loadFont: mocks.mockLoadFont,
35
+ unloadFont: mocks.mockUnloadFont,
36
+ prepareTextBatch: mocks.mockPrepareTextBatch,
37
+ dispose: mocks.mockClientDispose,
38
+ };
39
+ }),
40
+ }));
41
+
42
+ vi.mock("three", () => {
43
+ class MockDataTexture {
44
+ image: { data: Uint8Array; width: number; height: number };
45
+ minFilter = 0;
46
+ magFilter = 0;
47
+ generateMipmaps = true;
48
+ needsUpdate = false;
49
+ disposed = false;
50
+
51
+ constructor(
52
+ data: Uint8Array,
53
+ width: number,
54
+ height: number,
55
+ _format?: number,
56
+ _type?: number,
57
+ ) {
58
+ this.image = { data, width, height };
59
+ }
60
+
61
+ dispose() {
62
+ this.disposed = true;
63
+ }
64
+ }
65
+
66
+ return {
67
+ DataTexture: MockDataTexture,
68
+ LinearFilter: 1006,
69
+ NoColorSpace: "",
70
+ RedFormat: 1028,
71
+ RGBAFormat: 1023,
72
+ SRGBColorSpace: "srgb",
73
+ UnsignedByteType: 1009,
74
+ };
75
+ });
76
+
77
+ const mockFetch = vi.fn();
78
+ vi.stubGlobal("fetch", mockFetch);
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Helpers
82
+ // ---------------------------------------------------------------------------
83
+
84
+ function createMockConcurrencyManager() {
85
+ return { increment: vi.fn(), decrement: vi.fn() };
86
+ }
87
+
88
+ /** Latin + CJK two-face family. */
89
+ function createTestFamily(): FontFamily {
90
+ return {
91
+ family: "TestFamily",
92
+ faces: [
93
+ {
94
+ url: "https://fonts.test/latin.ttf",
95
+ unicodeRanges: [{ from: 0x0000, to: 0x00ff }],
96
+ },
97
+ {
98
+ url: "https://fonts.test/cjk.ttf",
99
+ unicodeRanges: [{ from: 0x4e00, to: 0x9fff }],
100
+ },
101
+ ],
102
+ };
103
+ }
104
+
105
+ function mockFetchSuccess() {
106
+ mockFetch.mockResolvedValue({
107
+ ok: true,
108
+ arrayBuffer: () => Promise.resolve(new ArrayBuffer(100)),
109
+ });
110
+ }
111
+
112
+ function makeCompositeKey(fontIndex: number, glyphId: number): bigint {
113
+ return (BigInt(fontIndex) << 32n) | BigInt(glyphId);
114
+ }
115
+
116
+ function createShapeResult(text: string, unitsPerEm = 1000): ShapeTextResult {
117
+ return {
118
+ glyphs: [...text].map((_, i) => ({
119
+ glyphId: i + 1,
120
+ fontIndex: 0,
121
+ compositeKey: makeCompositeKey(0, i + 1),
122
+ xAdvance: 500,
123
+ yAdvance: 0,
124
+ xOffset: 0,
125
+ yOffset: 0,
126
+ charClass: 0,
127
+ })),
128
+ metrics: [...text].map((_, i) => ({
129
+ glyphId: i + 1,
130
+ fontIndex: 0,
131
+ compositeKey: makeCompositeKey(0, i + 1),
132
+ atlasX: i * 32,
133
+ atlasY: 0,
134
+ atlasW: 32,
135
+ atlasH: 32,
136
+ bearingX: 0,
137
+ bearingY: 32,
138
+ isColor: false,
139
+ })),
140
+ unitsPerEm,
141
+ ascender: 800,
142
+ descender: -200,
143
+ lineGap: 0,
144
+ };
145
+ }
146
+
147
+ /** Set the prepareTextBatch mock to return shape results with a given atlasKey.
148
+ * The override is automatically qualified with `#q=false` so it matches what the
149
+ * real worker would store under the FontManager's qualified atlas keys. */
150
+ function setupBatchMock(atlasKeyOverride?: string) {
151
+ mocks.mockPrepareTextBatch.mockImplementation(
152
+ async (
153
+ fontUrl: string,
154
+ texts: string[],
155
+ ): Promise<BatchPrepareTextResult> => ({
156
+ results: texts.map((text) => ({
157
+ text,
158
+ shapeResult: createShapeResult(text),
159
+ })),
160
+ atlas: {
161
+ data: new Uint8Array([1, 2, 3, 4]),
162
+ width: 256,
163
+ height: 256,
164
+ channels: 1,
165
+ },
166
+ colorAtlas: null,
167
+ atlasKey: atlasKeyOverride ? `${atlasKeyOverride}#q=false` : fontUrl,
168
+ }),
169
+ );
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Tests
174
+ // ---------------------------------------------------------------------------
175
+
176
+ const FONT_URL = "https://fonts.test/test.ttf";
177
+ const WORKER_URL = "https://worker.test/font-worker.js";
178
+
179
+ describe("createSdfAtlasTexture", () => {
180
+ it("should create a DataTexture with correct dimensions and data", () => {
181
+ const data = new Uint8Array([10, 20, 30, 40]);
182
+ const tex = createSdfAtlasTexture(data, 2, 2, 1);
183
+
184
+ expect(tex.image.data).toBe(data);
185
+ expect(tex.image.width).toBe(2);
186
+ expect(tex.image.height).toBe(2);
187
+ });
188
+
189
+ it("should disable mipmaps and mark as needing update", () => {
190
+ const tex = createSdfAtlasTexture(new Uint8Array(4), 2, 2, 1);
191
+
192
+ expect(tex.generateMipmaps).toBe(false);
193
+ expect(tex.needsUpdate).toBe(true);
194
+ });
195
+ });
196
+
197
+ describe("FontManager", () => {
198
+ let manager: FontManager;
199
+
200
+ beforeEach(() => {
201
+ vi.clearAllMocks();
202
+ // Restore the default in case a test overrode the implementation.
203
+ mocks.mockLoadFont.mockResolvedValue({ ok: true, cmapRanges: null });
204
+ manager = new FontManager(WORKER_URL);
205
+ manager.setConcurrencyManager(createMockConcurrencyManager() as never);
206
+ mockFetchSuccess();
207
+ setupBatchMock();
208
+ });
209
+
210
+ afterEach(() => {
211
+ manager.dispose();
212
+ });
213
+
214
+ // -----------------------------------------------------------------------
215
+ // Atlas eviction notifications
216
+ // -----------------------------------------------------------------------
217
+
218
+ describe("onAtlasEvicted", () => {
219
+ const evictingBatch = () => {
220
+ mocks.mockPrepareTextBatch.mockImplementation(
221
+ async (
222
+ fontUrl: string,
223
+ texts: string[],
224
+ ): Promise<BatchPrepareTextResult> => ({
225
+ results: texts.map((text) => ({
226
+ text,
227
+ shapeResult: createShapeResult(text),
228
+ })),
229
+ atlas: {
230
+ data: new Uint8Array([1]),
231
+ width: 256,
232
+ height: 256,
233
+ channels: 1,
234
+ },
235
+ colorAtlas: null,
236
+ atlasKey: fontUrl,
237
+ evicted: true,
238
+ }),
239
+ );
240
+ };
241
+
242
+ it("notifies subscribers (coalesced) when an atlas evicts", async () => {
243
+ await manager.loadFont(FONT_URL, false);
244
+ const listener = vi.fn();
245
+ manager.onAtlasEvicted(FONT_URL, false, listener);
246
+ evictingBatch();
247
+
248
+ await manager.prepareText(FONT_URL, "hello", false);
249
+ await Promise.resolve(); // drain the coalescing microtask
250
+
251
+ expect(listener).toHaveBeenCalledTimes(1);
252
+ });
253
+
254
+ it("does not notify when nothing was evicted", async () => {
255
+ await manager.loadFont(FONT_URL, false);
256
+ const listener = vi.fn();
257
+ manager.onAtlasEvicted(FONT_URL, false, listener);
258
+ // beforeEach's default mock reports no eviction.
259
+
260
+ await manager.prepareText(FONT_URL, "hello", false);
261
+ await Promise.resolve();
262
+
263
+ expect(listener).not.toHaveBeenCalled();
264
+ });
265
+
266
+ it("stops notifying after unsubscribe", async () => {
267
+ await manager.loadFont(FONT_URL, false);
268
+ const listener = vi.fn();
269
+ const off = manager.onAtlasEvicted(FONT_URL, false, listener);
270
+ off();
271
+ evictingBatch();
272
+
273
+ await manager.prepareText(FONT_URL, "world", false);
274
+ await Promise.resolve();
275
+
276
+ expect(listener).not.toHaveBeenCalled();
277
+ });
278
+ });
279
+
280
+ // -----------------------------------------------------------------------
281
+ // Font family registration
282
+ // -----------------------------------------------------------------------
283
+
284
+ describe("registerFontFamily", () => {
285
+ it("should register a family that is then recognized by isFamily", () => {
286
+ manager.registerFontFamily(createTestFamily());
287
+ expect(manager.isFamily("TestFamily")).toBe(true);
288
+ });
289
+
290
+ it("should allow overwriting a family with the same name", () => {
291
+ manager.registerFontFamily(createTestFamily());
292
+ manager.registerFontFamily({
293
+ family: "TestFamily",
294
+ faces: [
295
+ {
296
+ url: "https://fonts.test/other.ttf",
297
+ unicodeRanges: [{ from: 0, to: 0xffff }],
298
+ },
299
+ ],
300
+ });
301
+ expect(manager.isFamily("TestFamily")).toBe(true);
302
+ });
303
+ });
304
+
305
+ describe("unregisterFontFamily", () => {
306
+ it("should remove a registered family", () => {
307
+ manager.registerFontFamily(createTestFamily());
308
+ manager.unregisterFontFamily("TestFamily");
309
+ expect(manager.isFamily("TestFamily")).toBe(false);
310
+ });
311
+
312
+ it("should be a no-op for unknown family names", () => {
313
+ expect(() => manager.unregisterFontFamily("Unknown")).not.toThrow();
314
+ });
315
+ });
316
+
317
+ describe("isFamily", () => {
318
+ it("should return false for unregistered identifiers", () => {
319
+ expect(manager.isFamily("Nope")).toBe(false);
320
+ });
321
+
322
+ it("should return true after registration", () => {
323
+ manager.registerFontFamily(createTestFamily());
324
+ expect(manager.isFamily("TestFamily")).toBe(true);
325
+ });
326
+
327
+ it("should return false after unregistration", () => {
328
+ manager.registerFontFamily(createTestFamily());
329
+ manager.unregisterFontFamily("TestFamily");
330
+ expect(manager.isFamily("TestFamily")).toBe(false);
331
+ });
332
+ });
333
+
334
+ // -----------------------------------------------------------------------
335
+ // loadFont
336
+ // -----------------------------------------------------------------------
337
+
338
+ describe("loadFont", () => {
339
+ it("should fetch the font file and pass it to the worker", async () => {
340
+ await manager.loadFont(FONT_URL, false);
341
+
342
+ expect(mockFetch).toHaveBeenCalledWith(FONT_URL);
343
+ expect(mocks.mockLoadFont).toHaveBeenCalledWith(
344
+ FONT_URL + "#q=false",
345
+ expect.any(ArrayBuffer),
346
+ undefined,
347
+ false,
348
+ );
349
+ });
350
+
351
+ it("should forward atlasKey to the worker", async () => {
352
+ await manager.loadFont(FONT_URL, false, "SharedAtlas");
353
+
354
+ expect(mocks.mockLoadFont).toHaveBeenCalledWith(
355
+ FONT_URL + "#q=false",
356
+ expect.any(ArrayBuffer),
357
+ "SharedAtlas#q=false",
358
+ false,
359
+ );
360
+ });
361
+
362
+ it("should skip fetching if the font is already loaded", async () => {
363
+ await manager.loadFont(FONT_URL, false);
364
+ mockFetch.mockClear();
365
+
366
+ await manager.loadFont(FONT_URL, false);
367
+
368
+ expect(mockFetch).not.toHaveBeenCalled();
369
+ });
370
+
371
+ it("should deduplicate concurrent requests for the same URL", async () => {
372
+ const p1 = manager.loadFont(FONT_URL, false);
373
+ const p2 = manager.loadFont(FONT_URL, false);
374
+ await Promise.all([p1, p2]);
375
+
376
+ expect(mockFetch).toHaveBeenCalledTimes(1);
377
+ });
378
+
379
+ it("should allow loading different URLs concurrently", async () => {
380
+ const p1 = manager.loadFont("https://fonts.test/a.ttf", false);
381
+ const p2 = manager.loadFont("https://fonts.test/b.ttf", false);
382
+ await Promise.all([p1, p2]);
383
+
384
+ expect(mockFetch).toHaveBeenCalledTimes(2);
385
+ });
386
+
387
+ it("should throw when fetch returns a non-OK response", async () => {
388
+ mockFetch.mockResolvedValue({ ok: false, status: 404 });
389
+
390
+ await expect(manager.loadFont(FONT_URL, false)).rejects.toThrow(
391
+ "failed to fetch font",
392
+ );
393
+ });
394
+
395
+ it("should throw when the worker rejects the font", async () => {
396
+ mocks.mockLoadFont.mockResolvedValueOnce({ ok: false, cmapRanges: null });
397
+
398
+ await expect(manager.loadFont(FONT_URL, false)).rejects.toThrow(
399
+ "WASM failed to load font",
400
+ );
401
+ });
402
+
403
+ it("should clean up state on error so a retry can succeed", async () => {
404
+ mockFetch.mockResolvedValueOnce({ ok: false, status: 500 });
405
+ await expect(manager.loadFont(FONT_URL, false)).rejects.toThrow();
406
+
407
+ // Retry should fetch again
408
+ mockFetchSuccess();
409
+ await manager.loadFont(FONT_URL, false);
410
+
411
+ expect(mockFetch).toHaveBeenCalledTimes(2);
412
+ });
413
+ });
414
+
415
+ // -----------------------------------------------------------------------
416
+ // unloadFont
417
+ // -----------------------------------------------------------------------
418
+
419
+ describe("unloadFont", () => {
420
+ it("should tell the worker to unload when ref count reaches 0", async () => {
421
+ await manager.loadFont(FONT_URL, false);
422
+ await manager.unloadFont(FONT_URL, false);
423
+
424
+ expect(mocks.mockUnloadFont).toHaveBeenCalledWith(FONT_URL + "#q=false");
425
+ });
426
+
427
+ it("should only decrement ref count when refs > 1", async () => {
428
+ await manager.loadFont(FONT_URL, false);
429
+ await manager.loadFont(FONT_URL, false); // refCount = 2
430
+
431
+ await manager.unloadFont(FONT_URL, false); // refCount → 1
432
+
433
+ expect(mocks.mockUnloadFont).not.toHaveBeenCalled();
434
+ });
435
+
436
+ it("should fully unload after all refs are released", async () => {
437
+ await manager.loadFont(FONT_URL, false);
438
+ await manager.loadFont(FONT_URL, false);
439
+
440
+ await manager.unloadFont(FONT_URL, false);
441
+ await manager.unloadFont(FONT_URL, false);
442
+
443
+ expect(mocks.mockUnloadFont).toHaveBeenCalledTimes(1);
444
+ });
445
+
446
+ it("should be a no-op for unknown font URLs", async () => {
447
+ await manager.unloadFont("https://fonts.test/unknown.ttf", false);
448
+ expect(mocks.mockUnloadFont).not.toHaveBeenCalled();
449
+ });
450
+
451
+ it("should dispose the texture when the last font sharing an atlas is unloaded", async () => {
452
+ setupBatchMock("shared");
453
+ await manager.loadFont("https://fonts.test/a.ttf", false, "shared");
454
+
455
+ await manager.prepareText("https://fonts.test/a.ttf", "hi", false);
456
+ const tex = manager.getAtlasTexture("https://fonts.test/a.ttf", false);
457
+ expect(tex).not.toBeNull();
458
+
459
+ await manager.unloadFont("https://fonts.test/a.ttf", false);
460
+
461
+ expect((tex as unknown as Record<string, unknown>).disposed).toBe(true);
462
+ });
463
+
464
+ it("should NOT dispose the texture when another font still shares the atlas", async () => {
465
+ setupBatchMock("shared");
466
+ await manager.loadFont("https://fonts.test/a.ttf", false, "shared");
467
+ await manager.loadFont("https://fonts.test/b.ttf", false, "shared");
468
+
469
+ await manager.prepareText("https://fonts.test/a.ttf", "hi", false);
470
+ const tex = manager.getAtlasTexture("https://fonts.test/a.ttf", false);
471
+
472
+ await manager.unloadFont("https://fonts.test/a.ttf", false);
473
+
474
+ // b.ttf still references "shared", so texture stays alive
475
+ expect((tex as unknown as Record<string, unknown>).disposed).toBe(false);
476
+ });
477
+
478
+ it("should clear shape cache for the unloaded font", async () => {
479
+ await manager.loadFont(FONT_URL, false);
480
+ await manager.prepareText(FONT_URL, "hello", false);
481
+ expect(manager.isTextPrepared(FONT_URL, "hello", false)).toBe(true);
482
+
483
+ await manager.unloadFont(FONT_URL, false);
484
+
485
+ expect(manager.isTextPrepared(FONT_URL, "hello", false)).toBe(false);
486
+ });
487
+ });
488
+
489
+ // -----------------------------------------------------------------------
490
+ // prepareText — standalone font
491
+ // -----------------------------------------------------------------------
492
+
493
+ describe("prepareText (standalone font)", () => {
494
+ it("should return immediately for empty text without calling worker", async () => {
495
+ await manager.loadFont(FONT_URL, false);
496
+ await manager.prepareText(FONT_URL, "", false);
497
+
498
+ expect(mocks.mockPrepareTextBatch).not.toHaveBeenCalled();
499
+ });
500
+
501
+ it("should shape text via the worker", async () => {
502
+ await manager.loadFont(FONT_URL, false);
503
+ await manager.prepareText(FONT_URL, "hello", false);
504
+
505
+ expect(mocks.mockPrepareTextBatch).toHaveBeenCalledWith(
506
+ FONT_URL + "#q=false",
507
+ ["hello"],
508
+ );
509
+ });
510
+
511
+ it("should cache results so repeated calls skip the worker", async () => {
512
+ await manager.loadFont(FONT_URL, false);
513
+ await manager.prepareText(FONT_URL, "hello", false);
514
+ mocks.mockPrepareTextBatch.mockClear();
515
+
516
+ await manager.prepareText(FONT_URL, "hello", false);
517
+
518
+ expect(mocks.mockPrepareTextBatch).not.toHaveBeenCalled();
519
+ });
520
+
521
+ it("should batch multiple synchronous prepareText calls into one worker call", async () => {
522
+ await manager.loadFont(FONT_URL, false);
523
+
524
+ const p1 = manager.prepareText(FONT_URL, "hello", false);
525
+ const p2 = manager.prepareText(FONT_URL, "world", false);
526
+ await Promise.all([p1, p2]);
527
+
528
+ expect(mocks.mockPrepareTextBatch).toHaveBeenCalledTimes(1);
529
+ expect(mocks.mockPrepareTextBatch).toHaveBeenCalledWith(
530
+ FONT_URL + "#q=false",
531
+ ["hello", "world"],
532
+ );
533
+ });
534
+
535
+ it("should deduplicate concurrent calls for the same text", async () => {
536
+ await manager.loadFont(FONT_URL, false);
537
+
538
+ const p1 = manager.prepareText(FONT_URL, "same", false);
539
+ const p2 = manager.prepareText(FONT_URL, "same", false);
540
+ await Promise.all([p1, p2]);
541
+
542
+ // Both promises should resolve; only one queue entry expected
543
+ expect(mocks.mockPrepareTextBatch).toHaveBeenCalledTimes(1);
544
+ });
545
+
546
+ it("should batch per-font so different fonts get separate worker calls", async () => {
547
+ const urlA = "https://fonts.test/a.ttf";
548
+ const urlB = "https://fonts.test/b.ttf";
549
+ await manager.loadFont(urlA, false);
550
+ await manager.loadFont(urlB, false);
551
+
552
+ const p1 = manager.prepareText(urlA, "alpha", false);
553
+ const p2 = manager.prepareText(urlB, "beta", false);
554
+ await Promise.all([p1, p2]);
555
+
556
+ expect(mocks.mockPrepareTextBatch).toHaveBeenCalledTimes(2);
557
+ expect(mocks.mockPrepareTextBatch).toHaveBeenCalledWith(
558
+ urlA + "#q=false",
559
+ ["alpha"],
560
+ );
561
+ expect(mocks.mockPrepareTextBatch).toHaveBeenCalledWith(
562
+ urlB + "#q=false",
563
+ ["beta"],
564
+ );
565
+ });
566
+
567
+ it("should store atlas data returned by the worker", async () => {
568
+ await manager.loadFont(FONT_URL, false);
569
+ await manager.prepareText(FONT_URL, "hello", false);
570
+
571
+ const atlas = manager.getAtlas(FONT_URL, false);
572
+ expect(atlas).toBeDefined();
573
+ expect(atlas!.width).toBe(256);
574
+ expect(atlas!.height).toBe(256);
575
+ });
576
+
577
+ it("should reject when the worker throws during batch processing", async () => {
578
+ await manager.loadFont(FONT_URL, false);
579
+ mocks.mockPrepareTextBatch.mockRejectedValueOnce(
580
+ new Error("worker crash"),
581
+ );
582
+
583
+ await expect(
584
+ manager.prepareText(FONT_URL, "fail", false),
585
+ ).rejects.toThrow("worker crash");
586
+ });
587
+
588
+ it("should reject all batched entries on worker failure", async () => {
589
+ await manager.loadFont(FONT_URL, false);
590
+ mocks.mockPrepareTextBatch.mockRejectedValueOnce(
591
+ new Error("batch failed"),
592
+ );
593
+
594
+ const p1 = manager.prepareText(FONT_URL, "a", false);
595
+ const p2 = manager.prepareText(FONT_URL, "b", false);
596
+
597
+ await expect(p1).rejects.toThrow("batch failed");
598
+ await expect(p2).rejects.toThrow("batch failed");
599
+ });
600
+ });
601
+
602
+ // -----------------------------------------------------------------------
603
+ // prepareText — font families
604
+ // -----------------------------------------------------------------------
605
+
606
+ describe("prepareText (font family)", () => {
607
+ beforeEach(() => {
608
+ manager.registerFontFamily(createTestFamily());
609
+ setupBatchMock("TestFamily");
610
+ });
611
+
612
+ it("should lazily load only the face URLs needed for the text", async () => {
613
+ const loadedFaces = new Set<string>();
614
+ // "Hello" is Basic Latin only → should load latin.ttf but NOT cjk.ttf
615
+ await manager.prepareText("TestFamily", "Hello", false, loadedFaces);
616
+
617
+ expect(loadedFaces.has("https://fonts.test/latin.ttf")).toBe(true);
618
+ expect(loadedFaces.has("https://fonts.test/cjk.ttf")).toBe(false);
619
+ });
620
+
621
+ it("should load multiple faces when text spans unicode ranges", async () => {
622
+ const loadedFaces = new Set<string>();
623
+ // Mix of Latin (Hi) and CJK (世界)
624
+ await manager.prepareText(
625
+ "TestFamily",
626
+ "Hi\u4e16\u754c",
627
+ false,
628
+ loadedFaces,
629
+ );
630
+
631
+ expect(loadedFaces.has("https://fonts.test/latin.ttf")).toBe(true);
632
+ expect(loadedFaces.has("https://fonts.test/cjk.ttf")).toBe(true);
633
+ });
634
+
635
+ it("should not reload a face that is already tracked in loadedFaces", async () => {
636
+ const loadedFaces = new Set<string>();
637
+ await manager.prepareText("TestFamily", "Hello", false, loadedFaces);
638
+ mockFetch.mockClear();
639
+
640
+ await manager.prepareText("TestFamily", "World", false, loadedFaces);
641
+
642
+ // latin.ttf was already in loadedFaces — no new fetch
643
+ expect(mockFetch).not.toHaveBeenCalled();
644
+ });
645
+
646
+ it("should produce a stitched ShapeTextResult for the full text", async () => {
647
+ await manager.prepareText("TestFamily", "Hi\u4e16", false);
648
+
649
+ const result = manager.shapeText("TestFamily", "Hi\u4e16", false);
650
+ expect(result).toBeDefined();
651
+ expect(result!.glyphs.length).toBeGreaterThan(0);
652
+ expect(result!.unitsPerEm).toBeGreaterThan(0);
653
+ });
654
+
655
+ it("should cache the stitched result under the family name", async () => {
656
+ await manager.prepareText("TestFamily", "Hi\u4e16", false);
657
+
658
+ expect(manager.isTextPrepared("TestFamily", "Hi\u4e16", false)).toBe(
659
+ true,
660
+ );
661
+ });
662
+
663
+ it("should not re-stitch if the same text was already prepared", async () => {
664
+ await manager.prepareText("TestFamily", "Hi\u4e16", false);
665
+ mocks.mockPrepareTextBatch.mockClear();
666
+
667
+ await manager.prepareText("TestFamily", "Hi\u4e16", false);
668
+
669
+ expect(mocks.mockPrepareTextBatch).not.toHaveBeenCalled();
670
+ });
671
+
672
+ it("should fall back to the first face for codepoints not in any range", async () => {
673
+ const loadedFaces = new Set<string>();
674
+ // Emoji (U+1F600) is not in any defined range → should fall back to latin.ttf
675
+ await manager.prepareText("TestFamily", "\u{1F600}", false, loadedFaces);
676
+
677
+ expect(loadedFaces.has("https://fonts.test/latin.ttf")).toBe(true);
678
+ expect(loadedFaces.has("https://fonts.test/cjk.ttf")).toBe(false);
679
+ });
680
+
681
+ it("should re-route codepoints the loaded font's cmap lacks to the next declaring face", async () => {
682
+ // Mimics Google Fonts boilerplate: latin.ttf DECLARES U+02BB (ʻ) but
683
+ // its cmap lacks the glyph; extended.ttf declares and truly has it.
684
+ // Loading a file replaces its face's ranges with the real cmap ranges.
685
+ const family: FontFamily = {
686
+ family: "Reroute",
687
+ faces: [
688
+ {
689
+ url: "https://fonts.test/latin.ttf",
690
+ unicodeRanges: [{ from: 0x0000, to: 0x02ff }],
691
+ },
692
+ {
693
+ url: "https://fonts.test/extended.ttf",
694
+ unicodeRanges: [{ from: 0x02b0, to: 0x02ff }],
695
+ },
696
+ ],
697
+ };
698
+ manager.registerFontFamily(family);
699
+ setupBatchMock("Reroute");
700
+ mocks.mockLoadFont.mockImplementation(async (url: string) => ({
701
+ ok: true,
702
+ cmapRanges: url.includes("latin.ttf")
703
+ ? new Uint32Array([0x0000, 0x02ba, 0x02bc, 0x02ff])
704
+ : new Uint32Array([0x02b0, 0x02ff]),
705
+ }));
706
+
707
+ const loadedFaces = new Set<string>();
708
+ await manager.prepareText("Reroute", "Oʻz", false, loadedFaces);
709
+
710
+ // The gap was discovered when latin.ttf loaded, so extended.ttf was
711
+ // loaded on the second pass and received the ʻ segment.
712
+ expect(loadedFaces.has("https://fonts.test/extended.ttf")).toBe(true);
713
+ expect(mocks.mockPrepareTextBatch).toHaveBeenCalledWith(
714
+ "https://fonts.test/extended.ttf#q=false",
715
+ ["ʻ"],
716
+ );
717
+ expect(mocks.mockPrepareTextBatch).toHaveBeenCalledWith(
718
+ "https://fonts.test/latin.ttf#q=false",
719
+ ["O", "z"],
720
+ );
721
+ });
722
+
723
+ it("should fall back to face 0 when no corrected range covers the codepoint", async () => {
724
+ const family: FontFamily = {
725
+ family: "NoAlternative",
726
+ faces: [
727
+ {
728
+ url: "https://fonts.test/only.ttf",
729
+ unicodeRanges: [{ from: 0x0000, to: 0x02ff }],
730
+ },
731
+ {
732
+ url: "https://fonts.test/cjk.ttf",
733
+ unicodeRanges: [{ from: 0x4e00, to: 0x9fff }],
734
+ },
735
+ ],
736
+ };
737
+ manager.registerFontFamily(family);
738
+ setupBatchMock("NoAlternative");
739
+ mocks.mockLoadFont.mockImplementation(async (url: string) => ({
740
+ ok: true,
741
+ cmapRanges: url.includes("only.ttf")
742
+ ? new Uint32Array([0x0000, 0x02ba, 0x02bc, 0x02ff])
743
+ : null,
744
+ }));
745
+
746
+ const loadedFaces = new Set<string>();
747
+ await manager.prepareText("NoAlternative", "Oʻz", false, loadedFaces);
748
+
749
+ // After correction ʻ is covered by no face — it falls back to face 0
750
+ // (only.ttf), so the text stays one segment and cjk.ttf is never
751
+ // fetched.
752
+ expect(loadedFaces.has("https://fonts.test/cjk.ttf")).toBe(false);
753
+ expect(mocks.mockPrepareTextBatch).toHaveBeenCalledWith(
754
+ "https://fonts.test/only.ttf#q=false",
755
+ ["Oʻz"],
756
+ );
757
+ });
758
+
759
+ it("should apply known cmap coverage to families registered after the font loaded", async () => {
760
+ mocks.mockLoadFont.mockResolvedValueOnce({
761
+ ok: true,
762
+ cmapRanges: new Uint32Array([0x41, 0x5a]),
763
+ });
764
+ await manager.loadFont("https://fonts.test/caps.ttf", false);
765
+
766
+ // Declared ranges are wrong (CJK); the known cmap (A-Z) wins.
767
+ manager.registerFontFamily({
768
+ family: "LateRegistration",
769
+ faces: [
770
+ {
771
+ url: "https://fonts.test/caps.ttf",
772
+ unicodeRanges: [{ from: 0x4e00, to: 0x9fff }],
773
+ },
774
+ {
775
+ url: "https://fonts.test/rest.ttf",
776
+ unicodeRanges: [{ from: 0x0000, to: 0xffff }],
777
+ },
778
+ ],
779
+ });
780
+ setupBatchMock("LateRegistration");
781
+
782
+ await manager.prepareText("LateRegistration", "AB", false);
783
+
784
+ expect(mocks.mockPrepareTextBatch).toHaveBeenCalledWith(
785
+ "https://fonts.test/caps.ttf#q=false",
786
+ ["AB"],
787
+ );
788
+ });
789
+
790
+ it("should not mutate the caller's FontFamily object when correcting ranges", async () => {
791
+ const callerFaces = [
792
+ {
793
+ url: "https://fonts.test/latin.ttf",
794
+ unicodeRanges: [{ from: 0x0000, to: 0x02ff }],
795
+ },
796
+ ];
797
+ manager.registerFontFamily({ family: "NoMutate", faces: callerFaces });
798
+ setupBatchMock("NoMutate");
799
+ mocks.mockLoadFont.mockResolvedValue({
800
+ ok: true,
801
+ cmapRanges: new Uint32Array([0x0000, 0x00ff]),
802
+ });
803
+
804
+ await manager.prepareText("NoMutate", "hi", false);
805
+
806
+ expect(callerFaces[0].unicodeRanges).toEqual([
807
+ { from: 0x0000, to: 0x02ff },
808
+ ]);
809
+ });
810
+
811
+ it("should keep spaces with the current face to avoid extra segments", async () => {
812
+ // Family with narrow Latin range (A-z only, no space)
813
+ const family: FontFamily = {
814
+ family: "SpaceTest",
815
+ faces: [
816
+ {
817
+ url: "https://fonts.test/narrow.ttf",
818
+ unicodeRanges: [{ from: 0x41, to: 0x7a }],
819
+ },
820
+ {
821
+ url: "https://fonts.test/fallback.ttf",
822
+ unicodeRanges: [{ from: 0x4e00, to: 0x9fff }],
823
+ },
824
+ ],
825
+ };
826
+ manager.registerFontFamily(family);
827
+ setupBatchMock("SpaceTest");
828
+
829
+ const loadedFaces = new Set<string>();
830
+ // "Hello World" — space between Latin words should stay with narrow.ttf
831
+ await manager.prepareText("SpaceTest", "Hello World", false, loadedFaces);
832
+
833
+ expect(loadedFaces.has("https://fonts.test/narrow.ttf")).toBe(true);
834
+ expect(loadedFaces.has("https://fonts.test/fallback.ttf")).toBe(false);
835
+ });
836
+
837
+ it("should handle a single-face family without segmentation overhead", async () => {
838
+ const family: FontFamily = {
839
+ family: "SingleFace",
840
+ faces: [
841
+ {
842
+ url: "https://fonts.test/all.ttf",
843
+ unicodeRanges: [{ from: 0, to: 0xffff }],
844
+ },
845
+ ],
846
+ };
847
+ manager.registerFontFamily(family);
848
+ setupBatchMock("SingleFace");
849
+
850
+ const loadedFaces = new Set<string>();
851
+ await manager.prepareText(
852
+ "SingleFace",
853
+ "Hello \u4e16\u754c",
854
+ false,
855
+ loadedFaces,
856
+ );
857
+
858
+ expect(loadedFaces.size).toBe(1);
859
+ });
860
+ });
861
+
862
+ // -----------------------------------------------------------------------
863
+ // Stitching with different unitsPerEm
864
+ // -----------------------------------------------------------------------
865
+
866
+ describe("stitching with different unitsPerEm", () => {
867
+ it("should normalize glyph advances to the first segment's unitsPerEm", async () => {
868
+ const latinResult = createShapeResult("Hi", 1000);
869
+ const cjkResult = createShapeResult("\u4e16", 2048);
870
+
871
+ mocks.mockPrepareTextBatch.mockImplementation(
872
+ async (
873
+ fontUrl: string,
874
+ texts: string[],
875
+ ): Promise<BatchPrepareTextResult> => ({
876
+ results: texts.map((text) => ({
877
+ text,
878
+ shapeResult: fontUrl.includes("cjk") ? cjkResult : latinResult,
879
+ })),
880
+ atlas: {
881
+ data: new Uint8Array([1]),
882
+ width: 64,
883
+ height: 64,
884
+ channels: 1,
885
+ },
886
+ colorAtlas: null,
887
+ atlasKey: "TestFamily",
888
+ }),
889
+ );
890
+
891
+ manager.registerFontFamily(createTestFamily());
892
+ await manager.prepareText("TestFamily", "Hi\u4e16", false);
893
+
894
+ const result = manager.shapeText("TestFamily", "Hi\u4e16", false);
895
+ expect(result).toBeDefined();
896
+ expect(result!.unitsPerEm).toBe(1000);
897
+
898
+ // The CJK glyph advance should be scaled by 1000/2048
899
+ const cjkGlyph = result!.glyphs[result!.glyphs.length - 1];
900
+ expect(cjkGlyph.xAdvance).toBeCloseTo(500 * (1000 / 2048));
901
+ });
902
+
903
+ it("should not scale when all segments share the same unitsPerEm", async () => {
904
+ mocks.mockPrepareTextBatch.mockImplementation(
905
+ async (
906
+ _fontUrl: string,
907
+ texts: string[],
908
+ ): Promise<BatchPrepareTextResult> => ({
909
+ results: texts.map((text) => ({
910
+ text,
911
+ shapeResult: createShapeResult(text, 1000),
912
+ })),
913
+ atlas: {
914
+ data: new Uint8Array([1]),
915
+ width: 64,
916
+ height: 64,
917
+ channels: 1,
918
+ },
919
+ colorAtlas: null,
920
+ atlasKey: "TestFamily",
921
+ }),
922
+ );
923
+
924
+ manager.registerFontFamily(createTestFamily());
925
+ await manager.prepareText("TestFamily", "Hi\u4e16", false);
926
+
927
+ const result = manager.shapeText("TestFamily", "Hi\u4e16", false);
928
+ // All glyphs should have unscaled xAdvance = 500
929
+ for (const g of result!.glyphs) {
930
+ expect(g.xAdvance).toBe(500);
931
+ }
932
+ });
933
+
934
+ it("should deduplicate metrics across segments", async () => {
935
+ // Both segments return a metric with fontIndex:0, glyphId:1
936
+ const sharedMetric = {
937
+ glyphId: 1,
938
+ fontIndex: 0,
939
+ compositeKey: makeCompositeKey(0, 1),
940
+ atlasX: 0,
941
+ atlasY: 0,
942
+ atlasW: 32,
943
+ atlasH: 32,
944
+ bearingX: 0,
945
+ bearingY: 32,
946
+ isColor: false,
947
+ };
948
+ mocks.mockPrepareTextBatch.mockImplementation(
949
+ async (
950
+ _fontUrl: string,
951
+ texts: string[],
952
+ ): Promise<BatchPrepareTextResult> => ({
953
+ results: texts.map((text) => ({
954
+ text,
955
+ shapeResult: {
956
+ glyphs: [
957
+ {
958
+ glyphId: 1,
959
+ fontIndex: 0,
960
+ compositeKey: makeCompositeKey(0, 1),
961
+ xAdvance: 500,
962
+ yAdvance: 0,
963
+ xOffset: 0,
964
+ yOffset: 0,
965
+ charClass: 0,
966
+ },
967
+ ],
968
+ metrics: [sharedMetric],
969
+ unitsPerEm: 1000,
970
+ ascender: 800,
971
+ descender: -200,
972
+ lineGap: 0,
973
+ },
974
+ })),
975
+ atlas: {
976
+ data: new Uint8Array([1]),
977
+ width: 64,
978
+ height: 64,
979
+ channels: 1,
980
+ },
981
+ colorAtlas: null,
982
+ atlasKey: "TestFamily",
983
+ }),
984
+ );
985
+
986
+ manager.registerFontFamily(createTestFamily());
987
+ await manager.prepareText("TestFamily", "A\u4e16", false);
988
+
989
+ const result = manager.shapeText("TestFamily", "A\u4e16", false);
990
+ // "0:1" appears in both segments but should only appear once
991
+ expect(result!.metrics.length).toBe(1);
992
+ });
993
+ });
994
+
995
+ // -----------------------------------------------------------------------
996
+ // Font family segmentation and stitching (detailed)
997
+ // -----------------------------------------------------------------------
998
+
999
+ describe("font family segmentation and stitching", () => {
1000
+ const LATIN = "https://fonts.test/latin.ttf";
1001
+ const CJK = "https://fonts.test/cjk.ttf";
1002
+ const ARABIC = "https://fonts.test/arabic.ttf";
1003
+
1004
+ let batchCalls: { fontUrl: string; texts: string[] }[];
1005
+
1006
+ type FaceConfig = {
1007
+ fontIndex: number;
1008
+ unitsPerEm: number;
1009
+ xAdvance: number;
1010
+ };
1011
+
1012
+ /** Strip the FontManager's `#q=<quality>` suffix so the test mocks can
1013
+ * reason about raw URLs even though the worker only sees qualified ones. */
1014
+ function stripQuality(qUrl: string): string {
1015
+ const idx = qUrl.indexOf("#q=");
1016
+ return idx === -1 ? qUrl : qUrl.slice(0, idx);
1017
+ }
1018
+
1019
+ /**
1020
+ * Tracking mock that records batch calls and returns distinguishable
1021
+ * ShapeTextResults per font URL (different fontIndex / xAdvance / unitsPerEm).
1022
+ */
1023
+ function setupTrackingMock(
1024
+ config: Record<string, FaceConfig>,
1025
+ atlasKey: string,
1026
+ ) {
1027
+ batchCalls = [];
1028
+ mocks.mockPrepareTextBatch.mockImplementation(
1029
+ async (
1030
+ fontUrl: string,
1031
+ texts: string[],
1032
+ ): Promise<BatchPrepareTextResult> => {
1033
+ const rawUrl = stripQuality(fontUrl);
1034
+ batchCalls.push({ fontUrl: rawUrl, texts });
1035
+ const cfg = config[rawUrl] ?? {
1036
+ fontIndex: 0,
1037
+ unitsPerEm: 1000,
1038
+ xAdvance: 500,
1039
+ };
1040
+ return {
1041
+ results: texts.map((text) => ({
1042
+ text,
1043
+ shapeResult: {
1044
+ glyphs: [...text].map((_, i) => ({
1045
+ glyphId: i + 1,
1046
+ fontIndex: cfg.fontIndex,
1047
+ compositeKey: makeCompositeKey(cfg.fontIndex, i + 1),
1048
+ xAdvance: cfg.xAdvance,
1049
+ yAdvance: 10,
1050
+ xOffset: 20,
1051
+ yOffset: 30,
1052
+ charClass: 0,
1053
+ })),
1054
+ metrics: [...text].map((_, i) => ({
1055
+ glyphId: i + 1,
1056
+ fontIndex: cfg.fontIndex,
1057
+ compositeKey: makeCompositeKey(cfg.fontIndex, i + 1),
1058
+ atlasX: i * 32,
1059
+ atlasY: 0,
1060
+ atlasW: 32,
1061
+ atlasH: 32,
1062
+ bearingX: 5,
1063
+ bearingY: 30,
1064
+ isColor: false,
1065
+ })),
1066
+ unitsPerEm: cfg.unitsPerEm,
1067
+ ascender: 800,
1068
+ descender: -200,
1069
+ lineGap: 0,
1070
+ },
1071
+ })),
1072
+ atlas: {
1073
+ data: new Uint8Array([1]),
1074
+ width: 64,
1075
+ height: 64,
1076
+ channels: 1,
1077
+ },
1078
+ colorAtlas: null,
1079
+ // Qualify the atlas key so FontManager's _resolveAtlasKey
1080
+ // (which appends `#q=<quality>`) can find the stored atlas.
1081
+ atlasKey: `${atlasKey}#q=false`,
1082
+ };
1083
+ },
1084
+ );
1085
+ }
1086
+
1087
+ /** Extract all texts sent to a specific font URL across all batch calls. */
1088
+ function textsFor(url: string): string[] {
1089
+ return batchCalls
1090
+ .filter((c) => c.fontUrl === url)
1091
+ .flatMap((c) => c.texts);
1092
+ }
1093
+
1094
+ // Standard two-face family (Latin 0x0000-0x00FF, CJK 0x4E00-0x9FFF)
1095
+ beforeEach(() => {
1096
+ manager.registerFontFamily({
1097
+ family: "Seg",
1098
+ faces: [
1099
+ { url: LATIN, unicodeRanges: [{ from: 0x0000, to: 0x00ff }] },
1100
+ { url: CJK, unicodeRanges: [{ from: 0x4e00, to: 0x9fff }] },
1101
+ ],
1102
+ });
1103
+ setupTrackingMock(
1104
+ {
1105
+ [LATIN]: { fontIndex: 0, unitsPerEm: 1000, xAdvance: 500 },
1106
+ [CJK]: { fontIndex: 1, unitsPerEm: 1000, xAdvance: 700 },
1107
+ },
1108
+ "Seg",
1109
+ );
1110
+ });
1111
+
1112
+ // ----- segmentation -----
1113
+
1114
+ describe("segmentation", () => {
1115
+ it("should produce a single segment for all-Latin text", async () => {
1116
+ await manager.prepareText("Seg", "Hello", false);
1117
+
1118
+ expect(textsFor(LATIN)).toEqual(["Hello"]);
1119
+ expect(textsFor(CJK)).toEqual([]);
1120
+ });
1121
+
1122
+ it("should produce a single segment for all-CJK text", async () => {
1123
+ await manager.prepareText("Seg", "\u4e16\u754c", false);
1124
+
1125
+ expect(textsFor(CJK)).toEqual(["\u4e16\u754c"]);
1126
+ expect(textsFor(LATIN)).toEqual([]);
1127
+ });
1128
+
1129
+ it("should split at the boundary between different ranges", async () => {
1130
+ // "Hi世界" → "Hi" latin, "世界" cjk
1131
+ await manager.prepareText("Seg", "Hi\u4e16\u754c", false);
1132
+
1133
+ expect(textsFor(LATIN)).toEqual(["Hi"]);
1134
+ expect(textsFor(CJK)).toEqual(["\u4e16\u754c"]);
1135
+ });
1136
+
1137
+ it("should split in reverse order (CJK then Latin)", async () => {
1138
+ await manager.prepareText("Seg", "\u4e16\u754cHi", false);
1139
+
1140
+ expect(textsFor(CJK)).toEqual(["\u4e16\u754c"]);
1141
+ expect(textsFor(LATIN)).toEqual(["Hi"]);
1142
+ });
1143
+
1144
+ it("should create separate segments for each alternating run", async () => {
1145
+ // "A世B界" → 4 segments: "A", "世", "B", "界"
1146
+ await manager.prepareText("Seg", "A\u4e16B\u754c", false);
1147
+
1148
+ expect(textsFor(LATIN)).toEqual(["A", "B"]);
1149
+ expect(textsFor(CJK)).toEqual(["\u4e16", "\u754c"]);
1150
+ });
1151
+
1152
+ it("should group consecutive same-face characters into one segment", async () => {
1153
+ // "ABCD世界好人" → "ABCD" latin, "世界好人" cjk
1154
+ await manager.prepareText("Seg", "ABCD\u4e16\u754c\u597d\u4eba", false);
1155
+
1156
+ expect(textsFor(LATIN)).toEqual(["ABCD"]);
1157
+ expect(textsFor(CJK)).toEqual(["\u4e16\u754c\u597d\u4eba"]);
1158
+ });
1159
+
1160
+ it("should keep space with the current face instead of re-evaluating", async () => {
1161
+ // "Hi 世界" → space stays with latin → "Hi " latin, "世界" cjk
1162
+ await manager.prepareText("Seg", "Hi \u4e16\u754c", false);
1163
+
1164
+ expect(textsFor(LATIN)).toEqual(["Hi "]);
1165
+ expect(textsFor(CJK)).toEqual(["\u4e16\u754c"]);
1166
+ });
1167
+
1168
+ it("should keep space with CJK face between CJK characters", async () => {
1169
+ // "世 界" → space stays with cjk face → "世 界" all cjk
1170
+ await manager.prepareText("Seg", "\u4e16 \u754c", false);
1171
+
1172
+ expect(textsFor(CJK)).toEqual(["\u4e16 \u754c"]);
1173
+ expect(textsFor(LATIN)).toEqual([]);
1174
+ });
1175
+
1176
+ it("should evaluate space normally when it is the first character", async () => {
1177
+ // Space (U+0020) at start: no currentUrl yet → evaluated by range
1178
+ // U+0020 is in Latin range (0x0000-0x00FF) → latin face
1179
+ await manager.prepareText("Seg", " Hello", false);
1180
+
1181
+ expect(textsFor(LATIN)).toEqual([" Hello"]);
1182
+ expect(textsFor(CJK)).toEqual([]);
1183
+ });
1184
+
1185
+ it("should evaluate leading space by range even when followed by CJK", async () => {
1186
+ // " 世界" → space (U+0020) at start maps to latin by range,
1187
+ // then "世界" maps to cjk → two segments
1188
+ await manager.prepareText("Seg", " \u4e16\u754c", false);
1189
+
1190
+ expect(textsFor(LATIN)).toEqual([" "]);
1191
+ expect(textsFor(CJK)).toEqual(["\u4e16\u754c"]);
1192
+ });
1193
+
1194
+ it("should fall back unmapped codepoints to the first face", async () => {
1195
+ // Emoji U+1F600 is not in Latin (0-0xFF) or CJK range → first face (latin)
1196
+ await manager.prepareText("Seg", "\u{1F600}", false);
1197
+
1198
+ expect(textsFor(LATIN)).toEqual(["\u{1F600}"]);
1199
+ expect(textsFor(CJK)).toEqual([]);
1200
+ });
1201
+
1202
+ it("should group unmapped codepoints with adjacent same-face text", async () => {
1203
+ // "A😀B" → A is latin, 😀 fallback to latin, B is latin → single segment
1204
+ await manager.prepareText("Seg", "A\u{1F600}B", false);
1205
+
1206
+ expect(textsFor(LATIN)).toEqual(["A\u{1F600}B"]);
1207
+ });
1208
+
1209
+ it("should split when unmapped codepoint sits between different faces", async () => {
1210
+ // "世😀B" → "世" cjk, "😀" fallback to latin, "B" latin → two segments
1211
+ await manager.prepareText("Seg", "\u4e16\u{1F600}B", false);
1212
+
1213
+ expect(textsFor(CJK)).toEqual(["\u4e16"]);
1214
+ expect(textsFor(LATIN)).toEqual(["\u{1F600}B"]);
1215
+ });
1216
+
1217
+ it("should handle a single character", async () => {
1218
+ await manager.prepareText("Seg", "A", false);
1219
+
1220
+ expect(textsFor(LATIN)).toEqual(["A"]);
1221
+ expect(batchCalls.length).toBe(1);
1222
+ });
1223
+
1224
+ it("should use the single-face fast path (whole text, one segment)", async () => {
1225
+ manager.registerFontFamily({
1226
+ family: "Single",
1227
+ faces: [{ url: LATIN, unicodeRanges: [{ from: 0, to: 0xffff }] }],
1228
+ });
1229
+ setupTrackingMock(
1230
+ { [LATIN]: { fontIndex: 0, unitsPerEm: 1000, xAdvance: 500 } },
1231
+ "Single",
1232
+ );
1233
+
1234
+ await manager.prepareText("Single", "Hello\u4e16\u{1F600}", false);
1235
+
1236
+ expect(textsFor(LATIN)).toEqual(["Hello\u4e16\u{1F600}"]);
1237
+ expect(batchCalls.length).toBe(1);
1238
+ });
1239
+
1240
+ it("should segment across three faces", async () => {
1241
+ manager.registerFontFamily({
1242
+ family: "Tri",
1243
+ faces: [
1244
+ { url: LATIN, unicodeRanges: [{ from: 0x0000, to: 0x00ff }] },
1245
+ {
1246
+ url: ARABIC,
1247
+ unicodeRanges: [{ from: 0x0600, to: 0x06ff }],
1248
+ },
1249
+ { url: CJK, unicodeRanges: [{ from: 0x4e00, to: 0x9fff }] },
1250
+ ],
1251
+ });
1252
+ setupTrackingMock(
1253
+ {
1254
+ [LATIN]: { fontIndex: 0, unitsPerEm: 1000, xAdvance: 500 },
1255
+ [ARABIC]: { fontIndex: 1, unitsPerEm: 1000, xAdvance: 600 },
1256
+ [CJK]: { fontIndex: 2, unitsPerEm: 1000, xAdvance: 700 },
1257
+ },
1258
+ "Tri",
1259
+ );
1260
+
1261
+ // "A" latin + "ب" (U+0628) arabic + "世" cjk
1262
+ await manager.prepareText("Tri", "A\u0628\u4e16", false);
1263
+
1264
+ expect(textsFor(LATIN)).toEqual(["A"]);
1265
+ expect(textsFor(ARABIC)).toEqual(["\u0628"]);
1266
+ expect(textsFor(CJK)).toEqual(["\u4e16"]);
1267
+ });
1268
+
1269
+ it("should give priority to the first face when unicode ranges overlap", async () => {
1270
+ manager.registerFontFamily({
1271
+ family: "Overlap",
1272
+ faces: [
1273
+ {
1274
+ url: LATIN,
1275
+ unicodeRanges: [{ from: 0x0000, to: 0xffff }],
1276
+ },
1277
+ { url: CJK, unicodeRanges: [{ from: 0x4e00, to: 0x9fff }] },
1278
+ ],
1279
+ });
1280
+ setupTrackingMock(
1281
+ {
1282
+ [LATIN]: { fontIndex: 0, unitsPerEm: 1000, xAdvance: 500 },
1283
+ [CJK]: { fontIndex: 1, unitsPerEm: 1000, xAdvance: 700 },
1284
+ },
1285
+ "Overlap",
1286
+ );
1287
+
1288
+ // "世" matches both ranges → first face (latin) wins
1289
+ await manager.prepareText("Overlap", "\u4e16", false);
1290
+
1291
+ expect(textsFor(LATIN)).toEqual(["\u4e16"]);
1292
+ expect(textsFor(CJK)).toEqual([]);
1293
+ });
1294
+
1295
+ it("should handle multiple spaces between faces without splitting", async () => {
1296
+ // "Hi 世" → spaces stay with latin → "Hi " latin, "世" cjk
1297
+ await manager.prepareText("Seg", "Hi \u4e16", false);
1298
+
1299
+ expect(textsFor(LATIN)).toEqual(["Hi "]);
1300
+ expect(textsFor(CJK)).toEqual(["\u4e16"]);
1301
+ });
1302
+
1303
+ it("should handle face with multiple disjoint unicode ranges", async () => {
1304
+ manager.registerFontFamily({
1305
+ family: "Multi",
1306
+ faces: [
1307
+ {
1308
+ url: LATIN,
1309
+ unicodeRanges: [
1310
+ { from: 0x0041, to: 0x005a }, // A-Z
1311
+ { from: 0x0061, to: 0x007a }, // a-z
1312
+ ],
1313
+ },
1314
+ { url: CJK, unicodeRanges: [{ from: 0x4e00, to: 0x9fff }] },
1315
+ ],
1316
+ });
1317
+ setupTrackingMock(
1318
+ {
1319
+ [LATIN]: { fontIndex: 0, unitsPerEm: 1000, xAdvance: 500 },
1320
+ [CJK]: { fontIndex: 1, unitsPerEm: 1000, xAdvance: 700 },
1321
+ },
1322
+ "Multi",
1323
+ );
1324
+
1325
+ // "AaBb" → all covered by the two disjoint Latin ranges → single segment
1326
+ await manager.prepareText("Multi", "AaBb", false);
1327
+
1328
+ expect(textsFor(LATIN)).toEqual(["AaBb"]);
1329
+ });
1330
+ });
1331
+
1332
+ // ----- stitching -----
1333
+
1334
+ describe("stitching", () => {
1335
+ it("should produce total glyph count equal to the text length", async () => {
1336
+ // "Hi世界" → 2 latin + 2 cjk = 4 glyphs
1337
+ await manager.prepareText("Seg", "Hi\u4e16\u754c", false);
1338
+ const result = manager.shapeText("Seg", "Hi\u4e16\u754c", false)!;
1339
+
1340
+ expect(result.glyphs.length).toBe(4);
1341
+ });
1342
+
1343
+ it("should preserve glyph fontIndex in text reading order", async () => {
1344
+ // fontIndex=0 for latin, fontIndex=1 for cjk
1345
+ // "Hi世" → [0, 0, 1]
1346
+ await manager.prepareText("Seg", "Hi\u4e16", false);
1347
+ const result = manager.shapeText("Seg", "Hi\u4e16", false)!;
1348
+
1349
+ expect(result.glyphs.map((g) => g.fontIndex)).toEqual([0, 0, 1]);
1350
+ });
1351
+
1352
+ it("should use the first segment's unitsPerEm as the target", async () => {
1353
+ setupTrackingMock(
1354
+ {
1355
+ [LATIN]: { fontIndex: 0, unitsPerEm: 2048, xAdvance: 1000 },
1356
+ [CJK]: { fontIndex: 1, unitsPerEm: 1000, xAdvance: 500 },
1357
+ },
1358
+ "Seg",
1359
+ );
1360
+
1361
+ await manager.prepareText("Seg", "A\u4e16", false);
1362
+ const result = manager.shapeText("Seg", "A\u4e16", false)!;
1363
+
1364
+ expect(result.unitsPerEm).toBe(2048);
1365
+ });
1366
+
1367
+ it("should scale all four positioning fields when unitsPerEm differs", async () => {
1368
+ setupTrackingMock(
1369
+ {
1370
+ [LATIN]: { fontIndex: 0, unitsPerEm: 1000, xAdvance: 500 },
1371
+ [CJK]: { fontIndex: 1, unitsPerEm: 2000, xAdvance: 800 },
1372
+ },
1373
+ "Seg",
1374
+ );
1375
+
1376
+ await manager.prepareText("Seg", "A\u4e16", false);
1377
+ const result = manager.shapeText("Seg", "A\u4e16", false)!;
1378
+
1379
+ // Latin glyph: no scaling (unitsPerEm matches target)
1380
+ expect(result.glyphs[0].xAdvance).toBe(500);
1381
+ expect(result.glyphs[0].yAdvance).toBe(10);
1382
+ expect(result.glyphs[0].xOffset).toBe(20);
1383
+ expect(result.glyphs[0].yOffset).toBe(30);
1384
+
1385
+ // CJK glyph: scale = 1000 / 2000 = 0.5
1386
+ expect(result.glyphs[1].xAdvance).toBeCloseTo(400); // 800 * 0.5
1387
+ expect(result.glyphs[1].yAdvance).toBeCloseTo(5); // 10 * 0.5
1388
+ expect(result.glyphs[1].xOffset).toBeCloseTo(10); // 20 * 0.5
1389
+ expect(result.glyphs[1].yOffset).toBeCloseTo(15); // 30 * 0.5
1390
+ });
1391
+
1392
+ it("should not scale when all segments share the same unitsPerEm", async () => {
1393
+ await manager.prepareText("Seg", "A\u4e16", false);
1394
+ const result = manager.shapeText("Seg", "A\u4e16", false)!;
1395
+
1396
+ expect(result.glyphs[0].xAdvance).toBe(500); // latin, unscaled
1397
+ expect(result.glyphs[1].xAdvance).toBe(700); // cjk, unscaled
1398
+ });
1399
+
1400
+ it("should include metrics from all font indexes", async () => {
1401
+ await manager.prepareText("Seg", "A\u4e16", false);
1402
+ const result = manager.shapeText("Seg", "A\u4e16", false)!;
1403
+
1404
+ const fontIndexes = new Set(result.metrics.map((m) => m.fontIndex));
1405
+ expect(fontIndexes).toEqual(new Set([0, 1]));
1406
+ });
1407
+
1408
+ it("should deduplicate metrics by fontIndex:glyphId keeping the first", async () => {
1409
+ // Override mock so both faces return the same fontIndex:glyphId
1410
+ mocks.mockPrepareTextBatch.mockImplementation(
1411
+ async (
1412
+ fontUrl: string,
1413
+ texts: string[],
1414
+ ): Promise<BatchPrepareTextResult> => {
1415
+ batchCalls.push({ fontUrl, texts });
1416
+ return {
1417
+ results: texts.map((text) => ({
1418
+ text,
1419
+ shapeResult: {
1420
+ glyphs: [
1421
+ {
1422
+ glyphId: 1,
1423
+ fontIndex: 0,
1424
+ compositeKey: makeCompositeKey(0, 1),
1425
+ xAdvance: 500,
1426
+ yAdvance: 0,
1427
+ xOffset: 0,
1428
+ yOffset: 0,
1429
+ charClass: 0,
1430
+ },
1431
+ ],
1432
+ metrics: [
1433
+ {
1434
+ glyphId: 1,
1435
+ fontIndex: 0,
1436
+ compositeKey: makeCompositeKey(0, 1),
1437
+ atlasX: stripQuality(fontUrl) === LATIN ? 0 : 99,
1438
+ atlasY: 0,
1439
+ atlasW: 32,
1440
+ atlasH: 32,
1441
+ bearingX: 5,
1442
+ bearingY: 30,
1443
+ isColor: false,
1444
+ },
1445
+ ],
1446
+ unitsPerEm: 1000,
1447
+ ascender: 800,
1448
+ descender: -200,
1449
+ lineGap: 0,
1450
+ },
1451
+ })),
1452
+ atlas: {
1453
+ data: new Uint8Array([1]),
1454
+ width: 64,
1455
+ height: 64,
1456
+ channels: 1,
1457
+ },
1458
+ colorAtlas: null,
1459
+ atlasKey: "Seg",
1460
+ };
1461
+ },
1462
+ );
1463
+
1464
+ await manager.prepareText("Seg", "A\u4e16", false);
1465
+ const result = manager.shapeText("Seg", "A\u4e16", false)!;
1466
+
1467
+ // fontIndex:0, glyphId:1 appears in both segments → first kept
1468
+ expect(result.metrics.length).toBe(1);
1469
+ expect(result.metrics[0].atlasX).toBe(0); // from latin (first segment)
1470
+ });
1471
+
1472
+ it("should correctly stitch when text returns to the same face", async () => {
1473
+ // "A世B" → latin "A", cjk "世", latin "B" — 3 segments
1474
+ await manager.prepareText("Seg", "A\u4e16B", false);
1475
+ const result = manager.shapeText("Seg", "A\u4e16B", false)!;
1476
+
1477
+ expect(result.glyphs.length).toBe(3);
1478
+ expect(result.glyphs.map((g) => g.fontIndex)).toEqual([0, 1, 0]);
1479
+ expect(result.glyphs.map((g) => g.xAdvance)).toEqual([500, 700, 500]);
1480
+ });
1481
+
1482
+ it("should handle many alternating segments", async () => {
1483
+ // "A世B界C" → 5 segments
1484
+ await manager.prepareText("Seg", "A\u4e16B\u754cC", false);
1485
+ const result = manager.shapeText("Seg", "A\u4e16B\u754cC", false)!;
1486
+
1487
+ expect(result.glyphs.length).toBe(5);
1488
+ expect(result.glyphs.map((g) => g.fontIndex)).toEqual([0, 1, 0, 1, 0]);
1489
+ });
1490
+
1491
+ it("should stitch three-face results in order", async () => {
1492
+ manager.registerFontFamily({
1493
+ family: "Tri",
1494
+ faces: [
1495
+ { url: LATIN, unicodeRanges: [{ from: 0x0000, to: 0x00ff }] },
1496
+ {
1497
+ url: ARABIC,
1498
+ unicodeRanges: [{ from: 0x0600, to: 0x06ff }],
1499
+ },
1500
+ { url: CJK, unicodeRanges: [{ from: 0x4e00, to: 0x9fff }] },
1501
+ ],
1502
+ });
1503
+ setupTrackingMock(
1504
+ {
1505
+ [LATIN]: { fontIndex: 0, unitsPerEm: 1000, xAdvance: 500 },
1506
+ [ARABIC]: { fontIndex: 1, unitsPerEm: 1000, xAdvance: 600 },
1507
+ [CJK]: { fontIndex: 2, unitsPerEm: 1000, xAdvance: 700 },
1508
+ },
1509
+ "Tri",
1510
+ );
1511
+
1512
+ await manager.prepareText("Tri", "A\u0628\u4e16", false);
1513
+ const result = manager.shapeText("Tri", "A\u0628\u4e16", false)!;
1514
+
1515
+ expect(result.glyphs.length).toBe(3);
1516
+ expect(result.glyphs.map((g) => g.fontIndex)).toEqual([0, 1, 2]);
1517
+ expect(result.glyphs.map((g) => g.xAdvance)).toEqual([500, 600, 700]);
1518
+ });
1519
+
1520
+ it("should scale only the segments whose unitsPerEm differs from the first", async () => {
1521
+ // Latin: 1000, Arabic: 2000 (scale 0.5), CJK: 500 (scale 2.0)
1522
+ manager.registerFontFamily({
1523
+ family: "Mixed",
1524
+ faces: [
1525
+ { url: LATIN, unicodeRanges: [{ from: 0x0000, to: 0x00ff }] },
1526
+ {
1527
+ url: ARABIC,
1528
+ unicodeRanges: [{ from: 0x0600, to: 0x06ff }],
1529
+ },
1530
+ { url: CJK, unicodeRanges: [{ from: 0x4e00, to: 0x9fff }] },
1531
+ ],
1532
+ });
1533
+ setupTrackingMock(
1534
+ {
1535
+ [LATIN]: { fontIndex: 0, unitsPerEm: 1000, xAdvance: 500 },
1536
+ [ARABIC]: { fontIndex: 1, unitsPerEm: 2000, xAdvance: 600 },
1537
+ [CJK]: { fontIndex: 2, unitsPerEm: 500, xAdvance: 200 },
1538
+ },
1539
+ "Mixed",
1540
+ );
1541
+
1542
+ await manager.prepareText("Mixed", "A\u0628\u4e16", false);
1543
+ const result = manager.shapeText("Mixed", "A\u0628\u4e16", false)!;
1544
+
1545
+ expect(result.unitsPerEm).toBe(1000);
1546
+ // Latin: no scale
1547
+ expect(result.glyphs[0].xAdvance).toBe(500);
1548
+ // Arabic: scale = 1000/2000 = 0.5
1549
+ expect(result.glyphs[1].xAdvance).toBeCloseTo(300);
1550
+ // CJK: scale = 1000/500 = 2.0
1551
+ expect(result.glyphs[2].xAdvance).toBeCloseTo(400);
1552
+ });
1553
+
1554
+ it("should produce a single-segment stitch with no transformation", async () => {
1555
+ // All Latin → one segment, stitch is identity
1556
+ await manager.prepareText("Seg", "Hello", false);
1557
+ const result = manager.shapeText("Seg", "Hello", false)!;
1558
+
1559
+ expect(result.glyphs.length).toBe(5);
1560
+ expect(result.unitsPerEm).toBe(1000);
1561
+ for (const g of result.glyphs) {
1562
+ expect(g.fontIndex).toBe(0);
1563
+ expect(g.xAdvance).toBe(500);
1564
+ }
1565
+ });
1566
+ });
1567
+
1568
+ // ----- graceful degradation when a segment fails to shape -----
1569
+
1570
+ describe("unshaped segments", () => {
1571
+ /** Mock that shapes Latin normally but returns no result for CJK,
1572
+ * simulating a worker that produced no glyphs for a segment (the matched
1573
+ * face lacks the codepoint, or it fell through to a face that can't shape
1574
+ * the script). The prepare promise still resolves; nothing is cached. */
1575
+ function setupPartialShapeMock() {
1576
+ mocks.mockPrepareTextBatch.mockImplementation(
1577
+ async (
1578
+ fontUrl: string,
1579
+ texts: string[],
1580
+ ): Promise<BatchPrepareTextResult> => {
1581
+ const rawUrl = stripQuality(fontUrl);
1582
+ const shapes = rawUrl === LATIN;
1583
+ return {
1584
+ results: texts.map((text) => ({
1585
+ text,
1586
+ shapeResult: shapes
1587
+ ? {
1588
+ glyphs: [...text].map((_, i) => ({
1589
+ glyphId: i + 1,
1590
+ fontIndex: 0,
1591
+ compositeKey: makeCompositeKey(0, i + 1),
1592
+ xAdvance: 500,
1593
+ yAdvance: 10,
1594
+ xOffset: 20,
1595
+ yOffset: 30,
1596
+ charClass: 0,
1597
+ })),
1598
+ metrics: [...text].map((_, i) => ({
1599
+ glyphId: i + 1,
1600
+ fontIndex: 0,
1601
+ compositeKey: makeCompositeKey(0, i + 1),
1602
+ atlasX: i * 32,
1603
+ atlasY: 0,
1604
+ atlasW: 32,
1605
+ atlasH: 32,
1606
+ bearingX: 5,
1607
+ bearingY: 30,
1608
+ isColor: false,
1609
+ })),
1610
+ unitsPerEm: 1000,
1611
+ ascender: 800,
1612
+ descender: -200,
1613
+ lineGap: 0,
1614
+ }
1615
+ : null,
1616
+ })),
1617
+ atlas: {
1618
+ data: new Uint8Array([1]),
1619
+ width: 64,
1620
+ height: 64,
1621
+ channels: 1,
1622
+ },
1623
+ colorAtlas: null,
1624
+ atlasKey: "Seg#q=false",
1625
+ };
1626
+ },
1627
+ );
1628
+ }
1629
+
1630
+ it("should not reject prepareText when a segment fails to shape", async () => {
1631
+ setupPartialShapeMock();
1632
+ await expect(
1633
+ manager.prepareText("Seg", "Hi世界", false),
1634
+ ).resolves.toBeUndefined();
1635
+ });
1636
+
1637
+ it("should stitch only the segments that shaped, dropping the rest", async () => {
1638
+ setupPartialShapeMock();
1639
+ // "Hi世界": Latin "Hi" shapes (2 glyphs); CJK "世界" yields nothing.
1640
+ await manager.prepareText("Seg", "Hi世界", false);
1641
+ const result = manager.shapeText("Seg", "Hi世界", false)!;
1642
+
1643
+ expect(result.glyphs.length).toBe(2);
1644
+ expect(result.glyphs.every((g) => g.fontIndex === 0)).toBe(true);
1645
+ expect(result.unitsPerEm).toBe(1000);
1646
+ });
1647
+
1648
+ it("should return an empty result when no segment shapes", async () => {
1649
+ setupPartialShapeMock();
1650
+ // All-CJK: every segment yields nothing → blank label, not a throw.
1651
+ await manager.prepareText("Seg", "世界", false);
1652
+ const result = manager.shapeText("Seg", "世界", false)!;
1653
+
1654
+ expect(result.glyphs).toEqual([]);
1655
+ expect(result.metrics).toEqual([]);
1656
+ expect(result.unitsPerEm).toBe(1000);
1657
+ });
1658
+ });
1659
+ });
1660
+
1661
+ // -----------------------------------------------------------------------
1662
+ // isTextPrepared
1663
+ // -----------------------------------------------------------------------
1664
+
1665
+ describe("isTextPrepared", () => {
1666
+ it("should return false for unprepared text", () => {
1667
+ expect(manager.isTextPrepared(FONT_URL, "hello", false)).toBe(false);
1668
+ });
1669
+
1670
+ it("should return false for unknown font identifiers", () => {
1671
+ expect(manager.isTextPrepared("unknown", "hello", false)).toBe(false);
1672
+ });
1673
+
1674
+ it("should return true after text is prepared", async () => {
1675
+ await manager.loadFont(FONT_URL, false);
1676
+ await manager.prepareText(FONT_URL, "hello", false);
1677
+
1678
+ expect(manager.isTextPrepared(FONT_URL, "hello", false)).toBe(true);
1679
+ });
1680
+
1681
+ it("should return false for different text with the same font", async () => {
1682
+ await manager.loadFont(FONT_URL, false);
1683
+ await manager.prepareText(FONT_URL, "hello", false);
1684
+
1685
+ expect(manager.isTextPrepared(FONT_URL, "world", false)).toBe(false);
1686
+ });
1687
+ });
1688
+
1689
+ // -----------------------------------------------------------------------
1690
+ // shapeText
1691
+ // -----------------------------------------------------------------------
1692
+
1693
+ describe("shapeText", () => {
1694
+ it("should return undefined for unprepared text", () => {
1695
+ expect(manager.shapeText(FONT_URL, "hello", false)).toBeUndefined();
1696
+ });
1697
+
1698
+ it("should return undefined for unknown font identifiers", () => {
1699
+ expect(manager.shapeText("unknown", "hello", false)).toBeUndefined();
1700
+ });
1701
+
1702
+ it("should return the shaped result after preparation", async () => {
1703
+ await manager.loadFont(FONT_URL, false);
1704
+ await manager.prepareText(FONT_URL, "hello", false);
1705
+
1706
+ const result = manager.shapeText(FONT_URL, "hello", false);
1707
+ expect(result).toBeDefined();
1708
+ expect(result!.glyphs).toBeInstanceOf(Array);
1709
+ expect(result!.metrics).toBeInstanceOf(Array);
1710
+ expect(result!.unitsPerEm).toBe(1000);
1711
+ });
1712
+
1713
+ it("should return the correct number of glyphs for the input text", async () => {
1714
+ await manager.loadFont(FONT_URL, false);
1715
+ await manager.prepareText(FONT_URL, "abc", false);
1716
+
1717
+ const result = manager.shapeText(FONT_URL, "abc", false);
1718
+ expect(result!.glyphs.length).toBe(3);
1719
+ });
1720
+ });
1721
+
1722
+ // -----------------------------------------------------------------------
1723
+ // getAtlas
1724
+ // -----------------------------------------------------------------------
1725
+
1726
+ describe("getAtlas", () => {
1727
+ it("should return undefined when no atlas exists", () => {
1728
+ expect(manager.getAtlas(FONT_URL, false)).toBeUndefined();
1729
+ });
1730
+
1731
+ it("should return atlas data after text preparation", async () => {
1732
+ await manager.loadFont(FONT_URL, false);
1733
+ await manager.prepareText(FONT_URL, "hello", false);
1734
+
1735
+ const atlas = manager.getAtlas(FONT_URL, false);
1736
+ expect(atlas).toBeDefined();
1737
+ expect(atlas!.data).toBeInstanceOf(Uint8Array);
1738
+ expect(atlas!.width).toBe(256);
1739
+ expect(atlas!.height).toBe(256);
1740
+ });
1741
+
1742
+ it("should resolve a family name to the shared atlas", async () => {
1743
+ manager.registerFontFamily(createTestFamily());
1744
+ setupBatchMock("TestFamily");
1745
+ await manager.prepareText("TestFamily", "Hello", false);
1746
+
1747
+ expect(manager.getAtlas("TestFamily", false)).toBeDefined();
1748
+ });
1749
+
1750
+ it("should resolve a font URL to its atlas key", async () => {
1751
+ setupBatchMock("shared");
1752
+ await manager.loadFont(FONT_URL, false, "shared");
1753
+ await manager.prepareText(FONT_URL, "hello", false);
1754
+
1755
+ // Accessing via the URL should resolve to the "shared" atlas key
1756
+ expect(manager.getAtlas(FONT_URL, false)).toBeDefined();
1757
+ });
1758
+ });
1759
+
1760
+ // -----------------------------------------------------------------------
1761
+ // getAtlasTexture
1762
+ // -----------------------------------------------------------------------
1763
+
1764
+ describe("getAtlasTexture", () => {
1765
+ it("should return null when no atlas data exists", () => {
1766
+ expect(manager.getAtlasTexture(FONT_URL, false)).toBeNull();
1767
+ });
1768
+
1769
+ it("should create a texture from atlas data", async () => {
1770
+ await manager.loadFont(FONT_URL, false);
1771
+ await manager.prepareText(FONT_URL, "hello", false);
1772
+
1773
+ const tex = manager.getAtlasTexture(FONT_URL, false);
1774
+ expect(tex).not.toBeNull();
1775
+ expect(tex!.needsUpdate).toBe(true);
1776
+ });
1777
+
1778
+ it("should return the same texture instance on subsequent calls", async () => {
1779
+ await manager.loadFont(FONT_URL, false);
1780
+ await manager.prepareText(FONT_URL, "hello", false);
1781
+
1782
+ const tex1 = manager.getAtlasTexture(FONT_URL, false);
1783
+ const tex2 = manager.getAtlasTexture(FONT_URL, false);
1784
+ expect(tex1).toBe(tex2);
1785
+ });
1786
+
1787
+ it("should update texture in-place when atlas is dirty", async () => {
1788
+ await manager.loadFont(FONT_URL, false);
1789
+ await manager.prepareText(FONT_URL, "hello", false);
1790
+ const tex1 = manager.getAtlasTexture(FONT_URL, false);
1791
+
1792
+ // Preparing more text makes the atlas dirty again
1793
+ await manager.prepareText(FONT_URL, "world", false);
1794
+ const tex2 = manager.getAtlasTexture(FONT_URL, false);
1795
+
1796
+ expect(tex2).toBe(tex1); // same instance, updated in place
1797
+ expect(tex2!.needsUpdate).toBe(true);
1798
+ });
1799
+
1800
+ it("should resolve family names to the shared texture", async () => {
1801
+ manager.registerFontFamily(createTestFamily());
1802
+ setupBatchMock("TestFamily");
1803
+ await manager.prepareText("TestFamily", "Hello", false);
1804
+
1805
+ const tex = manager.getAtlasTexture("TestFamily", false);
1806
+ expect(tex).not.toBeNull();
1807
+ });
1808
+
1809
+ it("should dispose the existing texture when atlas dimensions grow but preserve the instance", async () => {
1810
+ await manager.loadFont(FONT_URL, false);
1811
+ await manager.prepareText(FONT_URL, "hello", false);
1812
+ const tex1 = manager.getAtlasTexture(FONT_URL, false);
1813
+ expect(tex1).not.toBeNull();
1814
+ expect(tex1!.image.width).toBe(256);
1815
+ expect((tex1 as unknown as Record<string, unknown>).disposed).toBe(false);
1816
+
1817
+ // Second batch returns an atlas at the grown size — mirrors the worker
1818
+ // doubling the SDF atlas when it ran out of space.
1819
+ mocks.mockPrepareTextBatch.mockImplementationOnce(
1820
+ async (
1821
+ fontUrl: string,
1822
+ texts: string[],
1823
+ ): Promise<BatchPrepareTextResult> => ({
1824
+ results: texts.map((text) => ({
1825
+ text,
1826
+ shapeResult: createShapeResult(text),
1827
+ })),
1828
+ atlas: {
1829
+ data: new Uint8Array([5, 6, 7, 8]),
1830
+ width: 512,
1831
+ height: 512,
1832
+ channels: 1,
1833
+ },
1834
+ colorAtlas: null,
1835
+ atlasKey: fontUrl,
1836
+ }),
1837
+ );
1838
+ await manager.prepareText(FONT_URL, "world", false);
1839
+
1840
+ const tex2 = manager.getAtlasTexture(FONT_URL, false);
1841
+ expect(tex2).toBe(tex1); // same DataTexture instance preserved
1842
+ expect((tex2 as unknown as Record<string, unknown>).disposed).toBe(true);
1843
+ expect(tex2!.image.width).toBe(512);
1844
+ expect(tex2!.image.height).toBe(512);
1845
+ expect(tex2!.needsUpdate).toBe(true);
1846
+ });
1847
+
1848
+ it("should NOT dispose the existing texture when dimensions are unchanged", async () => {
1849
+ await manager.loadFont(FONT_URL, false);
1850
+ await manager.prepareText(FONT_URL, "hello", false);
1851
+ const tex1 = manager.getAtlasTexture(FONT_URL, false);
1852
+ expect((tex1 as unknown as Record<string, unknown>).disposed).toBe(false);
1853
+
1854
+ // Atlas data changes but dimensions stay the same — no GL reallocation
1855
+ // is needed and we should keep the existing handle alive.
1856
+ await manager.prepareText(FONT_URL, "world", false);
1857
+ const tex2 = manager.getAtlasTexture(FONT_URL, false);
1858
+
1859
+ expect(tex2).toBe(tex1);
1860
+ expect((tex2 as unknown as Record<string, unknown>).disposed).toBe(false);
1861
+ });
1862
+ });
1863
+
1864
+ // -----------------------------------------------------------------------
1865
+ // getColorAtlasTexture
1866
+ // -----------------------------------------------------------------------
1867
+
1868
+ describe("getColorAtlasTexture", () => {
1869
+ function setupColorBatchMock(width: number, height: number) {
1870
+ mocks.mockPrepareTextBatch.mockImplementationOnce(
1871
+ async (
1872
+ fontUrl: string,
1873
+ texts: string[],
1874
+ ): Promise<BatchPrepareTextResult> => ({
1875
+ results: texts.map((text) => ({
1876
+ text,
1877
+ shapeResult: createShapeResult(text),
1878
+ })),
1879
+ atlas: {
1880
+ data: new Uint8Array(4),
1881
+ width: 256,
1882
+ height: 256,
1883
+ channels: 1,
1884
+ },
1885
+ colorAtlas: {
1886
+ data: new Uint8Array(width * height * 4),
1887
+ width,
1888
+ height,
1889
+ channels: 4,
1890
+ },
1891
+ atlasKey: fontUrl,
1892
+ }),
1893
+ );
1894
+ }
1895
+
1896
+ it("should dispose the existing color texture when its dimensions grow", async () => {
1897
+ await manager.loadFont(FONT_URL, false);
1898
+ setupColorBatchMock(128, 128);
1899
+ await manager.prepareText(FONT_URL, "hello", false);
1900
+ const tex1 = manager.getColorAtlasTexture(FONT_URL, false);
1901
+ expect(tex1).not.toBeNull();
1902
+ expect(tex1!.image.width).toBe(128);
1903
+ expect((tex1 as unknown as Record<string, unknown>).disposed).toBe(false);
1904
+
1905
+ setupColorBatchMock(256, 256);
1906
+ await manager.prepareText(FONT_URL, "world", false);
1907
+ const tex2 = manager.getColorAtlasTexture(FONT_URL, false);
1908
+
1909
+ expect(tex2).toBe(tex1);
1910
+ expect((tex2 as unknown as Record<string, unknown>).disposed).toBe(true);
1911
+ expect(tex2!.image.width).toBe(256);
1912
+ expect(tex2!.image.height).toBe(256);
1913
+ expect(tex2!.needsUpdate).toBe(true);
1914
+ });
1915
+
1916
+ it("should NOT dispose the existing color texture when dimensions are unchanged", async () => {
1917
+ await manager.loadFont(FONT_URL, false);
1918
+ setupColorBatchMock(128, 128);
1919
+ await manager.prepareText(FONT_URL, "hello", false);
1920
+ const tex1 = manager.getColorAtlasTexture(FONT_URL, false);
1921
+ expect((tex1 as unknown as Record<string, unknown>).disposed).toBe(false);
1922
+
1923
+ setupColorBatchMock(128, 128);
1924
+ await manager.prepareText(FONT_URL, "world", false);
1925
+ const tex2 = manager.getColorAtlasTexture(FONT_URL, false);
1926
+
1927
+ expect(tex2).toBe(tex1);
1928
+ expect((tex2 as unknown as Record<string, unknown>).disposed).toBe(false);
1929
+ });
1930
+ });
1931
+
1932
+ // -----------------------------------------------------------------------
1933
+ // Client initialization
1934
+ // -----------------------------------------------------------------------
1935
+
1936
+ describe("client initialization", () => {
1937
+ it("should reject when concurrencyManager is not set", async () => {
1938
+ const bare = new FontManager(WORKER_URL);
1939
+
1940
+ await expect(bare.loadFont(FONT_URL, false)).rejects.toThrow(
1941
+ "concurrencyManager not set",
1942
+ );
1943
+
1944
+ bare.dispose();
1945
+ });
1946
+
1947
+ it("should create the FontWorkerClient only once across multiple loads", async () => {
1948
+ await manager.loadFont("https://fonts.test/a.ttf", false);
1949
+ await manager.loadFont("https://fonts.test/b.ttf", false);
1950
+
1951
+ expect(mocks.FontWorkerClient).toHaveBeenCalledTimes(1);
1952
+ });
1953
+
1954
+ it("should retry client creation after a failure", async () => {
1955
+ mocks.mockReady.mockRejectedValueOnce(new Error("init failed"));
1956
+
1957
+ await expect(manager.loadFont(FONT_URL, false)).rejects.toThrow();
1958
+
1959
+ // Second attempt should work (mockReady falls back to default resolved)
1960
+ mocks.mockReady.mockResolvedValueOnce(undefined);
1961
+ await manager.loadFont("https://fonts.test/retry.ttf", false);
1962
+
1963
+ expect(mocks.FontWorkerClient).toHaveBeenCalledTimes(2);
1964
+ });
1965
+ });
1966
+
1967
+ // -----------------------------------------------------------------------
1968
+ // dispose
1969
+ // -----------------------------------------------------------------------
1970
+
1971
+ describe("dispose", () => {
1972
+ it("should clear all caches", async () => {
1973
+ await manager.loadFont(FONT_URL, false);
1974
+ await manager.prepareText(FONT_URL, "hello", false);
1975
+ manager.registerFontFamily(createTestFamily());
1976
+
1977
+ manager.dispose();
1978
+
1979
+ expect(manager.isTextPrepared(FONT_URL, "hello", false)).toBe(false);
1980
+ expect(manager.getAtlas(FONT_URL, false)).toBeUndefined();
1981
+ expect(manager.getAtlasTexture(FONT_URL, false)).toBeNull();
1982
+ expect(manager.isFamily("TestFamily")).toBe(false);
1983
+ });
1984
+
1985
+ it("should dispose all cached textures", async () => {
1986
+ await manager.loadFont(FONT_URL, false);
1987
+ await manager.prepareText(FONT_URL, "hello", false);
1988
+ const tex = manager.getAtlasTexture(FONT_URL, false);
1989
+
1990
+ manager.dispose();
1991
+
1992
+ expect((tex as unknown as Record<string, unknown>).disposed).toBe(true);
1993
+ });
1994
+
1995
+ it("should dispose the worker client", async () => {
1996
+ await manager.loadFont(FONT_URL, false); // ensures client is created
1997
+ manager.dispose();
1998
+
1999
+ expect(mocks.mockClientDispose).toHaveBeenCalled();
2000
+ });
2001
+
2002
+ it("should not throw if called before a client was created", () => {
2003
+ expect(() => manager.dispose()).not.toThrow();
2004
+ });
2005
+
2006
+ it("should be safe to call multiple times", async () => {
2007
+ await manager.loadFont(FONT_URL, false);
2008
+ manager.dispose();
2009
+ expect(() => manager.dispose()).not.toThrow();
2010
+ });
2011
+ });
2012
+
2013
+ // -----------------------------------------------------------------------
2014
+ // Edge cases
2015
+ // -----------------------------------------------------------------------
2016
+
2017
+ describe("edge cases", () => {
2018
+ it("should reject all queued entries when _ensureClient fails during flush", async () => {
2019
+ // Load succeeds initially (client created)
2020
+ await manager.loadFont(FONT_URL, false);
2021
+
2022
+ // Now make prepareTextBatch fail in a way that simulates client issues
2023
+ mocks.mockPrepareTextBatch.mockRejectedValueOnce(
2024
+ new Error("connection lost"),
2025
+ );
2026
+
2027
+ await expect(
2028
+ manager.prepareText(FONT_URL, "oops", false),
2029
+ ).rejects.toThrow("connection lost");
2030
+ });
2031
+
2032
+ it("should handle preparing text for multiple fonts simultaneously", async () => {
2033
+ const urlA = "https://fonts.test/a.ttf";
2034
+ const urlB = "https://fonts.test/b.ttf";
2035
+ await manager.loadFont(urlA, false);
2036
+ await manager.loadFont(urlB, false);
2037
+
2038
+ await Promise.all([
2039
+ manager.prepareText(urlA, "alpha", false),
2040
+ manager.prepareText(urlB, "beta", false),
2041
+ ]);
2042
+
2043
+ expect(manager.isTextPrepared(urlA, "alpha", false)).toBe(true);
2044
+ expect(manager.isTextPrepared(urlB, "beta", false)).toBe(true);
2045
+ });
2046
+
2047
+ it("should handle batch returning null atlas", async () => {
2048
+ mocks.mockPrepareTextBatch.mockResolvedValueOnce({
2049
+ results: [{ text: "cached", shapeResult: createShapeResult("cached") }],
2050
+ atlas: null,
2051
+ colorAtlas: null,
2052
+ atlasKey: FONT_URL + "#q=false",
2053
+ } satisfies BatchPrepareTextResult);
2054
+
2055
+ await manager.loadFont(FONT_URL, false);
2056
+ await manager.prepareText(FONT_URL, "cached", false);
2057
+
2058
+ // No atlas was returned (all glyphs were already in atlas)
2059
+ // But shape result should still be cached
2060
+ expect(manager.isTextPrepared(FONT_URL, "cached", false)).toBe(true);
2061
+ });
2062
+
2063
+ it("should handle batch returning null shapeResult for a text entry", async () => {
2064
+ mocks.mockPrepareTextBatch.mockResolvedValueOnce({
2065
+ results: [{ text: "empty", shapeResult: null }],
2066
+ atlas: null,
2067
+ colorAtlas: null,
2068
+ atlasKey: FONT_URL + "#q=false",
2069
+ } satisfies BatchPrepareTextResult);
2070
+
2071
+ await manager.loadFont(FONT_URL, false);
2072
+ await manager.prepareText(FONT_URL, "empty", false);
2073
+
2074
+ // null shapeResult should not be cached
2075
+ expect(manager.isTextPrepared(FONT_URL, "empty", false)).toBe(false);
2076
+ });
2077
+
2078
+ it("should handle font family with empty text segments gracefully", async () => {
2079
+ manager.registerFontFamily(createTestFamily());
2080
+ setupBatchMock("TestFamily");
2081
+
2082
+ // Empty string returns early
2083
+ await manager.prepareText("TestFamily", "", false);
2084
+ expect(mocks.mockPrepareTextBatch).not.toHaveBeenCalled();
2085
+ });
2086
+ });
2087
+ });