ntk 8.17.4 → 8.17.6
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/lib/fontconfig.js +429 -97
- package/lib/renderingcontext_2d.js +134 -49
- package/lib/text/fontsource.js +22 -12
- package/lib/text/glyphs.js +34 -13
- package/package.json +1 -1
package/lib/fontconfig.js
CHANGED
|
@@ -93,18 +93,29 @@ export function noFontsError(reason, cause) {
|
|
|
93
93
|
return err;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
// css weight -> fontconfig weight
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
96
|
+
// css weight -> fontconfig weight: fontconfig's own table
|
|
97
|
+
// (FcWeightFromOpenTypeDouble), the one it reads every face's OS/2 weight
|
|
98
|
+
// through, so a pattern's weight sits where a face of that CSS weight does
|
|
99
|
+
const cssToFcWeight = [
|
|
100
|
+
[0, 0],
|
|
101
|
+
[100, 0], // thin
|
|
102
|
+
[200, 40], // extralight
|
|
103
|
+
[300, 50], // light
|
|
104
|
+
[350, 55], // demilight
|
|
105
|
+
[380, 75], // book
|
|
106
|
+
[400, 80], // regular
|
|
107
|
+
[500, 100], // medium
|
|
108
|
+
[600, 180], // demibold
|
|
109
|
+
[700, 200], // bold
|
|
110
|
+
[800, 205], // extrabold
|
|
111
|
+
[900, 210], // black
|
|
112
|
+
[1000, 215] // extrablack
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
// CSS's 400 and 500, and the heaviest weight there is, on fontconfig's scale
|
|
116
|
+
const REGULAR = 80;
|
|
117
|
+
const MEDIUM = 100;
|
|
118
|
+
const HEAVIEST = 215;
|
|
108
119
|
|
|
109
120
|
// formats fontkit can parse. Exported so the font-spec resolver filters a
|
|
110
121
|
// directory listing by exactly the same rule fc-match output is filtered by —
|
|
@@ -239,17 +250,27 @@ function fcName(name) {
|
|
|
239
250
|
return name.replace(/[\\\-:,]/g, '\\$&');
|
|
240
251
|
}
|
|
241
252
|
|
|
242
|
-
|
|
243
|
-
|
|
253
|
+
/**
|
|
254
|
+
* A pattern as fc-match is handed it (`fc`, which is also its cache key),
|
|
255
|
+
* with what CSS's matching needs of it besides: the family names it asks
|
|
256
|
+
* for, the weight on fontconfig's scale (its default, regular, where none
|
|
257
|
+
* is asked for) and whether it asks for italic.
|
|
258
|
+
*/
|
|
259
|
+
function specFor({ family, weight, style }) {
|
|
260
|
+
const names = String(platformFamilies(family || 'sans-serif'))
|
|
244
261
|
.split(',')
|
|
245
262
|
.map((name) => name.trim().replace(/^["']|["']$/g, ''))
|
|
246
|
-
.filter(Boolean)
|
|
247
|
-
|
|
248
|
-
.join(',');
|
|
263
|
+
.filter(Boolean);
|
|
264
|
+
let fc = names.map(fcName).join(',');
|
|
249
265
|
const fcWeight = normalizeWeight(weight);
|
|
250
266
|
if (fcWeight !== undefined) fc += `:weight=${fcWeight}`;
|
|
251
|
-
|
|
252
|
-
|
|
267
|
+
const italic = Boolean(style && style.includes('italic'));
|
|
268
|
+
if (italic) fc += ':slant=italic';
|
|
269
|
+
return { fc, names, weight: fcWeight ?? REGULAR, italic };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function patternFor(pattern) {
|
|
273
|
+
return specFor(pattern).fc;
|
|
253
274
|
}
|
|
254
275
|
|
|
255
276
|
// One command shared by the sync and async paths, so a prewarmed cache entry
|
|
@@ -259,52 +280,320 @@ function patternFor({ family, weight, style }) {
|
|
|
259
280
|
// localized aliases included (`Hiragino Sans`, `ヒラギノ角ゴシック`, and the
|
|
260
281
|
// style-suffixed forms of both are one face), and `--format` joins them with
|
|
261
282
|
// commas. The fields are tab-separated so a comma inside one costs nothing,
|
|
262
|
-
// and `charset` stays last because it is by far the longest.
|
|
283
|
+
// and `charset` stays last because it is by far the longest. Weight, slant
|
|
284
|
+
// and width are what CSS's matching reads (`cssFace`).
|
|
263
285
|
const fcMatchArgs = [
|
|
264
286
|
'-s',
|
|
265
287
|
'--format',
|
|
266
|
-
'%{file}\t%{postscriptname}\t%{family}\t%{charset}\n'
|
|
288
|
+
'%{file}\t%{postscriptname}\t%{family}\t%{weight}\t%{slant}\t%{width}\t%{charset}\n'
|
|
267
289
|
];
|
|
268
290
|
const fcMatchOpts = { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 };
|
|
269
291
|
|
|
292
|
+
/**
|
|
293
|
+
* A weight, slant or width as fontconfig prints it, as `[lo, hi]`: a number
|
|
294
|
+
* is both ends, and a variable font's range, `[80 200]`, its own. Null where
|
|
295
|
+
* there is none.
|
|
296
|
+
*/
|
|
297
|
+
function valueRange(text) {
|
|
298
|
+
const n = text ? Number(text) : NaN;
|
|
299
|
+
if (!Number.isNaN(n)) return [n, n];
|
|
300
|
+
const values = String(text ?? '')
|
|
301
|
+
.replace(/[[\]]/g, ' ')
|
|
302
|
+
.split(/[\s,]+/)
|
|
303
|
+
.filter(Boolean)
|
|
304
|
+
.map(Number);
|
|
305
|
+
if (values.length === 0 || values.some(Number.isNaN)) return null;
|
|
306
|
+
return [Math.min(...values), Math.max(...values)];
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* One face fontconfig named, as a candidate. `charset` is null where it was
|
|
311
|
+
* not asked for (the census's faces) — the face's coverage is not known, and
|
|
312
|
+
* `charsetHas` answers yes, so a fallback opens it and asks the font.
|
|
313
|
+
*/
|
|
314
|
+
function candidate(path, postscriptName, family, weight, slant, width, charset) {
|
|
315
|
+
// `families` keeps fontconfig's whole list, in its order; `family` is
|
|
316
|
+
// the first of them, which is the name fontconfig leads with for the
|
|
317
|
+
// current locale and the one to show in a UI.
|
|
318
|
+
const families = family ? family.split(',').filter(Boolean) : [];
|
|
319
|
+
return {
|
|
320
|
+
path,
|
|
321
|
+
postscriptName,
|
|
322
|
+
family: families[0] || '',
|
|
323
|
+
families,
|
|
324
|
+
charset,
|
|
325
|
+
_ranges: null,
|
|
326
|
+
_style: [weight, slant, width]
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* A candidate's weight, slant and width as ranges (`valueRange`), read the
|
|
332
|
+
* first time they are asked for — a chain's head is, the 150 faces behind
|
|
333
|
+
* it never are. Null where fontconfig did not say.
|
|
334
|
+
*/
|
|
335
|
+
function styleOf(face) {
|
|
336
|
+
if (Array.isArray(face._style)) {
|
|
337
|
+
const [weight, slant, width] = face._style.map(valueRange);
|
|
338
|
+
face._style = weight && slant && width ? { weight, slant, width } : null;
|
|
339
|
+
}
|
|
340
|
+
return face._style;
|
|
341
|
+
}
|
|
342
|
+
|
|
270
343
|
/** fc-match -s output -> candidates, filtered to formats fontkit can parse */
|
|
271
344
|
function parseMatches(out) {
|
|
272
345
|
const list = [];
|
|
273
346
|
for (const line of out.split('\n')) {
|
|
274
|
-
const [path, postscriptName, family, charset] = line.split('\t');
|
|
347
|
+
const [path, postscriptName, family, weight, slant, width, charset] = line.split('\t');
|
|
275
348
|
if (path && supported.test(path)) {
|
|
276
|
-
|
|
277
|
-
// the first of them, which is the name fontconfig leads with for the
|
|
278
|
-
// current locale and the one to show in a UI.
|
|
279
|
-
const families = family ? family.split(',').filter(Boolean) : [];
|
|
280
|
-
list.push({
|
|
281
|
-
path,
|
|
282
|
-
postscriptName,
|
|
283
|
-
family: families[0] || '',
|
|
284
|
-
families,
|
|
285
|
-
charset: charset || '',
|
|
286
|
-
_ranges: null
|
|
287
|
-
});
|
|
349
|
+
list.push(candidate(path, postscriptName, family, weight, slant, width, charset || ''));
|
|
288
350
|
}
|
|
289
351
|
}
|
|
290
352
|
return list;
|
|
291
353
|
}
|
|
292
354
|
|
|
293
355
|
/**
|
|
294
|
-
* fc-match output -> the cached candidate list for a pattern
|
|
295
|
-
*
|
|
296
|
-
*
|
|
356
|
+
* fc-match output -> the cached candidate list for a pattern, its head the
|
|
357
|
+
* face CSS picks (`cssChain`). Throws for output that parses to nothing
|
|
358
|
+
* usable, which is a fontconfig answer rather than a fontconfig failure and
|
|
359
|
+
* so is diagnosed separately.
|
|
297
360
|
*/
|
|
298
|
-
function cacheMatches(
|
|
361
|
+
function cacheMatches(spec, out) {
|
|
299
362
|
const list = parseMatches(out);
|
|
300
363
|
if (list.length === 0) {
|
|
301
364
|
throw noFontsError(
|
|
302
|
-
`fontconfig matched no font ntk can parse for "${fc}" (needs ` +
|
|
365
|
+
`fontconfig matched no font ntk can parse for "${spec.fc}" (needs ` +
|
|
303
366
|
'.ttf/.otf/.woff/.woff2/.ttc/.dfont — bitmap .pcf/.bdf fonts are not usable)'
|
|
304
367
|
);
|
|
305
368
|
}
|
|
306
|
-
|
|
307
|
-
|
|
369
|
+
const chain = cssChain(spec, list);
|
|
370
|
+
sortedCache.set(spec.fc, chain);
|
|
371
|
+
return chain;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// The census: every face fontconfig knows, a line each — file, PostScript
|
|
375
|
+
// name, families, weight, slant and width — from one fc-list a process,
|
|
376
|
+
// started beside the first prewarm's fc-matches (`spawnToFiles`) and read
|
|
377
|
+
// the first time a weight needs it (`cssFace`). 357 KB for 1,900 faces on a
|
|
378
|
+
// Mac, 45 ms to list. It is kept as the bytes it was read as and a family's
|
|
379
|
+
// lines found in it by a byte search: decoded whole, it was a millisecond of
|
|
380
|
+
// the layout that first needed it, and a family is a dozen of its lines.
|
|
381
|
+
const CENSUS_FORMAT = '%{file}\t%{postscriptname}\t%{family}\t%{weight}\t%{slant}\t%{width}\n';
|
|
382
|
+
|
|
383
|
+
// undefined until a prewarm starts it; then its prewarm entry, with `bytes`
|
|
384
|
+
// (undefined until read, null where there is none to read) and `faces`, the
|
|
385
|
+
// family -> faces lookups already made in it
|
|
386
|
+
let census;
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* The census's bytes — waited for synchronously while its child runs. Null
|
|
390
|
+
* where it failed, took too long, or never ran (no shell to run it in), and
|
|
391
|
+
* then for good: a face answered without it must not be answered otherwise
|
|
392
|
+
* a moment later.
|
|
393
|
+
*/
|
|
394
|
+
function censusBytes() {
|
|
395
|
+
if (!census) return null;
|
|
396
|
+
if (census.bytes !== undefined) return census.bytes;
|
|
397
|
+
const deadline = performance.now() + PREWARM_WAIT_MS;
|
|
398
|
+
let status = prewarmStatus(census.base);
|
|
399
|
+
while (status === null && !census.exited && performance.now() < deadline) {
|
|
400
|
+
Atomics.wait(nap, 0, 0, 1);
|
|
401
|
+
status = prewarmStatus(census.base);
|
|
402
|
+
}
|
|
403
|
+
census.bytes = null;
|
|
404
|
+
if (status === 0) {
|
|
405
|
+
try {
|
|
406
|
+
census.bytes = builtin('node:fs').readFileSync(`${census.base}.out`);
|
|
407
|
+
} catch {
|
|
408
|
+
// gone from under us: no census, as if it had failed
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
removePrewarm(census.base);
|
|
412
|
+
return census.bytes;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** once the census can be read without waiting: for a caller that awaits */
|
|
416
|
+
function censusSettled() {
|
|
417
|
+
return census && census.bytes === undefined && !census.exited ? census.promise : undefined;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// a family name as fontconfig compares one: case and blanks aside
|
|
421
|
+
const folded = (name) => name.replace(/\s+/g, '').toLowerCase();
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* The faces of the family fontconfig matched a pattern to, as the census has
|
|
425
|
+
* them — those ntk can open. Null where there is no census.
|
|
426
|
+
*
|
|
427
|
+
* The family is the name `head` was matched by: the first of the pattern's
|
|
428
|
+
* that the face answers to, or — for a generic, or a family fontconfig put
|
|
429
|
+
* another in place of — the name it leads with. Which name matters: `Avenir
|
|
430
|
+
* Next Ultra Light` is a family of two faces, and `Avenir Next` one of
|
|
431
|
+
* twelve that holds both.
|
|
432
|
+
*/
|
|
433
|
+
function familyFaces(names, head) {
|
|
434
|
+
const bytes = censusBytes();
|
|
435
|
+
if (bytes === null) return null;
|
|
436
|
+
let name;
|
|
437
|
+
for (const asked of names.map(folded)) {
|
|
438
|
+
name = head.families.find((f) => folded(f) === asked);
|
|
439
|
+
if (name) break;
|
|
440
|
+
}
|
|
441
|
+
name ??= head.families[0];
|
|
442
|
+
if (!name) return null;
|
|
443
|
+
const key = folded(name);
|
|
444
|
+
let faces = census.faces.get(key);
|
|
445
|
+
if (faces) return faces;
|
|
446
|
+
faces = [];
|
|
447
|
+
// fontconfig spells a name alike in both answers, so a search for it finds
|
|
448
|
+
// every line it is on, and the line's own family list says whether it is
|
|
449
|
+
// one of the face's names or only part of a longer one
|
|
450
|
+
for (let at = bytes.indexOf(name); at !== -1; ) {
|
|
451
|
+
const start = bytes.lastIndexOf(10, at) + 1;
|
|
452
|
+
let end = bytes.indexOf(10, at);
|
|
453
|
+
if (end === -1) end = bytes.length;
|
|
454
|
+
const line = bytes.toString('utf8', start, end);
|
|
455
|
+
const [path, postscriptName, family, weight, slant, width] = line.split('\t');
|
|
456
|
+
const face = candidate(path, postscriptName, family, weight, slant, width, null);
|
|
457
|
+
if (path && supported.test(path) && face.families.some((f) => folded(f) === key)) faces.push(face);
|
|
458
|
+
at = bytes.indexOf(name, end);
|
|
459
|
+
}
|
|
460
|
+
census.faces.set(key, faces);
|
|
461
|
+
return faces;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// CSS Fonts 4 §5.2's order, as ranks on fontconfig's scale, lowest first.
|
|
465
|
+
// Width (font-stretch, normal being 100): the normal width, then narrower
|
|
466
|
+
// ones nearest first, then wider ones.
|
|
467
|
+
function widthRank([lo, hi]) {
|
|
468
|
+
if (lo <= 100 && hi >= 100) return 0;
|
|
469
|
+
return hi < 100 ? 100 - hi : 1000 + lo - 100;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Slant: the one asked for, then oblique (fontconfig's 110), then the other.
|
|
473
|
+
function slantRank(italic, [lo, hi]) {
|
|
474
|
+
const asked = italic ? 100 : 0;
|
|
475
|
+
if (lo <= asked && hi >= asked) return 0;
|
|
476
|
+
return lo >= 105 ? 1 : 2;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Weight, for `weight` asked: between 400 and 500, up to 500 ascending, then
|
|
480
|
+
// lighter descending, then past 500 ascending; below 400 lighter first, above
|
|
481
|
+
// 500 heavier first. A variable face's range holds every weight in it.
|
|
482
|
+
// `regular` and `medium` are 400 and 500 on the scale the weights are on —
|
|
483
|
+
// fontconfig's here, CSS's in StaticFontSource, where an OS/2 weight class can
|
|
484
|
+
// be anything up to 65535 and so each direction is a million past the last.
|
|
485
|
+
export function weightRank(weight, [lo, hi], regular = REGULAR, medium = MEDIUM) {
|
|
486
|
+
if (lo <= weight && hi >= weight) return 0;
|
|
487
|
+
const x = hi < weight ? hi : lo;
|
|
488
|
+
if (weight >= regular && weight <= medium) {
|
|
489
|
+
if (x > weight && x <= medium) return x - weight;
|
|
490
|
+
return x < weight ? 1e6 + weight - x : 2e6 + x - weight;
|
|
491
|
+
}
|
|
492
|
+
if (weight < regular) return x < weight ? weight - x : 1e6 + x - weight;
|
|
493
|
+
return x > weight ? x - weight : 1e6 + weight - x;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Is `head`, the face fontconfig picked, the one CSS picks — whatever else
|
|
498
|
+
* the family holds?
|
|
499
|
+
*
|
|
500
|
+
* Fontconfig picks the face nearest in weight, after the slant: so no face
|
|
501
|
+
* of the head's slant is nearer the weight asked for than the head, and that
|
|
502
|
+
* is all one answer says about the family. The head is CSS's pick when every
|
|
503
|
+
* weight CSS would try before the head's lies nearer — 500 in Helvetica,
|
|
504
|
+
* whose regular is 80 against 100 asked, where CSS tries 500 alone first.
|
|
505
|
+
* 450 in Arial is not: 500 is as near as its regular, so a medium could be
|
|
506
|
+
* there, and CSS would take it. Then the census decides.
|
|
507
|
+
*
|
|
508
|
+
* Fontconfig ranks width after weight and CSS before it, so a head of
|
|
509
|
+
* another width than normal is never shown to be CSS's: 900 in Helvetica
|
|
510
|
+
* Neue is its condensed black. Nor is an italic head for an upright ask,
|
|
511
|
+
* where CSS tries oblique first.
|
|
512
|
+
*/
|
|
513
|
+
function provenPick(weight, italic, head) {
|
|
514
|
+
const style = styleOf(head);
|
|
515
|
+
if (!style) return false;
|
|
516
|
+
if (widthRank(style.width) !== 0) return false;
|
|
517
|
+
if (!italic && slantRank(false, style.slant) === 2) return false;
|
|
518
|
+
const [lo, hi] = style.weight;
|
|
519
|
+
if (lo <= weight && hi >= weight) return true;
|
|
520
|
+
const x = hi < weight ? hi : lo;
|
|
521
|
+
const near = Math.abs(x - weight);
|
|
522
|
+
// the weights CSS tries before x: from, whether it is open, to, whether it is
|
|
523
|
+
let from, fromOpen, to, toOpen;
|
|
524
|
+
if (weight >= REGULAR && weight <= MEDIUM) {
|
|
525
|
+
if (x > weight && x <= MEDIUM) [from, fromOpen, to, toOpen] = [weight, false, x, true];
|
|
526
|
+
else if (x < weight) [from, fromOpen, to, toOpen] = [x, true, MEDIUM, false];
|
|
527
|
+
else [from, fromOpen, to, toOpen] = [0, false, x, true];
|
|
528
|
+
} else if (weight < REGULAR) {
|
|
529
|
+
if (x < weight) [from, fromOpen, to, toOpen] = [x, true, weight, false];
|
|
530
|
+
else [from, fromOpen, to, toOpen] = [0, false, x, true];
|
|
531
|
+
} else if (x > weight) [from, fromOpen, to, toOpen] = [weight, false, x, true];
|
|
532
|
+
else [from, fromOpen, to, toOpen] = [x, true, HEAVIEST, false];
|
|
533
|
+
return (
|
|
534
|
+
(from > weight - near || (fromOpen && from >= weight - near)) &&
|
|
535
|
+
(to < weight + near || (toOpen && to <= weight + near))
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** a < b, comparing [width, slant, weight] ranks in turn */
|
|
540
|
+
function ranksBefore(a, b) {
|
|
541
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return a[i] < b[i];
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* The face CSS's matching (CSS Fonts 4 §5.2) picks for a pattern, given
|
|
547
|
+
* `head`, the face fontconfig picked: the head itself where it can be shown
|
|
548
|
+
* to be CSS's (`provenPick`), or where there is no census to say otherwise,
|
|
549
|
+
* and the family's best face by width, slant and weight in that order where
|
|
550
|
+
* there is one. Fontconfig picks the face nearest in weight, after the
|
|
551
|
+
* slant and before the width, so 520 in Arial was the regular, which a
|
|
552
|
+
* browser sets in the bold.
|
|
553
|
+
*
|
|
554
|
+
* The head wins ties: a face like it is fontconfig's to choose between. So
|
|
555
|
+
* does a head that is a variable font's instance where CSS picks the font —
|
|
556
|
+
* a weight its axis holds is set on the axis (`instantiate`) whichever face
|
|
557
|
+
* of the file it is opened through.
|
|
558
|
+
*
|
|
559
|
+
* The head is ranked as the census lists it, because an answer describes
|
|
560
|
+
* the face as fontconfig will render it: an upright face matched for an
|
|
561
|
+
* italic ask comes back oblique (90-synthetic.conf slants it), and ranked
|
|
562
|
+
* by that it beat its own family's upright faces — W1 of Hiragino Sans,
|
|
563
|
+
* which has no italics, at 160, where CSS takes W0.
|
|
564
|
+
*/
|
|
565
|
+
function cssFace(spec, head) {
|
|
566
|
+
if (!head || provenPick(spec.weight, spec.italic, head)) return head;
|
|
567
|
+
const faces = familyFaces(spec.names, head);
|
|
568
|
+
if (!faces || faces.length === 0) return head;
|
|
569
|
+
const ranks = (face) => {
|
|
570
|
+
const style = styleOf(face);
|
|
571
|
+
return style && [widthRank(style.width), slantRank(spec.italic, style.slant), weightRank(spec.weight, style.weight)];
|
|
572
|
+
};
|
|
573
|
+
const listed = faces.find((f) => f.path === head.path && f.postscriptName === head.postscriptName);
|
|
574
|
+
let best = null;
|
|
575
|
+
let bestRanks = ranks(listed ?? head);
|
|
576
|
+
for (const face of faces) {
|
|
577
|
+
const r = ranks(face);
|
|
578
|
+
if (r && (bestRanks === null || ranksBefore(r, bestRanks))) [best, bestRanks] = [face, r];
|
|
579
|
+
}
|
|
580
|
+
if (best === null) return head;
|
|
581
|
+
const [lo, hi] = styleOf(best).weight;
|
|
582
|
+
if (lo < hi && best.path === head.path) return head;
|
|
583
|
+
return best;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* A fallback chain with the face CSS picks at its head (`cssFace`). Where
|
|
588
|
+
* that is not fontconfig's head it is moved up from where the chain has it,
|
|
589
|
+
* or, as `-s` trims a face whose coverage the faces before it already give —
|
|
590
|
+
* a family's bold, behind its regular — put there from the census.
|
|
591
|
+
*/
|
|
592
|
+
function cssChain(spec, list) {
|
|
593
|
+
const face = cssFace(spec, list[0]);
|
|
594
|
+
if (face === list[0]) return list;
|
|
595
|
+
const same = (c) => c.path === face.path && c.postscriptName === face.postscriptName;
|
|
596
|
+
return [list.find(same) ?? face, ...list.filter((c) => !same(c))];
|
|
308
597
|
}
|
|
309
598
|
|
|
310
599
|
// fc -> { promise, base }: one child per pattern however many callers ask.
|
|
@@ -356,14 +645,18 @@ function runFcMatch(fc) {
|
|
|
356
645
|
// flag first), then a file base, a kind and a pattern for each job: `sorted`
|
|
357
646
|
// asks for the whole fallback chain, `best` for the one face fontconfig
|
|
358
647
|
// would pick, which it answers in half the time, since it sorts nothing and
|
|
359
|
-
// writes one line (`bestOfPrewarm`).
|
|
360
|
-
//
|
|
361
|
-
// fc-match
|
|
648
|
+
// writes one line (`bestOfPrewarm`). `census` lists every face, with the
|
|
649
|
+
// format in the pattern's place (`CENSUS_FORMAT`). The shell exits once
|
|
650
|
+
// every job has. Nothing but shell builtins runs besides fc-match and
|
|
651
|
+
// fc-list, so a PATH that holds those is enough; without fc-list there is no
|
|
652
|
+
// census, and fontconfig's own picks stand.
|
|
362
653
|
const PREWARM_SCRIPT = [
|
|
363
654
|
...fcMatchArgs.map((_, i) => `a${i}=\${${i + 1}}`),
|
|
364
655
|
`shift ${fcMatchArgs.length}`,
|
|
365
656
|
'while [ $# -gt 2 ]; do ' +
|
|
366
|
-
'if [ "$2" =
|
|
657
|
+
'if [ "$2" = census ]; then ' +
|
|
658
|
+
'(fc-list --format "$3" > "$1.out" 2> "$1.err"; echo $? > "$1.done") & ' +
|
|
659
|
+
'elif [ "$2" = best ]; then ' +
|
|
367
660
|
`(fc-match ${fcMatchArgs
|
|
368
661
|
.slice(1)
|
|
369
662
|
.map((_, i) => `"$a${i + 1}"`)
|
|
@@ -472,16 +765,22 @@ function removePrewarm(base) {
|
|
|
472
765
|
* set up: 634 KB apiece, most of it the coverage of every face fontconfig
|
|
473
766
|
* sorts behind the first.
|
|
474
767
|
*
|
|
768
|
+
* The first child also lists every face, once a process (the census, see
|
|
769
|
+
* `CENSUS_FORMAT`): a job of its own beside the matches, so a weight CSS
|
|
770
|
+
* matches differently from fontconfig costs no spawn of the app's.
|
|
771
|
+
*
|
|
475
772
|
* An entry a pattern, in order; null where files cannot be used, and the
|
|
476
773
|
* caller prewarms the old way.
|
|
477
774
|
*/
|
|
478
775
|
function spawnToFiles(fcs, bests = []) {
|
|
479
776
|
const cp = childProcess();
|
|
480
777
|
const all = [...fcs, ...bests];
|
|
481
|
-
const
|
|
778
|
+
const listing = census === undefined;
|
|
779
|
+
const bases = cp ? Array.from({ length: all.length + (listing ? 1 : 0) }, () => prewarmBase()) : [];
|
|
482
780
|
if (bases.length === 0 || bases[0] === null) return null;
|
|
483
781
|
const jobs = [];
|
|
484
782
|
all.forEach((fc, i) => jobs.push(bases[i], i < fcs.length ? 'sorted' : 'best', fc));
|
|
783
|
+
if (listing) jobs.push(bases[all.length], 'census', CENSUS_FORMAT);
|
|
485
784
|
let child;
|
|
486
785
|
try {
|
|
487
786
|
child = cp.spawn('/bin/sh', ['-c', PREWARM_SCRIPT, 'ntk-fc-match', ...fcMatchArgs, ...jobs], {
|
|
@@ -492,14 +791,22 @@ function spawnToFiles(fcs, bests = []) {
|
|
|
492
791
|
}
|
|
493
792
|
// `promise` resolves once the child is gone, with what it left in the
|
|
494
793
|
// files; `exited` tells a synchronous caller there is nothing to wait for
|
|
495
|
-
const
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
794
|
+
const entry = (base) => {
|
|
795
|
+
const e = { promise: null, base, answer: undefined, exited: false, gone: null };
|
|
796
|
+
e.promise = new Promise((resolve) => {
|
|
797
|
+
e.gone = resolve;
|
|
499
798
|
});
|
|
500
|
-
|
|
501
|
-
|
|
799
|
+
return e;
|
|
800
|
+
};
|
|
801
|
+
const entries = all.map((fc, i) => {
|
|
802
|
+
const e = entry(bases[i]);
|
|
803
|
+
(i < fcs.length ? inflight : bestInflight).set(fc, e);
|
|
804
|
+
return e;
|
|
502
805
|
});
|
|
806
|
+
if (listing) {
|
|
807
|
+
census = Object.assign(entry(bases[all.length]), { bytes: undefined, faces: new Map() });
|
|
808
|
+
entries.push(census);
|
|
809
|
+
}
|
|
503
810
|
const gone = () => {
|
|
504
811
|
for (const entry of entries) {
|
|
505
812
|
entry.exited = true;
|
|
@@ -551,8 +858,10 @@ function answerSync(fc, entry) {
|
|
|
551
858
|
* layout setting text in the face needs its first line. The rest waits in
|
|
552
859
|
* the file for the first character that falls back, which Latin text in a
|
|
553
860
|
* face that covers it never has.
|
|
861
|
+
*
|
|
862
|
+
* The face is the one CSS picks (`cssFace`), as the chain's head will be.
|
|
554
863
|
*/
|
|
555
|
-
function firstOfPrewarm(entry) {
|
|
864
|
+
function firstOfPrewarm(spec, entry) {
|
|
556
865
|
if (entry.first !== undefined) return entry.first;
|
|
557
866
|
const deadline = performance.now() + PREWARM_WAIT_MS;
|
|
558
867
|
let status = prewarmStatus(entry.base);
|
|
@@ -562,7 +871,7 @@ function firstOfPrewarm(entry) {
|
|
|
562
871
|
}
|
|
563
872
|
// not answered, or not yet: the whole read is what decides, and reports
|
|
564
873
|
if (status !== 0) return null;
|
|
565
|
-
entry.first = firstCandidate(`${entry.base}.out`);
|
|
874
|
+
entry.first = cssFace(spec, firstCandidate(`${entry.base}.out`));
|
|
566
875
|
return entry.first;
|
|
567
876
|
}
|
|
568
877
|
|
|
@@ -576,9 +885,10 @@ function firstOfPrewarm(entry) {
|
|
|
576
885
|
* in about half the time the whole chain takes (15 ms against 30 on a Linux
|
|
577
886
|
* desktop): no sort, one line. A layout that asks for a family warmed while
|
|
578
887
|
* its component rendered waits on the prewarm, and the chain was most of
|
|
579
|
-
* that wait.
|
|
888
|
+
* that wait. Fontconfig's face, that is: the one CSS picks may be another
|
|
889
|
+
* (`cssFace`).
|
|
580
890
|
*/
|
|
581
|
-
function bestOfPrewarm(entry) {
|
|
891
|
+
function bestOfPrewarm(spec, entry) {
|
|
582
892
|
if (entry.first !== undefined) return entry.first;
|
|
583
893
|
const deadline = performance.now() + PREWARM_WAIT_MS;
|
|
584
894
|
let left = readPrewarm(entry.base);
|
|
@@ -589,7 +899,7 @@ function bestOfPrewarm(entry) {
|
|
|
589
899
|
if (left === null) return null;
|
|
590
900
|
removePrewarm(entry.base);
|
|
591
901
|
const [best] = left.status === 0 ? parseMatches(left.out) : [];
|
|
592
|
-
entry.first = best
|
|
902
|
+
entry.first = best ? cssFace(spec, best) : null;
|
|
593
903
|
return entry.first;
|
|
594
904
|
}
|
|
595
905
|
|
|
@@ -665,7 +975,7 @@ function firstCandidate(file) {
|
|
|
665
975
|
* attempt abandoned
|
|
666
976
|
*/
|
|
667
977
|
export function prewarm(pattern = {}) {
|
|
668
|
-
return warm([
|
|
978
|
+
return warm([specFor(pattern)])[0];
|
|
669
979
|
}
|
|
670
980
|
|
|
671
981
|
/**
|
|
@@ -677,36 +987,39 @@ export function prewarm(pattern = {}) {
|
|
|
677
987
|
* up on; never rejects
|
|
678
988
|
*/
|
|
679
989
|
export function prewarmPatterns(patterns) {
|
|
680
|
-
return Promise.all(warm(patterns.map(
|
|
990
|
+
return Promise.all(warm(patterns.map(specFor))).then(() => {});
|
|
681
991
|
}
|
|
682
992
|
|
|
683
993
|
/**
|
|
684
|
-
* `prewarm` for a list of
|
|
994
|
+
* `prewarm` for a list of patterns (`specFor`): the ones neither cached nor
|
|
685
995
|
* already running start together, in one child (`spawnToFiles`). A promise a
|
|
686
996
|
* pattern, resolving once its answer is ready or given up on; none rejects.
|
|
687
997
|
*/
|
|
688
|
-
function warm(
|
|
689
|
-
if (unavailable) return
|
|
998
|
+
function warm(specs, best = null) {
|
|
999
|
+
if (unavailable) return specs.map(() => Promise.resolve());
|
|
1000
|
+
const fcs = specs.map((spec) => spec.fc);
|
|
690
1001
|
const fresh = [...new Set(fcs)].filter((fc) => !sortedCache.has(fc) && !inflight.has(fc));
|
|
691
1002
|
// the best face only beside its own chain: once that is running or read,
|
|
692
1003
|
// the chain's head answers as soon as a best job would
|
|
693
1004
|
const bests = best !== null && fresh.includes(best) && !bestInflight.has(best) ? [best] : [];
|
|
694
1005
|
if (fresh.length > 0) spawnToFiles(fresh, bests);
|
|
695
|
-
return
|
|
1006
|
+
return specs.map((spec) => {
|
|
1007
|
+
const { fc } = spec;
|
|
696
1008
|
if (sortedCache.has(fc)) return Promise.resolve();
|
|
697
1009
|
// a prewarm's answer waits in its files for the first ask
|
|
698
1010
|
const running = inflight.get(fc);
|
|
699
1011
|
if (running?.base) return running.promise;
|
|
700
1012
|
const pending = running?.promise ?? runFcMatch(fc);
|
|
701
|
-
return pending
|
|
702
|
-
(out) => {
|
|
1013
|
+
return pending
|
|
1014
|
+
.then(async (out) => {
|
|
1015
|
+
// the census decides the head: read once it is in, never waited on
|
|
1016
|
+
await censusSettled();
|
|
703
1017
|
if (!sortedCache.has(fc)) {
|
|
704
1018
|
const list = parseMatches(out);
|
|
705
|
-
if (list.length > 0) sortedCache.set(fc, list);
|
|
1019
|
+
if (list.length > 0) sortedCache.set(fc, cssChain(spec, list));
|
|
706
1020
|
}
|
|
707
|
-
}
|
|
708
|
-
() => {}
|
|
709
|
-
);
|
|
1021
|
+
})
|
|
1022
|
+
.catch(() => {});
|
|
710
1023
|
});
|
|
711
1024
|
}
|
|
712
1025
|
|
|
@@ -736,7 +1049,7 @@ export function prewarmFaces(pattern = {}) {
|
|
|
736
1049
|
// the one being asked for, or, warmed ahead, the regular
|
|
737
1050
|
const first = patternFor(pattern.weight === undefined && pattern.style === undefined ? { family, weight: 400, style: 'normal' } : pattern);
|
|
738
1051
|
warm(
|
|
739
|
-
FACES.map(([weight, style]) =>
|
|
1052
|
+
FACES.map(([weight, style]) => specFor({ family, weight, style })),
|
|
740
1053
|
first
|
|
741
1054
|
);
|
|
742
1055
|
}
|
|
@@ -757,14 +1070,18 @@ export function prewarmFaces(pattern = {}) {
|
|
|
757
1070
|
* sync throws do.
|
|
758
1071
|
*
|
|
759
1072
|
* @returns {Promise<Array<{path, postscriptName, family: string,
|
|
760
|
-
* families: string[], charset: string}>>}
|
|
1073
|
+
* families: string[], charset: string|null}>>}
|
|
761
1074
|
*/
|
|
762
1075
|
export async function matchSorted(pattern = {}) {
|
|
763
|
-
const
|
|
1076
|
+
const spec = specFor(pattern);
|
|
1077
|
+
const { fc } = spec;
|
|
764
1078
|
const cached = sortedCache.get(fc);
|
|
765
1079
|
if (cached) return cached;
|
|
766
1080
|
if (unavailable) throw noFontsError(unavailable);
|
|
767
1081
|
|
|
1082
|
+
// A caller that awaits may be the first to ask anything — a font picker
|
|
1083
|
+
// with no source constructed — and nothing else would start the census
|
|
1084
|
+
if (census === undefined) spawnToFiles([]);
|
|
768
1085
|
let out;
|
|
769
1086
|
const pending = inflight.get(fc);
|
|
770
1087
|
if (pending?.base) {
|
|
@@ -780,17 +1097,22 @@ export async function matchSorted(pattern = {}) {
|
|
|
780
1097
|
throw fcMatchError(err);
|
|
781
1098
|
}
|
|
782
1099
|
}
|
|
1100
|
+
// the census decides the head where fontconfig's own cannot be shown to be
|
|
1101
|
+
// CSS's (`cssChain`): read once it is in, so that this never waits on it
|
|
1102
|
+
await censusSettled();
|
|
783
1103
|
// A sync call may have answered this pattern while the child ran. Its list
|
|
784
1104
|
// is the cached one, and candidates memoize their parsed charset, so hand
|
|
785
1105
|
// back what everyone else already holds rather than a fresh copy.
|
|
786
|
-
return sortedCache.get(fc) ?? cacheMatches(
|
|
1106
|
+
return sortedCache.get(fc) ?? cacheMatches(spec, out);
|
|
787
1107
|
}
|
|
788
1108
|
|
|
789
1109
|
/**
|
|
790
1110
|
* Full fontconfig match list for a pattern, best match first — this is the
|
|
791
|
-
* system's font fallback chain
|
|
792
|
-
*
|
|
793
|
-
*
|
|
1111
|
+
* system's font fallback chain, headed by the face CSS's font matching picks
|
|
1112
|
+
* (`cssChain`). Each candidate carries the unicode coverage fontconfig knows
|
|
1113
|
+
* about (`charset`, lazily parsed via `charsetHas`; null for a head the
|
|
1114
|
+
* chain did not have), so a fallback font for a codepoint can be chosen
|
|
1115
|
+
* without opening font files —
|
|
794
1116
|
* and the family name fontconfig already knows, so a *list* of matches can be
|
|
795
1117
|
* shown without opening them either (issue #273: `sans-serif` returns 139
|
|
796
1118
|
* candidates here, and `Font.loadSync` is ~1.2ms a file).
|
|
@@ -800,10 +1122,11 @@ export async function matchSorted(pattern = {}) {
|
|
|
800
1122
|
* a caller that can should use `matchSorted` and not block on the spawn.
|
|
801
1123
|
*
|
|
802
1124
|
* @returns {Array<{path, postscriptName, family: string, families: string[],
|
|
803
|
-
* charset: string}>}
|
|
1125
|
+
* charset: string|null}>}
|
|
804
1126
|
*/
|
|
805
1127
|
export function matchSortedSync(pattern) {
|
|
806
|
-
const
|
|
1128
|
+
const spec = specFor(pattern);
|
|
1129
|
+
const { fc } = spec;
|
|
807
1130
|
const cached = sortedCache.get(fc);
|
|
808
1131
|
if (cached) return cached;
|
|
809
1132
|
if (unavailable) throw noFontsError(unavailable);
|
|
@@ -814,7 +1137,7 @@ export function matchSortedSync(pattern) {
|
|
|
814
1137
|
const pending = inflight.get(fc);
|
|
815
1138
|
if (pending?.base) {
|
|
816
1139
|
const out = answerSync(fc, pending);
|
|
817
|
-
if (out !== null) return sortedCache.get(fc) ?? cacheMatches(
|
|
1140
|
+
if (out !== null) return sortedCache.get(fc) ?? cacheMatches(spec, out);
|
|
818
1141
|
}
|
|
819
1142
|
let out;
|
|
820
1143
|
try {
|
|
@@ -822,7 +1145,7 @@ export function matchSortedSync(pattern) {
|
|
|
822
1145
|
} catch (err) {
|
|
823
1146
|
throw fcMatchError(err);
|
|
824
1147
|
}
|
|
825
|
-
return cacheMatches(
|
|
1148
|
+
return cacheMatches(spec, out);
|
|
826
1149
|
}
|
|
827
1150
|
|
|
828
1151
|
/**
|
|
@@ -832,22 +1155,23 @@ export function matchSortedSync(pattern) {
|
|
|
832
1155
|
* the first character it has to fall back for reads the chain whole.
|
|
833
1156
|
*
|
|
834
1157
|
* @returns {{path, postscriptName, family: string, families: string[],
|
|
835
|
-
* charset: string}}
|
|
1158
|
+
* charset: string|null}}
|
|
836
1159
|
*/
|
|
837
1160
|
export function matchFirstSync(pattern) {
|
|
838
|
-
const
|
|
1161
|
+
const spec = specFor(pattern);
|
|
1162
|
+
const { fc } = spec;
|
|
839
1163
|
const cached = sortedCache.get(fc);
|
|
840
1164
|
if (cached) return cached[0];
|
|
841
1165
|
if (unavailable) throw noFontsError(unavailable);
|
|
842
1166
|
prewarmFaces(pattern);
|
|
843
1167
|
const best = bestInflight.get(fc);
|
|
844
1168
|
if (best) {
|
|
845
|
-
const first = bestOfPrewarm(best);
|
|
1169
|
+
const first = bestOfPrewarm(spec, best);
|
|
846
1170
|
if (first) return first;
|
|
847
1171
|
}
|
|
848
1172
|
const pending = inflight.get(fc);
|
|
849
1173
|
if (pending?.base) {
|
|
850
|
-
const first = firstOfPrewarm(pending);
|
|
1174
|
+
const first = firstOfPrewarm(spec, pending);
|
|
851
1175
|
if (first) return first;
|
|
852
1176
|
}
|
|
853
1177
|
return matchSortedSync(pattern)[0];
|
|
@@ -873,8 +1197,12 @@ export function listFontsSync(pattern) {
|
|
|
873
1197
|
/**
|
|
874
1198
|
* Does a fc-match candidate's charset cover a codepoint?
|
|
875
1199
|
* The charset string is fontconfig's range format: "20-7e a0-ff 131 ...".
|
|
1200
|
+
* A face whose coverage is not known here — one CSS's matching put at a
|
|
1201
|
+
* chain's head, which `-s` had trimmed (`cssChain`) — may: it is opened and
|
|
1202
|
+
* the font asked.
|
|
876
1203
|
*/
|
|
877
1204
|
export function charsetHas(candidate, codepoint) {
|
|
1205
|
+
if (candidate.charset === null) return true;
|
|
878
1206
|
if (candidate._ranges === null) {
|
|
879
1207
|
const ranges = [];
|
|
880
1208
|
for (const part of candidate.charset.split(' ')) {
|
|
@@ -903,15 +1231,18 @@ export function charsetHas(candidate, codepoint) {
|
|
|
903
1231
|
|
|
904
1232
|
/**
|
|
905
1233
|
* A CSS font weight on fontconfig's scale, which is not CSS's: regular is 80
|
|
906
|
-
* there and black is 210. The
|
|
907
|
-
* of
|
|
908
|
-
* from regular to medium. CSS takes 1 to 1000
|
|
909
|
-
* is its nearest end.
|
|
1234
|
+
* there and black is 210. The table is fontconfig's own, and a weight between
|
|
1235
|
+
* two of its entries is as far between their fontconfig weights, so 450 is
|
|
1236
|
+
* 90, halfway from regular to medium. CSS takes 1 to 1000.
|
|
910
1237
|
*
|
|
911
1238
|
* Handed over as it was, 450 was a weight past twice black, and fontconfig
|
|
912
1239
|
* answered with the heaviest face a family has: Arial Bold, where a browser
|
|
913
|
-
* sets 450 in Arial Regular.
|
|
914
|
-
*
|
|
1240
|
+
* sets 450 in Arial Regular.
|
|
1241
|
+
*
|
|
1242
|
+
* Fontconfig's table rather than one of the hundreds alone, because it is
|
|
1243
|
+
* the one a face's own weight went through: a face whose OS/2 weight is 350
|
|
1244
|
+
* is 55 in every fc-match answer, and only the same table puts a pattern
|
|
1245
|
+
* asking for 340 on the lighter side of it (`cssFace`).
|
|
915
1246
|
*
|
|
916
1247
|
* A whole number, because a pattern is a cache key and an fc-match of its
|
|
917
1248
|
* own: the weights an axis is animated through then share the ones they
|
|
@@ -919,13 +1250,14 @@ export function charsetHas(candidate, codepoint) {
|
|
|
919
1250
|
*/
|
|
920
1251
|
function normalizeWeight(weight) {
|
|
921
1252
|
if (weight === undefined) return undefined;
|
|
922
|
-
if (weight === 'normal') return
|
|
923
|
-
if (weight === 'bold') return
|
|
1253
|
+
if (weight === 'normal') return REGULAR;
|
|
1254
|
+
if (weight === 'bold') return 200;
|
|
924
1255
|
const n = parseInt(weight, 10);
|
|
925
1256
|
if (Number.isNaN(n)) return undefined;
|
|
926
|
-
const css = Math.min(
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
1257
|
+
const css = Math.min(1000, Math.max(1, n));
|
|
1258
|
+
let i = 1;
|
|
1259
|
+
while (css > cssToFcWeight[i][0]) i++;
|
|
1260
|
+
const [lighterCss, lighter] = cssToFcWeight[i - 1];
|
|
1261
|
+
const [heavierCss, heavier] = cssToFcWeight[i];
|
|
1262
|
+
return Math.round(lighter + ((heavier - lighter) * (css - lighterCss)) / (heavierCss - lighterCss));
|
|
931
1263
|
}
|