@wxxb789/anc 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/README.md +63 -34
- package/bin/anc.mjs +120 -11
- package/package.json +8 -1
- package/scripts/copy-assets.js +141 -0
- package/scripts/init-repository.js +18 -0
- package/scripts/load-config.js +258 -5
- package/scripts/markdown-to-artifact.js +310 -6
- package/scripts/publish-media.js +468 -0
- package/scripts/resolve-links.js +318 -42
- package/scripts/scan-residue.js +17 -6
- package/scripts/verify-output-inventory.js +53 -0
- package/src/lib/content.js +22 -0
- package/src/lib/font-coverage.js +845 -0
- package/src/lib/markdown.js +257 -11
- package/src/lib/media-codec.js +355 -0
- package/src/lib/media-delivery.js +76 -0
- package/src/lib/media.js +1426 -0
- package/src/lib/schema.js +83 -8
- package/src/lib/svg-css.js +685 -0
- package/src/lib/svg-policy.js +1699 -0
- package/src/lib/svg-refusal.js +1 -0
- package/src/lib/svg-render.js +343 -0
- package/src/pages/notes/[slug].astro +10 -1
- package/src/pages/privacy.astro +9 -5
- package/src/styles/global.css +29 -0
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host font coverage for the SVG text gate.
|
|
3
|
+
*
|
|
4
|
+
* Goal 0012 refuses a document whose visible text the host cannot draw. The
|
|
5
|
+
* renderer cannot answer that question: Pango, when no font covers a character,
|
|
6
|
+
* draws a **hex box** containing the code point rather than nothing, so a
|
|
7
|
+
* missing CJK label is not a blank region and an ink check cannot see it.
|
|
8
|
+
* Measured on this Linux host: U+10FFF0 renders a box reading `10FFF0`, U+E000 a
|
|
9
|
+
* box reading `E000`, and `口` renders its own glyph — all three have ink.
|
|
10
|
+
*
|
|
11
|
+
* This module therefore answers the question Pango asks fontconfig: **is this
|
|
12
|
+
* code point in the `cmap` of any installed font?** It reads the sfnt table
|
|
13
|
+
* directory and only the `cmap` table of each font file, so a 250-font host is
|
|
14
|
+
* scanned by reading a few hundred kilobytes per file rather than the fonts
|
|
15
|
+
* themselves. Coverage is cached per process, because it is a property of the
|
|
16
|
+
* host and not of a build.
|
|
17
|
+
*
|
|
18
|
+
* ## What this is not
|
|
19
|
+
*
|
|
20
|
+
* It is not a font fallback engine and does not choose a family. Pango falls
|
|
21
|
+
* back through the host's font set per character — measured, CJK under
|
|
22
|
+
* `font-family="DejaVu Sans"` renders identically to the default stack — so the
|
|
23
|
+
* only question whose answer decides a reader's outcome is whether *any* font
|
|
24
|
+
* covers the character. It is also not a claim about other hosts: coverage is
|
|
25
|
+
* recomputed on the machine that builds the site, and byte identity across
|
|
26
|
+
* machines is explicitly not promised.
|
|
27
|
+
*
|
|
28
|
+
* If **no font files are discoverable at all**, every code point is reported
|
|
29
|
+
* missing. A host that cannot draw text cannot publish a diagram containing it,
|
|
30
|
+
* and a clear failure is better than an unverified success.
|
|
31
|
+
*/
|
|
32
|
+
import { closeSync, openSync, readSync, readdirSync, statSync } from 'node:fs';
|
|
33
|
+
import { homedir } from 'node:os';
|
|
34
|
+
import { dirname, extname, isAbsolute, join, resolve } from 'node:path';
|
|
35
|
+
import { SaxesParser } from 'saxes';
|
|
36
|
+
/** Font containers fontconfig enumerates that carry an sfnt `cmap`. */
|
|
37
|
+
const FONT_EXTENSIONS = {
|
|
38
|
+
'.ttf': true,
|
|
39
|
+
'.otf': true,
|
|
40
|
+
'.ttc': true,
|
|
41
|
+
'.otc': true,
|
|
42
|
+
};
|
|
43
|
+
/** A `cmap` larger than this is not a font this gate will read. */
|
|
44
|
+
const MAX_CMAP_BYTES = 16 * 1024 * 1024;
|
|
45
|
+
/** Directory recursion depth; deeper trees are configuration, not fonts. */
|
|
46
|
+
const MAX_DIRECTORY_DEPTH = 8;
|
|
47
|
+
/** Fontconfig `<include>` nesting; fontconfig itself has no useful deeper chain. */
|
|
48
|
+
const MAX_CONFIG_INCLUDE_DEPTH = 8;
|
|
49
|
+
/** Faces in one collection; real `.ttc` files carry a handful. */
|
|
50
|
+
const MAX_COLLECTION_FACES = 64;
|
|
51
|
+
/** Tables in one font directory. */
|
|
52
|
+
const MAX_TABLES = 4_096;
|
|
53
|
+
/** Per-process coverage, so a multi-SVG build reads each font once. */
|
|
54
|
+
const coverageCache = new Map();
|
|
55
|
+
/** Read a slice of an open file, or `undefined` when it cannot be read. */
|
|
56
|
+
function readSlice(fd, position, length) {
|
|
57
|
+
if (length <= 0 || length > MAX_CMAP_BYTES)
|
|
58
|
+
return undefined;
|
|
59
|
+
const buffer = Buffer.alloc(length);
|
|
60
|
+
let read = 0;
|
|
61
|
+
while (read < length) {
|
|
62
|
+
let count;
|
|
63
|
+
try {
|
|
64
|
+
count = readSync(fd, buffer, read, length - read, position + read);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
if (count === 0)
|
|
70
|
+
return undefined;
|
|
71
|
+
read += count;
|
|
72
|
+
}
|
|
73
|
+
return buffer;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Resolve a fontconfig `<dir>` or `<include>` path.
|
|
77
|
+
*
|
|
78
|
+
* `base` is the including file's directory for `<include>`; `<dir>` passes
|
|
79
|
+
* `undefined`, which preserves the old policy of not guessing where a relative
|
|
80
|
+
* font directory points.
|
|
81
|
+
*/
|
|
82
|
+
function resolveConfigPath(value, prefix, base) {
|
|
83
|
+
const path = value.trim();
|
|
84
|
+
if (path === '')
|
|
85
|
+
return undefined;
|
|
86
|
+
if (prefix === 'cwd')
|
|
87
|
+
return undefined;
|
|
88
|
+
if (prefix === 'xdg') {
|
|
89
|
+
// Fontconfig resolves an XDG *directory* against XDG_DATA_HOME and an
|
|
90
|
+
// XDG *include* against XDG_CONFIG_HOME. Using the data directory for an
|
|
91
|
+
// include silently missed ~/.config/fontconfig/fonts.conf.
|
|
92
|
+
const xdg = base === undefined
|
|
93
|
+
? (process.env['XDG_DATA_HOME'] ?? join(homedir(), '.local', 'share'))
|
|
94
|
+
: (process.env['XDG_CONFIG_HOME'] ?? join(homedir(), '.config'));
|
|
95
|
+
return join(xdg, path);
|
|
96
|
+
}
|
|
97
|
+
if (path === '~')
|
|
98
|
+
return homedir();
|
|
99
|
+
if (path.startsWith('~/'))
|
|
100
|
+
return join(homedir(), path.slice(2));
|
|
101
|
+
if (isAbsolute(path))
|
|
102
|
+
return path;
|
|
103
|
+
// A relative `<include>` is relative to the file that contains it. A relative
|
|
104
|
+
// `<dir>` has no such carrier here and is skipped, as before.
|
|
105
|
+
return base === undefined ? undefined : resolve(base, path);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Read one fontconfig XML file and its `<include>` chain.
|
|
109
|
+
*
|
|
110
|
+
* `<dir>` entries and the `prefix` attribute are honoured. An `<include>` is
|
|
111
|
+
* followed relative to the including file (or by `prefix`), up to
|
|
112
|
+
* {@link MAX_CONFIG_INCLUDE_DEPTH}; repeated, missing, unresolvable and
|
|
113
|
+
* too-deep includes are counted in `skippedIncludes`, never silently dropped
|
|
114
|
+
* from the count. A directory include (fontconfig's `conf.d`) is skipped here
|
|
115
|
+
* because the Linux branch enumerates `conf.d` directly. Malformed XML keeps
|
|
116
|
+
* whatever was parsed before the error.
|
|
117
|
+
*/
|
|
118
|
+
function parseFontConfig(path, depth, state) {
|
|
119
|
+
let resolvedPath;
|
|
120
|
+
try {
|
|
121
|
+
resolvedPath = resolve(path);
|
|
122
|
+
if (!statSync(resolvedPath).isFile())
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
if (state.visited.has(resolvedPath))
|
|
129
|
+
return false;
|
|
130
|
+
state.visited.add(resolvedPath);
|
|
131
|
+
let text;
|
|
132
|
+
try {
|
|
133
|
+
text = readTextFile(resolvedPath);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
const parser = new SaxesParser();
|
|
139
|
+
let directory;
|
|
140
|
+
let directoryPrefix;
|
|
141
|
+
let include;
|
|
142
|
+
let includePrefix;
|
|
143
|
+
let action;
|
|
144
|
+
let globText;
|
|
145
|
+
let patternFamilies;
|
|
146
|
+
let pateltName;
|
|
147
|
+
let stringText;
|
|
148
|
+
parser.on('opentag', (tag) => {
|
|
149
|
+
const name = tag.name.toLowerCase();
|
|
150
|
+
if (name === 'dir') {
|
|
151
|
+
directory = '';
|
|
152
|
+
directoryPrefix = tag.attributes['prefix'];
|
|
153
|
+
}
|
|
154
|
+
else if (name === 'include') {
|
|
155
|
+
include = '';
|
|
156
|
+
includePrefix = tag.attributes['prefix'];
|
|
157
|
+
}
|
|
158
|
+
else if (name === 'rejectfont') {
|
|
159
|
+
action = 'reject';
|
|
160
|
+
}
|
|
161
|
+
else if (name === 'acceptfont') {
|
|
162
|
+
action = 'accept';
|
|
163
|
+
}
|
|
164
|
+
else if (name === 'glob') {
|
|
165
|
+
globText = '';
|
|
166
|
+
}
|
|
167
|
+
else if (name === 'pattern') {
|
|
168
|
+
patternFamilies = [];
|
|
169
|
+
}
|
|
170
|
+
else if (name === 'patelt') {
|
|
171
|
+
pateltName = tag.attributes['name']?.toLowerCase();
|
|
172
|
+
}
|
|
173
|
+
else if (name === 'string') {
|
|
174
|
+
stringText = '';
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
const collect = (value) => {
|
|
178
|
+
if (directory !== undefined)
|
|
179
|
+
directory += value;
|
|
180
|
+
else if (include !== undefined)
|
|
181
|
+
include += value;
|
|
182
|
+
else if (globText !== undefined)
|
|
183
|
+
globText += value;
|
|
184
|
+
else if (stringText !== undefined)
|
|
185
|
+
stringText += value;
|
|
186
|
+
};
|
|
187
|
+
parser.on('text', collect);
|
|
188
|
+
parser.on('cdata', collect);
|
|
189
|
+
parser.on('closetag', (tag) => {
|
|
190
|
+
const name = tag.name.toLowerCase();
|
|
191
|
+
if (name === 'dir' && directory !== undefined) {
|
|
192
|
+
const target = resolveConfigPath(directory, directoryPrefix, undefined);
|
|
193
|
+
directory = undefined;
|
|
194
|
+
directoryPrefix = undefined;
|
|
195
|
+
if (target !== undefined)
|
|
196
|
+
state.directories.push(target);
|
|
197
|
+
}
|
|
198
|
+
else if (name === 'glob' && globText !== undefined) {
|
|
199
|
+
const glob = globText.trim();
|
|
200
|
+
globText = undefined;
|
|
201
|
+
if (action !== undefined && glob !== '')
|
|
202
|
+
state.rules.push({ action, glob });
|
|
203
|
+
}
|
|
204
|
+
else if (name === 'string' && stringText !== undefined) {
|
|
205
|
+
const text = stringText.trim();
|
|
206
|
+
stringText = undefined;
|
|
207
|
+
if (patternFamilies !== undefined && pateltName === 'family' && text !== '') {
|
|
208
|
+
patternFamilies.push(text);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else if (name === 'patelt') {
|
|
212
|
+
pateltName = undefined;
|
|
213
|
+
}
|
|
214
|
+
else if (name === 'pattern') {
|
|
215
|
+
if (action !== undefined && patternFamilies !== undefined) {
|
|
216
|
+
for (const family of patternFamilies)
|
|
217
|
+
state.rules.push({ action, family });
|
|
218
|
+
}
|
|
219
|
+
patternFamilies = undefined;
|
|
220
|
+
}
|
|
221
|
+
else if (name === 'rejectfont' || name === 'acceptfont') {
|
|
222
|
+
action = undefined;
|
|
223
|
+
}
|
|
224
|
+
else if (name === 'include' && include !== undefined) {
|
|
225
|
+
const target = resolveConfigPath(include, includePrefix, dirname(resolvedPath));
|
|
226
|
+
include = undefined;
|
|
227
|
+
includePrefix = undefined;
|
|
228
|
+
if (target === undefined || depth >= MAX_CONFIG_INCLUDE_DEPTH) {
|
|
229
|
+
state.skippedIncludes += 1;
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
let targetIsDirectory = false;
|
|
233
|
+
try {
|
|
234
|
+
targetIsDirectory = statSync(target).isDirectory();
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
state.skippedIncludes += 1;
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (targetIsDirectory) {
|
|
241
|
+
const directory = resolve(target);
|
|
242
|
+
state.followedDirectories.add(directory);
|
|
243
|
+
state.includeDirectories += 1;
|
|
244
|
+
let entries;
|
|
245
|
+
try {
|
|
246
|
+
entries = readdirSync(directory).sort();
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
state.skippedIncludes += 1;
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
for (const name of entries) {
|
|
253
|
+
if (!/^[0-9].*\.conf$/i.test(name))
|
|
254
|
+
continue;
|
|
255
|
+
if (parseFontConfig(join(directory, name), depth + 1, state))
|
|
256
|
+
state.includes += 1;
|
|
257
|
+
else
|
|
258
|
+
state.skippedIncludes += 1;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
else if (parseFontConfig(target, depth + 1, state)) {
|
|
262
|
+
state.includes += 1;
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
state.skippedIncludes += 1;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
let malformed = false;
|
|
271
|
+
try {
|
|
272
|
+
parser.write(text).close();
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
// A malformed config is the host's, not the site's; keep what parsed and
|
|
276
|
+
// report the file as skipped rather than as a followed include.
|
|
277
|
+
malformed = true;
|
|
278
|
+
}
|
|
279
|
+
return !malformed;
|
|
280
|
+
}
|
|
281
|
+
function readTextFile(path) {
|
|
282
|
+
const fd = openSync(path, 'r');
|
|
283
|
+
try {
|
|
284
|
+
const stat = statSync(path);
|
|
285
|
+
return readSlice(fd, 0, Math.min(stat.size, 4 * 1024 * 1024))?.toString('utf8') ?? '';
|
|
286
|
+
}
|
|
287
|
+
finally {
|
|
288
|
+
closeSync(fd);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* The master configuration fontconfig would read, and every candidate tried.
|
|
293
|
+
*
|
|
294
|
+
* An absolute name is used as written. Every other name — the implicit
|
|
295
|
+
* `fonts.conf` and an explicitly supplied relative `FONTCONFIG_FILE` alike — is
|
|
296
|
+
* searched through each colon-separated `FONTCONFIG_PATH` component in order and
|
|
297
|
+
* then `/etc/fonts`, with the first existing file winning. This is fontconfig's
|
|
298
|
+
* own search-list semantics: the path is a list of directories, not one
|
|
299
|
+
* directory whose name happens to contain colons.
|
|
300
|
+
*
|
|
301
|
+
* When no candidate exists the last one is returned, so the caller reads a path
|
|
302
|
+
* that does not exist and discovers nothing. An explicitly configured search
|
|
303
|
+
* list is not an invitation to borrow the default tree.
|
|
304
|
+
*
|
|
305
|
+
* Exported so the font-config test can lock the order of the candidates, not
|
|
306
|
+
* only the files that happen to be found at the end.
|
|
307
|
+
*/
|
|
308
|
+
export function resolveFontConfigMaster(name, configuredPath) {
|
|
309
|
+
if (isAbsolute(name)) {
|
|
310
|
+
const absolute = resolve(name);
|
|
311
|
+
return { master: absolute, tried: [absolute] };
|
|
312
|
+
}
|
|
313
|
+
const roots = (configuredPath ?? '').split(':').filter((component) => component !== '');
|
|
314
|
+
roots.push('/etc/fonts');
|
|
315
|
+
const tried = [];
|
|
316
|
+
for (const root of roots) {
|
|
317
|
+
const candidate = join(root, name);
|
|
318
|
+
tried.push(candidate);
|
|
319
|
+
try {
|
|
320
|
+
if (statSync(candidate).isFile())
|
|
321
|
+
return { master: candidate, tried };
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
// Not in this component; try the next one.
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return { master: tried[tried.length - 1], tried };
|
|
328
|
+
}
|
|
329
|
+
/** The host's font directories and the config includes followed to find them. */
|
|
330
|
+
function fontDirectories() {
|
|
331
|
+
const state = {
|
|
332
|
+
directories: [],
|
|
333
|
+
visited: new Set(),
|
|
334
|
+
followedDirectories: new Set(),
|
|
335
|
+
rules: [],
|
|
336
|
+
includes: 0,
|
|
337
|
+
includeDirectories: 0,
|
|
338
|
+
skippedIncludes: 0,
|
|
339
|
+
};
|
|
340
|
+
if (process.platform === 'linux') {
|
|
341
|
+
// Read exactly the master configuration fontconfig reads. FONTCONFIG_FILE
|
|
342
|
+
// names it directly; otherwise the implicit `fonts.conf` is searched through
|
|
343
|
+
// FONTCONFIG_PATH (default /etc/fonts). Both use the same resolution routine,
|
|
344
|
+
// so a colon-separated path is a search list for either spelling. A custom
|
|
345
|
+
// master is followed as written, including its `<include>` and `<dir>` chain;
|
|
346
|
+
// a missing candidate contributes no fonts rather than borrowing the default
|
|
347
|
+
// tree.
|
|
348
|
+
const configuredFile = process.env['FONTCONFIG_FILE'];
|
|
349
|
+
const configuredPath = process.env['FONTCONFIG_PATH'];
|
|
350
|
+
const customMaster = configuredFile !== undefined && configuredFile !== '';
|
|
351
|
+
const { master } = resolveFontConfigMaster(customMaster ? configuredFile : 'fonts.conf', configuredPath);
|
|
352
|
+
// /etc/fonts/conf.d is part of the *default* configuration, not of every
|
|
353
|
+
// configuration: the stock fonts.conf includes it as a directory, and the
|
|
354
|
+
// legacy loop keeps a host whose default fonts.conf omits that include
|
|
355
|
+
// working. The default is identified by the master it resolved to, so
|
|
356
|
+
// FONTCONFIG_PATH=/absent:/etc/fonts is the same configuration as no path at
|
|
357
|
+
// all, while a master found elsewhere never gains /etc/fonts/conf.d.
|
|
358
|
+
const defaultConfig = !customMaster && master === resolve('/etc/fonts/fonts.conf');
|
|
359
|
+
parseFontConfig(master, 0, state);
|
|
360
|
+
if (defaultConfig) {
|
|
361
|
+
const confDirectory = resolve('/etc/fonts/conf.d');
|
|
362
|
+
if (!state.followedDirectories.has(confDirectory)) {
|
|
363
|
+
try {
|
|
364
|
+
for (const name of readdirSync(confDirectory).sort()) {
|
|
365
|
+
if (/^[0-9].*\.conf$/i.test(name)) {
|
|
366
|
+
if (!parseFontConfig(join(confDirectory, name), 0, state))
|
|
367
|
+
state.skippedIncludes += 1;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
// No conf.d is ordinary; the base file and the per-user directories remain.
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
else if (process.platform === 'darwin') {
|
|
378
|
+
state.directories.push('/System/Library/Fonts', '/System/Library/Fonts/Supplemental', '/Library/Fonts', join(homedir(), 'Library', 'Fonts'));
|
|
379
|
+
}
|
|
380
|
+
else if (process.platform === 'win32') {
|
|
381
|
+
const windir = process.env['WINDIR'] ?? 'C:\\Windows';
|
|
382
|
+
state.directories.push(join(windir, 'Fonts'));
|
|
383
|
+
const local = process.env['LOCALAPPDATA'];
|
|
384
|
+
if (local !== undefined)
|
|
385
|
+
state.directories.push(join(local, 'Microsoft', 'Windows', 'Fonts'));
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
directories: [...new Set(state.directories)],
|
|
389
|
+
rules: state.rules,
|
|
390
|
+
includes: state.includes,
|
|
391
|
+
includeDirectories: state.includeDirectories,
|
|
392
|
+
skippedIncludes: state.skippedIncludes,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
/** Recursively collect font containers under one directory. */
|
|
396
|
+
function fontFilesUnder(directory, depth, into) {
|
|
397
|
+
if (depth > MAX_DIRECTORY_DEPTH)
|
|
398
|
+
return;
|
|
399
|
+
let entries;
|
|
400
|
+
try {
|
|
401
|
+
entries = readdirSync(directory).sort();
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
for (const entry of entries) {
|
|
407
|
+
const path = join(directory, entry);
|
|
408
|
+
let stat;
|
|
409
|
+
try {
|
|
410
|
+
stat = statSync(path);
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (stat.isDirectory()) {
|
|
416
|
+
fontFilesUnder(path, depth + 1, into);
|
|
417
|
+
}
|
|
418
|
+
else if (FONT_EXTENSIONS[extname(entry).toLowerCase()] === true) {
|
|
419
|
+
into.push(path);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
/** Expand a fontconfig glob's home or relative spelling to a complete path. */
|
|
424
|
+
function globPath(pattern) {
|
|
425
|
+
if (pattern === '~')
|
|
426
|
+
return homedir();
|
|
427
|
+
if (pattern.startsWith('~/'))
|
|
428
|
+
return join(homedir(), pattern.slice(2));
|
|
429
|
+
return resolve(pattern);
|
|
430
|
+
}
|
|
431
|
+
/** Fontconfig `<glob>` semantics: a shell pattern over the complete file path. */
|
|
432
|
+
function globMatchesFile(pattern, file) {
|
|
433
|
+
return globMatches(globPath(pattern), resolve(file));
|
|
434
|
+
}
|
|
435
|
+
/** Fontconfig's glob subset: `*`, `?`, and every other character literally. */
|
|
436
|
+
function globMatches(pattern, value) {
|
|
437
|
+
let source = '^';
|
|
438
|
+
for (const character of pattern) {
|
|
439
|
+
if (character === '*')
|
|
440
|
+
source += '.*';
|
|
441
|
+
else if (character === '?')
|
|
442
|
+
source += '.';
|
|
443
|
+
else
|
|
444
|
+
source += character.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
445
|
+
}
|
|
446
|
+
source += '$';
|
|
447
|
+
try {
|
|
448
|
+
return new RegExp(source, 'u').test(value);
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
/** Decode a UTF-16BE name-table string without allocating a swapped copy. */
|
|
455
|
+
function decodeUtf16Be(bytes) {
|
|
456
|
+
let text = '';
|
|
457
|
+
for (let index = 0; index + 1 < bytes.length; index += 2) {
|
|
458
|
+
text += String.fromCharCode(bytes.readUInt16BE(index));
|
|
459
|
+
}
|
|
460
|
+
return text;
|
|
461
|
+
}
|
|
462
|
+
/** Family names (IDs 1 and 16) from one sfnt `name` table. */
|
|
463
|
+
function familiesOfNameTable(table) {
|
|
464
|
+
if (table.length < 6)
|
|
465
|
+
return [];
|
|
466
|
+
const count = table.readUInt16BE(2);
|
|
467
|
+
const stringOffset = table.readUInt16BE(4);
|
|
468
|
+
const families = [];
|
|
469
|
+
for (let index = 0; index < count; index += 1) {
|
|
470
|
+
const at = 6 + index * 12;
|
|
471
|
+
if (at + 12 > table.length)
|
|
472
|
+
break;
|
|
473
|
+
const platform = table.readUInt16BE(at);
|
|
474
|
+
const encoding = table.readUInt16BE(at + 2);
|
|
475
|
+
const nameId = table.readUInt16BE(at + 6);
|
|
476
|
+
if (nameId !== 1 && nameId !== 16)
|
|
477
|
+
continue;
|
|
478
|
+
const length = table.readUInt16BE(at + 8);
|
|
479
|
+
const offset = table.readUInt16BE(at + 10);
|
|
480
|
+
const start = stringOffset + offset;
|
|
481
|
+
if (start + length > table.length)
|
|
482
|
+
continue;
|
|
483
|
+
const raw = table.subarray(start, start + length);
|
|
484
|
+
let family;
|
|
485
|
+
if (platform === 0 || (platform === 3 && (encoding === 1 || encoding === 10))) {
|
|
486
|
+
family = decodeUtf16Be(raw);
|
|
487
|
+
}
|
|
488
|
+
else if (platform === 1 && encoding === 0) {
|
|
489
|
+
family = raw.toString('latin1');
|
|
490
|
+
}
|
|
491
|
+
const trimmed = family?.replace(/\0/g, '').trim();
|
|
492
|
+
if (trimmed !== undefined && trimmed !== '')
|
|
493
|
+
families.push(trimmed);
|
|
494
|
+
}
|
|
495
|
+
return [...new Set(families)];
|
|
496
|
+
}
|
|
497
|
+
/** Read the family names of every face in one font file. */
|
|
498
|
+
function familiesOfFile(file) {
|
|
499
|
+
let fd;
|
|
500
|
+
let size;
|
|
501
|
+
try {
|
|
502
|
+
fd = openSync(file, 'r');
|
|
503
|
+
size = statSync(file).size;
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
return [];
|
|
507
|
+
}
|
|
508
|
+
try {
|
|
509
|
+
let header = readSlice(fd, 0, 12);
|
|
510
|
+
if (header === undefined)
|
|
511
|
+
return [];
|
|
512
|
+
if (header.toString('latin1', 0, 4) === 'ttcf') {
|
|
513
|
+
const count = Math.min(header.readUInt32BE(8), MAX_COLLECTION_FACES);
|
|
514
|
+
const extended = readSlice(fd, 0, 12 + count * 4);
|
|
515
|
+
if (extended !== undefined)
|
|
516
|
+
header = extended;
|
|
517
|
+
}
|
|
518
|
+
const families = [];
|
|
519
|
+
for (const base of faceDirectories(header)) {
|
|
520
|
+
const directory = readSlice(fd, base, 12);
|
|
521
|
+
if (directory === undefined || directory.length < 12)
|
|
522
|
+
continue;
|
|
523
|
+
const tables = Math.min(directory.readUInt16BE(4), MAX_TABLES);
|
|
524
|
+
const records = readSlice(fd, base + 12, tables * 16);
|
|
525
|
+
if (records === undefined)
|
|
526
|
+
continue;
|
|
527
|
+
for (let index = 0; index < tables; index += 1) {
|
|
528
|
+
const at = index * 16;
|
|
529
|
+
if (at + 16 > records.length)
|
|
530
|
+
break;
|
|
531
|
+
if (records.toString('latin1', at, at + 4) !== 'name')
|
|
532
|
+
continue;
|
|
533
|
+
const offset = records.readUInt32BE(at + 8);
|
|
534
|
+
const length = records.readUInt32BE(at + 12);
|
|
535
|
+
if (offset + length > size)
|
|
536
|
+
break;
|
|
537
|
+
const table = readSlice(fd, offset, Math.min(length, MAX_CMAP_BYTES));
|
|
538
|
+
if (table !== undefined)
|
|
539
|
+
families.push(...familiesOfNameTable(table));
|
|
540
|
+
break;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return [...new Set(families)];
|
|
544
|
+
}
|
|
545
|
+
finally {
|
|
546
|
+
closeSync(fd);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Filter the discovered files through fontconfig's `<rejectfont>` and
|
|
551
|
+
* `<acceptfont>` rules. Rules are applied in configuration order and the last
|
|
552
|
+
* matching rule wins, so a later accept re-admits a glob rejected earlier. A
|
|
553
|
+
* `<glob>` matches the file's basename; a `<pattern>` is matched against family
|
|
554
|
+
* names parsed from the font's `name` table (IDs 1 and 16).
|
|
555
|
+
*/
|
|
556
|
+
function applyFontRules(files, rules) {
|
|
557
|
+
// An accept rule of either kind can only protect a font from a reject rule;
|
|
558
|
+
// with no reject rule it cannot remove anything, and evaluating it would read
|
|
559
|
+
// every name table for no change.
|
|
560
|
+
if (!rules.some((rule) => rule.action === 'reject'))
|
|
561
|
+
return [...files];
|
|
562
|
+
const families = new Map();
|
|
563
|
+
const matchesPattern = (file, rule) => {
|
|
564
|
+
if (rule.family === undefined)
|
|
565
|
+
return false;
|
|
566
|
+
let names = families.get(file);
|
|
567
|
+
if (names === undefined) {
|
|
568
|
+
names = familiesOfFile(file);
|
|
569
|
+
families.set(file, names);
|
|
570
|
+
}
|
|
571
|
+
const expected = rule.family.toLowerCase();
|
|
572
|
+
return names.some((name) => name.toLowerCase() === expected);
|
|
573
|
+
};
|
|
574
|
+
const acceptGlobs = rules.filter((rule) => rule.action === 'accept' && rule.glob !== undefined);
|
|
575
|
+
const rejectGlobs = rules.filter((rule) => rule.action === 'reject' && rule.glob !== undefined);
|
|
576
|
+
const acceptPatterns = rules.filter((rule) => rule.action === 'accept' && rule.family !== undefined);
|
|
577
|
+
const rejectPatterns = rules.filter((rule) => rule.action === 'reject' && rule.family !== undefined);
|
|
578
|
+
const filenameAccepted = (path) => {
|
|
579
|
+
if (acceptGlobs.some((rule) => globMatchesFile(rule.glob, path)))
|
|
580
|
+
return true;
|
|
581
|
+
return !rejectGlobs.some((rule) => globMatchesFile(rule.glob, path));
|
|
582
|
+
};
|
|
583
|
+
const directoryRejected = (file) => {
|
|
584
|
+
let directory = dirname(resolve(file));
|
|
585
|
+
const ancestors = [];
|
|
586
|
+
while (directory !== dirname(directory)) {
|
|
587
|
+
ancestors.unshift(directory);
|
|
588
|
+
directory = dirname(directory);
|
|
589
|
+
}
|
|
590
|
+
return ancestors.some((ancestor) => !filenameAccepted(ancestor));
|
|
591
|
+
};
|
|
592
|
+
return files.filter((file) => {
|
|
593
|
+
// fontconfig applies filename selectors to each scanned directory before it
|
|
594
|
+
// considers the font file, then to the file; a rejected directory is never
|
|
595
|
+
// searched, so no file accept can rescue a font inside it.
|
|
596
|
+
if (directoryRejected(file))
|
|
597
|
+
return false;
|
|
598
|
+
if (!filenameAccepted(file))
|
|
599
|
+
return false;
|
|
600
|
+
// Pattern selectors run after the filename selectors, with the accept set
|
|
601
|
+
// checked first. They cannot override a filename reject.
|
|
602
|
+
if (acceptPatterns.some((rule) => matchesPattern(file, rule)))
|
|
603
|
+
return true;
|
|
604
|
+
return !rejectPatterns.some((rule) => matchesPattern(file, rule));
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
/** One discovery walk per process: a build cannot change the host font set. */
|
|
608
|
+
let discoveryCache;
|
|
609
|
+
/** The font files this host would offer fontconfig, deterministically ordered. */
|
|
610
|
+
export function discoverFonts() {
|
|
611
|
+
if (discoveryCache !== undefined)
|
|
612
|
+
return discoveryCache;
|
|
613
|
+
const { directories, rules, includes, includeDirectories, skippedIncludes } = fontDirectories();
|
|
614
|
+
const files = [];
|
|
615
|
+
for (const directory of directories)
|
|
616
|
+
fontFilesUnder(directory, 0, files);
|
|
617
|
+
files.sort();
|
|
618
|
+
const offered = applyFontRules(files, rules);
|
|
619
|
+
return (discoveryCache = {
|
|
620
|
+
directories,
|
|
621
|
+
files: offered,
|
|
622
|
+
includes,
|
|
623
|
+
includeDirectories,
|
|
624
|
+
skippedIncludes,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
/** Offset tables of the faces in one sfnt container. */
|
|
628
|
+
function faceDirectories(buffer) {
|
|
629
|
+
if (buffer.length < 12)
|
|
630
|
+
return [];
|
|
631
|
+
if (buffer.toString('latin1', 0, 4) === 'ttcf') {
|
|
632
|
+
const count = Math.min(buffer.readUInt32BE(8), MAX_COLLECTION_FACES);
|
|
633
|
+
const faces = [];
|
|
634
|
+
for (let index = 0; index < count; index++) {
|
|
635
|
+
const at = 12 + index * 4;
|
|
636
|
+
if (at + 4 > buffer.length)
|
|
637
|
+
break;
|
|
638
|
+
faces.push(buffer.readUInt32BE(at));
|
|
639
|
+
}
|
|
640
|
+
return faces;
|
|
641
|
+
}
|
|
642
|
+
return [0];
|
|
643
|
+
}
|
|
644
|
+
/** Whether one code point has a non-zero glyph in a single `cmap` table. */
|
|
645
|
+
function coveredBySubtable(cmap, offset, codePoint) {
|
|
646
|
+
if (offset + 2 > cmap.length)
|
|
647
|
+
return false;
|
|
648
|
+
const format = cmap.readUInt16BE(offset);
|
|
649
|
+
if (format === 0) {
|
|
650
|
+
if (codePoint > 0xff || offset + 6 + 256 > cmap.length)
|
|
651
|
+
return false;
|
|
652
|
+
return cmap[offset + 6 + codePoint] !== 0;
|
|
653
|
+
}
|
|
654
|
+
if (format === 4) {
|
|
655
|
+
if (codePoint > 0xffff || offset + 14 > cmap.length)
|
|
656
|
+
return false;
|
|
657
|
+
const segments = cmap.readUInt16BE(offset + 6) >> 1;
|
|
658
|
+
const endBase = offset + 14;
|
|
659
|
+
const startBase = endBase + segments * 2 + 2;
|
|
660
|
+
const deltaBase = startBase + segments * 2;
|
|
661
|
+
const rangeBase = deltaBase + segments * 2;
|
|
662
|
+
if (rangeBase + segments * 2 > cmap.length)
|
|
663
|
+
return false;
|
|
664
|
+
let low = 0;
|
|
665
|
+
let high = segments - 1;
|
|
666
|
+
while (low <= high) {
|
|
667
|
+
const middle = (low + high) >> 1;
|
|
668
|
+
const end = cmap.readUInt16BE(endBase + middle * 2);
|
|
669
|
+
if (end < codePoint)
|
|
670
|
+
low = middle + 1;
|
|
671
|
+
else
|
|
672
|
+
high = middle - 1;
|
|
673
|
+
}
|
|
674
|
+
if (low >= segments)
|
|
675
|
+
return false;
|
|
676
|
+
const start = cmap.readUInt16BE(startBase + low * 2);
|
|
677
|
+
if (start > codePoint)
|
|
678
|
+
return false;
|
|
679
|
+
const delta = cmap.readInt16BE(deltaBase + low * 2);
|
|
680
|
+
const rangeOffset = cmap.readUInt16BE(rangeBase + low * 2);
|
|
681
|
+
if (rangeOffset === 0)
|
|
682
|
+
return ((codePoint + delta) & 0xffff) !== 0;
|
|
683
|
+
const glyphAt = rangeBase + low * 2 + rangeOffset + (codePoint - start) * 2;
|
|
684
|
+
if (glyphAt + 2 > cmap.length)
|
|
685
|
+
return false;
|
|
686
|
+
const glyph = cmap.readUInt16BE(glyphAt);
|
|
687
|
+
return glyph !== 0 && ((glyph + delta) & 0xffff) !== 0;
|
|
688
|
+
}
|
|
689
|
+
if (format === 6) {
|
|
690
|
+
if (offset + 10 > cmap.length)
|
|
691
|
+
return false;
|
|
692
|
+
const first = cmap.readUInt16BE(offset + 6);
|
|
693
|
+
const count = cmap.readUInt16BE(offset + 8);
|
|
694
|
+
if (codePoint < first || codePoint >= first + count)
|
|
695
|
+
return false;
|
|
696
|
+
const glyphAt = offset + 10 + (codePoint - first) * 2;
|
|
697
|
+
return glyphAt + 2 <= cmap.length && cmap.readUInt16BE(glyphAt) !== 0;
|
|
698
|
+
}
|
|
699
|
+
if (format === 12) {
|
|
700
|
+
if (offset + 16 > cmap.length)
|
|
701
|
+
return false;
|
|
702
|
+
const groups = Math.min(cmap.readUInt32BE(offset + 12), 1_000_000);
|
|
703
|
+
const groupsBase = offset + 16;
|
|
704
|
+
if (groupsBase + groups * 12 > cmap.length)
|
|
705
|
+
return false;
|
|
706
|
+
let low = 0;
|
|
707
|
+
let high = groups - 1;
|
|
708
|
+
while (low <= high) {
|
|
709
|
+
const middle = (low + high) >> 1;
|
|
710
|
+
const at = groupsBase + middle * 12;
|
|
711
|
+
const start = cmap.readUInt32BE(at);
|
|
712
|
+
const end = cmap.readUInt32BE(at + 4);
|
|
713
|
+
if (codePoint < start)
|
|
714
|
+
high = middle - 1;
|
|
715
|
+
else if (codePoint > end)
|
|
716
|
+
low = middle + 1;
|
|
717
|
+
else {
|
|
718
|
+
const glyph = cmap.readUInt32BE(at + 8) + (codePoint - start);
|
|
719
|
+
return glyph !== 0;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
return false;
|
|
723
|
+
}
|
|
724
|
+
return false;
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Whether one code point has a glyph in an accepted Unicode subtable of one
|
|
728
|
+
* `cmap`.
|
|
729
|
+
*
|
|
730
|
+
* Exported for the synthetic Macintosh-cmap test; the scan itself uses this
|
|
731
|
+
* function directly.
|
|
732
|
+
*/
|
|
733
|
+
export function coveredByCmap(cmap, codePoint) {
|
|
734
|
+
if (cmap.length < 4)
|
|
735
|
+
return false;
|
|
736
|
+
const tables = cmap.readUInt16BE(2);
|
|
737
|
+
for (let index = 0; index < tables; index++) {
|
|
738
|
+
const record = 4 + index * 8;
|
|
739
|
+
if (record + 8 > cmap.length)
|
|
740
|
+
break;
|
|
741
|
+
const platform = cmap.readUInt16BE(record);
|
|
742
|
+
const encoding = cmap.readUInt16BE(record + 2);
|
|
743
|
+
// Pango and fontconfig ask the Unicode charmaps. A Macintosh or symbol
|
|
744
|
+
// record indexed as Unicode would report a glyph the renderer never draws.
|
|
745
|
+
const unicode = platform === 0 || (platform === 3 && (encoding === 1 || encoding === 10));
|
|
746
|
+
if (!unicode)
|
|
747
|
+
continue;
|
|
748
|
+
const offset = cmap.readUInt32BE(record + 4);
|
|
749
|
+
if (coveredBySubtable(cmap, offset, codePoint))
|
|
750
|
+
return true;
|
|
751
|
+
}
|
|
752
|
+
return false;
|
|
753
|
+
}
|
|
754
|
+
/** The `cmap` tables of every face in one font file. */
|
|
755
|
+
function cmapsOfFile(fd, size) {
|
|
756
|
+
let header = readSlice(fd, 0, 12);
|
|
757
|
+
if (header === undefined)
|
|
758
|
+
return [];
|
|
759
|
+
// A collection's offset array lives past the 12-byte preamble, so the first
|
|
760
|
+
// read is extended before the faces are enumerated. Reading only 12–16 bytes
|
|
761
|
+
// would silently see face 0 and miss a glyph that lives in a sibling face of a
|
|
762
|
+
// `.ttc`, which is exactly how Noto CJK is distributed.
|
|
763
|
+
if (header.toString('latin1', 0, 4) === 'ttcf') {
|
|
764
|
+
const count = Math.min(header.readUInt32BE(8), MAX_COLLECTION_FACES);
|
|
765
|
+
const extended = readSlice(fd, 0, 12 + count * 4);
|
|
766
|
+
if (extended !== undefined)
|
|
767
|
+
header = extended;
|
|
768
|
+
}
|
|
769
|
+
const cmaps = [];
|
|
770
|
+
for (const base of faceDirectories(header)) {
|
|
771
|
+
const directory = readSlice(fd, base, 12);
|
|
772
|
+
if (directory === undefined || directory.length < 12)
|
|
773
|
+
continue;
|
|
774
|
+
const tables = Math.min(directory.readUInt16BE(4), MAX_TABLES);
|
|
775
|
+
const records = readSlice(fd, base + 12, tables * 16);
|
|
776
|
+
if (records === undefined)
|
|
777
|
+
continue;
|
|
778
|
+
for (let index = 0; index < tables; index++) {
|
|
779
|
+
const at = index * 16;
|
|
780
|
+
if (at + 16 > records.length)
|
|
781
|
+
break;
|
|
782
|
+
if (records.toString('latin1', at, at + 4) !== 'cmap')
|
|
783
|
+
continue;
|
|
784
|
+
const offset = records.readUInt32BE(at + 8);
|
|
785
|
+
const length = records.readUInt32BE(at + 12);
|
|
786
|
+
if (offset + length > size)
|
|
787
|
+
break;
|
|
788
|
+
const cmap = readSlice(fd, offset, Math.min(length, MAX_CMAP_BYTES));
|
|
789
|
+
if (cmap !== undefined)
|
|
790
|
+
cmaps.push(cmap);
|
|
791
|
+
break;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return cmaps;
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* The code points this host cannot draw, with the fonts that were consulted.
|
|
798
|
+
*
|
|
799
|
+
* One pass over the font files, not one pass per code point: each file's `cmap`
|
|
800
|
+
* tables are read once and answer every still-unresolved code point, and the
|
|
801
|
+
* scan stops as soon as none remain. Latin text therefore normally stops at the
|
|
802
|
+
* first font, while CJK text reaches the CJK families.
|
|
803
|
+
*
|
|
804
|
+
* @param codePoints Distinct code points a document's text uses.
|
|
805
|
+
* @returns the missing subset in input order, plus the discovery count for the
|
|
806
|
+
* private diagnostic.
|
|
807
|
+
*/
|
|
808
|
+
export function missingCodePoints(codePoints) {
|
|
809
|
+
const { files } = discoverFonts();
|
|
810
|
+
const remaining = new Set();
|
|
811
|
+
for (const codePoint of codePoints) {
|
|
812
|
+
if (coverageCache.get(codePoint) !== true)
|
|
813
|
+
remaining.add(codePoint);
|
|
814
|
+
}
|
|
815
|
+
for (const file of files) {
|
|
816
|
+
if (remaining.size === 0)
|
|
817
|
+
break;
|
|
818
|
+
let fd;
|
|
819
|
+
let size;
|
|
820
|
+
try {
|
|
821
|
+
fd = openSync(file, 'r');
|
|
822
|
+
size = statSync(file).size;
|
|
823
|
+
}
|
|
824
|
+
catch {
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
let cmaps;
|
|
828
|
+
try {
|
|
829
|
+
cmaps = cmapsOfFile(fd, size);
|
|
830
|
+
}
|
|
831
|
+
finally {
|
|
832
|
+
closeSync(fd);
|
|
833
|
+
}
|
|
834
|
+
if (cmaps.length === 0)
|
|
835
|
+
continue;
|
|
836
|
+
for (const codePoint of remaining) {
|
|
837
|
+
if (cmaps.some((cmap) => coveredByCmap(cmap, codePoint))) {
|
|
838
|
+
coverageCache.set(codePoint, true);
|
|
839
|
+
remaining.delete(codePoint);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
const missing = codePoints.filter((codePoint) => coverageCache.get(codePoint) !== true);
|
|
844
|
+
return { missing, fonts: files.length };
|
|
845
|
+
}
|