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 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,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
- // `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
+ // `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`). 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.
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" = best ]; then ' +
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 bases = cp ? all.map(() => prewarmBase()) : [];
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 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;
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
- (i < fcs.length ? inflight : bestInflight).set(fc, entry);
501
- return entry;
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 ?? null;
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([patternFor(pattern)])[0];
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(patternFor))).then(() => {});
990
+ return Promise.all(warm(patterns.map(specFor))).then(() => {});
681
991
  }
682
992
 
683
993
  /**
684
- * `prewarm` for a list of fontconfig patterns: the ones neither cached nor
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(fcs, best = null) {
689
- if (unavailable) return fcs.map(() => Promise.resolve());
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 fcs.map((fc) => {
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.then(
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]) => patternFor({ family, 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 fc = patternFor(pattern);
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(fc, out);
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. 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 —
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 fc = patternFor(pattern);
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(fc, out);
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(fc, out);
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 fc = patternFor(pattern);
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 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.
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. At 90 fontconfig picks the face nearest in
914
- * weight, which is the regular's 80 and not the bold's 200.
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 cssToFcWeight[400];
923
- if (weight === 'bold') return cssToFcWeight[700];
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(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);
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
  }