@smallpen/local-package 0.1.0-alpha.1 → 0.1.0-alpha.11
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/assets/fonts/SourceSansPro-OFL.txt +46 -0
- package/assets/fonts/WorkSans-OFL.txt +46 -0
- package/package.json +4 -4
- package/runtime/fonts/OFL.txt +46 -0
- package/runtime/fonts/sourcesanspro-black.ttf +0 -0
- package/runtime/fonts/sourcesanspro-blackitalic.ttf +0 -0
- package/runtime/fonts/sourcesanspro-bold.ttf +0 -0
- package/runtime/fonts/sourcesanspro-bolditalic.ttf +0 -0
- package/runtime/fonts/sourcesanspro-extralight.ttf +0 -0
- package/runtime/fonts/sourcesanspro-extralightitalic.ttf +0 -0
- package/runtime/fonts/sourcesanspro-italic.ttf +0 -0
- package/runtime/fonts/sourcesanspro-light.ttf +0 -0
- package/runtime/fonts/sourcesanspro-lightitalic.ttf +0 -0
- package/runtime/fonts/sourcesanspro-regular.ttf +0 -0
- package/runtime/fonts/sourcesanspro-semibold.ttf +0 -0
- package/runtime/fonts/sourcesanspro-semibolditalic.ttf +0 -0
- package/runtime/manifest.json +7 -0
- package/runtime/render-wasm.js +15 -0
- package/runtime/render-wasm.wasm +0 -0
- package/runtime/renderer.mjs +169652 -0
- package/src/draft-import.mjs +1 -11
- package/src/font-convert.mjs +23 -1
- package/src/fs-utils.mjs +95 -0
- package/src/index.mjs +19 -3
- package/src/initialize.mjs +363 -136
- package/src/kiwi-runtime.mjs +38 -36
- package/src/library-network.mjs +189 -0
- package/src/local-package-backend.mjs +215 -24
- package/src/local-package.mjs +589 -177
- package/src/local-workspace-session.mjs +278 -96
- package/src/migrate-themes.mjs +116 -0
- package/src/remote-library.mjs +167 -28
- package/src/render-fonts.mjs +172 -0
- package/src/render-shared.mjs +346 -0
- package/src/render.mjs +71 -3557
- package/src/workspace.mjs +178 -17
- package/src/webp-decode.mjs +0 -73
package/src/remote-library.mjs
CHANGED
|
@@ -10,13 +10,25 @@ import {
|
|
|
10
10
|
SmallPenError,
|
|
11
11
|
} from "@smallpen/core";
|
|
12
12
|
|
|
13
|
+
import { createLibraryFetch } from "./library-network.mjs";
|
|
14
|
+
|
|
13
15
|
const MAX_REMOTE_FILE_BYTES = 50 * 1024 * 1024;
|
|
16
|
+
const MAX_REMOTE_MANIFEST_BYTES = 4 * 1024 * 1024;
|
|
14
17
|
const MAX_REMOTE_PACKAGE_BYTES = 200 * 1024 * 1024;
|
|
18
|
+
const REMOTE_REQUEST_TIMEOUT_MS = 30000;
|
|
19
|
+
const MAX_REMOTE_REDIRECTS = 5;
|
|
20
|
+
// Each entry and blob is one request: a manifest listing millions of tiny
|
|
21
|
+
// files must not turn one Library open into millions of downloads.
|
|
22
|
+
export const MAX_REMOTE_FILES = 10000;
|
|
15
23
|
|
|
16
24
|
export function defaultLibraryCacheRoot() {
|
|
17
25
|
return join(homedir(), ".smallpen", "library-cache");
|
|
18
26
|
}
|
|
19
27
|
|
|
28
|
+
export function remoteManifestUrl(sourceUrl) {
|
|
29
|
+
return manifestUrl(sourceUrl).href;
|
|
30
|
+
}
|
|
31
|
+
|
|
20
32
|
function manifestUrl(sourceUrl) {
|
|
21
33
|
const parsed = new URL(sourceUrl);
|
|
22
34
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
@@ -33,43 +45,137 @@ function manifestUrl(sourceUrl) {
|
|
|
33
45
|
return parsed;
|
|
34
46
|
}
|
|
35
47
|
|
|
36
|
-
|
|
48
|
+
function tooLarge(label, byteLength, url, limit) {
|
|
49
|
+
return new SmallPenError(
|
|
50
|
+
"remote_library_too_large",
|
|
51
|
+
`${label} exceeds the remote Library file limit`,
|
|
52
|
+
{ byteLength, limit, url },
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Reads the body as a stream and stops as soon as it passes the file limit,
|
|
57
|
+
// so an oversized or endless response is never buffered whole.
|
|
58
|
+
async function responseBytes(response, label, url, limit) {
|
|
37
59
|
if (!response.ok) {
|
|
60
|
+
await response.body?.cancel().catch(() => {});
|
|
38
61
|
throw new SmallPenError(
|
|
39
62
|
"remote_library_unavailable",
|
|
40
63
|
`${label} returned HTTP ${response.status}`,
|
|
41
|
-
{ status: response.status, url
|
|
64
|
+
{ status: response.status, url },
|
|
42
65
|
);
|
|
43
66
|
}
|
|
44
67
|
const declared = Number(response.headers.get("content-length"));
|
|
45
|
-
if (Number.isFinite(declared) && declared >
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
`${label} exceeds the remote Library file limit`,
|
|
49
|
-
{ byteLength: declared, url: response.url },
|
|
50
|
-
);
|
|
68
|
+
if (Number.isFinite(declared) && declared > limit) {
|
|
69
|
+
await response.body?.cancel().catch(() => {});
|
|
70
|
+
throw tooLarge(label, declared, url, limit);
|
|
51
71
|
}
|
|
52
|
-
const
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
72
|
+
const reader = response.body?.getReader?.();
|
|
73
|
+
if (!reader) {
|
|
74
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
75
|
+
if (bytes.byteLength > limit) {
|
|
76
|
+
throw tooLarge(label, bytes.byteLength, url, limit);
|
|
77
|
+
}
|
|
78
|
+
return bytes;
|
|
79
|
+
}
|
|
80
|
+
const chunks = [];
|
|
81
|
+
let byteLength = 0;
|
|
82
|
+
while (true) {
|
|
83
|
+
const { done, value } = await reader.read();
|
|
84
|
+
if (done) break;
|
|
85
|
+
byteLength += value.byteLength;
|
|
86
|
+
if (byteLength > limit) {
|
|
87
|
+
await reader.cancel().catch(() => {});
|
|
88
|
+
throw tooLarge(label, byteLength, url, limit);
|
|
89
|
+
}
|
|
90
|
+
chunks.push(value);
|
|
91
|
+
}
|
|
92
|
+
const bytes = new Uint8Array(byteLength);
|
|
93
|
+
let offset = 0;
|
|
94
|
+
for (const chunk of chunks) {
|
|
95
|
+
bytes.set(chunk, offset);
|
|
96
|
+
offset += chunk.byteLength;
|
|
59
97
|
}
|
|
60
98
|
return bytes;
|
|
61
99
|
}
|
|
62
100
|
|
|
63
|
-
|
|
64
|
-
|
|
101
|
+
// Every request has a total time budget (headers and body) and follows
|
|
102
|
+
// redirects only within the Library's origin: a Library URL must not be able
|
|
103
|
+
// to steer Background requests to another host.
|
|
104
|
+
async function fetchBytes(
|
|
105
|
+
{ fetchImpl, timeoutMs },
|
|
106
|
+
url,
|
|
107
|
+
label,
|
|
108
|
+
limit = MAX_REMOTE_FILE_BYTES,
|
|
109
|
+
) {
|
|
110
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
111
|
+
const origin = new URL(url).origin;
|
|
112
|
+
let current = url;
|
|
113
|
+
try {
|
|
114
|
+
for (let redirects = 0; ; redirects += 1) {
|
|
115
|
+
const response = await fetchImpl(current, { redirect: "manual", signal });
|
|
116
|
+
if (!(response.status >= 300 && response.status < 400)) {
|
|
117
|
+
return await responseBytes(response, label, current, limit);
|
|
118
|
+
}
|
|
119
|
+
await response.body?.cancel().catch(() => {});
|
|
120
|
+
const location = response.headers.get("location");
|
|
121
|
+
const next = location ? new URL(location, current) : undefined;
|
|
122
|
+
if (!next || next.origin !== origin || redirects >= MAX_REMOTE_REDIRECTS) {
|
|
123
|
+
throw new SmallPenError(
|
|
124
|
+
"remote_library_redirect",
|
|
125
|
+
`${label} redirected outside the Library origin`,
|
|
126
|
+
{ status: response.status, url: current },
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
current = next.href;
|
|
130
|
+
}
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (signal.aborted && !(error instanceof SmallPenError)) {
|
|
133
|
+
throw new SmallPenError(
|
|
134
|
+
"remote_library_timeout",
|
|
135
|
+
`${label} did not finish within ${timeoutMs} ms`,
|
|
136
|
+
{ url },
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
// Refused connections, DNS and TLS failures arrive as bare transport
|
|
140
|
+
// errors; name the Library and keep the system error code.
|
|
141
|
+
if (!(error instanceof SmallPenError)) {
|
|
142
|
+
const cause = networkErrorCode(error);
|
|
143
|
+
throw new SmallPenError(
|
|
144
|
+
"remote_library_unavailable",
|
|
145
|
+
`${label} could not be fetched: ${cause ?? error?.message ?? error}`,
|
|
146
|
+
cause ? { cause, url } : { url },
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// fetch() wraps the system error in `cause`, and a failed multi-address
|
|
154
|
+
// connect is an AggregateError: find the first ECONNREFUSED/ENOTFOUND/
|
|
155
|
+
// CERT_*-style code anywhere in that chain.
|
|
156
|
+
function networkErrorCode(error, depth = 0) {
|
|
157
|
+
if (!error || typeof error !== "object" || depth > 4) return undefined;
|
|
158
|
+
if (typeof error.code === "string" && /^[A-Z][A-Z0-9_]+$/.test(error.code)) {
|
|
159
|
+
return error.code;
|
|
160
|
+
}
|
|
161
|
+
for (const child of [error.cause, ...(Array.isArray(error.errors) ? error.errors : [])]) {
|
|
162
|
+
const code = networkErrorCode(child, depth + 1);
|
|
163
|
+
if (code) return code;
|
|
164
|
+
}
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function fetchValue(request, url, label, limit) {
|
|
169
|
+
const bytes = await fetchBytes(request, url, label, limit);
|
|
65
170
|
let value;
|
|
66
171
|
try {
|
|
67
172
|
value = JSON.parse(new TextDecoder().decode(bytes));
|
|
68
|
-
} catch
|
|
173
|
+
} catch {
|
|
174
|
+
// JSON.parse messages quote the payload; never echo remote content.
|
|
69
175
|
throw new SmallPenError(
|
|
70
176
|
"invalid_remote_library",
|
|
71
177
|
`${label} is not valid JSON`,
|
|
72
|
-
{
|
|
178
|
+
{ url },
|
|
73
179
|
);
|
|
74
180
|
}
|
|
75
181
|
return { bytes, value };
|
|
@@ -91,20 +197,30 @@ function collectBlobPaths(value, result = new Set()) {
|
|
|
91
197
|
return result;
|
|
92
198
|
}
|
|
93
199
|
|
|
94
|
-
|
|
200
|
+
function tooManyFiles(count, manifest) {
|
|
201
|
+
return new SmallPenError(
|
|
202
|
+
"remote_library_too_large",
|
|
203
|
+
"Remote Library lists more files than the package limit",
|
|
204
|
+
{ count, limit: MAX_REMOTE_FILES, url: manifest.href },
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function downloadRemotePackage(sourceUrl, request) {
|
|
95
209
|
const manifest = manifestUrl(sourceUrl);
|
|
96
210
|
const base = new URL("./", manifest);
|
|
97
211
|
const manifestResult = await fetchValue(
|
|
98
|
-
|
|
212
|
+
request,
|
|
99
213
|
manifest.href,
|
|
100
214
|
"Remote Library manifest",
|
|
215
|
+
MAX_REMOTE_MANIFEST_BYTES,
|
|
101
216
|
);
|
|
102
217
|
const { entries } = listPackageEntries(manifestResult.value);
|
|
218
|
+
if (entries.length > MAX_REMOTE_FILES) throw tooManyFiles(entries.length, manifest);
|
|
103
219
|
const values = new Map([["manifest.json", manifestResult.value]]);
|
|
104
220
|
let totalBytes = manifestResult.bytes.byteLength;
|
|
105
221
|
for (const entry of entries) {
|
|
106
222
|
const result = await fetchValue(
|
|
107
|
-
|
|
223
|
+
request,
|
|
108
224
|
new URL(entry, base).href,
|
|
109
225
|
`Remote Library entry ${entry}`,
|
|
110
226
|
);
|
|
@@ -118,9 +234,14 @@ async function downloadRemotePackage(sourceUrl, fetchImpl) {
|
|
|
118
234
|
}
|
|
119
235
|
values.set(entry, result.value);
|
|
120
236
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
237
|
+
const blobs = collectBlobPaths([...values.values()]);
|
|
238
|
+
if (entries.length + blobs.size > MAX_REMOTE_FILES) {
|
|
239
|
+
throw tooManyFiles(entries.length + blobs.size, manifest);
|
|
240
|
+
}
|
|
241
|
+
for (const blob of blobs) {
|
|
242
|
+
const bytes = await fetchBytes(
|
|
243
|
+
request,
|
|
244
|
+
new URL(blob, base).href,
|
|
124
245
|
`Remote Library blob ${blob}`,
|
|
125
246
|
);
|
|
126
247
|
totalBytes += bytes.byteLength;
|
|
@@ -223,7 +344,13 @@ async function readSnapshotCache(cacheRoot, sourceUrl) {
|
|
|
223
344
|
"remote_library_cache_corrupt",
|
|
224
345
|
"Cached Library content failed integrity validation",
|
|
225
346
|
{
|
|
226
|
-
|
|
347
|
+
// JSON.parse messages quote the (remote-origin) cached content.
|
|
348
|
+
reason:
|
|
349
|
+
error instanceof SyntaxError
|
|
350
|
+
? "invalid JSON"
|
|
351
|
+
: error instanceof Error
|
|
352
|
+
? error.message
|
|
353
|
+
: String(error),
|
|
227
354
|
sourceUrl: manifestUrl(sourceUrl).href,
|
|
228
355
|
},
|
|
229
356
|
);
|
|
@@ -242,7 +369,15 @@ async function readSnapshotCache(cacheRoot, sourceUrl) {
|
|
|
242
369
|
|
|
243
370
|
export async function openRemoteLibrary(
|
|
244
371
|
sourceUrl,
|
|
245
|
-
{
|
|
372
|
+
{
|
|
373
|
+
cacheRoot,
|
|
374
|
+
allowPrivateNetwork,
|
|
375
|
+
expectedPackageId,
|
|
376
|
+
// A caller-supplied fetch owns its own destination policy.
|
|
377
|
+
fetchImpl = createLibraryFetch({ allowPrivateNetwork }),
|
|
378
|
+
refresh = false,
|
|
379
|
+
requestTimeoutMs = REMOTE_REQUEST_TIMEOUT_MS,
|
|
380
|
+
} = {},
|
|
246
381
|
) {
|
|
247
382
|
let cached;
|
|
248
383
|
let corruptCacheError;
|
|
@@ -263,7 +398,10 @@ export async function openRemoteLibrary(
|
|
|
263
398
|
}
|
|
264
399
|
}
|
|
265
400
|
try {
|
|
266
|
-
const snapshot = await downloadRemotePackage(sourceUrl,
|
|
401
|
+
const snapshot = await downloadRemotePackage(sourceUrl, {
|
|
402
|
+
fetchImpl,
|
|
403
|
+
timeoutMs: requestTimeoutMs,
|
|
404
|
+
});
|
|
267
405
|
if (
|
|
268
406
|
expectedPackageId !== undefined &&
|
|
269
407
|
snapshot.manifest.packageId !== expectedPackageId
|
|
@@ -295,6 +433,7 @@ export async function openRemoteLibrary(
|
|
|
295
433
|
cache: "stale",
|
|
296
434
|
sourceUrl: manifestUrl(sourceUrl).href,
|
|
297
435
|
warning: error instanceof Error ? error.message : String(error),
|
|
436
|
+
warningCode: error?.code ?? "remote_library_unavailable",
|
|
298
437
|
},
|
|
299
438
|
};
|
|
300
439
|
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import opentype from "opentype.js";
|
|
6
|
+
import { SmallPenError } from "@smallpen/core";
|
|
7
|
+
|
|
8
|
+
const cache = join(homedir(), ".cache", "smallpen", "render-fonts");
|
|
9
|
+
const downloads = new Map();
|
|
10
|
+
const parsed = new WeakMap();
|
|
11
|
+
export const faceKey = (face) => `${face.fontId}/${face.weight}/${face.style}`;
|
|
12
|
+
|
|
13
|
+
async function catalogFont(url) {
|
|
14
|
+
const address = new URL(url);
|
|
15
|
+
if (
|
|
16
|
+
address.protocol !== "https:" ||
|
|
17
|
+
address.hostname !== "fonts.gstatic.com" ||
|
|
18
|
+
!address.pathname.startsWith("/s/") ||
|
|
19
|
+
address.username ||
|
|
20
|
+
address.password ||
|
|
21
|
+
address.port
|
|
22
|
+
)
|
|
23
|
+
throw new SmallPenError(
|
|
24
|
+
"invalid_font_url",
|
|
25
|
+
"The shared font catalog returned an invalid font URL",
|
|
26
|
+
);
|
|
27
|
+
const path = join(
|
|
28
|
+
cache,
|
|
29
|
+
createHash("sha256").update(url).digest("hex") + ".ttf",
|
|
30
|
+
);
|
|
31
|
+
try {
|
|
32
|
+
return await readFile(path);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (error.code !== "ENOENT") throw error;
|
|
35
|
+
}
|
|
36
|
+
const response = await fetch(url, {
|
|
37
|
+
redirect: "error",
|
|
38
|
+
signal: AbortSignal.timeout(20_000),
|
|
39
|
+
});
|
|
40
|
+
if (!response.ok)
|
|
41
|
+
throw new SmallPenError(
|
|
42
|
+
"font_resource_unavailable",
|
|
43
|
+
`Font download failed (${response.status})`,
|
|
44
|
+
);
|
|
45
|
+
const limit = 32 * 1024 * 1024;
|
|
46
|
+
const chunks = [];
|
|
47
|
+
let length = 0;
|
|
48
|
+
for await (const chunk of response.body) {
|
|
49
|
+
length += chunk.length;
|
|
50
|
+
if (length > limit)
|
|
51
|
+
throw new SmallPenError(
|
|
52
|
+
"font_resource_too_large",
|
|
53
|
+
"Font exceeds the resource limit",
|
|
54
|
+
);
|
|
55
|
+
chunks.push(chunk);
|
|
56
|
+
}
|
|
57
|
+
const bytes = Buffer.concat(chunks);
|
|
58
|
+
await mkdir(cache, { recursive: true });
|
|
59
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
60
|
+
await writeFile(temporary, bytes);
|
|
61
|
+
await rename(temporary, path);
|
|
62
|
+
return bytes;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Host resource loading only. Family/weight/style and fallback selection come
|
|
66
|
+
// from App's shared font catalog; Skia owns shaping, metrics and drawing.
|
|
67
|
+
export async function provisionFonts(requests, available, sourceIds) {
|
|
68
|
+
const faces = new Map(available.map((face) => [faceKey(face), face]));
|
|
69
|
+
const selected = new Map();
|
|
70
|
+
const diagnostics = [];
|
|
71
|
+
for (const request of requests) {
|
|
72
|
+
const key = faceKey(request);
|
|
73
|
+
if (selected.has(key)) continue;
|
|
74
|
+
let face = faces.get(key);
|
|
75
|
+
if (!face && request.url) {
|
|
76
|
+
let download = downloads.get(request.url);
|
|
77
|
+
if (!download) {
|
|
78
|
+
download = catalogFont(request.url).catch((error) => {
|
|
79
|
+
downloads.delete(request.url);
|
|
80
|
+
throw error;
|
|
81
|
+
});
|
|
82
|
+
downloads.set(request.url, download);
|
|
83
|
+
}
|
|
84
|
+
face = { ...request, bytes: await download };
|
|
85
|
+
}
|
|
86
|
+
if (
|
|
87
|
+
!face &&
|
|
88
|
+
!request.fontId.startsWith("custom-") &&
|
|
89
|
+
!request.fontId.startsWith("gfont-")
|
|
90
|
+
) {
|
|
91
|
+
face = faces.get(faceKey({ ...request, fontId: "sourcesanspro" }));
|
|
92
|
+
}
|
|
93
|
+
if (!face?.bytes)
|
|
94
|
+
throw new SmallPenError(
|
|
95
|
+
"font_resource_unavailable",
|
|
96
|
+
`Shared renderer cannot load ${request.fontId} ${request.weight} ${request.style}`,
|
|
97
|
+
);
|
|
98
|
+
selected.set(key, {
|
|
99
|
+
...face,
|
|
100
|
+
fontId: request.fontId,
|
|
101
|
+
isFallback: request.isFallback,
|
|
102
|
+
isEmoji: request.isEmoji,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
const fonts = [...selected.values()];
|
|
106
|
+
const coverage = (face) => {
|
|
107
|
+
let font = parsed.get(face.bytes);
|
|
108
|
+
if (!font) {
|
|
109
|
+
const bytes = face.bytes;
|
|
110
|
+
font = opentype.parse(
|
|
111
|
+
bytes.buffer.slice(
|
|
112
|
+
bytes.byteOffset,
|
|
113
|
+
bytes.byteOffset + bytes.byteLength,
|
|
114
|
+
),
|
|
115
|
+
);
|
|
116
|
+
parsed.set(bytes, font);
|
|
117
|
+
}
|
|
118
|
+
return font;
|
|
119
|
+
};
|
|
120
|
+
const fallback = fonts.filter((face) => face.isFallback);
|
|
121
|
+
const missingGlyphs = new Map();
|
|
122
|
+
const substitutions = new Set();
|
|
123
|
+
for (const request of requests) {
|
|
124
|
+
if (!request.nodeId) continue;
|
|
125
|
+
const nodeId = sourceIds[request.nodeId];
|
|
126
|
+
const primaryFace = selected.get(faceKey(request));
|
|
127
|
+
const identity = (value) =>
|
|
128
|
+
String(value)
|
|
129
|
+
.toLowerCase()
|
|
130
|
+
.replace(/[^a-z0-9]/g, "");
|
|
131
|
+
const renamedBuiltin =
|
|
132
|
+
request.fontId === "sourcesanspro" &&
|
|
133
|
+
request.family &&
|
|
134
|
+
!primaryFace.names?.some(
|
|
135
|
+
(name) => identity(name) === identity(request.family),
|
|
136
|
+
);
|
|
137
|
+
if (
|
|
138
|
+
((!faces.has(faceKey(request)) && !request.url) || renamedBuiltin) &&
|
|
139
|
+
!substitutions.has(nodeId)
|
|
140
|
+
) {
|
|
141
|
+
substitutions.add(nodeId);
|
|
142
|
+
const requestedFont = request.family ?? request.fontId;
|
|
143
|
+
diagnostics.push({
|
|
144
|
+
code: "font_render_fallback",
|
|
145
|
+
nodeId,
|
|
146
|
+
path: nodeId,
|
|
147
|
+
message: `Renderer substituted Source Sans Pro for ${requestedFont}`,
|
|
148
|
+
details: { requestedFont, substituteFont: "Source Sans Pro" },
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
const primary = coverage(selected.get(faceKey(request)));
|
|
152
|
+
const missing = missingGlyphs.get(nodeId) ?? new Set();
|
|
153
|
+
for (const character of new Set(request.text)) {
|
|
154
|
+
if (/\s|[\u200B-\u200F\uFE00-\uFE0F]/u.test(character)) continue;
|
|
155
|
+
if (
|
|
156
|
+
!primary.charToGlyphIndex(character) &&
|
|
157
|
+
!fallback.some((face) => coverage(face).charToGlyphIndex(character))
|
|
158
|
+
)
|
|
159
|
+
missing.add(character);
|
|
160
|
+
}
|
|
161
|
+
missingGlyphs.set(nodeId, missing);
|
|
162
|
+
}
|
|
163
|
+
for (const [nodeId, missing] of missingGlyphs)
|
|
164
|
+
if (missing.size)
|
|
165
|
+
diagnostics.push({
|
|
166
|
+
code: "text_missing_glyph",
|
|
167
|
+
nodeId,
|
|
168
|
+
path: nodeId,
|
|
169
|
+
message: `No shared font covers ${[...missing].map((c) => "U+" + c.codePointAt(0).toString(16).toUpperCase()).join(", ")}`,
|
|
170
|
+
});
|
|
171
|
+
return { fonts, diagnostics, missingGlyphs };
|
|
172
|
+
}
|