ntk 8.17.3 → 8.17.5

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 CHANGED
@@ -93,18 +93,29 @@ export function noFontsError(reason, cause) {
93
93
  return err;
94
94
  }
95
95
 
96
- // css weight -> fontconfig weight constants
97
- const cssToFcWeight = {
98
- 100: 0, // thin
99
- 200: 40, // extralight
100
- 300: 50, // light
101
- 400: 80, // regular
102
- 500: 100, // medium
103
- 600: 180, // demibold
104
- 700: 200, // bold
105
- 800: 205, // extrabold
106
- 900: 210 // black
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
- function patternFor({ family, weight, style }) {
243
- let fc = String(platformFamilies(family || 'sans-serif'))
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
- .map(fcName)
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
- if (style && style.includes('italic')) fc += ':slant=italic';
252
- return fc;
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,317 @@ 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
- // `families` keeps fontconfig's whole list, in its order; `family` is
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. Throws for
295
- * output that parses to nothing usable, which is a fontconfig answer rather
296
- * than a fontconfig failure and so is diagnosed separately.
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(fc, out) {
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
- sortedCache.set(fc, list);
307
- return list;
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
+ function weightRank(weight, [lo, hi]) {
483
+ if (lo <= weight && hi >= weight) return 0;
484
+ const x = hi < weight ? hi : lo;
485
+ if (weight >= REGULAR && weight <= MEDIUM) {
486
+ if (x > weight && x <= MEDIUM) return x - weight;
487
+ return x < weight ? 1000 + weight - x : 2000 + x - weight;
488
+ }
489
+ if (weight < REGULAR) return x < weight ? weight - x : 1000 + x - weight;
490
+ return x > weight ? x - weight : 1000 + weight - x;
491
+ }
492
+
493
+ /**
494
+ * Is `head`, the face fontconfig picked, the one CSS picks — whatever else
495
+ * the family holds?
496
+ *
497
+ * Fontconfig picks the face nearest in weight, after the slant: so no face
498
+ * of the head's slant is nearer the weight asked for than the head, and that
499
+ * is all one answer says about the family. The head is CSS's pick when every
500
+ * weight CSS would try before the head's lies nearer — 500 in Helvetica,
501
+ * whose regular is 80 against 100 asked, where CSS tries 500 alone first.
502
+ * 450 in Arial is not: 500 is as near as its regular, so a medium could be
503
+ * there, and CSS would take it. Then the census decides.
504
+ *
505
+ * Fontconfig ranks width after weight and CSS before it, so a head of
506
+ * another width than normal is never shown to be CSS's: 900 in Helvetica
507
+ * Neue is its condensed black. Nor is an italic head for an upright ask,
508
+ * where CSS tries oblique first.
509
+ */
510
+ function provenPick(weight, italic, head) {
511
+ const style = styleOf(head);
512
+ if (!style) return false;
513
+ if (widthRank(style.width) !== 0) return false;
514
+ if (!italic && slantRank(false, style.slant) === 2) return false;
515
+ const [lo, hi] = style.weight;
516
+ if (lo <= weight && hi >= weight) return true;
517
+ const x = hi < weight ? hi : lo;
518
+ const near = Math.abs(x - weight);
519
+ // the weights CSS tries before x: from, whether it is open, to, whether it is
520
+ let from, fromOpen, to, toOpen;
521
+ if (weight >= REGULAR && weight <= MEDIUM) {
522
+ if (x > weight && x <= MEDIUM) [from, fromOpen, to, toOpen] = [weight, false, x, true];
523
+ else if (x < weight) [from, fromOpen, to, toOpen] = [x, true, MEDIUM, false];
524
+ else [from, fromOpen, to, toOpen] = [0, false, x, true];
525
+ } else if (weight < REGULAR) {
526
+ if (x < weight) [from, fromOpen, to, toOpen] = [x, true, weight, false];
527
+ else [from, fromOpen, to, toOpen] = [0, false, x, true];
528
+ } else if (x > weight) [from, fromOpen, to, toOpen] = [weight, false, x, true];
529
+ else [from, fromOpen, to, toOpen] = [x, true, HEAVIEST, false];
530
+ return (
531
+ (from > weight - near || (fromOpen && from >= weight - near)) &&
532
+ (to < weight + near || (toOpen && to <= weight + near))
533
+ );
534
+ }
535
+
536
+ /** a < b, comparing [width, slant, weight] ranks in turn */
537
+ function ranksBefore(a, b) {
538
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return a[i] < b[i];
539
+ return false;
540
+ }
541
+
542
+ /**
543
+ * The face CSS's matching (CSS Fonts 4 §5.2) picks for a pattern, given
544
+ * `head`, the face fontconfig picked: the head itself where it can be shown
545
+ * to be CSS's (`provenPick`), or where there is no census to say otherwise,
546
+ * and the family's best face by width, slant and weight in that order where
547
+ * there is one. Fontconfig picks the face nearest in weight, after the
548
+ * slant and before the width, so 520 in Arial was the regular, which a
549
+ * browser sets in the bold.
550
+ *
551
+ * The head wins ties: a face like it is fontconfig's to choose between. So
552
+ * does a head that is a variable font's instance where CSS picks the font —
553
+ * a weight its axis holds is set on the axis (`instantiate`) whichever face
554
+ * of the file it is opened through.
555
+ *
556
+ * The head is ranked as the census lists it, because an answer describes
557
+ * the face as fontconfig will render it: an upright face matched for an
558
+ * italic ask comes back oblique (90-synthetic.conf slants it), and ranked
559
+ * by that it beat its own family's upright faces — W1 of Hiragino Sans,
560
+ * which has no italics, at 160, where CSS takes W0.
561
+ */
562
+ function cssFace(spec, head) {
563
+ if (!head || provenPick(spec.weight, spec.italic, head)) return head;
564
+ const faces = familyFaces(spec.names, head);
565
+ if (!faces || faces.length === 0) return head;
566
+ const ranks = (face) => {
567
+ const style = styleOf(face);
568
+ return style && [widthRank(style.width), slantRank(spec.italic, style.slant), weightRank(spec.weight, style.weight)];
569
+ };
570
+ const listed = faces.find((f) => f.path === head.path && f.postscriptName === head.postscriptName);
571
+ let best = null;
572
+ let bestRanks = ranks(listed ?? head);
573
+ for (const face of faces) {
574
+ const r = ranks(face);
575
+ if (r && (bestRanks === null || ranksBefore(r, bestRanks))) [best, bestRanks] = [face, r];
576
+ }
577
+ if (best === null) return head;
578
+ const [lo, hi] = styleOf(best).weight;
579
+ if (lo < hi && best.path === head.path) return head;
580
+ return best;
581
+ }
582
+
583
+ /**
584
+ * A fallback chain with the face CSS picks at its head (`cssFace`). Where
585
+ * that is not fontconfig's head it is moved up from where the chain has it,
586
+ * or, as `-s` trims a face whose coverage the faces before it already give —
587
+ * a family's bold, behind its regular — put there from the census.
588
+ */
589
+ function cssChain(spec, list) {
590
+ const face = cssFace(spec, list[0]);
591
+ if (face === list[0]) return list;
592
+ const same = (c) => c.path === face.path && c.postscriptName === face.postscriptName;
593
+ return [list.find(same) ?? face, ...list.filter((c) => !same(c))];
308
594
  }
309
595
 
310
596
  // fc -> { promise, base }: one child per pattern however many callers ask.
@@ -356,14 +642,18 @@ function runFcMatch(fc) {
356
642
  // flag first), then a file base, a kind and a pattern for each job: `sorted`
357
643
  // asks for the whole fallback chain, `best` for the one face fontconfig
358
644
  // would pick, which it answers in half the time, since it sorts nothing and
359
- // writes one line (`bestOfPrewarm`). The shell exits once every job has.
360
- // Nothing but shell builtins runs besides fc-match, so a PATH that holds
361
- // fc-match alone is enough.
645
+ // writes one line (`bestOfPrewarm`). `census` lists every face, with the
646
+ // format in the pattern's place (`CENSUS_FORMAT`). The shell exits once
647
+ // every job has. Nothing but shell builtins runs besides fc-match and
648
+ // fc-list, so a PATH that holds those is enough; without fc-list there is no
649
+ // census, and fontconfig's own picks stand.
362
650
  const PREWARM_SCRIPT = [
363
651
  ...fcMatchArgs.map((_, i) => `a${i}=\${${i + 1}}`),
364
652
  `shift ${fcMatchArgs.length}`,
365
653
  'while [ $# -gt 2 ]; do ' +
366
- 'if [ "$2" = best ]; then ' +
654
+ 'if [ "$2" = census ]; then ' +
655
+ '(fc-list --format "$3" > "$1.out" 2> "$1.err"; echo $? > "$1.done") & ' +
656
+ 'elif [ "$2" = best ]; then ' +
367
657
  `(fc-match ${fcMatchArgs
368
658
  .slice(1)
369
659
  .map((_, i) => `"$a${i + 1}"`)
@@ -472,16 +762,22 @@ function removePrewarm(base) {
472
762
  * set up: 634 KB apiece, most of it the coverage of every face fontconfig
473
763
  * sorts behind the first.
474
764
  *
765
+ * The first child also lists every face, once a process (the census, see
766
+ * `CENSUS_FORMAT`): a job of its own beside the matches, so a weight CSS
767
+ * matches differently from fontconfig costs no spawn of the app's.
768
+ *
475
769
  * An entry a pattern, in order; null where files cannot be used, and the
476
770
  * caller prewarms the old way.
477
771
  */
478
772
  function spawnToFiles(fcs, bests = []) {
479
773
  const cp = childProcess();
480
774
  const all = [...fcs, ...bests];
481
- const bases = cp ? all.map(() => prewarmBase()) : [];
775
+ const listing = census === undefined;
776
+ const bases = cp ? Array.from({ length: all.length + (listing ? 1 : 0) }, () => prewarmBase()) : [];
482
777
  if (bases.length === 0 || bases[0] === null) return null;
483
778
  const jobs = [];
484
779
  all.forEach((fc, i) => jobs.push(bases[i], i < fcs.length ? 'sorted' : 'best', fc));
780
+ if (listing) jobs.push(bases[all.length], 'census', CENSUS_FORMAT);
485
781
  let child;
486
782
  try {
487
783
  child = cp.spawn('/bin/sh', ['-c', PREWARM_SCRIPT, 'ntk-fc-match', ...fcMatchArgs, ...jobs], {
@@ -492,14 +788,22 @@ function spawnToFiles(fcs, bests = []) {
492
788
  }
493
789
  // `promise` resolves once the child is gone, with what it left in the
494
790
  // files; `exited` tells a synchronous caller there is nothing to wait for
495
- const entries = all.map((fc, i) => {
496
- const entry = { promise: null, base: bases[i], answer: undefined, exited: false, gone: null };
497
- entry.promise = new Promise((resolve) => {
498
- entry.gone = resolve;
791
+ const entry = (base) => {
792
+ const e = { promise: null, base, answer: undefined, exited: false, gone: null };
793
+ e.promise = new Promise((resolve) => {
794
+ e.gone = resolve;
499
795
  });
500
- (i < fcs.length ? inflight : bestInflight).set(fc, entry);
501
- return entry;
796
+ return e;
797
+ };
798
+ const entries = all.map((fc, i) => {
799
+ const e = entry(bases[i]);
800
+ (i < fcs.length ? inflight : bestInflight).set(fc, e);
801
+ return e;
502
802
  });
803
+ if (listing) {
804
+ census = Object.assign(entry(bases[all.length]), { bytes: undefined, faces: new Map() });
805
+ entries.push(census);
806
+ }
503
807
  const gone = () => {
504
808
  for (const entry of entries) {
505
809
  entry.exited = true;
@@ -551,8 +855,10 @@ function answerSync(fc, entry) {
551
855
  * layout setting text in the face needs its first line. The rest waits in
552
856
  * the file for the first character that falls back, which Latin text in a
553
857
  * face that covers it never has.
858
+ *
859
+ * The face is the one CSS picks (`cssFace`), as the chain's head will be.
554
860
  */
555
- function firstOfPrewarm(entry) {
861
+ function firstOfPrewarm(spec, entry) {
556
862
  if (entry.first !== undefined) return entry.first;
557
863
  const deadline = performance.now() + PREWARM_WAIT_MS;
558
864
  let status = prewarmStatus(entry.base);
@@ -562,7 +868,7 @@ function firstOfPrewarm(entry) {
562
868
  }
563
869
  // not answered, or not yet: the whole read is what decides, and reports
564
870
  if (status !== 0) return null;
565
- entry.first = firstCandidate(`${entry.base}.out`);
871
+ entry.first = cssFace(spec, firstCandidate(`${entry.base}.out`));
566
872
  return entry.first;
567
873
  }
568
874
 
@@ -576,9 +882,10 @@ function firstOfPrewarm(entry) {
576
882
  * in about half the time the whole chain takes (15 ms against 30 on a Linux
577
883
  * desktop): no sort, one line. A layout that asks for a family warmed while
578
884
  * its component rendered waits on the prewarm, and the chain was most of
579
- * that wait.
885
+ * that wait. Fontconfig's face, that is: the one CSS picks may be another
886
+ * (`cssFace`).
580
887
  */
581
- function bestOfPrewarm(entry) {
888
+ function bestOfPrewarm(spec, entry) {
582
889
  if (entry.first !== undefined) return entry.first;
583
890
  const deadline = performance.now() + PREWARM_WAIT_MS;
584
891
  let left = readPrewarm(entry.base);
@@ -589,7 +896,7 @@ function bestOfPrewarm(entry) {
589
896
  if (left === null) return null;
590
897
  removePrewarm(entry.base);
591
898
  const [best] = left.status === 0 ? parseMatches(left.out) : [];
592
- entry.first = best ?? null;
899
+ entry.first = best ? cssFace(spec, best) : null;
593
900
  return entry.first;
594
901
  }
595
902
 
@@ -665,7 +972,7 @@ function firstCandidate(file) {
665
972
  * attempt abandoned
666
973
  */
667
974
  export function prewarm(pattern = {}) {
668
- return warm([patternFor(pattern)])[0];
975
+ return warm([specFor(pattern)])[0];
669
976
  }
670
977
 
671
978
  /**
@@ -677,36 +984,39 @@ export function prewarm(pattern = {}) {
677
984
  * up on; never rejects
678
985
  */
679
986
  export function prewarmPatterns(patterns) {
680
- return Promise.all(warm(patterns.map(patternFor))).then(() => {});
987
+ return Promise.all(warm(patterns.map(specFor))).then(() => {});
681
988
  }
682
989
 
683
990
  /**
684
- * `prewarm` for a list of fontconfig patterns: the ones neither cached nor
991
+ * `prewarm` for a list of patterns (`specFor`): the ones neither cached nor
685
992
  * already running start together, in one child (`spawnToFiles`). A promise a
686
993
  * pattern, resolving once its answer is ready or given up on; none rejects.
687
994
  */
688
- function warm(fcs, best = null) {
689
- if (unavailable) return fcs.map(() => Promise.resolve());
995
+ function warm(specs, best = null) {
996
+ if (unavailable) return specs.map(() => Promise.resolve());
997
+ const fcs = specs.map((spec) => spec.fc);
690
998
  const fresh = [...new Set(fcs)].filter((fc) => !sortedCache.has(fc) && !inflight.has(fc));
691
999
  // the best face only beside its own chain: once that is running or read,
692
1000
  // the chain's head answers as soon as a best job would
693
1001
  const bests = best !== null && fresh.includes(best) && !bestInflight.has(best) ? [best] : [];
694
1002
  if (fresh.length > 0) spawnToFiles(fresh, bests);
695
- return fcs.map((fc) => {
1003
+ return specs.map((spec) => {
1004
+ const { fc } = spec;
696
1005
  if (sortedCache.has(fc)) return Promise.resolve();
697
1006
  // a prewarm's answer waits in its files for the first ask
698
1007
  const running = inflight.get(fc);
699
1008
  if (running?.base) return running.promise;
700
1009
  const pending = running?.promise ?? runFcMatch(fc);
701
- return pending.then(
702
- (out) => {
1010
+ return pending
1011
+ .then(async (out) => {
1012
+ // the census decides the head: read once it is in, never waited on
1013
+ await censusSettled();
703
1014
  if (!sortedCache.has(fc)) {
704
1015
  const list = parseMatches(out);
705
- if (list.length > 0) sortedCache.set(fc, list);
1016
+ if (list.length > 0) sortedCache.set(fc, cssChain(spec, list));
706
1017
  }
707
- },
708
- () => {}
709
- );
1018
+ })
1019
+ .catch(() => {});
710
1020
  });
711
1021
  }
712
1022
 
@@ -736,7 +1046,7 @@ export function prewarmFaces(pattern = {}) {
736
1046
  // the one being asked for, or, warmed ahead, the regular
737
1047
  const first = patternFor(pattern.weight === undefined && pattern.style === undefined ? { family, weight: 400, style: 'normal' } : pattern);
738
1048
  warm(
739
- FACES.map(([weight, style]) => patternFor({ family, weight, style })),
1049
+ FACES.map(([weight, style]) => specFor({ family, weight, style })),
740
1050
  first
741
1051
  );
742
1052
  }
@@ -757,14 +1067,18 @@ export function prewarmFaces(pattern = {}) {
757
1067
  * sync throws do.
758
1068
  *
759
1069
  * @returns {Promise<Array<{path, postscriptName, family: string,
760
- * families: string[], charset: string}>>}
1070
+ * families: string[], charset: string|null}>>}
761
1071
  */
762
1072
  export async function matchSorted(pattern = {}) {
763
- const fc = patternFor(pattern);
1073
+ const spec = specFor(pattern);
1074
+ const { fc } = spec;
764
1075
  const cached = sortedCache.get(fc);
765
1076
  if (cached) return cached;
766
1077
  if (unavailable) throw noFontsError(unavailable);
767
1078
 
1079
+ // A caller that awaits may be the first to ask anything — a font picker
1080
+ // with no source constructed — and nothing else would start the census
1081
+ if (census === undefined) spawnToFiles([]);
768
1082
  let out;
769
1083
  const pending = inflight.get(fc);
770
1084
  if (pending?.base) {
@@ -780,17 +1094,22 @@ export async function matchSorted(pattern = {}) {
780
1094
  throw fcMatchError(err);
781
1095
  }
782
1096
  }
1097
+ // the census decides the head where fontconfig's own cannot be shown to be
1098
+ // CSS's (`cssChain`): read once it is in, so that this never waits on it
1099
+ await censusSettled();
783
1100
  // A sync call may have answered this pattern while the child ran. Its list
784
1101
  // is the cached one, and candidates memoize their parsed charset, so hand
785
1102
  // back what everyone else already holds rather than a fresh copy.
786
- return sortedCache.get(fc) ?? cacheMatches(fc, out);
1103
+ return sortedCache.get(fc) ?? cacheMatches(spec, out);
787
1104
  }
788
1105
 
789
1106
  /**
790
1107
  * Full fontconfig match list for a pattern, best match first — this is the
791
- * system's font fallback chain. Each candidate carries the unicode coverage
792
- * fontconfig knows about (`charset`, lazily parsed via `charsetHas`), so a
793
- * fallback font for a codepoint can be chosen without opening font files —
1108
+ * system's font fallback chain, headed by the face CSS's font matching picks
1109
+ * (`cssChain`). Each candidate carries the unicode coverage fontconfig knows
1110
+ * about (`charset`, lazily parsed via `charsetHas`; null for a head the
1111
+ * chain did not have), so a fallback font for a codepoint can be chosen
1112
+ * without opening font files —
794
1113
  * and the family name fontconfig already knows, so a *list* of matches can be
795
1114
  * shown without opening them either (issue #273: `sans-serif` returns 139
796
1115
  * candidates here, and `Font.loadSync` is ~1.2ms a file).
@@ -800,10 +1119,11 @@ export async function matchSorted(pattern = {}) {
800
1119
  * a caller that can should use `matchSorted` and not block on the spawn.
801
1120
  *
802
1121
  * @returns {Array<{path, postscriptName, family: string, families: string[],
803
- * charset: string}>}
1122
+ * charset: string|null}>}
804
1123
  */
805
1124
  export function matchSortedSync(pattern) {
806
- const fc = patternFor(pattern);
1125
+ const spec = specFor(pattern);
1126
+ const { fc } = spec;
807
1127
  const cached = sortedCache.get(fc);
808
1128
  if (cached) return cached;
809
1129
  if (unavailable) throw noFontsError(unavailable);
@@ -814,7 +1134,7 @@ export function matchSortedSync(pattern) {
814
1134
  const pending = inflight.get(fc);
815
1135
  if (pending?.base) {
816
1136
  const out = answerSync(fc, pending);
817
- if (out !== null) return sortedCache.get(fc) ?? cacheMatches(fc, out);
1137
+ if (out !== null) return sortedCache.get(fc) ?? cacheMatches(spec, out);
818
1138
  }
819
1139
  let out;
820
1140
  try {
@@ -822,7 +1142,7 @@ export function matchSortedSync(pattern) {
822
1142
  } catch (err) {
823
1143
  throw fcMatchError(err);
824
1144
  }
825
- return cacheMatches(fc, out);
1145
+ return cacheMatches(spec, out);
826
1146
  }
827
1147
 
828
1148
  /**
@@ -832,22 +1152,23 @@ export function matchSortedSync(pattern) {
832
1152
  * the first character it has to fall back for reads the chain whole.
833
1153
  *
834
1154
  * @returns {{path, postscriptName, family: string, families: string[],
835
- * charset: string}}
1155
+ * charset: string|null}}
836
1156
  */
837
1157
  export function matchFirstSync(pattern) {
838
- const fc = patternFor(pattern);
1158
+ const spec = specFor(pattern);
1159
+ const { fc } = spec;
839
1160
  const cached = sortedCache.get(fc);
840
1161
  if (cached) return cached[0];
841
1162
  if (unavailable) throw noFontsError(unavailable);
842
1163
  prewarmFaces(pattern);
843
1164
  const best = bestInflight.get(fc);
844
1165
  if (best) {
845
- const first = bestOfPrewarm(best);
1166
+ const first = bestOfPrewarm(spec, best);
846
1167
  if (first) return first;
847
1168
  }
848
1169
  const pending = inflight.get(fc);
849
1170
  if (pending?.base) {
850
- const first = firstOfPrewarm(pending);
1171
+ const first = firstOfPrewarm(spec, pending);
851
1172
  if (first) return first;
852
1173
  }
853
1174
  return matchSortedSync(pattern)[0];
@@ -873,8 +1194,12 @@ export function listFontsSync(pattern) {
873
1194
  /**
874
1195
  * Does a fc-match candidate's charset cover a codepoint?
875
1196
  * The charset string is fontconfig's range format: "20-7e a0-ff 131 ...".
1197
+ * A face whose coverage is not known here — one CSS's matching put at a
1198
+ * chain's head, which `-s` had trimmed (`cssChain`) — may: it is opened and
1199
+ * the font asked.
876
1200
  */
877
1201
  export function charsetHas(candidate, codepoint) {
1202
+ if (candidate.charset === null) return true;
878
1203
  if (candidate._ranges === null) {
879
1204
  const ranges = [];
880
1205
  for (const part of candidate.charset.split(' ')) {
@@ -903,15 +1228,18 @@ export function charsetHas(candidate, codepoint) {
903
1228
 
904
1229
  /**
905
1230
  * A CSS font weight on fontconfig's scale, which is not CSS's: regular is 80
906
- * there and black is 210. The hundreds are the table's; a weight between two
907
- * of them is as far between their fontconfig weights, so 450 is 90, halfway
908
- * from regular to medium. CSS takes 1 to 1000, and what is outside the table
909
- * is its nearest end.
1231
+ * there and black is 210. The table is fontconfig's own, and a weight between
1232
+ * two of its entries is as far between their fontconfig weights, so 450 is
1233
+ * 90, halfway from regular to medium. CSS takes 1 to 1000.
910
1234
  *
911
1235
  * Handed over as it was, 450 was a weight past twice black, and fontconfig
912
1236
  * answered with the heaviest face a family has: Arial Bold, where a browser
913
- * sets 450 in Arial Regular. At 90 fontconfig picks the face nearest in
914
- * weight, which is the regular's 80 and not the bold's 200.
1237
+ * sets 450 in Arial Regular.
1238
+ *
1239
+ * Fontconfig's table rather than one of the hundreds alone, because it is
1240
+ * the one a face's own weight went through: a face whose OS/2 weight is 350
1241
+ * is 55 in every fc-match answer, and only the same table puts a pattern
1242
+ * asking for 340 on the lighter side of it (`cssFace`).
915
1243
  *
916
1244
  * A whole number, because a pattern is a cache key and an fc-match of its
917
1245
  * own: the weights an axis is animated through then share the ones they
@@ -919,13 +1247,14 @@ export function charsetHas(candidate, codepoint) {
919
1247
  */
920
1248
  function normalizeWeight(weight) {
921
1249
  if (weight === undefined) return undefined;
922
- if (weight === 'normal') return cssToFcWeight[400];
923
- if (weight === 'bold') return cssToFcWeight[700];
1250
+ if (weight === 'normal') return REGULAR;
1251
+ if (weight === 'bold') return 200;
924
1252
  const n = parseInt(weight, 10);
925
1253
  if (Number.isNaN(n)) return undefined;
926
- const css = Math.min(900, Math.max(100, n));
927
- const below = Math.floor(css / 100) * 100;
928
- const lighter = cssToFcWeight[below];
929
- if (css === below) return lighter;
930
- return Math.round(lighter + ((cssToFcWeight[below + 100] - lighter) * (css - below)) / 100);
1254
+ const css = Math.min(1000, Math.max(1, n));
1255
+ let i = 1;
1256
+ while (css > cssToFcWeight[i][0]) i++;
1257
+ const [lighterCss, lighter] = cssToFcWeight[i - 1];
1258
+ const [heavierCss, heavier] = cssToFcWeight[i];
1259
+ return Math.round(lighter + ((heavier - lighter) * (css - lighterCss)) / (heavierCss - lighterCss));
931
1260
  }