@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,286 @@
|
|
|
1
|
+
import type { FontFace, FontFamily, UnicodeRange } from "./types";
|
|
2
|
+
|
|
3
|
+
/** Filters selecting which `@font-face` blocks of a stylesheet to include. */
|
|
4
|
+
export type CssFontFaceFilter = {
|
|
5
|
+
/**
|
|
6
|
+
* Only include blocks whose `font-family` matches one of these names
|
|
7
|
+
* (quotes stripped, case-insensitive). Useful when a stylesheet declares
|
|
8
|
+
* several families and only some should become faces.
|
|
9
|
+
*
|
|
10
|
+
* When given as an array, it also sets face priority: faces are ordered by
|
|
11
|
+
* their family's position in the array (stable within a family). This
|
|
12
|
+
* matters for stylesheets like the Google Fonts CSS API that sort
|
|
13
|
+
* `@font-face` blocks alphabetically regardless of request order — face
|
|
14
|
+
* order decides which face wins for codepoints covered by several faces,
|
|
15
|
+
* and `faces[0]` is the fallback for uncovered codepoints.
|
|
16
|
+
*/
|
|
17
|
+
fontFamily?: string | string[];
|
|
18
|
+
/** Only include blocks whose `font-weight` matches, e.g. `800` or `"100 900"`. */
|
|
19
|
+
fontWeight?: string | number;
|
|
20
|
+
/** Only include blocks whose `font-style` matches, e.g. `"normal"` or `"italic"`. */
|
|
21
|
+
fontStyle?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type ParseCssFontFamilyOptions = CssFontFaceFilter & {
|
|
25
|
+
/** Base URL used to resolve relative `src: url(...)` references. */
|
|
26
|
+
baseUrl?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type FetchCssFontFamilyOptions = CssFontFaceFilter & {
|
|
30
|
+
/** Extra `fetch` options (e.g. credentials for a private font host). */
|
|
31
|
+
requestInit?: RequestInit;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse a CSS `unicode-range` descriptor value (e.g. `"U+0-7F, U+131, U+4??"`)
|
|
36
|
+
* into inclusive codepoint ranges. Supports single codepoints (`U+26`),
|
|
37
|
+
* intervals (`U+0102-0103`) and wildcards (`U+4??`).
|
|
38
|
+
*/
|
|
39
|
+
export function parseCssUnicodeRange(value: string): UnicodeRange[] {
|
|
40
|
+
const ranges: UnicodeRange[] = [];
|
|
41
|
+
for (const token of value.split(",")) {
|
|
42
|
+
const trimmed = token.trim();
|
|
43
|
+
if (!trimmed) continue;
|
|
44
|
+
const match = /^u\+([0-9a-f?]{1,6})(?:-([0-9a-f]{1,6}))?$/i.exec(trimmed);
|
|
45
|
+
if (!match) {
|
|
46
|
+
throw new Error(`Invalid unicode-range token: "${trimmed}"`);
|
|
47
|
+
}
|
|
48
|
+
const [, start, end] = match;
|
|
49
|
+
let range: UnicodeRange;
|
|
50
|
+
if (start.includes("?")) {
|
|
51
|
+
// Wildcard form: `U+4??` means U+400-4FF. A wildcard cannot be
|
|
52
|
+
// combined with an explicit end.
|
|
53
|
+
if (end !== undefined) {
|
|
54
|
+
throw new Error(`Invalid unicode-range token: "${trimmed}"`);
|
|
55
|
+
}
|
|
56
|
+
range = {
|
|
57
|
+
from: parseInt(start.replace(/\?/g, "0"), 16),
|
|
58
|
+
to: parseInt(start.replace(/\?/g, "f"), 16),
|
|
59
|
+
};
|
|
60
|
+
} else {
|
|
61
|
+
const from = parseInt(start, 16);
|
|
62
|
+
range = { from, to: end !== undefined ? parseInt(end, 16) : from };
|
|
63
|
+
}
|
|
64
|
+
if (range.from > range.to || range.to > 0x10ffff) {
|
|
65
|
+
throw new Error(`Invalid unicode-range token: "${trimmed}"`);
|
|
66
|
+
}
|
|
67
|
+
ranges.push(range);
|
|
68
|
+
}
|
|
69
|
+
return ranges;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A single parsed `@font-face` block's descriptors. */
|
|
73
|
+
type CssFontFaceBlock = {
|
|
74
|
+
fontFamily?: string;
|
|
75
|
+
fontStyle?: string;
|
|
76
|
+
fontWeight?: string;
|
|
77
|
+
src?: string;
|
|
78
|
+
unicodeRange?: string;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Covers every codepoint — used when a block declares no `unicode-range`. */
|
|
82
|
+
const FULL_RANGE: UnicodeRange[] = [{ from: 0, to: 0x10ffff }];
|
|
83
|
+
|
|
84
|
+
function stripQuotes(value: string): string {
|
|
85
|
+
const first = value[0];
|
|
86
|
+
if ((first === '"' || first === "'") && value.endsWith(first)) {
|
|
87
|
+
return value.slice(1, -1);
|
|
88
|
+
}
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Extract the font URL from a `src` descriptor, preferring woff2 sources. */
|
|
93
|
+
function parseSrcUrl(src: string): string | undefined {
|
|
94
|
+
const sources = [
|
|
95
|
+
...src.matchAll(
|
|
96
|
+
/url\(\s*("[^"]*"|'[^']*'|[^)]*?)\s*\)(\s*format\(\s*("[^"]*"|'[^']*'|[^)]*?)\s*\))?/gi,
|
|
97
|
+
),
|
|
98
|
+
];
|
|
99
|
+
if (sources.length === 0) return undefined;
|
|
100
|
+
const woff2 = sources.find(
|
|
101
|
+
(m) => m[3] && stripQuotes(m[3]).toLowerCase() === "woff2",
|
|
102
|
+
);
|
|
103
|
+
return stripQuotes((woff2 ?? sources[0])[1].trim());
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseFontFaceBlocks(cssText: string): CssFontFaceBlock[] {
|
|
107
|
+
const withoutComments = cssText.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
108
|
+
const blocks: CssFontFaceBlock[] = [];
|
|
109
|
+
for (const [, body] of withoutComments.matchAll(
|
|
110
|
+
/@font-face\s*\{([^}]*)\}/gi,
|
|
111
|
+
)) {
|
|
112
|
+
const block: CssFontFaceBlock = {};
|
|
113
|
+
for (const declaration of body.split(";")) {
|
|
114
|
+
const colon = declaration.indexOf(":");
|
|
115
|
+
if (colon < 0) continue;
|
|
116
|
+
const property = declaration.slice(0, colon).trim().toLowerCase();
|
|
117
|
+
const value = declaration.slice(colon + 1).trim();
|
|
118
|
+
if (property === "font-family") block.fontFamily = stripQuotes(value);
|
|
119
|
+
else if (property === "font-style") block.fontStyle = value;
|
|
120
|
+
else if (property === "font-weight") block.fontWeight = value;
|
|
121
|
+
else if (property === "src") block.src = value;
|
|
122
|
+
else if (property === "unicode-range") block.unicodeRange = value;
|
|
123
|
+
}
|
|
124
|
+
blocks.push(block);
|
|
125
|
+
}
|
|
126
|
+
return blocks;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Normalize a CSS descriptor value for comparison: CSS keywords are
|
|
131
|
+
* case-insensitive, and whitespace (e.g. in variable-font ranges like
|
|
132
|
+
* `"100 900"`) is insignificant beyond separating tokens.
|
|
133
|
+
*/
|
|
134
|
+
function normalizeCssValue(value: string): string {
|
|
135
|
+
return value.trim().replace(/\s+/g, " ").toLowerCase();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function matchesFilter(
|
|
139
|
+
block: CssFontFaceBlock,
|
|
140
|
+
filter: CssFontFaceFilter,
|
|
141
|
+
): boolean {
|
|
142
|
+
if (filter.fontFamily !== undefined) {
|
|
143
|
+
const wanted = Array.isArray(filter.fontFamily)
|
|
144
|
+
? filter.fontFamily
|
|
145
|
+
: [filter.fontFamily];
|
|
146
|
+
const name = block.fontFamily?.toLowerCase();
|
|
147
|
+
if (!wanted.some((w) => w.toLowerCase() === name)) return false;
|
|
148
|
+
}
|
|
149
|
+
if (
|
|
150
|
+
filter.fontWeight !== undefined &&
|
|
151
|
+
normalizeCssValue(block.fontWeight ?? "") !==
|
|
152
|
+
normalizeCssValue(String(filter.fontWeight))
|
|
153
|
+
) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
if (
|
|
157
|
+
filter.fontStyle !== undefined &&
|
|
158
|
+
normalizeCssValue(block.fontStyle ?? "") !==
|
|
159
|
+
normalizeCssValue(filter.fontStyle)
|
|
160
|
+
) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Build a {@link FontFamily} from CSS `@font-face` rules, so faces and their
|
|
168
|
+
* unicode ranges don't have to be written by hand. Each `@font-face` block
|
|
169
|
+
* becomes one face: its `src` URL and its `unicode-range` (faces stay
|
|
170
|
+
* lazily downloaded — a face is only fetched once text actually needs a
|
|
171
|
+
* codepoint in its ranges). Blocks without a `unicode-range` cover all
|
|
172
|
+
* codepoints.
|
|
173
|
+
*
|
|
174
|
+
* Face priority follows CSS semantics: later `@font-face` rules take
|
|
175
|
+
* precedence when their unicode-ranges overlap, so faces come out in
|
|
176
|
+
* reverse stylesheet order (use the `fontFamily` array to order families
|
|
177
|
+
* explicitly). The resulting first face doubles as the fallback for
|
|
178
|
+
* codepoints no face covers — same semantics as a hand-written
|
|
179
|
+
* {@link FontFamily}.
|
|
180
|
+
*
|
|
181
|
+
* @param family Family name to register the faces under.
|
|
182
|
+
* @param cssText Stylesheet text containing `@font-face` rules.
|
|
183
|
+
* @param options Optional block filters and a `baseUrl` for relative URLs.
|
|
184
|
+
*/
|
|
185
|
+
export function parseFontFamilyFromCss(
|
|
186
|
+
family: string,
|
|
187
|
+
cssText: string,
|
|
188
|
+
options: ParseCssFontFamilyOptions = {},
|
|
189
|
+
): FontFamily {
|
|
190
|
+
const faces: { face: FontFace; cssFamily: string }[] = [];
|
|
191
|
+
for (const block of parseFontFaceBlocks(cssText)) {
|
|
192
|
+
if (!matchesFilter(block, options)) continue;
|
|
193
|
+
if (block.src === undefined) continue;
|
|
194
|
+
const rawUrl = parseSrcUrl(block.src);
|
|
195
|
+
if (rawUrl === undefined) continue;
|
|
196
|
+
const url = options.baseUrl
|
|
197
|
+
? new URL(rawUrl, options.baseUrl).toString()
|
|
198
|
+
: rawUrl;
|
|
199
|
+
const unicodeRanges = block.unicodeRange
|
|
200
|
+
? parseCssUnicodeRange(block.unicodeRange)
|
|
201
|
+
: FULL_RANGE;
|
|
202
|
+
faces.push({
|
|
203
|
+
face: { url, unicodeRanges },
|
|
204
|
+
cssFamily: block.fontFamily?.toLowerCase() ?? "",
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
if (faces.length === 0) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`parseFontFamilyFromCss: no @font-face rules matched for family "${family}"`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
// CSS gives LATER @font-face rules precedence when their unicode-ranges
|
|
213
|
+
// overlap, so reverse the block order. Stylesheets rely on this: e.g.
|
|
214
|
+
// Google Fonts' `latin-ext` block declares broad ranges (U+0100-024F)
|
|
215
|
+
// that include codepoints only present in the `latin` file declared
|
|
216
|
+
// after it — without the reversal those codepoints would route to a face
|
|
217
|
+
// missing the glyph and shape as tofu.
|
|
218
|
+
faces.reverse();
|
|
219
|
+
if (Array.isArray(options.fontFamily)) {
|
|
220
|
+
// Some stylesheets (e.g. the Google Fonts CSS API) order @font-face
|
|
221
|
+
// blocks alphabetically, so restore the priority the caller asked for.
|
|
222
|
+
const priority = options.fontFamily.map((name) => name.toLowerCase());
|
|
223
|
+
// Families absent from the list rank last, not first (indexOf's -1
|
|
224
|
+
// would otherwise give them top priority).
|
|
225
|
+
const rank = (cssFamily: string) => {
|
|
226
|
+
const index = priority.indexOf(cssFamily);
|
|
227
|
+
return index === -1 ? priority.length : index;
|
|
228
|
+
};
|
|
229
|
+
faces.sort((a, b) => rank(a.cssFamily) - rank(b.cssFamily));
|
|
230
|
+
}
|
|
231
|
+
return { family, faces: faces.map(({ face }) => face) };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Fetch one or more stylesheets and build a {@link FontFamily} from their
|
|
236
|
+
* `@font-face` rules — see {@link parseFontFamilyFromCss}. Only the CSS is
|
|
237
|
+
* downloaded here; font files stay lazily fetched per face as text needs
|
|
238
|
+
* them.
|
|
239
|
+
*
|
|
240
|
+
* Works directly with the Google Fonts CSS API, which declares one
|
|
241
|
+
* `@font-face` per script subset with `unicode-range` (CJK families are
|
|
242
|
+
* split into ~100 lazily-loaded slices):
|
|
243
|
+
*
|
|
244
|
+
* ```ts
|
|
245
|
+
* const family = await fetchFontFamilyFromCss(
|
|
246
|
+
* "labels",
|
|
247
|
+
* "https://fonts.googleapis.com/css2?family=Archivo:wght@800&family=Noto+Sans+JP:wght@800",
|
|
248
|
+
* );
|
|
249
|
+
* view.addFontFamily(family);
|
|
250
|
+
* ```
|
|
251
|
+
*
|
|
252
|
+
* When passing multiple URLs, face priority follows URL order, then block
|
|
253
|
+
* order within each stylesheet.
|
|
254
|
+
*
|
|
255
|
+
* @param family Family name to register the faces under.
|
|
256
|
+
* @param cssUrl Stylesheet URL(s) to fetch.
|
|
257
|
+
* @param options Optional block filters and fetch options.
|
|
258
|
+
*/
|
|
259
|
+
export async function fetchFontFamilyFromCss(
|
|
260
|
+
family: string,
|
|
261
|
+
cssUrl: string | string[],
|
|
262
|
+
options: FetchCssFontFamilyOptions = {},
|
|
263
|
+
): Promise<FontFamily> {
|
|
264
|
+
const urls = Array.isArray(cssUrl) ? cssUrl : [cssUrl];
|
|
265
|
+
const { requestInit, ...filter } = options;
|
|
266
|
+
const faces: FontFace[] = [];
|
|
267
|
+
const cssTexts = await Promise.all(
|
|
268
|
+
urls.map(async (url) => {
|
|
269
|
+
const response = await fetch(url, requestInit);
|
|
270
|
+
if (!response.ok) {
|
|
271
|
+
throw new Error(
|
|
272
|
+
`fetchFontFamilyFromCss: failed to fetch "${url}" (${response.status})`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
// response.url follows redirects, making it the right base for
|
|
276
|
+
// relative src URLs.
|
|
277
|
+
return { text: await response.text(), baseUrl: response.url || url };
|
|
278
|
+
}),
|
|
279
|
+
);
|
|
280
|
+
for (const { text, baseUrl } of cssTexts) {
|
|
281
|
+
faces.push(
|
|
282
|
+
...parseFontFamilyFromCss(family, text, { ...filter, baseUrl }).faces,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
return { family, faces };
|
|
286
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/// <reference lib="webworker" />
|
|
2
|
+
|
|
3
|
+
import init, {
|
|
4
|
+
type FontAtlas,
|
|
5
|
+
FontCache,
|
|
6
|
+
composite_key,
|
|
7
|
+
type ShapeTextResult as WasmShapeTextResult,
|
|
8
|
+
type WasmShapedGlyph,
|
|
9
|
+
type WasmGlyphMetrics,
|
|
10
|
+
} from "@navaramap/engine-font-worker";
|
|
11
|
+
|
|
12
|
+
let fontCache: FontCache;
|
|
13
|
+
/** WASM linear memory, kept from init() for heap-size reporting. */
|
|
14
|
+
let wasmMemory: WebAssembly.Memory | undefined;
|
|
15
|
+
|
|
16
|
+
async function ensureWasm(): Promise<void> {
|
|
17
|
+
if (!fontCache) {
|
|
18
|
+
const output = await init();
|
|
19
|
+
wasmMemory = output.memory;
|
|
20
|
+
fontCache = new FontCache();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function convertGlyphs(glyphs: WasmShapedGlyph[]) {
|
|
25
|
+
return glyphs.map((g) => {
|
|
26
|
+
const out = {
|
|
27
|
+
glyphId: g.glyph_id,
|
|
28
|
+
fontIndex: g.font_index,
|
|
29
|
+
compositeKey: composite_key(g.font_index, g.glyph_id),
|
|
30
|
+
xAdvance: g.x_advance,
|
|
31
|
+
yAdvance: g.y_advance,
|
|
32
|
+
xOffset: g.x_offset,
|
|
33
|
+
yOffset: g.y_offset,
|
|
34
|
+
charClass: g.char_class,
|
|
35
|
+
};
|
|
36
|
+
g.free();
|
|
37
|
+
return out;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function convertMetrics(metrics: WasmGlyphMetrics[]) {
|
|
42
|
+
return metrics.map((m) => {
|
|
43
|
+
const out = {
|
|
44
|
+
glyphId: m.glyph_id,
|
|
45
|
+
fontIndex: m.font_index,
|
|
46
|
+
compositeKey: composite_key(m.font_index, m.glyph_id),
|
|
47
|
+
atlasX: m.atlas_x,
|
|
48
|
+
atlasY: m.atlas_y,
|
|
49
|
+
atlasW: m.atlas_w,
|
|
50
|
+
atlasH: m.atlas_h,
|
|
51
|
+
bearingX: m.bearing_x,
|
|
52
|
+
bearingY: m.bearing_y,
|
|
53
|
+
isColor: m.is_color,
|
|
54
|
+
};
|
|
55
|
+
m.free();
|
|
56
|
+
return out;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function convertShapeResult(sr: WasmShapeTextResult | undefined) {
|
|
61
|
+
if (!sr) return null;
|
|
62
|
+
const result = {
|
|
63
|
+
glyphs: convertGlyphs(sr.glyphs),
|
|
64
|
+
metrics: convertMetrics(sr.metrics),
|
|
65
|
+
unitsPerEm: sr.units_per_em,
|
|
66
|
+
ascender: sr.ascender,
|
|
67
|
+
descender: sr.descender,
|
|
68
|
+
lineGap: sr.line_gap,
|
|
69
|
+
};
|
|
70
|
+
sr.free();
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Take a transferable snapshot of `atlas` and free the WASM-owned object. */
|
|
75
|
+
function snapshot(atlas: FontAtlas | undefined) {
|
|
76
|
+
if (!atlas) return null;
|
|
77
|
+
const out = {
|
|
78
|
+
data: atlas.data.buffer,
|
|
79
|
+
width: atlas.width,
|
|
80
|
+
height: atlas.height,
|
|
81
|
+
channels: atlas.channels,
|
|
82
|
+
};
|
|
83
|
+
atlas.free();
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type FontWorkerMessageType =
|
|
88
|
+
| "init"
|
|
89
|
+
| "loadFont"
|
|
90
|
+
| "unloadFont"
|
|
91
|
+
| "prepareTextBatch"
|
|
92
|
+
| "retainGlyphs"
|
|
93
|
+
| "releaseGlyphs"
|
|
94
|
+
| "getMemoryStats";
|
|
95
|
+
|
|
96
|
+
const ctx = self as unknown as DedicatedWorkerGlobalScope;
|
|
97
|
+
|
|
98
|
+
ctx.onmessage = async (e: MessageEvent) => {
|
|
99
|
+
const msg = e.data;
|
|
100
|
+
const id: number = msg.id;
|
|
101
|
+
const msgType: FontWorkerMessageType = msg.type;
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
await ensureWasm();
|
|
105
|
+
|
|
106
|
+
switch (msgType) {
|
|
107
|
+
case "init": {
|
|
108
|
+
// Used to await worker readiness; optionally delivers the memory
|
|
109
|
+
// budget (font data + atlas pixels; caps further atlas growth).
|
|
110
|
+
const fontBudgetBytes = (
|
|
111
|
+
msg.payload as { fontBudgetBytes?: number } | undefined
|
|
112
|
+
)?.fontBudgetBytes;
|
|
113
|
+
fontCache.setBudget(fontBudgetBytes);
|
|
114
|
+
ctx.postMessage({ id, type: "result", payload: null });
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
case "getMemoryStats": {
|
|
119
|
+
const stats = fontCache.memoryStats();
|
|
120
|
+
const payload = {
|
|
121
|
+
heapBytes: wasmMemory?.buffer.byteLength ?? 0,
|
|
122
|
+
fontCount: stats.fontCount,
|
|
123
|
+
atlasCount: stats.atlasCount,
|
|
124
|
+
glyphCount: stats.glyphCount,
|
|
125
|
+
fontBytes: stats.fontBytes,
|
|
126
|
+
atlasBytes: stats.atlasBytes,
|
|
127
|
+
colorAtlasBytes: stats.colorAtlasBytes,
|
|
128
|
+
budgetBytes: stats.budgetBytes === 0 ? undefined : stats.budgetBytes,
|
|
129
|
+
};
|
|
130
|
+
stats.free();
|
|
131
|
+
ctx.postMessage({ id, type: "result", payload });
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
case "loadFont": {
|
|
136
|
+
const { url, data, atlasKey, highQuality } = msg.payload as {
|
|
137
|
+
url: string;
|
|
138
|
+
data: ArrayBuffer;
|
|
139
|
+
atlasKey?: string;
|
|
140
|
+
highQuality: boolean;
|
|
141
|
+
};
|
|
142
|
+
const bytes = new Uint8Array(data);
|
|
143
|
+
// Map TS quality → WASM mode string. `false` is treated as the SDF
|
|
144
|
+
// path by the Rust side (see `wasm_load_font`).
|
|
145
|
+
const mode = highQuality ? "msdf" : "sdf";
|
|
146
|
+
// On success the worker returns the font's cmap coverage as
|
|
147
|
+
// flattened [from, to, ...] ranges so the FontManager can correct a
|
|
148
|
+
// face's declared unicode ranges once per file.
|
|
149
|
+
const wasmRanges =
|
|
150
|
+
fontCache.loadFont(
|
|
151
|
+
url,
|
|
152
|
+
bytes.length,
|
|
153
|
+
(buf: Uint8Array) => {
|
|
154
|
+
buf.set(bytes);
|
|
155
|
+
},
|
|
156
|
+
atlasKey,
|
|
157
|
+
mode,
|
|
158
|
+
) ?? null;
|
|
159
|
+
// Copy before transferring: if the returned array is a view into
|
|
160
|
+
// WASM memory, transferring its buffer would detach the whole heap.
|
|
161
|
+
const cmapRanges = wasmRanges ? new Uint32Array(wasmRanges) : null;
|
|
162
|
+
ctx.postMessage(
|
|
163
|
+
{
|
|
164
|
+
id,
|
|
165
|
+
type: "result",
|
|
166
|
+
payload: { ok: cmapRanges !== null, cmapRanges },
|
|
167
|
+
},
|
|
168
|
+
cmapRanges ? [cmapRanges.buffer] : [],
|
|
169
|
+
);
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
case "unloadFont": {
|
|
174
|
+
const { url } = msg.payload as { url: string };
|
|
175
|
+
const ok = fontCache.unloadFont(url);
|
|
176
|
+
ctx.postMessage({ id, type: "result", payload: { ok } });
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
case "prepareTextBatch": {
|
|
181
|
+
const { fontUrl, texts } = msg.payload as {
|
|
182
|
+
fontUrl: string;
|
|
183
|
+
texts: string[];
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
let sdfAtlasChanged = false;
|
|
187
|
+
let colorAtlasChanged = false;
|
|
188
|
+
let evicted = false;
|
|
189
|
+
const results = texts.map((text) => {
|
|
190
|
+
const sr = fontCache.shapeText(fontUrl, text);
|
|
191
|
+
if (sr?.atlas_changed) {
|
|
192
|
+
if (sr.is_color) colorAtlasChanged = true;
|
|
193
|
+
else sdfAtlasChanged = true;
|
|
194
|
+
}
|
|
195
|
+
// Read `evicted` before convertShapeResult frees the WASM result.
|
|
196
|
+
if (sr?.evicted) evicted = true;
|
|
197
|
+
return { text, shapeResult: convertShapeResult(sr) };
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Snapshot atlases by atlas key (family name or URL) so shared atlases
|
|
201
|
+
// are returned correctly for font-family faces.
|
|
202
|
+
const atlasKey = fontCache.getAtlasKey(fontUrl) ?? fontUrl;
|
|
203
|
+
const atlas = sdfAtlasChanged
|
|
204
|
+
? snapshot(fontCache.getFontAtlas(atlasKey))
|
|
205
|
+
: null;
|
|
206
|
+
const colorAtlas = colorAtlasChanged
|
|
207
|
+
? snapshot(fontCache.getColorAtlas(atlasKey))
|
|
208
|
+
: null;
|
|
209
|
+
|
|
210
|
+
const transfers: Transferable[] = [];
|
|
211
|
+
if (atlas) transfers.push(atlas.data);
|
|
212
|
+
if (colorAtlas) transfers.push(colorAtlas.data);
|
|
213
|
+
ctx.postMessage(
|
|
214
|
+
{
|
|
215
|
+
id,
|
|
216
|
+
type: "result",
|
|
217
|
+
payload: { results, atlas, colorAtlas, atlasKey, evicted },
|
|
218
|
+
},
|
|
219
|
+
transfers,
|
|
220
|
+
);
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
case "retainGlyphs":
|
|
225
|
+
case "releaseGlyphs": {
|
|
226
|
+
const { atlasKey, keys } = msg.payload as {
|
|
227
|
+
atlasKey: string;
|
|
228
|
+
keys: BigUint64Array;
|
|
229
|
+
};
|
|
230
|
+
if (msgType === "retainGlyphs") {
|
|
231
|
+
fontCache.retainGlyphs(atlasKey, keys);
|
|
232
|
+
} else {
|
|
233
|
+
fontCache.releaseGlyphs(atlasKey, keys);
|
|
234
|
+
}
|
|
235
|
+
ctx.postMessage({ id, type: "result", payload: null });
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
default: {
|
|
240
|
+
ctx.postMessage({
|
|
241
|
+
id,
|
|
242
|
+
type: "error",
|
|
243
|
+
payload: { message: `Unknown message type: ${msgType}` },
|
|
244
|
+
});
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
} catch (err) {
|
|
249
|
+
ctx.postMessage({
|
|
250
|
+
id,
|
|
251
|
+
type: "error",
|
|
252
|
+
payload: { message: String(err) },
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export {
|
|
2
|
+
fetchFontFamilyFromCss,
|
|
3
|
+
parseCssUnicodeRange,
|
|
4
|
+
parseFontFamilyFromCss,
|
|
5
|
+
} from "./cssFontFamily";
|
|
6
|
+
export type {
|
|
7
|
+
CssFontFaceFilter,
|
|
8
|
+
FetchCssFontFamilyOptions,
|
|
9
|
+
ParseCssFontFamilyOptions,
|
|
10
|
+
} from "./cssFontFamily";
|
|
11
|
+
export {
|
|
12
|
+
FontManager,
|
|
13
|
+
createColorAtlasTexture,
|
|
14
|
+
createSdfAtlasTexture,
|
|
15
|
+
} from "./FontManager";
|
|
16
|
+
export { FontWorkerClient } from "./FontWorkerClient";
|
|
17
|
+
export { LRUMap } from "./LRUMap";
|
|
18
|
+
export { COLOR_GLYPH_PX_SIZE, GlyphCharClass, atlasRangePx } from "./types";
|
|
19
|
+
export type {
|
|
20
|
+
FontAtlasData,
|
|
21
|
+
FontFace,
|
|
22
|
+
FontFamily,
|
|
23
|
+
FontWorkerMemoryStats,
|
|
24
|
+
GlyphMetrics,
|
|
25
|
+
ShapedGlyph,
|
|
26
|
+
ShapeTextResult,
|
|
27
|
+
UnicodeRange,
|
|
28
|
+
} from "./types";
|