@navaramap/font 0.0.2 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +7 -2
- package/dist/index.js +286 -362
- package/dist/index.umd.cjs +1 -1
- package/package.json +4 -3
- package/src/cssFontFamily.test.ts +82 -174
- package/src/cssFontFamily.ts +40 -174
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
1
|
+
import { describe, it, expect, afterEach, vi, beforeEach } from "vitest";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
fetchFontFamilyFromCss,
|
|
@@ -6,182 +6,53 @@ import {
|
|
|
6
6
|
parseFontFamilyFromCss,
|
|
7
7
|
} from "./cssFontFamily";
|
|
8
8
|
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
`;
|
|
9
|
+
// The @font-face / unicode-range parsing itself lives in Rust (navara_wasm_api)
|
|
10
|
+
// and is covered by that crate's tests. Here the WASM module is mocked so these
|
|
11
|
+
// tests stay WASM-free and focus on the TypeScript orchestration: lazy init,
|
|
12
|
+
// delegation, and the fetch handling in `fetchFontFamilyFromCss`.
|
|
13
|
+
const { initApi, wasmParseCssUnicodeRange, wasmParseFontFamilyFromCss } =
|
|
14
|
+
vi.hoisted(() => ({
|
|
15
|
+
initApi: vi.fn(async () => undefined),
|
|
16
|
+
wasmParseCssUnicodeRange: vi.fn(),
|
|
17
|
+
wasmParseFontFamilyFromCss: vi.fn(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock("@navaramap/engine-api", () => ({
|
|
21
|
+
default: initApi,
|
|
22
|
+
parseCssUnicodeRange: wasmParseCssUnicodeRange,
|
|
23
|
+
parseFontFamilyFromCss: wasmParseFontFamilyFromCss,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.clearAllMocks();
|
|
28
|
+
});
|
|
42
29
|
|
|
43
30
|
describe("parseCssUnicodeRange", () => {
|
|
44
|
-
it("
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
expect(
|
|
52
|
-
|
|
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
|
-
]);
|
|
31
|
+
it("initializes the WASM module and delegates", async () => {
|
|
32
|
+
const ranges = [{ from: 0x26, to: 0x26 }];
|
|
33
|
+
wasmParseCssUnicodeRange.mockReturnValue(ranges);
|
|
34
|
+
// First wrapper call of the module: init runs here. Init is memoized
|
|
35
|
+
// (see `ensureApi`), so later tests share it and don't re-invoke it —
|
|
36
|
+
// hence the init assertion lives only in this first test.
|
|
37
|
+
const result = await parseCssUnicodeRange("U+26");
|
|
38
|
+
expect(initApi).toHaveBeenCalled();
|
|
39
|
+
expect(wasmParseCssUnicodeRange).toHaveBeenCalledWith("U+26");
|
|
40
|
+
expect(result).toBe(ranges);
|
|
73
41
|
});
|
|
74
42
|
});
|
|
75
43
|
|
|
76
44
|
describe("parseFontFamilyFromCss", () => {
|
|
77
|
-
it("
|
|
78
|
-
const family =
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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",
|
|
45
|
+
it("delegates to the WASM parser with options", async () => {
|
|
46
|
+
const family = { family: "labels", faces: [] };
|
|
47
|
+
wasmParseFontFamilyFromCss.mockReturnValue(family);
|
|
48
|
+
const options = { fontFamily: "Archivo", baseUrl: "https://a.test/" };
|
|
49
|
+
const result = await parseFontFamilyFromCss("labels", "css", options);
|
|
50
|
+
expect(wasmParseFontFamilyFromCss).toHaveBeenCalledWith(
|
|
51
|
+
"labels",
|
|
52
|
+
"css",
|
|
53
|
+
options,
|
|
167
54
|
);
|
|
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/);
|
|
55
|
+
expect(result).toBe(family);
|
|
185
56
|
});
|
|
186
57
|
});
|
|
187
58
|
|
|
@@ -192,8 +63,8 @@ describe("fetchFontFamilyFromCss", () => {
|
|
|
192
63
|
|
|
193
64
|
it("fetches stylesheets and concatenates faces in URL order", async () => {
|
|
194
65
|
const cssByUrl: Record<string, string> = {
|
|
195
|
-
"https://a.test/fonts.css":
|
|
196
|
-
"https://b.test/fonts.css":
|
|
66
|
+
"https://a.test/fonts.css": "@font-face-a",
|
|
67
|
+
"https://b.test/fonts.css": "@font-face-b",
|
|
197
68
|
};
|
|
198
69
|
vi.stubGlobal(
|
|
199
70
|
"fetch",
|
|
@@ -203,16 +74,53 @@ describe("fetchFontFamilyFromCss", () => {
|
|
|
203
74
|
text: async () => cssByUrl[url],
|
|
204
75
|
})),
|
|
205
76
|
);
|
|
77
|
+
// Echo the resolved baseUrl back as the face URL so we can assert both the
|
|
78
|
+
// per-stylesheet baseUrl threading and the URL-order concatenation.
|
|
79
|
+
wasmParseFontFamilyFromCss.mockImplementation(
|
|
80
|
+
(family: string, _cssText: string, options: { baseUrl?: string }) => ({
|
|
81
|
+
family,
|
|
82
|
+
faces: [{ url: options.baseUrl, unicodeRanges: [] }],
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
|
|
206
86
|
const family = await fetchFontFamilyFromCss("labels", [
|
|
207
87
|
"https://a.test/fonts.css",
|
|
208
88
|
"https://b.test/fonts.css",
|
|
209
89
|
]);
|
|
90
|
+
|
|
91
|
+
expect(family.family).toBe("labels");
|
|
210
92
|
expect(family.faces.map((f) => f.url)).toEqual([
|
|
211
|
-
"https://a.test/
|
|
212
|
-
"https://b.test/
|
|
93
|
+
"https://a.test/fonts.css",
|
|
94
|
+
"https://b.test/fonts.css",
|
|
213
95
|
]);
|
|
214
96
|
});
|
|
215
97
|
|
|
98
|
+
it("passes requestInit to fetch and filter options (minus requestInit) to the parser", async () => {
|
|
99
|
+
const requestInit = { credentials: "include" as const };
|
|
100
|
+
const fetchMock = vi.fn(async (url: string) => ({
|
|
101
|
+
ok: true,
|
|
102
|
+
url,
|
|
103
|
+
text: async () => "css",
|
|
104
|
+
}));
|
|
105
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
106
|
+
wasmParseFontFamilyFromCss.mockReturnValue({ family: "labels", faces: [] });
|
|
107
|
+
|
|
108
|
+
await fetchFontFamilyFromCss("labels", "https://a.test/fonts.css", {
|
|
109
|
+
requestInit,
|
|
110
|
+
fontFamily: "Archivo",
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
114
|
+
"https://a.test/fonts.css",
|
|
115
|
+
requestInit,
|
|
116
|
+
);
|
|
117
|
+
// requestInit must not leak into the parser options.
|
|
118
|
+
expect(wasmParseFontFamilyFromCss).toHaveBeenCalledWith("labels", "css", {
|
|
119
|
+
fontFamily: "Archivo",
|
|
120
|
+
baseUrl: "https://a.test/fonts.css",
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
216
124
|
it("throws on a failed response", async () => {
|
|
217
125
|
vi.stubGlobal(
|
|
218
126
|
"fetch",
|
package/src/cssFontFamily.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import
|
|
1
|
+
import initApi, {
|
|
2
|
+
parseCssUnicodeRange as wasmParseCssUnicodeRange,
|
|
3
|
+
parseFontFamilyFromCss as wasmParseFontFamilyFromCss,
|
|
4
|
+
} from "@navaramap/engine-api";
|
|
5
|
+
|
|
6
|
+
import type { FontFamily, UnicodeRange } from "./types";
|
|
2
7
|
|
|
3
8
|
/** Filters selecting which `@font-face` blocks of a stylesheet to include. */
|
|
4
9
|
export type CssFontFaceFilter = {
|
|
@@ -31,136 +36,32 @@ export type FetchCssFontFamilyOptions = CssFontFaceFilter & {
|
|
|
31
36
|
requestInit?: RequestInit;
|
|
32
37
|
};
|
|
33
38
|
|
|
39
|
+
// The @font-face / unicode-range parsing lives in Rust (`navara_wasm_api`,
|
|
40
|
+
// published as `@navaramap/engine-api`); these functions are thin wrappers that
|
|
41
|
+
// initialize that WASM module and delegate. Parsing correctness is covered by
|
|
42
|
+
// the crate's Rust tests; the TS tests here cover the fetch orchestration.
|
|
43
|
+
//
|
|
44
|
+
// The WASM module is instantiated lazily and at most once — the promise is
|
|
45
|
+
// memoized so concurrent callers share a single init (`__wbg_init` itself is
|
|
46
|
+
// idempotent once resolved, but memoizing avoids a redundant instantiate while
|
|
47
|
+
// the first is still in flight).
|
|
48
|
+
let apiReady: Promise<unknown> | undefined;
|
|
49
|
+
function ensureApi(): Promise<unknown> {
|
|
50
|
+
return (apiReady ??= initApi());
|
|
51
|
+
}
|
|
52
|
+
|
|
34
53
|
/**
|
|
35
54
|
* Parse a CSS `unicode-range` descriptor value (e.g. `"U+0-7F, U+131, U+4??"`)
|
|
36
55
|
* into inclusive codepoint ranges. Supports single codepoints (`U+26`),
|
|
37
56
|
* intervals (`U+0102-0103`) and wildcards (`U+4??`).
|
|
57
|
+
*
|
|
58
|
+
* Backed by WASM, so it resolves once `@navaramap/engine-api` has initialized.
|
|
38
59
|
*/
|
|
39
|
-
export function parseCssUnicodeRange(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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;
|
|
60
|
+
export async function parseCssUnicodeRange(
|
|
61
|
+
value: string,
|
|
62
|
+
): Promise<UnicodeRange[]> {
|
|
63
|
+
await ensureApi();
|
|
64
|
+
return wasmParseCssUnicodeRange(value) as UnicodeRange[];
|
|
164
65
|
}
|
|
165
66
|
|
|
166
67
|
/**
|
|
@@ -178,57 +79,20 @@ function matchesFilter(
|
|
|
178
79
|
* codepoints no face covers — same semantics as a hand-written
|
|
179
80
|
* {@link FontFamily}.
|
|
180
81
|
*
|
|
82
|
+
* The parsing runs in WASM (`@navaramap/engine-api`), so this resolves once
|
|
83
|
+
* that module has initialized.
|
|
84
|
+
*
|
|
181
85
|
* @param family Family name to register the faces under.
|
|
182
86
|
* @param cssText Stylesheet text containing `@font-face` rules.
|
|
183
87
|
* @param options Optional block filters and a `baseUrl` for relative URLs.
|
|
184
88
|
*/
|
|
185
|
-
export function parseFontFamilyFromCss(
|
|
89
|
+
export async function parseFontFamilyFromCss(
|
|
186
90
|
family: string,
|
|
187
91
|
cssText: string,
|
|
188
92
|
options: ParseCssFontFamilyOptions = {},
|
|
189
|
-
): FontFamily {
|
|
190
|
-
|
|
191
|
-
|
|
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) };
|
|
93
|
+
): Promise<FontFamily> {
|
|
94
|
+
await ensureApi();
|
|
95
|
+
return wasmParseFontFamilyFromCss(family, cssText, options) as FontFamily;
|
|
232
96
|
}
|
|
233
97
|
|
|
234
98
|
/**
|
|
@@ -263,7 +127,7 @@ export async function fetchFontFamilyFromCss(
|
|
|
263
127
|
): Promise<FontFamily> {
|
|
264
128
|
const urls = Array.isArray(cssUrl) ? cssUrl : [cssUrl];
|
|
265
129
|
const { requestInit, ...filter } = options;
|
|
266
|
-
const faces:
|
|
130
|
+
const faces: FontFamily["faces"] = [];
|
|
267
131
|
const cssTexts = await Promise.all(
|
|
268
132
|
urls.map(async (url) => {
|
|
269
133
|
const response = await fetch(url, requestInit);
|
|
@@ -278,9 +142,11 @@ export async function fetchFontFamilyFromCss(
|
|
|
278
142
|
}),
|
|
279
143
|
);
|
|
280
144
|
for (const { text, baseUrl } of cssTexts) {
|
|
281
|
-
|
|
282
|
-
...
|
|
283
|
-
|
|
145
|
+
const parsed = await parseFontFamilyFromCss(family, text, {
|
|
146
|
+
...filter,
|
|
147
|
+
baseUrl,
|
|
148
|
+
});
|
|
149
|
+
faces.push(...parsed.faces);
|
|
284
150
|
}
|
|
285
151
|
return { family, faces };
|
|
286
152
|
}
|