@solidtv/renderer 1.6.2 → 1.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/exports/index.d.ts +1 -0
- package/dist/exports/index.js +3 -0
- package/dist/exports/index.js.map +1 -1
- package/dist/src/core/lib/ImageWorker.d.ts +2 -1
- package/dist/src/core/lib/ImageWorker.js +25 -3
- package/dist/src/core/lib/ImageWorker.js.map +1 -1
- package/dist/src/core/text-rendering/CanvasFontHandler.js +28 -8
- package/dist/src/core/text-rendering/CanvasFontHandler.js.map +1 -1
- package/dist/src/core/text-rendering/FontPrefetch.d.ts +55 -0
- package/dist/src/core/text-rendering/FontPrefetch.js +140 -0
- package/dist/src/core/text-rendering/FontPrefetch.js.map +1 -0
- package/dist/src/core/text-rendering/SdfFontHandler.js +42 -10
- package/dist/src/core/text-rendering/SdfFontHandler.js.map +1 -1
- package/dist/src/main-api/Renderer.d.ts +10 -10
- package/dist/src/main-api/Renderer.js +3 -3
- package/dist/src/main-api/Renderer.js.map +1 -1
- package/dist/tsconfig.dist.tsbuildinfo +1 -1
- package/exports/index.ts +7 -0
- package/package.json +1 -1
- package/src/core/lib/ImageWorker.test.ts +26 -2
- package/src/core/lib/ImageWorker.ts +24 -3
- package/src/core/text-rendering/CanvasFontHandler.ts +32 -11
- package/src/core/text-rendering/FontPrefetch.ts +195 -0
- package/src/core/text-rendering/SdfFontHandler.ts +50 -10
- package/src/core/text-rendering/tests/FontPrefetch.test.ts +341 -0
- package/src/core/text-rendering/tests/SdfFontHandler.test.ts +43 -0
- package/src/main-api/Renderer.ts +13 -13
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Font prefetching — stage-free.
|
|
3
|
+
*
|
|
4
|
+
* Font bytes normally only start moving once a Stage exists, because
|
|
5
|
+
* `Stage.loadFont()` is the only entry point. That serializes the network
|
|
6
|
+
* behind the whole synchronous boot (GL context, buffers, shaders, root node)
|
|
7
|
+
* and, for SDF, behind the texture manager's async init as well.
|
|
8
|
+
*
|
|
9
|
+
* Nothing about *fetching* a font needs a Stage: the SDF atlas is a JSON
|
|
10
|
+
* document plus an image, and a web font is a `FontFace`. This module starts
|
|
11
|
+
* that work as early as an app can call it — typically before the renderer is
|
|
12
|
+
* constructed — and parks the results. The font handlers pick them up from
|
|
13
|
+
* here when `loadFont()` eventually runs, so the round-trip overlaps CPU boot
|
|
14
|
+
* instead of following it.
|
|
15
|
+
*
|
|
16
|
+
* Everything here uses XHR rather than `fetch` to match the rest of the
|
|
17
|
+
* renderer (Chrome 38 / webOS 3 have no `fetch`).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { FontLoadOptions } from './TextRenderer.js';
|
|
21
|
+
import type { SdfFontData } from './SdfFontHandler.js';
|
|
22
|
+
import { fetchJson } from '../lib/utils.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A prefetched SDF font: the parsed atlas description and the atlas image,
|
|
26
|
+
* fetched concurrently. Either promise resolves to `null` if its request
|
|
27
|
+
* failed — the handler then falls back to its normal load path rather than
|
|
28
|
+
* inheriting the failure.
|
|
29
|
+
*/
|
|
30
|
+
export interface PrefetchedSdfFont {
|
|
31
|
+
data: Promise<SdfFontData | null>;
|
|
32
|
+
atlas: Promise<Blob | null>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const sdfPrefetch = new Map<string, PrefetchedSdfFont>();
|
|
36
|
+
const canvasPrefetch = new Map<string, Promise<FontFace[] | null>>();
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Options accepted by {@link prefetchFont}. Identical to the options passed to
|
|
40
|
+
* `Stage.loadFont()`, plus the optional SDF `type` discriminator that
|
|
41
|
+
* framework layers already carry on their font descriptors.
|
|
42
|
+
*/
|
|
43
|
+
export type FontPrefetchOptions = FontLoadOptions & {
|
|
44
|
+
type?: 'ssdf' | 'msdf' | 'canvas';
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Swallow a rejection into `null` so a prefetch that fails can never surface
|
|
49
|
+
* as an unhandled rejection. A prefetch is an optimization: when it fails the
|
|
50
|
+
* handler just does the work itself.
|
|
51
|
+
*/
|
|
52
|
+
const orNull = <T>(promise: Promise<T>): Promise<T | null> =>
|
|
53
|
+
promise.then(
|
|
54
|
+
(value) => value,
|
|
55
|
+
() => null,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Normalize an atlas description response into usable font data, or `null` if
|
|
60
|
+
* it is unusable.
|
|
61
|
+
*
|
|
62
|
+
* `fetchJson` resolves rather than rejects on a status of 0 (the file://
|
|
63
|
+
* allowance), so a failed request arrives here as `null`. Engines without
|
|
64
|
+
* `responseType = 'json'` support hand back the raw string instead of a parsed
|
|
65
|
+
* object — the SDF handler tolerates both, so this does too.
|
|
66
|
+
*/
|
|
67
|
+
const normalizeSdfData = (response: unknown): SdfFontData | null => {
|
|
68
|
+
let data = response;
|
|
69
|
+
|
|
70
|
+
if (typeof data === 'string') {
|
|
71
|
+
try {
|
|
72
|
+
data = JSON.parse(data);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (data === null || typeof data !== 'object') {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Reject anything the handler would reject anyway, so a malformed prefetch
|
|
83
|
+
// falls back to a fresh fetch instead of failing the load outright.
|
|
84
|
+
return (data as SdfFontData).chars !== undefined
|
|
85
|
+
? (data as SdfFontData)
|
|
86
|
+
: null;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Start downloading a font before a renderer exists.
|
|
91
|
+
*
|
|
92
|
+
* Safe to call more than once for the same font — the first call wins and
|
|
93
|
+
* later ones are ignored. Never throws and never rejects; failures degrade to
|
|
94
|
+
* the handler's normal load path.
|
|
95
|
+
*
|
|
96
|
+
* The descriptor decides what is fetched: an `atlasDataUrl` means an SDF font
|
|
97
|
+
* (atlas JSON + atlas image, in parallel), otherwise a `fontUrl` means a web
|
|
98
|
+
* font (`FontFace.load()`).
|
|
99
|
+
*
|
|
100
|
+
* @param options - The same options later passed to `Stage.loadFont()`
|
|
101
|
+
*/
|
|
102
|
+
export const prefetchFont = (options: FontPrefetchOptions): void => {
|
|
103
|
+
const { fontFamily, fontUrl, atlasUrl, atlasDataUrl } = options;
|
|
104
|
+
const names = Array.isArray(fontFamily) ? fontFamily : [fontFamily];
|
|
105
|
+
const primary = names[0];
|
|
106
|
+
|
|
107
|
+
if (primary === undefined) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// SDF: atlas description and atlas image are independent requests. The
|
|
112
|
+
// handler fetches them sequentially (it needs the JSON before it knows the
|
|
113
|
+
// atlas is usable); here there is no such constraint, so overlap them.
|
|
114
|
+
if (atlasDataUrl !== undefined && options.type !== 'canvas') {
|
|
115
|
+
if (sdfPrefetch.has(primary) === true) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
sdfPrefetch.set(primary, {
|
|
120
|
+
data: orNull(fetchJson(atlasDataUrl, 'json')).then(normalizeSdfData),
|
|
121
|
+
atlas:
|
|
122
|
+
atlasUrl !== undefined
|
|
123
|
+
? orNull(fetchJson(atlasUrl, 'blob') as Promise<Blob>).then((blob) =>
|
|
124
|
+
blob instanceof Blob ? blob : null,
|
|
125
|
+
)
|
|
126
|
+
: Promise.resolve(null),
|
|
127
|
+
});
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Web font: `FontFace.load()` starts the download immediately. The face is
|
|
132
|
+
// not registered with the document here — that needs the platform, so the
|
|
133
|
+
// handler does it at attach time.
|
|
134
|
+
if (fontUrl !== undefined) {
|
|
135
|
+
if (canvasPrefetch.has(primary) === true) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (typeof FontFace === 'undefined') {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
canvasPrefetch.set(
|
|
144
|
+
primary,
|
|
145
|
+
orNull(
|
|
146
|
+
Promise.all(
|
|
147
|
+
names.map((name) => new FontFace(name, `url(${fontUrl})`).load()),
|
|
148
|
+
),
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Claim a prefetched SDF font, removing it from the pool.
|
|
156
|
+
*
|
|
157
|
+
* One-shot by design: a load that fails and retries must go back to the
|
|
158
|
+
* network rather than replaying a stale (possibly failed) prefetch.
|
|
159
|
+
*
|
|
160
|
+
* @param fontFamily - Primary font family name
|
|
161
|
+
*/
|
|
162
|
+
export const takeSdfPrefetch = (
|
|
163
|
+
fontFamily: string,
|
|
164
|
+
): PrefetchedSdfFont | undefined => {
|
|
165
|
+
const prefetched = sdfPrefetch.get(fontFamily);
|
|
166
|
+
if (prefetched !== undefined) {
|
|
167
|
+
sdfPrefetch.delete(fontFamily);
|
|
168
|
+
}
|
|
169
|
+
return prefetched;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Claim prefetched web font faces, removing them from the pool. One-shot for
|
|
174
|
+
* the same reason as {@link takeSdfPrefetch}.
|
|
175
|
+
*
|
|
176
|
+
* @param fontFamily - Primary font family name
|
|
177
|
+
*/
|
|
178
|
+
export const takeCanvasPrefetch = (
|
|
179
|
+
fontFamily: string,
|
|
180
|
+
): Promise<FontFace[] | null> | undefined => {
|
|
181
|
+
const prefetched = canvasPrefetch.get(fontFamily);
|
|
182
|
+
if (prefetched !== undefined) {
|
|
183
|
+
canvasPrefetch.delete(fontFamily);
|
|
184
|
+
}
|
|
185
|
+
return prefetched;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Drop every pending prefetch. Test/teardown helper — in-flight requests are
|
|
190
|
+
* not aborted, their results are simply no longer claimable.
|
|
191
|
+
*/
|
|
192
|
+
export const clearFontPrefetch = (): void => {
|
|
193
|
+
sdfPrefetch.clear();
|
|
194
|
+
canvasPrefetch.clear();
|
|
195
|
+
};
|
|
@@ -13,6 +13,7 @@ import { hasZeroWidthSpace } from './Utils.js';
|
|
|
13
13
|
import { normalizeFontMetrics } from './TextLayoutEngine.js';
|
|
14
14
|
import { isProductionEnvironment } from '../../utils.js';
|
|
15
15
|
import type { TextureError } from '../TextureError.js';
|
|
16
|
+
import { takeSdfPrefetch } from './FontPrefetch.js';
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* SDF Font Data structure matching msdf-bmfont-xml output
|
|
@@ -315,6 +316,11 @@ export const loadFont = (
|
|
|
315
316
|
const names = Array.isArray(fontFamily) ? fontFamily : [fontFamily];
|
|
316
317
|
const primary = names[0]!;
|
|
317
318
|
|
|
319
|
+
// A prefetch started before the renderer existed, if any. Claimed here —
|
|
320
|
+
// ahead of the early returns — so that a font which turns out to need no
|
|
321
|
+
// load still releases the prefetched atlas blob rather than pinning it.
|
|
322
|
+
const prefetched = takeSdfPrefetch(primary);
|
|
323
|
+
|
|
318
324
|
// Early return if already loaded
|
|
319
325
|
if (fontCache.get(primary) !== undefined) {
|
|
320
326
|
return Promise.resolve();
|
|
@@ -380,7 +386,15 @@ export const loadFont = (
|
|
|
380
386
|
// success it processes + caches the font and wakes parked nodes. On failure
|
|
381
387
|
// it drops the dead atlas texture (createTexture caches by `src`, so the
|
|
382
388
|
// next attempt must evict it to build a fresh one) and rejects.
|
|
383
|
-
|
|
389
|
+
//
|
|
390
|
+
// `atlasBlob` is the prefetched atlas image when one was claimed. Passing it
|
|
391
|
+
// as `src` skips a second download; `key` is set to the atlas URL so the
|
|
392
|
+
// texture cache key is identical to the one the URL path produces — the two
|
|
393
|
+
// paths must not produce two cache entries for the same atlas.
|
|
394
|
+
const loadAtlas = (
|
|
395
|
+
fontData: SdfFontData,
|
|
396
|
+
atlasBlob: Blob | null,
|
|
397
|
+
): Promise<void> => {
|
|
384
398
|
if (!fontData || !fontData.chars) {
|
|
385
399
|
return Promise.reject(new Error('Invalid SDF font data format'));
|
|
386
400
|
}
|
|
@@ -395,7 +409,8 @@ export const loadFont = (
|
|
|
395
409
|
return new Promise<void>((resolve, reject) => {
|
|
396
410
|
// create new atlas texture using ImageTexture
|
|
397
411
|
const atlasTexture = stage.txManager.createTexture('ImageTexture', {
|
|
398
|
-
src: atlasUrl,
|
|
412
|
+
src: atlasBlob !== null ? atlasBlob : atlasUrl,
|
|
413
|
+
key: atlasUrl,
|
|
399
414
|
premultiplyAlpha: false,
|
|
400
415
|
});
|
|
401
416
|
|
|
@@ -454,15 +469,31 @@ export const loadFont = (
|
|
|
454
469
|
});
|
|
455
470
|
};
|
|
456
471
|
|
|
457
|
-
// Initial attempt plus up to MAX_FONT_LOAD_RETRIES automatic reloads.
|
|
472
|
+
// Initial attempt plus up to MAX_FONT_LOAD_RETRIES automatic reloads. Only
|
|
473
|
+
// the first attempt uses the prefetch; a retry goes back to the network
|
|
474
|
+
// rather than replaying a payload that may be exactly what failed.
|
|
458
475
|
const loadPromise = (async (): Promise<void> => {
|
|
459
476
|
let lastError: unknown;
|
|
460
477
|
for (let attempt = 0; attempt <= MAX_FONT_LOAD_RETRIES; attempt++) {
|
|
461
478
|
try {
|
|
462
|
-
|
|
463
|
-
//
|
|
464
|
-
|
|
465
|
-
|
|
479
|
+
// Both halves of a prefetch are already in flight in parallel; the
|
|
480
|
+
// fallbacks below are the original sequential path.
|
|
481
|
+
const fontData =
|
|
482
|
+
(attempt === 0 && prefetched !== undefined
|
|
483
|
+
? await prefetched.data
|
|
484
|
+
: null) ?? (await fetchFontData());
|
|
485
|
+
|
|
486
|
+
const atlasBlob =
|
|
487
|
+
attempt === 0 && prefetched !== undefined
|
|
488
|
+
? await prefetched.atlas
|
|
489
|
+
: null;
|
|
490
|
+
|
|
491
|
+
await loadAtlas(fontData, atlasBlob);
|
|
492
|
+
// Success: clear the in-flight markers (the font now lives in
|
|
493
|
+
// fontCache) — parked nodes were already woken inside loadAtlas.
|
|
494
|
+
for (let i = 0; i < names.length; i++) {
|
|
495
|
+
fontLoadPromises.delete(names[i]!);
|
|
496
|
+
}
|
|
466
497
|
return;
|
|
467
498
|
} catch (error) {
|
|
468
499
|
lastError = error;
|
|
@@ -477,21 +508,30 @@ export const loadFont = (
|
|
|
477
508
|
}
|
|
478
509
|
}
|
|
479
510
|
|
|
480
|
-
// Every attempt failed. Clear the in-flight
|
|
511
|
+
// Every attempt failed. Clear the in-flight markers so the font can be
|
|
481
512
|
// requested again and drop any partial cache entry. nodesWaitingForFont
|
|
482
513
|
// is deliberately kept: nodes parked here must survive so a later
|
|
483
514
|
// loadFont() (which reuses the list) can still wake them if the font
|
|
484
515
|
// eventually loads. The list shrinks as nodes self-remove via
|
|
485
516
|
// stopWaitingForFont on destroy.
|
|
486
|
-
fontLoadPromises.delete(primary);
|
|
487
517
|
for (let i = 0; i < names.length; i++) {
|
|
518
|
+
fontLoadPromises.delete(names[i]!);
|
|
488
519
|
fontCache.delete(names[i]!);
|
|
489
520
|
}
|
|
490
521
|
console.error(`Failed to load SDF font: ${primary}`, lastError);
|
|
491
522
|
throw lastError;
|
|
492
523
|
})();
|
|
493
524
|
|
|
494
|
-
|
|
525
|
+
// Mark the load in flight under *every* name, not just the primary.
|
|
526
|
+
// `canRenderFont` treats a name with no cache entry and no in-flight promise
|
|
527
|
+
// as unrenderable, so keying only the primary made an alias unresolvable for
|
|
528
|
+
// the whole load: the stage would either fall through to the Canvas engine
|
|
529
|
+
// (permanently, with a substitute face) or, on an SDF-only engine list,
|
|
530
|
+
// throw "No compatible text renderer found". Registering every name also
|
|
531
|
+
// makes a later loadFont() for an alias dedupe onto this same load.
|
|
532
|
+
for (let i = 0; i < names.length; i++) {
|
|
533
|
+
fontLoadPromises.set(names[i]!, loadPromise);
|
|
534
|
+
}
|
|
495
535
|
return loadPromise;
|
|
496
536
|
};
|
|
497
537
|
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
prefetchFont,
|
|
4
|
+
clearFontPrefetch,
|
|
5
|
+
takeSdfPrefetch,
|
|
6
|
+
takeCanvasPrefetch,
|
|
7
|
+
} from '../FontPrefetch.js';
|
|
8
|
+
import { loadFont as loadSdfFont, isFontLoaded } from '../SdfFontHandler.js';
|
|
9
|
+
import {
|
|
10
|
+
loadFont as loadCanvasFont,
|
|
11
|
+
isFontLoaded as isCanvasFontLoaded,
|
|
12
|
+
} from '../CanvasFontHandler.js';
|
|
13
|
+
import { EventEmitter } from '../../../common/EventEmitter.js';
|
|
14
|
+
import type { Stage } from '../../Stage.js';
|
|
15
|
+
import type { ImageTextureProps } from '../../textures/ImageTexture.js';
|
|
16
|
+
|
|
17
|
+
const VALID_FONT_DATA = {
|
|
18
|
+
chars: [{}],
|
|
19
|
+
kernings: [],
|
|
20
|
+
lightningMetrics: {
|
|
21
|
+
ascender: 800,
|
|
22
|
+
descender: -200,
|
|
23
|
+
lineGap: 200,
|
|
24
|
+
unitsPerEm: 1000,
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Records every request so tests can assert what was fetched, when, and in
|
|
29
|
+
// what order. Responses are keyed by URL and delivered on demand, letting a
|
|
30
|
+
// test hold a request open to observe concurrency.
|
|
31
|
+
class RecordingXHR {
|
|
32
|
+
static requests: RecordingXHR[] = [];
|
|
33
|
+
static responses: Record<string, unknown> = {};
|
|
34
|
+
|
|
35
|
+
status = 200;
|
|
36
|
+
response: unknown = null;
|
|
37
|
+
responseType = '';
|
|
38
|
+
url = '';
|
|
39
|
+
onload: (() => void) | null = null;
|
|
40
|
+
onerror: (() => void) | null = null;
|
|
41
|
+
onreadystatechange: (() => void) | null = null;
|
|
42
|
+
readyState = 0;
|
|
43
|
+
|
|
44
|
+
open(_method: string, url: string): void {
|
|
45
|
+
this.url = url;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
send(): void {
|
|
49
|
+
RecordingXHR.requests.push(this);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Deliver the configured response for this request's URL. */
|
|
53
|
+
respond(status = 200): void {
|
|
54
|
+
this.status = status;
|
|
55
|
+
this.response = RecordingXHR.responses[this.url] ?? null;
|
|
56
|
+
this.readyState = 4;
|
|
57
|
+
if (this.onreadystatechange !== null) this.onreadystatechange();
|
|
58
|
+
if (this.onload !== null) this.onload();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Deliver every outstanding request. */
|
|
62
|
+
static respondAll(): void {
|
|
63
|
+
RecordingXHR.requests.forEach((r) => r.respond());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
static reset(): void {
|
|
67
|
+
RecordingXHR.requests = [];
|
|
68
|
+
RecordingXHR.responses = {};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// `XMLHttpRequest.DONE` is read by fetchJson; the class stand-in must carry it.
|
|
73
|
+
(RecordingXHR as unknown as { DONE: number }).DONE = 4;
|
|
74
|
+
|
|
75
|
+
function makeFakeTexture() {
|
|
76
|
+
const tex = new EventEmitter() as unknown as EventEmitter & {
|
|
77
|
+
state: string;
|
|
78
|
+
preventCleanup: boolean;
|
|
79
|
+
setRenderableOwner: (owner: string, val: boolean) => void;
|
|
80
|
+
};
|
|
81
|
+
tex.state = 'loading';
|
|
82
|
+
tex.preventCleanup = false;
|
|
83
|
+
tex.setRenderableOwner = () => {};
|
|
84
|
+
return tex;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type FakeTexture = ReturnType<typeof makeFakeTexture>;
|
|
88
|
+
|
|
89
|
+
// Stage stub that records the props each atlas texture was created with, so
|
|
90
|
+
// tests can assert whether the prefetched blob (or the URL) was used.
|
|
91
|
+
function makeStage(): {
|
|
92
|
+
stage: Stage;
|
|
93
|
+
textures: FakeTexture[];
|
|
94
|
+
textureProps: ImageTextureProps[];
|
|
95
|
+
} {
|
|
96
|
+
const textures: FakeTexture[] = [];
|
|
97
|
+
const textureProps: ImageTextureProps[] = [];
|
|
98
|
+
const stage = {
|
|
99
|
+
txManager: {
|
|
100
|
+
createTexture: (_type: string, props: ImageTextureProps) => {
|
|
101
|
+
textureProps.push(props);
|
|
102
|
+
const t = makeFakeTexture();
|
|
103
|
+
textures.push(t);
|
|
104
|
+
return t;
|
|
105
|
+
},
|
|
106
|
+
removeTextureFromCache: () => {},
|
|
107
|
+
},
|
|
108
|
+
} as unknown as Stage;
|
|
109
|
+
return { stage, textures, textureProps };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
|
|
113
|
+
|
|
114
|
+
const sdfOpts = (fontFamily: string) => ({
|
|
115
|
+
fontFamily,
|
|
116
|
+
atlasUrl: `${fontFamily}.png`,
|
|
117
|
+
atlasDataUrl: `${fontFamily}.json`,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('FontPrefetch — SDF', () => {
|
|
121
|
+
let originalXHR: unknown;
|
|
122
|
+
let originalBlob: unknown;
|
|
123
|
+
|
|
124
|
+
beforeEach(() => {
|
|
125
|
+
originalXHR = (globalThis as unknown as { XMLHttpRequest: unknown })
|
|
126
|
+
.XMLHttpRequest;
|
|
127
|
+
(globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest =
|
|
128
|
+
RecordingXHR;
|
|
129
|
+
// jsdom provides Blob; guard for environments that do not.
|
|
130
|
+
originalBlob = (globalThis as unknown as { Blob: unknown }).Blob;
|
|
131
|
+
RecordingXHR.reset();
|
|
132
|
+
clearFontPrefetch();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
afterEach(() => {
|
|
136
|
+
(globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest =
|
|
137
|
+
originalXHR;
|
|
138
|
+
(globalThis as unknown as { Blob: unknown }).Blob = originalBlob;
|
|
139
|
+
clearFontPrefetch();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('requests the atlas description and the atlas image concurrently', () => {
|
|
143
|
+
prefetchFont(sdfOpts('ConcurrentFont'));
|
|
144
|
+
|
|
145
|
+
// Both are in flight before either has responded — the handler's own path
|
|
146
|
+
// only starts the image fetch after the JSON resolves.
|
|
147
|
+
expect(RecordingXHR.requests.map((r) => r.url).sort()).toEqual([
|
|
148
|
+
'ConcurrentFont.json',
|
|
149
|
+
'ConcurrentFont.png',
|
|
150
|
+
]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('starts no request for a font it has already prefetched', () => {
|
|
154
|
+
prefetchFont(sdfOpts('DedupeFont'));
|
|
155
|
+
expect(RecordingXHR.requests.length).toBe(2);
|
|
156
|
+
|
|
157
|
+
prefetchFont(sdfOpts('DedupeFont'));
|
|
158
|
+
expect(RecordingXHR.requests.length).toBe(2);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('hands the prefetched payload to loadFont instead of refetching', async () => {
|
|
162
|
+
const font = sdfOpts('PrefetchHit');
|
|
163
|
+
const blob = new Blob(['atlas-bytes']);
|
|
164
|
+
RecordingXHR.responses['PrefetchHit.json'] = VALID_FONT_DATA;
|
|
165
|
+
RecordingXHR.responses['PrefetchHit.png'] = blob;
|
|
166
|
+
|
|
167
|
+
prefetchFont(font);
|
|
168
|
+
RecordingXHR.respondAll();
|
|
169
|
+
const prefetchRequestCount = RecordingXHR.requests.length;
|
|
170
|
+
|
|
171
|
+
const { stage, textures, textureProps } = makeStage();
|
|
172
|
+
const promise = loadSdfFont(
|
|
173
|
+
stage,
|
|
174
|
+
font as Parameters<typeof loadSdfFont>[1],
|
|
175
|
+
);
|
|
176
|
+
await flush();
|
|
177
|
+
textures[0]!.emit('loaded');
|
|
178
|
+
await promise;
|
|
179
|
+
|
|
180
|
+
// No second trip to the network for either resource.
|
|
181
|
+
expect(RecordingXHR.requests.length).toBe(prefetchRequestCount);
|
|
182
|
+
// The atlas texture was built from the prefetched bytes...
|
|
183
|
+
expect(textureProps[0]!.src).toBe(blob);
|
|
184
|
+
// ...while still keying the texture cache by URL, so the blob path and the
|
|
185
|
+
// URL path resolve to the same cache entry.
|
|
186
|
+
expect(textureProps[0]!.key).toBe('PrefetchHit.png');
|
|
187
|
+
expect(isFontLoaded('PrefetchHit')).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('falls back to fetching when the prefetch failed', async () => {
|
|
191
|
+
const font = sdfOpts('PrefetchFail');
|
|
192
|
+
// No responses registered -> fetchJson resolves null -> unusable prefetch.
|
|
193
|
+
prefetchFont(font);
|
|
194
|
+
RecordingXHR.respondAll();
|
|
195
|
+
RecordingXHR.reset();
|
|
196
|
+
|
|
197
|
+
RecordingXHR.responses['PrefetchFail.json'] = VALID_FONT_DATA;
|
|
198
|
+
|
|
199
|
+
const { stage, textures, textureProps } = makeStage();
|
|
200
|
+
const promise = loadSdfFont(
|
|
201
|
+
stage,
|
|
202
|
+
font as Parameters<typeof loadSdfFont>[1],
|
|
203
|
+
);
|
|
204
|
+
// The handler runs its own XHR for the atlas description.
|
|
205
|
+
await flush();
|
|
206
|
+
RecordingXHR.respondAll();
|
|
207
|
+
await flush();
|
|
208
|
+
textures[0]!.emit('loaded');
|
|
209
|
+
await promise;
|
|
210
|
+
|
|
211
|
+
expect(RecordingXHR.requests.map((r) => r.url)).toEqual([
|
|
212
|
+
'PrefetchFail.json',
|
|
213
|
+
]);
|
|
214
|
+
// Atlas falls back to the URL rather than a blob.
|
|
215
|
+
expect(textureProps[0]!.src).toBe('PrefetchFail.png');
|
|
216
|
+
expect(isFontLoaded('PrefetchFail')).toBe(true);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('rejects a malformed atlas description rather than loading it', async () => {
|
|
220
|
+
RecordingXHR.responses['Malformed.json'] = { not: 'font data' };
|
|
221
|
+
prefetchFont(sdfOpts('Malformed'));
|
|
222
|
+
RecordingXHR.respondAll();
|
|
223
|
+
|
|
224
|
+
await expect(takeSdfPrefetch('Malformed')!.data).resolves.toBe(null);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('parses an atlas description that arrives as a string', async () => {
|
|
228
|
+
RecordingXHR.responses['StringJson.json'] = JSON.stringify(VALID_FONT_DATA);
|
|
229
|
+
prefetchFont(sdfOpts('StringJson'));
|
|
230
|
+
RecordingXHR.respondAll();
|
|
231
|
+
|
|
232
|
+
await expect(takeSdfPrefetch('StringJson')!.data).resolves.toMatchObject({
|
|
233
|
+
chars: [{}],
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('claims a prefetch only once', () => {
|
|
238
|
+
prefetchFont(sdfOpts('OnceOnly'));
|
|
239
|
+
|
|
240
|
+
expect(takeSdfPrefetch('OnceOnly')).not.toBe(undefined);
|
|
241
|
+
expect(takeSdfPrefetch('OnceOnly')).toBe(undefined);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('ignores a canvas-typed descriptor even when it carries atlas urls', () => {
|
|
245
|
+
prefetchFont({ ...sdfOpts('CanvasTyped'), type: 'canvas' });
|
|
246
|
+
|
|
247
|
+
expect(takeSdfPrefetch('CanvasTyped')).toBe(undefined);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe('FontPrefetch — web fonts', () => {
|
|
252
|
+
class FakeFontFace {
|
|
253
|
+
static created: FakeFontFace[] = [];
|
|
254
|
+
constructor(public family: string, public source: string) {
|
|
255
|
+
FakeFontFace.created.push(this);
|
|
256
|
+
}
|
|
257
|
+
load(): Promise<FakeFontFace> {
|
|
258
|
+
return Promise.resolve(this);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
let originalFontFace: unknown;
|
|
263
|
+
|
|
264
|
+
beforeEach(() => {
|
|
265
|
+
originalFontFace = (globalThis as unknown as { FontFace: unknown })
|
|
266
|
+
.FontFace;
|
|
267
|
+
(globalThis as unknown as { FontFace: unknown }).FontFace = FakeFontFace;
|
|
268
|
+
FakeFontFace.created = [];
|
|
269
|
+
clearFontPrefetch();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
afterEach(() => {
|
|
273
|
+
(globalThis as unknown as { FontFace: unknown }).FontFace =
|
|
274
|
+
originalFontFace;
|
|
275
|
+
clearFontPrefetch();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('starts the download for every registered name', async () => {
|
|
279
|
+
prefetchFont({
|
|
280
|
+
fontFamily: ['WebPrimary', 'WebAlias'],
|
|
281
|
+
fontUrl: 'web.woff2',
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
expect(FakeFontFace.created.map((f) => f.family)).toEqual([
|
|
285
|
+
'WebPrimary',
|
|
286
|
+
'WebAlias',
|
|
287
|
+
]);
|
|
288
|
+
await expect(takeCanvasPrefetch('WebPrimary')).resolves.toHaveLength(2);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('resolves to null when the download fails, so the handler can retry', async () => {
|
|
292
|
+
vi.spyOn(FakeFontFace.prototype, 'load').mockRejectedValueOnce(
|
|
293
|
+
new Error('network'),
|
|
294
|
+
);
|
|
295
|
+
prefetchFont({ fontFamily: 'WebFail', fontUrl: 'web.woff2' });
|
|
296
|
+
|
|
297
|
+
await expect(takeCanvasPrefetch('WebFail')).resolves.toBe(null);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('does nothing when the engine has no FontFace', () => {
|
|
301
|
+
(globalThis as unknown as { FontFace: unknown }).FontFace = undefined;
|
|
302
|
+
prefetchFont({ fontFamily: 'NoFontFace', fontUrl: 'web.woff2' });
|
|
303
|
+
|
|
304
|
+
expect(takeCanvasPrefetch('NoFontFace')).toBe(undefined);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('registers prefetched faces with the platform without reloading them', async () => {
|
|
308
|
+
prefetchFont({ fontFamily: 'CanvasHit', fontUrl: 'web.woff2' });
|
|
309
|
+
const createdByPrefetch = FakeFontFace.created.length;
|
|
310
|
+
|
|
311
|
+
const added: FakeFontFace[] = [];
|
|
312
|
+
const stage = {
|
|
313
|
+
platform: { addFont: (f: FakeFontFace) => added.push(f) },
|
|
314
|
+
} as unknown as Stage;
|
|
315
|
+
|
|
316
|
+
await loadCanvasFont(stage, {
|
|
317
|
+
fontFamily: 'CanvasHit',
|
|
318
|
+
fontUrl: 'web.woff2',
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// No extra FontFace was constructed — the prefetched one was used.
|
|
322
|
+
expect(FakeFontFace.created.length).toBe(createdByPrefetch);
|
|
323
|
+
expect(added).toEqual(FakeFontFace.created);
|
|
324
|
+
expect(isCanvasFontLoaded('CanvasHit')).toBe(true);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('loads normally when there is no prefetch to claim', async () => {
|
|
328
|
+
const added: FakeFontFace[] = [];
|
|
329
|
+
const stage = {
|
|
330
|
+
platform: { addFont: (f: FakeFontFace) => added.push(f) },
|
|
331
|
+
} as unknown as Stage;
|
|
332
|
+
|
|
333
|
+
await loadCanvasFont(stage, {
|
|
334
|
+
fontFamily: 'CanvasMiss',
|
|
335
|
+
fontUrl: 'web.woff2',
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
expect(FakeFontFace.created.map((f) => f.family)).toEqual(['CanvasMiss']);
|
|
339
|
+
expect(isCanvasFontLoaded('CanvasMiss')).toBe(true);
|
|
340
|
+
});
|
|
341
|
+
});
|