@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.
- package/LICENSE +8 -0
- package/README.md +3 -0
- package/dist/index.d.ts +556 -0
- package/dist/index.js +3517 -0
- package/dist/index.umd.cjs +1 -0
- package/package.json +51 -0
- package/src/FontManager.test.ts +2087 -0
- package/src/FontManager.ts +1064 -0
- package/src/FontWorkerClient.ts +183 -0
- package/src/LRUMap.test.ts +124 -0
- package/src/LRUMap.ts +57 -0
- package/src/cssFontFamily.test.ts +230 -0
- package/src/cssFontFamily.ts +286 -0
- package/src/fontWorker.ts +255 -0
- package/src/index.ts +28 -0
- package/src/types.ts +150 -0
- package/src/vite-env.d.ts +1 -0
|
@@ -0,0 +1,1064 @@
|
|
|
1
|
+
import type { ConcurrencyManager } from "@navaramap/worker";
|
|
2
|
+
import {
|
|
3
|
+
DataTexture,
|
|
4
|
+
LinearFilter,
|
|
5
|
+
NoColorSpace,
|
|
6
|
+
RedFormat,
|
|
7
|
+
RGBAFormat,
|
|
8
|
+
SRGBColorSpace,
|
|
9
|
+
UnsignedByteType,
|
|
10
|
+
} from "three";
|
|
11
|
+
|
|
12
|
+
import { FontWorkerClient } from "./FontWorkerClient";
|
|
13
|
+
import { LRUMap } from "./LRUMap";
|
|
14
|
+
import type {
|
|
15
|
+
FontAtlasData,
|
|
16
|
+
FontFace,
|
|
17
|
+
FontFamily,
|
|
18
|
+
FontWorkerMemoryStats,
|
|
19
|
+
GlyphMetrics,
|
|
20
|
+
ShapedGlyph,
|
|
21
|
+
ShapeTextResult,
|
|
22
|
+
UnicodeRange,
|
|
23
|
+
} from "./types";
|
|
24
|
+
|
|
25
|
+
/** Internal cache key combining an identifier with a quality, so SDF and
|
|
26
|
+
* MSDF variants of the same font live as separate entries. */
|
|
27
|
+
const _q = (identifier: string, highQuality: boolean): string =>
|
|
28
|
+
`${identifier}#q=${highQuality}`;
|
|
29
|
+
|
|
30
|
+
/** Create an SDF/MSDF distance-field atlas DataTexture.
|
|
31
|
+
*
|
|
32
|
+
* `channels`: 1 → R8 SDF (sampled as `.r`); 4 → RGBA8 MTSDF (three MSDF
|
|
33
|
+
* channels + true SDF in alpha, sampled as `median(.rgb)`). RGB8 isn't
|
|
34
|
+
* supported because three.js dropped `RGBFormat` in r137. */
|
|
35
|
+
export function createSdfAtlasTexture(
|
|
36
|
+
data: Uint8Array,
|
|
37
|
+
width: number,
|
|
38
|
+
height: number,
|
|
39
|
+
channels: number,
|
|
40
|
+
): DataTexture {
|
|
41
|
+
const format = channels === 4 ? RGBAFormat : RedFormat;
|
|
42
|
+
// Distance values are linear, not sRGB.
|
|
43
|
+
return createAtlasTexture(data, width, height, format, NoColorSpace);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Create an RGBA color atlas DataTexture for COLRv1 glyphs.
|
|
47
|
+
* Pixel data is unpremultiplied sRGB; three.js linearizes on sample. */
|
|
48
|
+
export function createColorAtlasTexture(
|
|
49
|
+
data: Uint8Array,
|
|
50
|
+
width: number,
|
|
51
|
+
height: number,
|
|
52
|
+
): DataTexture {
|
|
53
|
+
return createAtlasTexture(data, width, height, RGBAFormat, SRGBColorSpace);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function createAtlasTexture(
|
|
57
|
+
data: Uint8Array,
|
|
58
|
+
width: number,
|
|
59
|
+
height: number,
|
|
60
|
+
format: typeof RedFormat | typeof RGBAFormat,
|
|
61
|
+
colorSpace: typeof NoColorSpace | typeof SRGBColorSpace,
|
|
62
|
+
): DataTexture {
|
|
63
|
+
const tex = new DataTexture(data, width, height, format, UnsignedByteType);
|
|
64
|
+
tex.minFilter = LinearFilter;
|
|
65
|
+
tex.magFilter = LinearFilter;
|
|
66
|
+
tex.generateMipmaps = false;
|
|
67
|
+
tex.colorSpace = colorSpace;
|
|
68
|
+
tex.needsUpdate = true;
|
|
69
|
+
return tex;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Maximum number of shaped text results to cache before LRU eviction. */
|
|
73
|
+
const SHAPE_CACHE_MAX_SIZE = 10_000;
|
|
74
|
+
|
|
75
|
+
/** Decode the worker's flattened `[from, to, ...]` cmap range pairs. */
|
|
76
|
+
const decodeCmapRanges = (flat: Uint32Array): UnicodeRange[] => {
|
|
77
|
+
const ranges: UnicodeRange[] = [];
|
|
78
|
+
for (let i = 0; i + 1 < flat.length; i += 2) {
|
|
79
|
+
ranges.push({ from: flat[i], to: flat[i + 1] });
|
|
80
|
+
}
|
|
81
|
+
return ranges;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Manages font loading, text shaping, and SDF atlas access.
|
|
86
|
+
*
|
|
87
|
+
* Delegates heavy computation (shaping, SDF rasterization, atlas packing)
|
|
88
|
+
* to a dedicated Web Worker via FontWorkerClient. GPU texture management
|
|
89
|
+
* (DataTexture creation/update) stays on the main thread.
|
|
90
|
+
*/
|
|
91
|
+
export class FontManager {
|
|
92
|
+
private _workerUrl: string | URL;
|
|
93
|
+
private _client: FontWorkerClient | undefined;
|
|
94
|
+
private _clientPromise: Promise<FontWorkerClient> | undefined;
|
|
95
|
+
private _concurrencyManager: ConcurrencyManager | undefined;
|
|
96
|
+
/** Worker cache budget, delivered when the (lazy) client is created. */
|
|
97
|
+
private _fontBudgetBytes: number | undefined;
|
|
98
|
+
/** Tracks in-flight fetch promises to avoid duplicate requests. */
|
|
99
|
+
private _pending = new Map<string, Promise<void>>();
|
|
100
|
+
/** Tracks fonts that have been successfully loaded. */
|
|
101
|
+
private _loaded = new Set<string>();
|
|
102
|
+
/** Cache shaped text results to avoid redundant worker calls (LRU-evicted). */
|
|
103
|
+
private _shapeCache = new Map<string, LRUMap<string, ShapeTextResult>>();
|
|
104
|
+
/** Cache atlas data per font to avoid redundant copies. */
|
|
105
|
+
private _atlasCache = new Map<string, FontAtlasData>();
|
|
106
|
+
/** Cache color atlas data per font (COLRv1 RGBA). */
|
|
107
|
+
private _colorAtlasCache = new Map<string, FontAtlasData>();
|
|
108
|
+
/** Tracks whether the atlas cache is stale (new glyphs may have been rasterized). */
|
|
109
|
+
private _atlasDirty = new Set<string>();
|
|
110
|
+
/** Tracks whether the color atlas cache is stale. */
|
|
111
|
+
private _colorAtlasDirty = new Set<string>();
|
|
112
|
+
/** Shared GPU texture per font — all meshes using the same font share one DataTexture. */
|
|
113
|
+
private _textureCache = new Map<string, DataTexture>();
|
|
114
|
+
/** Shared color atlas texture per atlas key (RGBA). */
|
|
115
|
+
private _colorTextureCache = new Map<string, DataTexture>();
|
|
116
|
+
/** Tracks in-flight prepareText promises to deduplicate. */
|
|
117
|
+
private _preparePending = new Map<string, Promise<void>>();
|
|
118
|
+
/** Microtask batch queue: per-font list of texts awaiting worker dispatch. */
|
|
119
|
+
private _batchQueue = new Map<
|
|
120
|
+
string,
|
|
121
|
+
{
|
|
122
|
+
text: string;
|
|
123
|
+
cacheKey: string;
|
|
124
|
+
resolve: () => void;
|
|
125
|
+
reject: (reason: unknown) => void;
|
|
126
|
+
}[]
|
|
127
|
+
>();
|
|
128
|
+
/** Whether a microtask flush is already scheduled. */
|
|
129
|
+
private _batchScheduled = false;
|
|
130
|
+
|
|
131
|
+
private _refCount = new Map<string, number>();
|
|
132
|
+
/** Registered font families, keyed by family name. */
|
|
133
|
+
private _families = new Map<string, FontFamily>();
|
|
134
|
+
/** Real cmap coverage per font file URL, reported by the worker when the
|
|
135
|
+
* file is first loaded. Declared face ranges can differ from what the
|
|
136
|
+
* file contains (e.g. shared per-subset boilerplate in Google Fonts CSS
|
|
137
|
+
* claims codepoints the file lacks, which would shape as tofu), so
|
|
138
|
+
* registered faces pointing at the URL get their `unicodeRanges` replaced
|
|
139
|
+
* with these — once per file, never per shaped text. */
|
|
140
|
+
private _cmapRanges = new Map<string, UnicodeRange[]>();
|
|
141
|
+
/** Maps font URL → atlas key (family name or URL for standalone fonts). */
|
|
142
|
+
private _atlasKeys = new Map<string, string>();
|
|
143
|
+
|
|
144
|
+
/** Per-atlas generation counter. Bumped when the worker reports it evicted
|
|
145
|
+
* glyphs from an atlas; cached shape results carry the generation they were
|
|
146
|
+
* built under, so any from before the eviction are re-shaped on next use.
|
|
147
|
+
* Visible labels are *normally* unaffected — their glyphs are pinned and not
|
|
148
|
+
* evicted — but a large burst can evict a still-in-flight glyph before its
|
|
149
|
+
* retain lands; `onAtlasEvicted` lets consumers rebuild such stale meshes. */
|
|
150
|
+
private _atlasGeneration = new Map<string, number>();
|
|
151
|
+
/** Eviction listeners keyed by resolved atlas key. Notified (coalesced on a
|
|
152
|
+
* microtask) after an eviction bumps an atlas's generation, so visible
|
|
153
|
+
* consumers can rebuild meshes whose baked metrics may now be stale. */
|
|
154
|
+
private _evictListeners = new Map<string, Set<() => void>>();
|
|
155
|
+
/** Atlas keys with an eviction awaiting notification, plus its schedule flag. */
|
|
156
|
+
private _pendingEvictNotify = new Set<string>();
|
|
157
|
+
private _evictNotifyScheduled = false;
|
|
158
|
+
/** Pending visible-label refcount increments per atlas key (one entry per
|
|
159
|
+
* occurrence), flushed to the worker on a microtask. */
|
|
160
|
+
private _retainQueue = new Map<string, bigint[]>();
|
|
161
|
+
/** Pending visible-label refcount decrements per atlas key. */
|
|
162
|
+
private _releaseQueue = new Map<string, bigint[]>();
|
|
163
|
+
/** Whether a retain/release flush is already scheduled. */
|
|
164
|
+
private _refFlushScheduled = false;
|
|
165
|
+
|
|
166
|
+
constructor(workerUrl: string | URL) {
|
|
167
|
+
this._workerUrl = workerUrl;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
setConcurrencyManager(concurrencyManager: ConcurrencyManager) {
|
|
171
|
+
this._concurrencyManager = concurrencyManager;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Register a font family with multiple faces.
|
|
176
|
+
* No font files are loaded until text is prepared.
|
|
177
|
+
*/
|
|
178
|
+
registerFontFamily(family: FontFamily): void {
|
|
179
|
+
// Clone the faces so cmap corrections (see `_cmapRanges`) never mutate
|
|
180
|
+
// caller-owned data; faces whose file was already loaded pick up their
|
|
181
|
+
// real coverage immediately.
|
|
182
|
+
this._families.set(family.family, {
|
|
183
|
+
family: family.family,
|
|
184
|
+
faces: family.faces.map((face) => ({
|
|
185
|
+
url: face.url,
|
|
186
|
+
unicodeRanges: this._cmapRanges.get(face.url) ?? face.unicodeRanges,
|
|
187
|
+
})),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Remove a registered font family. No font resources are unloaded here —
|
|
193
|
+
* each mesh that used this family tracks its loaded face URLs and unloads
|
|
194
|
+
* them individually on dispose.
|
|
195
|
+
*/
|
|
196
|
+
unregisterFontFamily(familyName: string): void {
|
|
197
|
+
this._families.delete(familyName);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Check whether a font identifier is a registered family name. */
|
|
201
|
+
isFamily(fontIdentifier: string): boolean {
|
|
202
|
+
return this._families.has(fontIdentifier);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Split `text` into runs of consecutive characters that map to the same face.
|
|
207
|
+
* Each character picks the first face whose unicode ranges contain it,
|
|
208
|
+
* falling back to face 0. Spaces stick to the current run to avoid breaking
|
|
209
|
+
* up whitespace.
|
|
210
|
+
*/
|
|
211
|
+
private _segmentTextByFace(
|
|
212
|
+
faces: FontFace[],
|
|
213
|
+
text: string,
|
|
214
|
+
): { url: string; text: string }[] {
|
|
215
|
+
if (faces.length === 0) {
|
|
216
|
+
throw new Error("FontManager: font family has no faces");
|
|
217
|
+
}
|
|
218
|
+
if (faces.length === 1) return [{ url: faces[0].url, text }];
|
|
219
|
+
|
|
220
|
+
const segments: { url: string; text: string }[] = [];
|
|
221
|
+
let currentUrl = "";
|
|
222
|
+
let currentText = "";
|
|
223
|
+
|
|
224
|
+
for (const ch of text) {
|
|
225
|
+
const url =
|
|
226
|
+
ch === " " && currentUrl
|
|
227
|
+
? currentUrl
|
|
228
|
+
: this._findFaceForCodepoint(faces, ch.codePointAt(0) ?? 0);
|
|
229
|
+
|
|
230
|
+
if (url === currentUrl) {
|
|
231
|
+
currentText += ch;
|
|
232
|
+
} else {
|
|
233
|
+
if (currentText) segments.push({ url: currentUrl, text: currentText });
|
|
234
|
+
currentUrl = url;
|
|
235
|
+
currentText = ch;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (currentText) segments.push({ url: currentUrl, text: currentText });
|
|
239
|
+
return segments;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** First face whose unicode ranges contain `codepoint`, or face 0. */
|
|
243
|
+
private _findFaceForCodepoint(faces: FontFace[], codepoint: number): string {
|
|
244
|
+
for (const face of faces) {
|
|
245
|
+
for (const range of face.unicodeRanges) {
|
|
246
|
+
if (codepoint >= range.from && codepoint <= range.to) {
|
|
247
|
+
return face.url;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return faces[0].url;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Replace the unicode ranges of every registered face pointing at
|
|
255
|
+
* `rawUrl` with the font file's real cmap coverage (see `_cmapRanges`). */
|
|
256
|
+
private _applyCmapRanges(rawUrl: string): void {
|
|
257
|
+
const ranges = this._cmapRanges.get(rawUrl);
|
|
258
|
+
if (!ranges) return;
|
|
259
|
+
for (const family of this._families.values()) {
|
|
260
|
+
for (const face of family.faces) {
|
|
261
|
+
if (face.url === rawUrl) face.unicodeRanges = ranges;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private _ensureClient(): Promise<FontWorkerClient> {
|
|
267
|
+
if (this._client) return Promise.resolve(this._client);
|
|
268
|
+
if (this._clientPromise) return this._clientPromise;
|
|
269
|
+
if (!this._concurrencyManager) {
|
|
270
|
+
return Promise.reject(
|
|
271
|
+
new Error("FontManager: concurrencyManager not set"),
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
const cm = this._concurrencyManager;
|
|
275
|
+
this._clientPromise = (async () => {
|
|
276
|
+
const client = new FontWorkerClient(this._workerUrl, cm, {
|
|
277
|
+
fontBudgetBytes: this._fontBudgetBytes,
|
|
278
|
+
});
|
|
279
|
+
await client.ready();
|
|
280
|
+
this._client = client;
|
|
281
|
+
return client;
|
|
282
|
+
})();
|
|
283
|
+
this._clientPromise.catch(() => {
|
|
284
|
+
this._clientPromise = undefined;
|
|
285
|
+
});
|
|
286
|
+
return this._clientPromise;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Sets the worker-side cache budget (font data + atlas pixels). Must be
|
|
291
|
+
* called before the first font loads — the worker is created lazily and
|
|
292
|
+
* the budget is delivered with its init message.
|
|
293
|
+
*/
|
|
294
|
+
setMemoryBudget(bytes: number | undefined): void {
|
|
295
|
+
this._fontBudgetBytes = bytes;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Snapshot of the font worker's memory usage, or `undefined` while no
|
|
300
|
+
* worker has been spawned (no fonts in use = zero cost).
|
|
301
|
+
*/
|
|
302
|
+
async memoryStats(): Promise<FontWorkerMemoryStats | undefined> {
|
|
303
|
+
return this._client?.getMemoryStats();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Load a font from a URL. Fetches the font file and sends it to the worker.
|
|
308
|
+
* Returns immediately if the font is already loaded. Deduplicates concurrent requests.
|
|
309
|
+
*
|
|
310
|
+
* `atlasKey`: optional shared atlas identifier (e.g. font family name).
|
|
311
|
+
* `highQuality`: which atlas raster path to use. Two materials referencing the
|
|
312
|
+
* same `url` but different `highQuality` values produce two independent
|
|
313
|
+
* (font, atlas) entries — both live side-by-side. The browser HTTP cache
|
|
314
|
+
* makes the second fetch effectively free.
|
|
315
|
+
*/
|
|
316
|
+
async loadFont(
|
|
317
|
+
url: string,
|
|
318
|
+
highQuality: boolean,
|
|
319
|
+
atlasKey?: string,
|
|
320
|
+
): Promise<void> {
|
|
321
|
+
const qUrl = _q(url, highQuality);
|
|
322
|
+
if (this._loaded.has(qUrl)) {
|
|
323
|
+
this._refCount.set(qUrl, (this._refCount.get(qUrl) ?? 0) + 1);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (this._pending.has(qUrl)) {
|
|
328
|
+
this._refCount.set(qUrl, (this._refCount.get(qUrl) ?? 0) + 1);
|
|
329
|
+
return this._pending.get(qUrl);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// qAtlasKey is `undefined` (worker falls back to the URL, which is itself
|
|
333
|
+
// already qualified) when the caller didn't supply an atlasKey. When they
|
|
334
|
+
// did, we qualify it so families with the same name but different highQuality
|
|
335
|
+
// get distinct atlases.
|
|
336
|
+
const qAtlasKey = atlasKey ? _q(atlasKey, highQuality) : undefined;
|
|
337
|
+
const promise = this._fetchAndLoad(url, qUrl, qAtlasKey, highQuality);
|
|
338
|
+
this._refCount.set(qUrl, 1);
|
|
339
|
+
this._pending.set(qUrl, promise);
|
|
340
|
+
|
|
341
|
+
this._shapeCache.set(
|
|
342
|
+
qUrl,
|
|
343
|
+
new LRUMap<string, ShapeTextResult>(SHAPE_CACHE_MAX_SIZE),
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
await promise;
|
|
348
|
+
this._loaded.add(qUrl);
|
|
349
|
+
this._atlasKeys.set(qUrl, qAtlasKey ?? qUrl);
|
|
350
|
+
} catch (err) {
|
|
351
|
+
this._refCount.delete(qUrl);
|
|
352
|
+
this._shapeCache.delete(qUrl);
|
|
353
|
+
throw err;
|
|
354
|
+
} finally {
|
|
355
|
+
this._pending.delete(qUrl);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async unloadFont(url: string, highQuality: boolean) {
|
|
360
|
+
const qUrl = _q(url, highQuality);
|
|
361
|
+
const count = this._refCount.get(qUrl);
|
|
362
|
+
if (!count) return;
|
|
363
|
+
if (count > 1) {
|
|
364
|
+
this._refCount.set(qUrl, count - 1);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const atlasKey = this._atlasKeys.get(qUrl) ?? qUrl;
|
|
369
|
+
this._loaded.delete(qUrl);
|
|
370
|
+
this._refCount.delete(qUrl);
|
|
371
|
+
this._shapeCache.delete(qUrl);
|
|
372
|
+
this._atlasKeys.delete(qUrl);
|
|
373
|
+
|
|
374
|
+
// Drop the atlas only when no other loaded font still references it.
|
|
375
|
+
const stillReferenced = [...this._atlasKeys.values()].includes(atlasKey);
|
|
376
|
+
if (!stillReferenced) {
|
|
377
|
+
this._shapeCache.delete(atlasKey);
|
|
378
|
+
this._atlasCache.delete(atlasKey);
|
|
379
|
+
this._atlasDirty.delete(atlasKey);
|
|
380
|
+
this._colorAtlasCache.delete(atlasKey);
|
|
381
|
+
this._colorAtlasDirty.delete(atlasKey);
|
|
382
|
+
this._atlasGeneration.delete(atlasKey);
|
|
383
|
+
this._textureCache.get(atlasKey)?.dispose();
|
|
384
|
+
this._textureCache.delete(atlasKey);
|
|
385
|
+
this._colorTextureCache.get(atlasKey)?.dispose();
|
|
386
|
+
this._colorTextureCache.delete(atlasKey);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (this._client) {
|
|
390
|
+
const result = await this._client.unloadFont(qUrl);
|
|
391
|
+
if (!result.ok) {
|
|
392
|
+
console.warn(
|
|
393
|
+
`Failed to unload font "${url}" (${highQuality}) in worker`,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Prepare text for rendering: shapes text and updates atlas in the worker.
|
|
401
|
+
* Must be called (and awaited) before shapeText() will return data for this text.
|
|
402
|
+
* Accepts either a font URL or a registered font family name.
|
|
403
|
+
*
|
|
404
|
+
* For font families, face URLs are loaded lazily — only the faces whose
|
|
405
|
+
* unicode ranges cover `text` are fetched. Pass a caller-owned `loadedFaces`
|
|
406
|
+
* Set to track which URLs were loaded; the caller must call unloadFont() for
|
|
407
|
+
* each URL in the set on dispose. Passing the same set across multiple calls
|
|
408
|
+
* ensures each face URL is loaded exactly once per caller.
|
|
409
|
+
*
|
|
410
|
+
* For standalone font URLs the font must already be loaded via loadFont().
|
|
411
|
+
*/
|
|
412
|
+
async prepareText(
|
|
413
|
+
fontIdentifier: string,
|
|
414
|
+
text: string,
|
|
415
|
+
highQuality: boolean,
|
|
416
|
+
loadedFaces?: Set<string>,
|
|
417
|
+
): Promise<void> {
|
|
418
|
+
if (!text) return;
|
|
419
|
+
|
|
420
|
+
const family = this._families.get(fontIdentifier);
|
|
421
|
+
if (family) {
|
|
422
|
+
// Lazy path: load only the face URLs needed for this text that this
|
|
423
|
+
// caller has not loaded yet. `loadedFaces` tracks raw face URLs (no
|
|
424
|
+
// highQuality suffix), matching the URLs the caller later passes to
|
|
425
|
+
// `unloadFont(url, highQuality)`. A single set is therefore tied to one
|
|
426
|
+
// highQuality — callers switching highQuality should use a fresh set.
|
|
427
|
+
const tracker = loadedFaces ?? new Set<string>();
|
|
428
|
+
// Load faces until segmentation is stable: the first load of a font
|
|
429
|
+
// file replaces its faces' declared unicode ranges with the file's
|
|
430
|
+
// real cmap coverage (see `_cmapRanges`), which can re-route
|
|
431
|
+
// codepoints the declared ranges over-promised to another face. Each
|
|
432
|
+
// iteration loads at least one new face, so the loop is bounded by
|
|
433
|
+
// the face count.
|
|
434
|
+
let passesLeft = family.faces.length + 1;
|
|
435
|
+
while (passesLeft-- > 0) {
|
|
436
|
+
const segments = this._segmentTextByFace(family.faces, text);
|
|
437
|
+
const missing = [...new Set(segments.map((s) => s.url))].filter(
|
|
438
|
+
(url) => !tracker.has(url),
|
|
439
|
+
);
|
|
440
|
+
if (missing.length === 0) break;
|
|
441
|
+
await Promise.all(
|
|
442
|
+
missing.map(async (url) => {
|
|
443
|
+
await this.loadFont(url, highQuality, fontIdentifier);
|
|
444
|
+
tracker.add(url);
|
|
445
|
+
}),
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
return this._prepareFamilyText(fontIdentifier, family, text, highQuality);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return this._prepareSingleFontText(fontIdentifier, text, highQuality);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Check if text has been prepared (sync).
|
|
456
|
+
* Accepts either a font URL or a registered font family name.
|
|
457
|
+
*/
|
|
458
|
+
isTextPrepared(
|
|
459
|
+
fontIdentifier: string,
|
|
460
|
+
text: string,
|
|
461
|
+
highQuality: boolean,
|
|
462
|
+
): boolean {
|
|
463
|
+
return this._isPreparedFresh(
|
|
464
|
+
_q(fontIdentifier, highQuality),
|
|
465
|
+
text,
|
|
466
|
+
this._resolveAtlasKey(fontIdentifier, highQuality),
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Get shaped text from cache. Returns undefined if not yet prepared.
|
|
472
|
+
* Call prepareText() first.
|
|
473
|
+
* Accepts either a font URL or a registered font family name.
|
|
474
|
+
*/
|
|
475
|
+
shapeText(
|
|
476
|
+
fontIdentifier: string,
|
|
477
|
+
text: string,
|
|
478
|
+
highQuality: boolean,
|
|
479
|
+
): ShapeTextResult | undefined {
|
|
480
|
+
return this._shapeCache.get(_q(fontIdentifier, highQuality))?.get(text);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Prepare text for a single standalone font (non-family path). */
|
|
484
|
+
private async _prepareSingleFontText(
|
|
485
|
+
fontUrl: string,
|
|
486
|
+
text: string,
|
|
487
|
+
highQuality: boolean,
|
|
488
|
+
): Promise<void> {
|
|
489
|
+
const qUrl = _q(fontUrl, highQuality);
|
|
490
|
+
const cacheKey = this._cacheKey(qUrl, text);
|
|
491
|
+
if (
|
|
492
|
+
this._isPreparedFresh(
|
|
493
|
+
qUrl,
|
|
494
|
+
text,
|
|
495
|
+
this._resolveAtlasKey(fontUrl, highQuality),
|
|
496
|
+
)
|
|
497
|
+
)
|
|
498
|
+
return;
|
|
499
|
+
if (this._preparePending.has(cacheKey))
|
|
500
|
+
return this._preparePending.get(cacheKey);
|
|
501
|
+
|
|
502
|
+
const promise = new Promise<void>((resolve, reject) => {
|
|
503
|
+
let queue = this._batchQueue.get(qUrl);
|
|
504
|
+
if (!queue) {
|
|
505
|
+
queue = [];
|
|
506
|
+
this._batchQueue.set(qUrl, queue);
|
|
507
|
+
}
|
|
508
|
+
queue.push({ text, cacheKey, resolve, reject });
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
this._preparePending.set(cacheKey, promise);
|
|
512
|
+
|
|
513
|
+
if (!this._batchScheduled) {
|
|
514
|
+
this._batchScheduled = true;
|
|
515
|
+
queueMicrotask(() => this._flushBatch());
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
try {
|
|
519
|
+
await promise;
|
|
520
|
+
} finally {
|
|
521
|
+
this._preparePending.delete(cacheKey);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Prepare text for a font family: segment, shape per-face, stitch. */
|
|
526
|
+
private async _prepareFamilyText(
|
|
527
|
+
familyName: string,
|
|
528
|
+
family: FontFamily,
|
|
529
|
+
text: string,
|
|
530
|
+
highQuality: boolean,
|
|
531
|
+
): Promise<void> {
|
|
532
|
+
const qFamily = _q(familyName, highQuality);
|
|
533
|
+
// Already stitched and still current for the atlas?
|
|
534
|
+
if (this._isPreparedFresh(qFamily, text, qFamily)) return;
|
|
535
|
+
|
|
536
|
+
const pendingKey = this._cacheKey(qFamily, text);
|
|
537
|
+
if (this._preparePending.has(pendingKey))
|
|
538
|
+
return this._preparePending.get(pendingKey);
|
|
539
|
+
|
|
540
|
+
const promise = (async () => {
|
|
541
|
+
const segments = this._segmentTextByFace(family.faces, text);
|
|
542
|
+
|
|
543
|
+
// Shape each segment. Face URLs were loaded lazily by prepareText above.
|
|
544
|
+
await Promise.all(
|
|
545
|
+
segments.map((seg) =>
|
|
546
|
+
this._prepareSingleFontText(seg.url, seg.text, highQuality),
|
|
547
|
+
),
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
// Stitch per-segment results into one combined result
|
|
551
|
+
const stitched = this._stitchSegments(segments, highQuality);
|
|
552
|
+
|
|
553
|
+
let familyShapeCache = this._shapeCache.get(qFamily);
|
|
554
|
+
if (!familyShapeCache) {
|
|
555
|
+
familyShapeCache = new LRUMap<string, ShapeTextResult>(
|
|
556
|
+
SHAPE_CACHE_MAX_SIZE,
|
|
557
|
+
);
|
|
558
|
+
this._shapeCache.set(qFamily, familyShapeCache);
|
|
559
|
+
}
|
|
560
|
+
// Stamp with the family atlas's current generation so an eviction
|
|
561
|
+
// afterward marks this stitched result stale.
|
|
562
|
+
stitched._generation = this._currentGen(qFamily);
|
|
563
|
+
familyShapeCache.set(text, stitched);
|
|
564
|
+
})();
|
|
565
|
+
|
|
566
|
+
this._preparePending.set(pendingKey, promise);
|
|
567
|
+
try {
|
|
568
|
+
await promise;
|
|
569
|
+
} finally {
|
|
570
|
+
this._preparePending.delete(pendingKey);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Combine shaped results from multiple face segments into one result.
|
|
575
|
+
*
|
|
576
|
+
* All glyph advances/offsets and bearing values are normalized to the first
|
|
577
|
+
* segment's unitsPerEm so the consumer can apply a single scale factor.
|
|
578
|
+
*/
|
|
579
|
+
private _stitchSegments(
|
|
580
|
+
segments: { url: string; text: string }[],
|
|
581
|
+
highQuality: boolean,
|
|
582
|
+
): ShapeTextResult {
|
|
583
|
+
const allGlyphs: ShapedGlyph[] = [];
|
|
584
|
+
const metricsMap = new Map<string, GlyphMetrics>();
|
|
585
|
+
let targetUnitsPerEm = 0;
|
|
586
|
+
// Line metrics come from the first shaped segment — the same face that
|
|
587
|
+
// defines the target unitsPerEm, so no rescaling is needed.
|
|
588
|
+
let ascender = 0;
|
|
589
|
+
let descender = 0;
|
|
590
|
+
let lineGap = 0;
|
|
591
|
+
|
|
592
|
+
for (const seg of segments) {
|
|
593
|
+
const result = this._shapeCache
|
|
594
|
+
.get(_q(seg.url, highQuality))
|
|
595
|
+
?.get(seg.text);
|
|
596
|
+
// A segment can be absent when the worker produced no glyphs for it: the
|
|
597
|
+
// face matched by unicode range may not actually contain the codepoint, or
|
|
598
|
+
// segmentation fell through to face 0 (which can't shape this script). The
|
|
599
|
+
// prepare promise still resolves in that case (the result is simply never
|
|
600
|
+
// cached), so skip the unshaped segment and stitch the rest rather than
|
|
601
|
+
// aborting the whole label — one uncovered glyph shouldn't drop the name.
|
|
602
|
+
if (!result) continue;
|
|
603
|
+
|
|
604
|
+
if (targetUnitsPerEm === 0) {
|
|
605
|
+
targetUnitsPerEm = result.unitsPerEm;
|
|
606
|
+
ascender = result.ascender;
|
|
607
|
+
descender = result.descender;
|
|
608
|
+
lineGap = result.lineGap;
|
|
609
|
+
}
|
|
610
|
+
const scale = targetUnitsPerEm / result.unitsPerEm;
|
|
611
|
+
|
|
612
|
+
for (const g of result.glyphs) {
|
|
613
|
+
allGlyphs.push(
|
|
614
|
+
scale === 1
|
|
615
|
+
? g
|
|
616
|
+
: {
|
|
617
|
+
...g,
|
|
618
|
+
xAdvance: g.xAdvance * scale,
|
|
619
|
+
yAdvance: g.yAdvance * scale,
|
|
620
|
+
xOffset: g.xOffset * scale,
|
|
621
|
+
yOffset: g.yOffset * scale,
|
|
622
|
+
},
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
for (const m of result.metrics) {
|
|
627
|
+
const key = `${m.fontIndex}:${m.glyphId}`;
|
|
628
|
+
if (!metricsMap.has(key)) {
|
|
629
|
+
metricsMap.set(key, m);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (targetUnitsPerEm === 0) {
|
|
635
|
+
// Every segment was unshaped (e.g. a name entirely in an uncovered
|
|
636
|
+
// script). Return an empty result so the label renders blank instead of
|
|
637
|
+
// throwing; `unitsPerEm` is irrelevant with zero glyphs but must be
|
|
638
|
+
// nonzero so the consumer's scale factor stays finite.
|
|
639
|
+
return {
|
|
640
|
+
glyphs: [],
|
|
641
|
+
metrics: [],
|
|
642
|
+
unitsPerEm: 1000,
|
|
643
|
+
ascender: 800,
|
|
644
|
+
descender: -200,
|
|
645
|
+
lineGap: 0,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return {
|
|
650
|
+
glyphs: allGlyphs,
|
|
651
|
+
metrics: [...metricsMap.values()],
|
|
652
|
+
unitsPerEm: targetUnitsPerEm,
|
|
653
|
+
ascender,
|
|
654
|
+
descender,
|
|
655
|
+
lineGap,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Get the SDF atlas data for a loaded font from cache.
|
|
661
|
+
* Accepts either a font URL or a registered font family name.
|
|
662
|
+
* For families, the atlas is shared across all faces.
|
|
663
|
+
*/
|
|
664
|
+
getAtlas(
|
|
665
|
+
fontIdentifier: string,
|
|
666
|
+
highQuality: boolean,
|
|
667
|
+
): FontAtlasData | undefined {
|
|
668
|
+
const key = this._resolveAtlasKey(fontIdentifier, highQuality);
|
|
669
|
+
return this._atlasCache.get(key);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Shared GPU DataTexture for a font's atlas. Returns the same instance
|
|
674
|
+
* across callers and updates it in-place when the atlas grows. Accepts a
|
|
675
|
+
* font URL or a family name.
|
|
676
|
+
*/
|
|
677
|
+
getAtlasTexture(
|
|
678
|
+
fontIdentifier: string,
|
|
679
|
+
highQuality: boolean,
|
|
680
|
+
): DataTexture | null {
|
|
681
|
+
return this._getOrUpdateTexture(
|
|
682
|
+
this._resolveAtlasKey(fontIdentifier, highQuality),
|
|
683
|
+
this._atlasCache,
|
|
684
|
+
this._atlasDirty,
|
|
685
|
+
this._textureCache,
|
|
686
|
+
(a) => createSdfAtlasTexture(a.data, a.width, a.height, a.channels),
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** Color atlas data for a loaded COLRv1 font. `undefined` for monochrome. */
|
|
691
|
+
getColorAtlas(
|
|
692
|
+
fontIdentifier: string,
|
|
693
|
+
highQuality: boolean,
|
|
694
|
+
): FontAtlasData | undefined {
|
|
695
|
+
const key = this._resolveAtlasKey(fontIdentifier, highQuality);
|
|
696
|
+
return this._colorAtlasCache.get(key);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Shared GPU DataTexture for a font's COLRv1 color atlas. `null` when no
|
|
701
|
+
* color glyphs have been rasterized under this key.
|
|
702
|
+
*/
|
|
703
|
+
getColorAtlasTexture(
|
|
704
|
+
fontIdentifier: string,
|
|
705
|
+
highQuality: boolean,
|
|
706
|
+
): DataTexture | null {
|
|
707
|
+
return this._getOrUpdateTexture(
|
|
708
|
+
this._resolveAtlasKey(fontIdentifier, highQuality),
|
|
709
|
+
this._colorAtlasCache,
|
|
710
|
+
this._colorAtlasDirty,
|
|
711
|
+
this._colorTextureCache,
|
|
712
|
+
(a) => createColorAtlasTexture(a.data, a.width, a.height),
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Shared "get cached texture, create or refresh from atlas data" path.
|
|
717
|
+
* When the atlas grew, dispose() releases the GL handle so three.js
|
|
718
|
+
* reallocates at the new size on the next render instead of overflowing
|
|
719
|
+
* texSubImage2D. The DataTexture instance is preserved so existing meshes
|
|
720
|
+
* keep their refs. */
|
|
721
|
+
private _getOrUpdateTexture(
|
|
722
|
+
key: string,
|
|
723
|
+
atlasCache: Map<string, FontAtlasData>,
|
|
724
|
+
dirtySet: Set<string>,
|
|
725
|
+
textureCache: Map<string, DataTexture>,
|
|
726
|
+
create: (atlas: FontAtlasData) => DataTexture,
|
|
727
|
+
): DataTexture | null {
|
|
728
|
+
if (!dirtySet.has(key)) {
|
|
729
|
+
const cached = textureCache.get(key);
|
|
730
|
+
if (cached) return cached;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const atlasData = atlasCache.get(key);
|
|
734
|
+
if (!atlasData) return null;
|
|
735
|
+
|
|
736
|
+
dirtySet.delete(key);
|
|
737
|
+
|
|
738
|
+
const existing = textureCache.get(key);
|
|
739
|
+
if (existing) {
|
|
740
|
+
const dimsChanged =
|
|
741
|
+
existing.image?.width !== atlasData.width ||
|
|
742
|
+
existing.image?.height !== atlasData.height;
|
|
743
|
+
if (dimsChanged) existing.dispose();
|
|
744
|
+
existing.image = {
|
|
745
|
+
data: atlasData.data,
|
|
746
|
+
width: atlasData.width,
|
|
747
|
+
height: atlasData.height,
|
|
748
|
+
};
|
|
749
|
+
existing.needsUpdate = true;
|
|
750
|
+
return existing;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const tex = create(atlasData);
|
|
754
|
+
textureCache.set(key, tex);
|
|
755
|
+
return tex;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
dispose() {
|
|
759
|
+
for (const tex of this._textureCache.values()) tex.dispose();
|
|
760
|
+
for (const tex of this._colorTextureCache.values()) tex.dispose();
|
|
761
|
+
this._pending.clear();
|
|
762
|
+
this._preparePending.clear();
|
|
763
|
+
this._loaded.clear();
|
|
764
|
+
this._refCount.clear();
|
|
765
|
+
this._shapeCache.clear();
|
|
766
|
+
this._atlasGeneration.clear();
|
|
767
|
+
this._retainQueue.clear();
|
|
768
|
+
this._releaseQueue.clear();
|
|
769
|
+
this._refFlushScheduled = false;
|
|
770
|
+
this._atlasCache.clear();
|
|
771
|
+
this._atlasDirty.clear();
|
|
772
|
+
this._colorAtlasCache.clear();
|
|
773
|
+
this._colorAtlasDirty.clear();
|
|
774
|
+
this._textureCache.clear();
|
|
775
|
+
this._colorTextureCache.clear();
|
|
776
|
+
this._families.clear();
|
|
777
|
+
this._atlasKeys.clear();
|
|
778
|
+
this._client?.dispose();
|
|
779
|
+
this._client = undefined;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
private _cacheKey(fontUrl: string, text: string): string {
|
|
783
|
+
return fontUrl + "\0" + text;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** Current generation of an atlas. Bumped whenever the atlas evicts glyphs;
|
|
787
|
+
* cached shape results stamped with an older generation are treated as
|
|
788
|
+
* stale (their metrics may point at reused rects) and re-shaped on next use. */
|
|
789
|
+
private _currentGen(atlasKey: string): number {
|
|
790
|
+
return this._atlasGeneration.get(atlasKey) ?? 0;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** Whether `text` has a cached shape result that is still current for its
|
|
794
|
+
* atlas. A result from before the atlas last evicted is treated as not
|
|
795
|
+
* prepared, forcing a re-shape that re-rasterizes any evicted glyphs. */
|
|
796
|
+
private _isPreparedFresh(
|
|
797
|
+
shapeCacheKey: string,
|
|
798
|
+
text: string,
|
|
799
|
+
atlasKey: string,
|
|
800
|
+
): boolean {
|
|
801
|
+
const entry = this._shapeCache.get(shapeCacheKey)?.get(text);
|
|
802
|
+
return !!entry && (entry._generation ?? 0) === this._currentGen(atlasKey);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Subscribe to evictions on the atlas backing `(fontIdentifier, highQuality)`.
|
|
807
|
+
* The listener fires (coalesced on a microtask) after the worker reports that
|
|
808
|
+
* atlas evicted glyphs — the cue for a visible consumer to rebuild any mesh
|
|
809
|
+
* whose baked atlas rects may now be stale. Returns an unsubscribe function.
|
|
810
|
+
*/
|
|
811
|
+
onAtlasEvicted(
|
|
812
|
+
fontIdentifier: string,
|
|
813
|
+
highQuality: boolean,
|
|
814
|
+
listener: () => void,
|
|
815
|
+
): () => void {
|
|
816
|
+
const atlasKey = this._resolveAtlasKey(fontIdentifier, highQuality);
|
|
817
|
+
let set = this._evictListeners.get(atlasKey);
|
|
818
|
+
if (!set) {
|
|
819
|
+
set = new Set();
|
|
820
|
+
this._evictListeners.set(atlasKey, set);
|
|
821
|
+
}
|
|
822
|
+
set.add(listener);
|
|
823
|
+
return () => {
|
|
824
|
+
const s = this._evictListeners.get(atlasKey);
|
|
825
|
+
if (!s) return;
|
|
826
|
+
s.delete(listener);
|
|
827
|
+
if (s.size === 0) this._evictListeners.delete(atlasKey);
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** Queue an eviction notification for `atlasKey`, coalescing a burst of
|
|
832
|
+
* per-batch evictions into a single microtask-deferred fan-out. */
|
|
833
|
+
private _scheduleEvictNotify(atlasKey: string): void {
|
|
834
|
+
if (!this._evictListeners.has(atlasKey)) return;
|
|
835
|
+
this._pendingEvictNotify.add(atlasKey);
|
|
836
|
+
if (this._evictNotifyScheduled) return;
|
|
837
|
+
this._evictNotifyScheduled = true;
|
|
838
|
+
queueMicrotask(() => this._flushEvictNotify());
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
private _flushEvictNotify(): void {
|
|
842
|
+
this._evictNotifyScheduled = false;
|
|
843
|
+
const keys = this._pendingEvictNotify;
|
|
844
|
+
this._pendingEvictNotify = new Set();
|
|
845
|
+
for (const key of keys) {
|
|
846
|
+
const set = this._evictListeners.get(key);
|
|
847
|
+
if (!set) continue;
|
|
848
|
+
// Snapshot: a listener may unsubscribe (or rebuild + re-subscribe) while
|
|
849
|
+
// iterating.
|
|
850
|
+
for (const listener of [...set]) listener();
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Add one visible-label reference to each glyph (composite keys) so the
|
|
856
|
+
* worker keeps them in the atlas while on screen. Call when a label using
|
|
857
|
+
* these glyphs becomes visible; balance with {@link releaseGlyphs}.
|
|
858
|
+
*/
|
|
859
|
+
retainGlyphs(
|
|
860
|
+
fontIdentifier: string,
|
|
861
|
+
highQuality: boolean,
|
|
862
|
+
keys: bigint[],
|
|
863
|
+
): void {
|
|
864
|
+
if (keys.length === 0) return;
|
|
865
|
+
const atlasKey = this._resolveAtlasKey(fontIdentifier, highQuality);
|
|
866
|
+
this._enqueueRef(this._retainQueue, atlasKey, keys);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** Drop one visible-label reference from each glyph. Call when a label hides
|
|
870
|
+
* or is disposed. */
|
|
871
|
+
releaseGlyphs(
|
|
872
|
+
fontIdentifier: string,
|
|
873
|
+
highQuality: boolean,
|
|
874
|
+
keys: bigint[],
|
|
875
|
+
): void {
|
|
876
|
+
if (keys.length === 0) return;
|
|
877
|
+
const atlasKey = this._resolveAtlasKey(fontIdentifier, highQuality);
|
|
878
|
+
this._enqueueRef(this._releaseQueue, atlasKey, keys);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
private _enqueueRef(
|
|
882
|
+
queue: Map<string, bigint[]>,
|
|
883
|
+
atlasKey: string,
|
|
884
|
+
keys: bigint[],
|
|
885
|
+
): void {
|
|
886
|
+
let arr = queue.get(atlasKey);
|
|
887
|
+
if (!arr) {
|
|
888
|
+
arr = [];
|
|
889
|
+
queue.set(atlasKey, arr);
|
|
890
|
+
}
|
|
891
|
+
for (const k of keys) arr.push(k);
|
|
892
|
+
|
|
893
|
+
if (!this._refFlushScheduled) {
|
|
894
|
+
this._refFlushScheduled = true;
|
|
895
|
+
queueMicrotask(() => this._flushRefs());
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/** Send queued refcount changes to the worker. Retains are sent before
|
|
900
|
+
* releases so a glyph hidden and re-shown in the same tick never momentarily
|
|
901
|
+
* drops to zero. */
|
|
902
|
+
private _flushRefs(): void {
|
|
903
|
+
this._refFlushScheduled = false;
|
|
904
|
+
const retain = this._retainQueue;
|
|
905
|
+
const release = this._releaseQueue;
|
|
906
|
+
this._retainQueue = new Map();
|
|
907
|
+
this._releaseQueue = new Map();
|
|
908
|
+
|
|
909
|
+
const client = this._client;
|
|
910
|
+
if (!client) return;
|
|
911
|
+
|
|
912
|
+
for (const [atlasKey, keys] of retain) {
|
|
913
|
+
if (keys.length > 0)
|
|
914
|
+
client.retainGlyphs(atlasKey, BigUint64Array.from(keys));
|
|
915
|
+
}
|
|
916
|
+
for (const [atlasKey, keys] of release) {
|
|
917
|
+
if (keys.length > 0)
|
|
918
|
+
client.releaseGlyphs(atlasKey, BigUint64Array.from(keys));
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/** Resolve a (font identifier, quality) pair to its qualified atlas key.
|
|
923
|
+
* Family names get a quality suffix and become their own atlas key; font
|
|
924
|
+
* URLs are looked up via `_atlasKeys` (already-qualified keys). */
|
|
925
|
+
private _resolveAtlasKey(
|
|
926
|
+
fontIdentifier: string,
|
|
927
|
+
highQuality: boolean,
|
|
928
|
+
): string {
|
|
929
|
+
if (this._families.has(fontIdentifier))
|
|
930
|
+
return _q(fontIdentifier, highQuality);
|
|
931
|
+
return (
|
|
932
|
+
this._atlasKeys.get(_q(fontIdentifier, highQuality)) ??
|
|
933
|
+
_q(fontIdentifier, highQuality)
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/** Fetch + worker-load a font for a single (url, quality) combination.
|
|
938
|
+
*
|
|
939
|
+
* `rawUrl` is the network URL (no `#q=...` fragment). `qUrl` and
|
|
940
|
+
* `qAtlasKey` are the qualified identifiers the worker will use as
|
|
941
|
+
* FontEntry / atlas keys. The worker side treats `qUrl` as opaque, so the
|
|
942
|
+
* same `rawUrl` can be loaded twice (once per quality) without collision.
|
|
943
|
+
*/
|
|
944
|
+
private async _fetchAndLoad(
|
|
945
|
+
rawUrl: string,
|
|
946
|
+
qUrl: string,
|
|
947
|
+
qAtlasKey: string | undefined,
|
|
948
|
+
highQuality: boolean,
|
|
949
|
+
): Promise<void> {
|
|
950
|
+
const client = await this._ensureClient();
|
|
951
|
+
|
|
952
|
+
const response = await fetch(rawUrl);
|
|
953
|
+
if (!response.ok) {
|
|
954
|
+
throw new Error(
|
|
955
|
+
`FontManager: failed to fetch font from ${rawUrl}: ${response.status}`,
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
960
|
+
const result = await client.loadFont(
|
|
961
|
+
qUrl,
|
|
962
|
+
arrayBuffer,
|
|
963
|
+
qAtlasKey,
|
|
964
|
+
highQuality,
|
|
965
|
+
);
|
|
966
|
+
|
|
967
|
+
if (!result.ok) {
|
|
968
|
+
throw new Error(`FontManager: WASM failed to load font from ${rawUrl}`);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// Once per file (quality variants share the same bytes): replace the
|
|
972
|
+
// registered faces' declared unicode ranges with the file's real cmap
|
|
973
|
+
// coverage. An empty result means the cmap was unparsable — keep the
|
|
974
|
+
// declared ranges rather than treating the font as covering nothing.
|
|
975
|
+
if (result.cmapRanges?.length && !this._cmapRanges.has(rawUrl)) {
|
|
976
|
+
this._cmapRanges.set(rawUrl, decodeCmapRanges(result.cmapRanges));
|
|
977
|
+
this._applyCmapRanges(rawUrl);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
private async _flushBatch(): Promise<void> {
|
|
982
|
+
this._batchScheduled = false;
|
|
983
|
+
|
|
984
|
+
// Snapshot and clear the queue so new calls during await start a fresh batch
|
|
985
|
+
const queue = this._batchQueue;
|
|
986
|
+
this._batchQueue = new Map();
|
|
987
|
+
|
|
988
|
+
let client: FontWorkerClient;
|
|
989
|
+
try {
|
|
990
|
+
client = await this._ensureClient();
|
|
991
|
+
} catch (err) {
|
|
992
|
+
// If we fail to obtain a client, reject all queued entries so their
|
|
993
|
+
// corresponding promises do not remain pending indefinitely.
|
|
994
|
+
for (const [, entries] of queue) {
|
|
995
|
+
for (const entry of entries) {
|
|
996
|
+
entry.reject(err);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
const processFontBatch = async (
|
|
1003
|
+
fontUrl: string,
|
|
1004
|
+
entries: {
|
|
1005
|
+
text: string;
|
|
1006
|
+
cacheKey: string;
|
|
1007
|
+
resolve: () => void;
|
|
1008
|
+
reject: (reason: unknown) => void;
|
|
1009
|
+
}[],
|
|
1010
|
+
) => {
|
|
1011
|
+
const texts = entries.map((e) => e.text);
|
|
1012
|
+
|
|
1013
|
+
try {
|
|
1014
|
+
const batchResult = await client.prepareTextBatch(fontUrl, texts);
|
|
1015
|
+
const atlasKey = batchResult.atlasKey;
|
|
1016
|
+
|
|
1017
|
+
// If the worker evicted glyphs from this atlas, bump its generation so
|
|
1018
|
+
// any cached result from before the eviction is treated as stale and
|
|
1019
|
+
// re-shaped on next use. Bump before stamping the fresh results below
|
|
1020
|
+
// so they carry the new generation.
|
|
1021
|
+
if (batchResult.evicted) {
|
|
1022
|
+
this._atlasGeneration.set(atlasKey, this._currentGen(atlasKey) + 1);
|
|
1023
|
+
// Wake any consumers so they can rebuild visible meshes whose baked
|
|
1024
|
+
// metrics this eviction may have invalidated.
|
|
1025
|
+
this._scheduleEvictNotify(atlasKey);
|
|
1026
|
+
}
|
|
1027
|
+
const generation = this._currentGen(atlasKey);
|
|
1028
|
+
|
|
1029
|
+
// Cache each per-text result, stamped with the current generation.
|
|
1030
|
+
for (const item of batchResult.results) {
|
|
1031
|
+
if (item.shapeResult) {
|
|
1032
|
+
item.shapeResult._generation = generation;
|
|
1033
|
+
this._shapeCache.get(fontUrl)?.set(item.text, item.shapeResult);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// Update atlases once for the entire batch, keyed by atlas key
|
|
1038
|
+
// (family name for font-family faces, or font URL for standalone fonts)
|
|
1039
|
+
if (batchResult.atlas) {
|
|
1040
|
+
this._atlasCache.set(atlasKey, batchResult.atlas);
|
|
1041
|
+
this._atlasDirty.add(atlasKey);
|
|
1042
|
+
}
|
|
1043
|
+
if (batchResult.colorAtlas) {
|
|
1044
|
+
this._colorAtlasCache.set(atlasKey, batchResult.colorAtlas);
|
|
1045
|
+
this._colorAtlasDirty.add(atlasKey);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
for (const entry of entries) {
|
|
1049
|
+
entry.resolve();
|
|
1050
|
+
}
|
|
1051
|
+
} catch (err) {
|
|
1052
|
+
for (const entry of entries) {
|
|
1053
|
+
entry.reject(err);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
};
|
|
1057
|
+
|
|
1058
|
+
await Promise.all(
|
|
1059
|
+
[...queue].map(([fontUrl, entries]) =>
|
|
1060
|
+
processFontBatch(fontUrl, entries),
|
|
1061
|
+
),
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
}
|