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 +426 -97
- package/lib/renderingcontext_2d.js +173 -61
- package/lib/text/glyphs.js +34 -13
- package/lib/widgets/svgview.js +209 -72
- 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,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
|
-
|
|
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
|
+
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`).
|
|
360
|
-
//
|
|
361
|
-
// fc-match
|
|
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" =
|
|
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
|
|
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
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
501
|
-
|
|
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
|
|
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([
|
|
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(
|
|
987
|
+
return Promise.all(warm(patterns.map(specFor))).then(() => {});
|
|
681
988
|
}
|
|
682
989
|
|
|
683
990
|
/**
|
|
684
|
-
* `prewarm` for a list of
|
|
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(
|
|
689
|
-
if (unavailable) return
|
|
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
|
|
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
|
|
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]) =>
|
|
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
|
|
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(
|
|
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
|
|
792
|
-
*
|
|
793
|
-
*
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
907
|
-
* of
|
|
908
|
-
* from regular to medium. CSS takes 1 to 1000
|
|
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.
|
|
914
|
-
*
|
|
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
|
|
923
|
-
if (weight === 'bold') return
|
|
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(
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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
|
}
|