@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,183 @@
|
|
|
1
|
+
import type { ConcurrencyManager } from "@navaramap/worker";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
ShapeTextResult,
|
|
5
|
+
FontAtlasData,
|
|
6
|
+
BatchPrepareTextResult,
|
|
7
|
+
FontWorkerMemoryStats,
|
|
8
|
+
} from "./types";
|
|
9
|
+
|
|
10
|
+
export type FontWorkerClientOptions = {
|
|
11
|
+
/** Memory budget for the worker's font caches (font data + atlas pixels).
|
|
12
|
+
* Caps further atlas growth; the WASM heap itself never shrinks. */
|
|
13
|
+
fontBudgetBytes?: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Main-thread client that communicates with the dedicated font Web Worker.
|
|
18
|
+
* Uses a request/response protocol with incrementing message IDs.
|
|
19
|
+
*/
|
|
20
|
+
export class FontWorkerClient {
|
|
21
|
+
private _worker: Worker;
|
|
22
|
+
private _concurrencyManager: ConcurrencyManager;
|
|
23
|
+
private _nextId = 0;
|
|
24
|
+
private _pending = new Map<
|
|
25
|
+
number,
|
|
26
|
+
{ resolve: (value: unknown) => void; reject: (reason: unknown) => void }
|
|
27
|
+
>();
|
|
28
|
+
private _ready: Promise<void>;
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
workerUrl: string | URL,
|
|
32
|
+
concurrencyManager: ConcurrencyManager,
|
|
33
|
+
options?: FontWorkerClientOptions,
|
|
34
|
+
) {
|
|
35
|
+
this._concurrencyManager = concurrencyManager;
|
|
36
|
+
|
|
37
|
+
this._worker = new Worker(workerUrl, {
|
|
38
|
+
type: import.meta.env.PROD ? undefined : "module",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
this._worker.onmessage = (e: MessageEvent) => {
|
|
42
|
+
const { id, type, payload } = e.data;
|
|
43
|
+
const pending = this._pending.get(id);
|
|
44
|
+
if (!pending) return;
|
|
45
|
+
this._pending.delete(id);
|
|
46
|
+
|
|
47
|
+
if (type === "error") {
|
|
48
|
+
pending.reject(new Error(payload.message));
|
|
49
|
+
} else {
|
|
50
|
+
pending.resolve(payload);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
this._worker.onerror = (e) => {
|
|
55
|
+
console.error("FontWorkerClient: worker error", e);
|
|
56
|
+
// Reject all pending requests so callers don't hang indefinitely.
|
|
57
|
+
this.dispose();
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Trigger WASM init and resolve once the worker is ready; the init
|
|
61
|
+
// payload also delivers the memory budget.
|
|
62
|
+
this._ready = this._send("init", {
|
|
63
|
+
fontBudgetBytes: options?.fontBudgetBytes,
|
|
64
|
+
}).then(() => undefined);
|
|
65
|
+
this._concurrencyManager.increment();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Wait for the WASM module to be initialized in the worker. */
|
|
69
|
+
async ready(): Promise<void> {
|
|
70
|
+
return this._ready;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Load a font file into the worker's FontCache. Transfers the ArrayBuffer.
|
|
74
|
+
* `atlasKey`: optional shared atlas identifier (e.g. font family name).
|
|
75
|
+
* When provided, all fonts loaded with the same key share a single atlas.
|
|
76
|
+
* `highQuality`: whether to use the high-quality atlas raster path. The Rust side creates the
|
|
77
|
+
* atlas with this mode on the first load; subsequent loads under the same
|
|
78
|
+
* `atlasKey` are expected to reuse the same quality (the TS layer
|
|
79
|
+
* guarantees this by including the highQuality flag in both `url` and `atlasKey`).
|
|
80
|
+
*
|
|
81
|
+
* On success, `cmapRanges` holds the font's real cmap coverage as
|
|
82
|
+
* flattened `[from, to, ...]` inclusive codepoint ranges (empty when the
|
|
83
|
+
* face has no parsable unicode cmap — treat as unknown). */
|
|
84
|
+
async loadFont(
|
|
85
|
+
url: string,
|
|
86
|
+
data: ArrayBuffer,
|
|
87
|
+
atlasKey: string | undefined,
|
|
88
|
+
highQuality: boolean,
|
|
89
|
+
): Promise<{ ok: boolean; cmapRanges: Uint32Array | null }> {
|
|
90
|
+
return this._send("loadFont", { url, data, atlasKey, highQuality }, [
|
|
91
|
+
data,
|
|
92
|
+
]) as Promise<{
|
|
93
|
+
ok: boolean;
|
|
94
|
+
cmapRanges: Uint32Array | null;
|
|
95
|
+
}>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async unloadFont(url: string): Promise<{ ok: boolean }> {
|
|
99
|
+
return this._send("unloadFont", { url }) as Promise<{ ok: boolean }>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Add one visible-label reference to each glyph (composite keys) under
|
|
103
|
+
* `atlasKey`. Fire-and-forget; the worker replies so `_pending` is cleared.
|
|
104
|
+
* Transfers the key buffer — the caller discards it after this call. */
|
|
105
|
+
retainGlyphs(atlasKey: string, keys: BigUint64Array): void {
|
|
106
|
+
void this._send("retainGlyphs", { atlasKey, keys }, [keys.buffer]);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Drop one visible-label reference from each glyph under `atlasKey`. */
|
|
110
|
+
releaseGlyphs(atlasKey: string, keys: BigUint64Array): void {
|
|
111
|
+
void this._send("releaseGlyphs", { atlasKey, keys }, [keys.buffer]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Shape multiple texts in one worker round-trip. */
|
|
115
|
+
async prepareTextBatch(
|
|
116
|
+
fontUrl: string,
|
|
117
|
+
texts: string[],
|
|
118
|
+
): Promise<BatchPrepareTextResult> {
|
|
119
|
+
type RawAtlas = {
|
|
120
|
+
data: ArrayBuffer;
|
|
121
|
+
width: number;
|
|
122
|
+
height: number;
|
|
123
|
+
channels: number;
|
|
124
|
+
};
|
|
125
|
+
const raw = (await this._send("prepareTextBatch", {
|
|
126
|
+
fontUrl,
|
|
127
|
+
texts,
|
|
128
|
+
})) as {
|
|
129
|
+
results: { text: string; shapeResult: ShapeTextResult | null }[];
|
|
130
|
+
atlas: RawAtlas | null;
|
|
131
|
+
colorAtlas: RawAtlas | null;
|
|
132
|
+
atlasKey: string;
|
|
133
|
+
evicted: boolean;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const wrap = (raw: RawAtlas | null): FontAtlasData | null =>
|
|
137
|
+
raw
|
|
138
|
+
? {
|
|
139
|
+
data: new Uint8Array(raw.data),
|
|
140
|
+
width: raw.width,
|
|
141
|
+
height: raw.height,
|
|
142
|
+
channels: raw.channels,
|
|
143
|
+
}
|
|
144
|
+
: null;
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
results: raw.results,
|
|
148
|
+
atlas: wrap(raw.atlas),
|
|
149
|
+
colorAtlas: wrap(raw.colorAtlas),
|
|
150
|
+
atlasKey: raw.atlasKey,
|
|
151
|
+
evicted: raw.evicted,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Snapshot of the worker's WASM heap and font-cache memory usage. */
|
|
156
|
+
async getMemoryStats(): Promise<FontWorkerMemoryStats> {
|
|
157
|
+
return this._send(
|
|
158
|
+
"getMemoryStats",
|
|
159
|
+
undefined,
|
|
160
|
+
) as Promise<FontWorkerMemoryStats>;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
dispose(): void {
|
|
164
|
+
this._worker.terminate();
|
|
165
|
+
for (const pending of this._pending.values()) {
|
|
166
|
+
pending.reject(new Error("FontWorkerClient disposed"));
|
|
167
|
+
}
|
|
168
|
+
this._pending.clear();
|
|
169
|
+
this._concurrencyManager.decrement();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private _send(
|
|
173
|
+
type: string,
|
|
174
|
+
payload: unknown,
|
|
175
|
+
transfers?: Transferable[],
|
|
176
|
+
): Promise<unknown> {
|
|
177
|
+
const id = this._nextId++;
|
|
178
|
+
return new Promise((resolve, reject) => {
|
|
179
|
+
this._pending.set(id, { resolve, reject });
|
|
180
|
+
this._worker.postMessage({ id, type, payload }, transfers ?? []);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { LRUMap } from "./LRUMap";
|
|
4
|
+
|
|
5
|
+
describe("LRUMap", () => {
|
|
6
|
+
let lru: LRUMap<string, number>;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
lru = new LRUMap<string, number>(3);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("set and get", () => {
|
|
13
|
+
it("should store and retrieve values", () => {
|
|
14
|
+
lru.set("a", 1);
|
|
15
|
+
expect(lru.get("a")).toBe(1);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("should return undefined for missing keys", () => {
|
|
19
|
+
expect(lru.get("missing")).toBeUndefined();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("should overwrite existing keys", () => {
|
|
23
|
+
lru.set("a", 1);
|
|
24
|
+
lru.set("a", 2);
|
|
25
|
+
expect(lru.get("a")).toBe(2);
|
|
26
|
+
expect(lru.size).toBe(1);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("eviction", () => {
|
|
31
|
+
it("should evict the least recently used entry when exceeding maxSize", () => {
|
|
32
|
+
lru.set("a", 1);
|
|
33
|
+
lru.set("b", 2);
|
|
34
|
+
lru.set("c", 3);
|
|
35
|
+
lru.set("d", 4); // should evict "a"
|
|
36
|
+
|
|
37
|
+
expect(lru.has("a")).toBe(false);
|
|
38
|
+
expect(lru.get("b")).toBe(2);
|
|
39
|
+
expect(lru.get("c")).toBe(3);
|
|
40
|
+
expect(lru.get("d")).toBe(4);
|
|
41
|
+
expect(lru.size).toBe(3);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("should refresh entry on get, preventing eviction", () => {
|
|
45
|
+
lru.set("a", 1);
|
|
46
|
+
lru.set("b", 2);
|
|
47
|
+
lru.set("c", 3);
|
|
48
|
+
|
|
49
|
+
lru.get("a"); // refresh "a", making "b" the oldest
|
|
50
|
+
|
|
51
|
+
lru.set("d", 4); // should evict "b"
|
|
52
|
+
|
|
53
|
+
expect(lru.has("a")).toBe(true);
|
|
54
|
+
expect(lru.has("b")).toBe(false);
|
|
55
|
+
expect(lru.has("d")).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("should refresh entry on set with existing key", () => {
|
|
59
|
+
lru.set("a", 1);
|
|
60
|
+
lru.set("b", 2);
|
|
61
|
+
lru.set("c", 3);
|
|
62
|
+
|
|
63
|
+
lru.set("a", 10); // refresh "a", making "b" the oldest
|
|
64
|
+
|
|
65
|
+
lru.set("d", 4); // should evict "b"
|
|
66
|
+
|
|
67
|
+
expect(lru.get("a")).toBe(10);
|
|
68
|
+
expect(lru.has("b")).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("has", () => {
|
|
73
|
+
it("should return true for existing keys", () => {
|
|
74
|
+
lru.set("a", 1);
|
|
75
|
+
expect(lru.has("a")).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("should return false for missing keys", () => {
|
|
79
|
+
expect(lru.has("a")).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("delete", () => {
|
|
84
|
+
it("should remove an entry", () => {
|
|
85
|
+
lru.set("a", 1);
|
|
86
|
+
expect(lru.delete("a")).toBe(true);
|
|
87
|
+
expect(lru.has("a")).toBe(false);
|
|
88
|
+
expect(lru.size).toBe(0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("should return false when deleting a non-existent key", () => {
|
|
92
|
+
expect(lru.delete("a")).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("clear", () => {
|
|
97
|
+
it("should remove all entries", () => {
|
|
98
|
+
lru.set("a", 1);
|
|
99
|
+
lru.set("b", 2);
|
|
100
|
+
lru.clear();
|
|
101
|
+
expect(lru.size).toBe(0);
|
|
102
|
+
expect(lru.has("a")).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("size", () => {
|
|
107
|
+
it("should reflect the current number of entries", () => {
|
|
108
|
+
expect(lru.size).toBe(0);
|
|
109
|
+
lru.set("a", 1);
|
|
110
|
+
expect(lru.size).toBe(1);
|
|
111
|
+
lru.set("b", 2);
|
|
112
|
+
expect(lru.size).toBe(2);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("should not exceed maxSize", () => {
|
|
116
|
+
lru.set("a", 1);
|
|
117
|
+
lru.set("b", 2);
|
|
118
|
+
lru.set("c", 3);
|
|
119
|
+
lru.set("d", 4);
|
|
120
|
+
lru.set("e", 5);
|
|
121
|
+
expect(lru.size).toBe(3);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
});
|
package/src/LRUMap.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Map with a maximum capacity that evicts the least-recently used entries.
|
|
3
|
+
*
|
|
4
|
+
* Both `get` and `set` refresh the entry's position, making it the most recent.
|
|
5
|
+
* When the map exceeds `maxSize`, the least-recently used entries are evicted.
|
|
6
|
+
*/
|
|
7
|
+
export class LRUMap<K, V> {
|
|
8
|
+
private _map = new Map<K, V>();
|
|
9
|
+
private _maxSize: number;
|
|
10
|
+
|
|
11
|
+
constructor(maxSize: number) {
|
|
12
|
+
this._maxSize = maxSize;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
get size(): number {
|
|
16
|
+
return this._map.size;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
has(key: K): boolean {
|
|
20
|
+
return this._map.has(key);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get(key: K): V | undefined {
|
|
24
|
+
const value = this._map.get(key);
|
|
25
|
+
if (value !== undefined) {
|
|
26
|
+
// Touch: move to end (most recently used)
|
|
27
|
+
this._map.delete(key);
|
|
28
|
+
this._map.set(key, value);
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
set(key: K, value: V): void {
|
|
34
|
+
// Delete first so re-insertion moves it to the end (most recent)
|
|
35
|
+
if (this._map.has(key)) {
|
|
36
|
+
this._map.delete(key);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
this._map.set(key, value);
|
|
40
|
+
|
|
41
|
+
// Evict oldest entries if over capacity
|
|
42
|
+
while (this._map.size > this._maxSize) {
|
|
43
|
+
const oldest = this._map.keys().next();
|
|
44
|
+
if (!oldest.done) {
|
|
45
|
+
this._map.delete(oldest.value);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
delete(key: K): boolean {
|
|
51
|
+
return this._map.delete(key);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
clear(): void {
|
|
55
|
+
this._map.clear();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
fetchFontFamilyFromCss,
|
|
5
|
+
parseCssUnicodeRange,
|
|
6
|
+
parseFontFamilyFromCss,
|
|
7
|
+
} from "./cssFontFamily";
|
|
8
|
+
|
|
9
|
+
// Trimmed-down Google Fonts CSS API response: two subsets of one family plus
|
|
10
|
+
// a second family, each block with its own unicode-range.
|
|
11
|
+
const GOOGLE_FONTS_CSS = `
|
|
12
|
+
/* vietnamese */
|
|
13
|
+
@font-face {
|
|
14
|
+
font-family: 'Archivo';
|
|
15
|
+
font-style: normal;
|
|
16
|
+
font-weight: 800;
|
|
17
|
+
font-stretch: 125%;
|
|
18
|
+
font-display: swap;
|
|
19
|
+
src: url(https://fonts.gstatic.com/s/archivo/v25/viet.woff2) format('woff2');
|
|
20
|
+
unicode-range: U+0102-0103, U+0110-0111, U+1EA0-1EF9, U+20AB;
|
|
21
|
+
}
|
|
22
|
+
/* latin */
|
|
23
|
+
@font-face {
|
|
24
|
+
font-family: 'Archivo';
|
|
25
|
+
font-style: normal;
|
|
26
|
+
font-weight: 800;
|
|
27
|
+
font-stretch: 125%;
|
|
28
|
+
font-display: swap;
|
|
29
|
+
src: url(https://fonts.gstatic.com/s/archivo/v25/latin.woff2) format('woff2');
|
|
30
|
+
unicode-range: U+0000-00FF, U+2000-206F;
|
|
31
|
+
}
|
|
32
|
+
/* [58] */
|
|
33
|
+
@font-face {
|
|
34
|
+
font-family: 'Noto Sans JP';
|
|
35
|
+
font-style: normal;
|
|
36
|
+
font-weight: 800;
|
|
37
|
+
font-display: swap;
|
|
38
|
+
src: url(https://fonts.gstatic.com/s/notosansjp/v56/slice.58.woff2) format('woff2');
|
|
39
|
+
unicode-range: U+4E00-4EFF;
|
|
40
|
+
}
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
describe("parseCssUnicodeRange", () => {
|
|
44
|
+
it("parses single codepoints, intervals, and wildcards", () => {
|
|
45
|
+
expect(parseCssUnicodeRange("U+26")).toEqual([{ from: 0x26, to: 0x26 }]);
|
|
46
|
+
expect(parseCssUnicodeRange("U+0102-0103, U+20AB")).toEqual([
|
|
47
|
+
{ from: 0x102, to: 0x103 },
|
|
48
|
+
{ from: 0x20ab, to: 0x20ab },
|
|
49
|
+
]);
|
|
50
|
+
expect(parseCssUnicodeRange("U+4??")).toEqual([{ from: 0x400, to: 0x4ff }]);
|
|
51
|
+
expect(parseCssUnicodeRange("u+1ea0-1ef9")).toEqual([
|
|
52
|
+
{ from: 0x1ea0, to: 0x1ef9 },
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("rejects malformed tokens", () => {
|
|
57
|
+
expect(() => parseCssUnicodeRange("0102-0103")).toThrow();
|
|
58
|
+
expect(() => parseCssUnicodeRange("U+GGGG")).toThrow();
|
|
59
|
+
expect(() => parseCssUnicodeRange("U+4??-4FF")).toThrow();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("rejects ranges whose end precedes their start", () => {
|
|
63
|
+
expect(() => parseCssUnicodeRange("U+200-100")).toThrow();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("rejects codepoints beyond U+10FFFF", () => {
|
|
67
|
+
expect(() => parseCssUnicodeRange("U+110000")).toThrow();
|
|
68
|
+
expect(() => parseCssUnicodeRange("U+10FFFF-110000")).toThrow();
|
|
69
|
+
expect(() => parseCssUnicodeRange("U+??????")).toThrow();
|
|
70
|
+
expect(parseCssUnicodeRange("U+10FFFF")).toEqual([
|
|
71
|
+
{ from: 0x10ffff, to: 0x10ffff },
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("parseFontFamilyFromCss", () => {
|
|
77
|
+
it("builds one face per @font-face block, in reverse stylesheet order", () => {
|
|
78
|
+
const family = parseFontFamilyFromCss("labels", GOOGLE_FONTS_CSS);
|
|
79
|
+
expect(family.family).toBe("labels");
|
|
80
|
+
expect(family.faces).toHaveLength(3);
|
|
81
|
+
// CSS gives later @font-face rules precedence on range overlap, so
|
|
82
|
+
// blocks come out reversed: Archivo's latin block ends up before its
|
|
83
|
+
// latin-ext/vietnamese ones. Use the fontFamily array to order families.
|
|
84
|
+
expect(family.faces[0].url).toContain("notosansjp");
|
|
85
|
+
expect(family.faces[1]).toEqual({
|
|
86
|
+
url: "https://fonts.gstatic.com/s/archivo/v25/latin.woff2",
|
|
87
|
+
unicodeRanges: [
|
|
88
|
+
{ from: 0x0, to: 0xff },
|
|
89
|
+
{ from: 0x2000, to: 0x206f },
|
|
90
|
+
],
|
|
91
|
+
});
|
|
92
|
+
expect(family.faces[2].url).toContain("viet");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("routes overlap-declared codepoints to the later (more specific) block", () => {
|
|
96
|
+
// Mimics Google Fonts: latin-ext declares a broad range including
|
|
97
|
+
// U+0131, but the glyph only exists in the latin file declared after it.
|
|
98
|
+
const css = `
|
|
99
|
+
@font-face { font-family: A; font-style: normal; font-weight: 800; src: url(latin-ext.woff2); unicode-range: U+0100-024F; }
|
|
100
|
+
@font-face { font-family: A; font-style: normal; font-weight: 800; src: url(latin.woff2); unicode-range: U+0000-00FF, U+0131; }
|
|
101
|
+
`;
|
|
102
|
+
const family = parseFontFamilyFromCss("labels", css);
|
|
103
|
+
expect(family.faces.map((f) => f.url)).toEqual([
|
|
104
|
+
"latin.woff2",
|
|
105
|
+
"latin-ext.woff2",
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("filters blocks by font-family name", () => {
|
|
110
|
+
const family = parseFontFamilyFromCss("labels", GOOGLE_FONTS_CSS, {
|
|
111
|
+
fontFamily: "Noto Sans JP",
|
|
112
|
+
});
|
|
113
|
+
expect(family.faces).toHaveLength(1);
|
|
114
|
+
expect(family.faces[0].url).toContain("notosansjp");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("orders faces by fontFamily array position, not stylesheet order", () => {
|
|
118
|
+
// Google Fonts sorts blocks alphabetically; the caller's array wins.
|
|
119
|
+
const family = parseFontFamilyFromCss("labels", GOOGLE_FONTS_CSS, {
|
|
120
|
+
fontFamily: ["Noto Sans JP", "Archivo"],
|
|
121
|
+
});
|
|
122
|
+
expect(family.faces.map((f) => f.url)).toEqual([
|
|
123
|
+
"https://fonts.gstatic.com/s/notosansjp/v56/slice.58.woff2",
|
|
124
|
+
"https://fonts.gstatic.com/s/archivo/v25/latin.woff2",
|
|
125
|
+
"https://fonts.gstatic.com/s/archivo/v25/viet.woff2",
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("filters blocks by weight and style", () => {
|
|
130
|
+
const css = `
|
|
131
|
+
@font-face { font-family: A; font-style: italic; font-weight: 400; src: url(a-italic.woff2); }
|
|
132
|
+
@font-face { font-family: A; font-style: normal; font-weight: 700; src: url(a-bold.woff2); }
|
|
133
|
+
`;
|
|
134
|
+
const family = parseFontFamilyFromCss("labels", css, {
|
|
135
|
+
fontStyle: "normal",
|
|
136
|
+
fontWeight: 700,
|
|
137
|
+
});
|
|
138
|
+
expect(family.faces).toHaveLength(1);
|
|
139
|
+
expect(family.faces[0].url).toBe("a-bold.woff2");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("normalizes whitespace and case in weight/style filters", () => {
|
|
143
|
+
const css = `
|
|
144
|
+
@font-face { font-family: A; font-style: normal; font-weight: 100 900; src: url(a-var.woff2); }
|
|
145
|
+
`;
|
|
146
|
+
const family = parseFontFamilyFromCss("labels", css, {
|
|
147
|
+
fontStyle: "Normal",
|
|
148
|
+
fontWeight: " 100 900 ",
|
|
149
|
+
});
|
|
150
|
+
expect(family.faces).toHaveLength(1);
|
|
151
|
+
expect(family.faces[0].url).toBe("a-var.woff2");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("covers all codepoints when a block has no unicode-range", () => {
|
|
155
|
+
const css = `@font-face { font-family: A; src: url(a.woff2); }`;
|
|
156
|
+
const family = parseFontFamilyFromCss("labels", css);
|
|
157
|
+
expect(family.faces[0].unicodeRanges).toEqual([{ from: 0, to: 0x10ffff }]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("resolves relative src URLs against baseUrl", () => {
|
|
161
|
+
const css = `@font-face { font-family: A; src: url(./fonts/a.woff2); unicode-range: U+0-7F; }`;
|
|
162
|
+
const family = parseFontFamilyFromCss("labels", css, {
|
|
163
|
+
baseUrl: "https://example.com/styles/fonts.css",
|
|
164
|
+
});
|
|
165
|
+
expect(family.faces[0].url).toBe(
|
|
166
|
+
"https://example.com/styles/fonts/a.woff2",
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("prefers the woff2 source when src lists several formats", () => {
|
|
171
|
+
const css = `@font-face {
|
|
172
|
+
font-family: A;
|
|
173
|
+
src: url("a.eot") format("embedded-opentype"), url('a.woff2') format('woff2'), url(a.ttf) format(truetype);
|
|
174
|
+
}`;
|
|
175
|
+
const family = parseFontFamilyFromCss("labels", css);
|
|
176
|
+
expect(family.faces[0].url).toBe("a.woff2");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("throws when no block matches", () => {
|
|
180
|
+
expect(() =>
|
|
181
|
+
parseFontFamilyFromCss("labels", GOOGLE_FONTS_CSS, {
|
|
182
|
+
fontFamily: "Nope",
|
|
183
|
+
}),
|
|
184
|
+
).toThrow(/no @font-face rules matched/);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("fetchFontFamilyFromCss", () => {
|
|
189
|
+
afterEach(() => {
|
|
190
|
+
vi.unstubAllGlobals();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("fetches stylesheets and concatenates faces in URL order", async () => {
|
|
194
|
+
const cssByUrl: Record<string, string> = {
|
|
195
|
+
"https://a.test/fonts.css": `@font-face { font-family: A; src: url(/a.woff2); unicode-range: U+0-7F; }`,
|
|
196
|
+
"https://b.test/fonts.css": `@font-face { font-family: B; src: url(/b.woff2); unicode-range: U+4E00-9FFF; }`,
|
|
197
|
+
};
|
|
198
|
+
vi.stubGlobal(
|
|
199
|
+
"fetch",
|
|
200
|
+
vi.fn(async (url: string) => ({
|
|
201
|
+
ok: true,
|
|
202
|
+
url,
|
|
203
|
+
text: async () => cssByUrl[url],
|
|
204
|
+
})),
|
|
205
|
+
);
|
|
206
|
+
const family = await fetchFontFamilyFromCss("labels", [
|
|
207
|
+
"https://a.test/fonts.css",
|
|
208
|
+
"https://b.test/fonts.css",
|
|
209
|
+
]);
|
|
210
|
+
expect(family.faces.map((f) => f.url)).toEqual([
|
|
211
|
+
"https://a.test/a.woff2",
|
|
212
|
+
"https://b.test/b.woff2",
|
|
213
|
+
]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("throws on a failed response", async () => {
|
|
217
|
+
vi.stubGlobal(
|
|
218
|
+
"fetch",
|
|
219
|
+
vi.fn(async () => ({
|
|
220
|
+
ok: false,
|
|
221
|
+
status: 403,
|
|
222
|
+
url: "",
|
|
223
|
+
text: async () => "",
|
|
224
|
+
})),
|
|
225
|
+
);
|
|
226
|
+
await expect(
|
|
227
|
+
fetchFontFamilyFromCss("labels", "https://a.test/fonts.css"),
|
|
228
|
+
).rejects.toThrow(/403/);
|
|
229
|
+
});
|
|
230
|
+
});
|