ntk 8.13.1 → 8.14.1
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/app.js +24 -0
- package/lib/cliprect.js +13 -2
- package/lib/color.js +21 -0
- package/lib/fontconfig.js +273 -55
- package/lib/renderingcontext_2d.js +41 -16
- package/lib/text/anchors.js +81 -0
- package/lib/text/font.js +81 -3
- package/lib/text/fontmanager.js +97 -12
- package/lib/text/fontsource.js +25 -0
- package/lib/text/layout.js +67 -23
- package/lib/text/marks.js +212 -0
- package/lib/text/shape.js +17 -8
- package/package.json +1 -1
package/lib/app.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { connectionGone } from './cleanup.js';
|
|
2
|
+
import { cssColor } from './color.js';
|
|
2
3
|
import Clipboard from './clipboard.js';
|
|
3
4
|
import { CursorCache } from './cursor.js';
|
|
4
5
|
import {
|
|
@@ -147,6 +148,8 @@ export default class App {
|
|
|
147
148
|
sharedGlyphsFor(this);
|
|
148
149
|
this._cursors = null;
|
|
149
150
|
this._solidPictures = new Map();
|
|
151
|
+
// the same pictures by the CSS colour that asked for them (solidPictureOf)
|
|
152
|
+
this._solidByName = new Map();
|
|
150
153
|
this._rasterizer = undefined;
|
|
151
154
|
this._shm = undefined;
|
|
152
155
|
this._extensionPromises = new Map();
|
|
@@ -992,6 +995,26 @@ export default class App {
|
|
|
992
995
|
return p;
|
|
993
996
|
}
|
|
994
997
|
|
|
998
|
+
/**
|
|
999
|
+
* The solid picture a CSS colour string paints with. A UI sets the same few
|
|
1000
|
+
* strings again on every paint — every box's background, every border —
|
|
1001
|
+
* and the way to `solidPicture` is a parse, a premultiply and a key made of
|
|
1002
|
+
* four numbers turned into text; by the string it is one lookup. Throws, as
|
|
1003
|
+
* `fillStyle` always has, for a string that is not a colour.
|
|
1004
|
+
*/
|
|
1005
|
+
solidPictureOf(value) {
|
|
1006
|
+
let p = this._solidByName.get(value);
|
|
1007
|
+
if (p !== undefined) return p;
|
|
1008
|
+
const c = cssColor(value);
|
|
1009
|
+
if (!c) throw new Error(`Not a color: ${JSON.stringify(value)}`);
|
|
1010
|
+
p = this.solidPicture(c[0], c[1], c[2], c[3]);
|
|
1011
|
+
// spellings are unbounded where solids are not: an app animating a
|
|
1012
|
+
// colour writes a new string every frame
|
|
1013
|
+
if (this._solidByName.size >= 1024) this._solidByName.clear();
|
|
1014
|
+
this._solidByName.set(value, p);
|
|
1015
|
+
return p;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
995
1018
|
// flush pending requests and close the connection
|
|
996
1019
|
close() {
|
|
997
1020
|
if (this._cursors) {
|
|
@@ -1012,6 +1035,7 @@ export default class App {
|
|
|
1012
1035
|
picture._sourcePixmap?.destroy();
|
|
1013
1036
|
}
|
|
1014
1037
|
this._solidPictures.clear();
|
|
1038
|
+
this._solidByName.clear();
|
|
1015
1039
|
// GPU contexts are shared by every direct GL surface on this connection
|
|
1016
1040
|
// (see renderingcontext_gles.js), so they outlive individual contexts and
|
|
1017
1041
|
// are the connection's to release. Each holds an EGL display, a GBM device
|
package/lib/cliprect.js
CHANGED
|
@@ -4,8 +4,19 @@
|
|
|
4
4
|
// out of the paint that drew it. A box of a code block twenty thousand lines
|
|
5
5
|
// tall in a scroll pane was one, filled as a rounded rectangle.
|
|
6
6
|
//
|
|
7
|
-
// Only what reaches past the
|
|
8
|
-
// as it came, so a drawing that fits is sent exactly
|
|
7
|
+
// Only what reaches past what the wire can carry is clipped (`WIRE_REACH`):
|
|
8
|
+
// everything else goes out as it came, so a drawing that fits is sent exactly
|
|
9
|
+
// as before.
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* How far from a picture's origin a coordinate may reach and still be
|
|
13
|
+
* carried: half of what 16.16 fixed point and a 16-bit origin hold, so an
|
|
14
|
+
* offset the size of a surface on top of it still fits. Geometry within it
|
|
15
|
+
* goes to the server as it came, and the server clips it — cutting it here
|
|
16
|
+
* costs more than that, and a zoomed graph has hundreds of edges a little
|
|
17
|
+
* past the window every frame. Only geometry past it is cut.
|
|
18
|
+
*/
|
|
19
|
+
export const WIRE_REACH = 16384;
|
|
9
20
|
|
|
10
21
|
/** Whether every point of a flat `[x, y, …]` list is inside the rectangle. */
|
|
11
22
|
export function withinRect(pts, x0, y0, x1, y1) {
|
package/lib/color.js
CHANGED
|
@@ -42,6 +42,17 @@ function parseHex(value) {
|
|
|
42
42
|
return [part(0) / 255, part(1) / 255, part(2) / 255, hasAlpha ? part(3) / 255 : 1];
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// Each spelling is parsed once. A UI draws with a few dozen colours and sets
|
|
46
|
+
// them again on every paint — every box's background, every border, every
|
|
47
|
+
// edge of a graph — and a parse is a regular expression and an allocation
|
|
48
|
+
// or two: 6% of a 2D graph pan's frame, 3% of a table's. Bounded, and
|
|
49
|
+
// dropped whole rather than evicted, since a palette is a few dozen strings
|
|
50
|
+
// (react-x11's Cocoa context keeps the same cache for the same reason). The
|
|
51
|
+
// caller gets a copy, as it always has: an array handed out is its to keep
|
|
52
|
+
// or scale.
|
|
53
|
+
const parsed = new Map();
|
|
54
|
+
const PARSED_MAX = 512;
|
|
55
|
+
|
|
45
56
|
/**
|
|
46
57
|
* Parse a CSS colour to `[r, g, b, a]` floats in 0..1 with **straight**
|
|
47
58
|
* (unassociated) alpha, or null if it is not a colour.
|
|
@@ -57,6 +68,16 @@ function parseHex(value) {
|
|
|
57
68
|
*/
|
|
58
69
|
export function cssColorStraight(value) {
|
|
59
70
|
if (typeof value !== 'string') return null;
|
|
71
|
+
let rgba = parsed.get(value);
|
|
72
|
+
if (rgba === undefined) {
|
|
73
|
+
rgba = parseStraight(value);
|
|
74
|
+
if (parsed.size >= PARSED_MAX) parsed.clear();
|
|
75
|
+
parsed.set(value, rgba);
|
|
76
|
+
}
|
|
77
|
+
return rgba && rgba.slice();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseStraight(value) {
|
|
60
81
|
const v = value.trim();
|
|
61
82
|
if (!v) return null;
|
|
62
83
|
if (v.toLowerCase() === 'transparent') return [0, 0, 0, 0];
|
package/lib/fontconfig.js
CHANGED
|
@@ -294,6 +294,11 @@ function cacheMatches(fc, out) {
|
|
|
294
294
|
// child whose answer only reaches the event loop.
|
|
295
295
|
const inflight = new Map();
|
|
296
296
|
|
|
297
|
+
// fc -> the prewarm entry of a `best` job: the one face fontconfig picks for a
|
|
298
|
+
// pattern, asked for beside its chain so a layout can take it first
|
|
299
|
+
// (`bestOfPrewarm`)
|
|
300
|
+
const bestInflight = new Map();
|
|
301
|
+
|
|
297
302
|
/**
|
|
298
303
|
* fc-match for a pattern, off the event loop — one child per pattern however
|
|
299
304
|
* many callers ask at once, so a prewarm and an awaiting `matchSorted` share
|
|
@@ -326,11 +331,31 @@ function runFcMatch(fc) {
|
|
|
326
331
|
return entry.promise;
|
|
327
332
|
}
|
|
328
333
|
|
|
329
|
-
// What a prewarm's child runs: fc-match
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
+
// What a prewarm's child runs: an fc-match for each job, side by side, each
|
|
335
|
+
// with its answer in a file and then — once that file is complete — its exit
|
|
336
|
+
// status in another, which is what a synchronous caller waits for
|
|
337
|
+
// (`answerSync`). The arguments are fc-match's own (`fcMatchArgs`, the sort
|
|
338
|
+
// flag first), then a file base, a kind and a pattern for each job: `sorted`
|
|
339
|
+
// asks for the whole fallback chain, `best` for the one face fontconfig
|
|
340
|
+
// would pick, which it answers in half the time, since it sorts nothing and
|
|
341
|
+
// writes one line (`bestOfPrewarm`). The shell exits once every job has.
|
|
342
|
+
// Nothing but shell builtins runs besides fc-match, so a PATH that holds
|
|
343
|
+
// fc-match alone is enough.
|
|
344
|
+
const PREWARM_SCRIPT = [
|
|
345
|
+
...fcMatchArgs.map((_, i) => `a${i}=\${${i + 1}}`),
|
|
346
|
+
`shift ${fcMatchArgs.length}`,
|
|
347
|
+
'while [ $# -gt 2 ]; do ' +
|
|
348
|
+
'if [ "$2" = best ]; then ' +
|
|
349
|
+
`(fc-match ${fcMatchArgs
|
|
350
|
+
.slice(1)
|
|
351
|
+
.map((_, i) => `"$a${i + 1}"`)
|
|
352
|
+
.join(' ')} "$3" > "$1.out" 2> "$1.err"; echo $? > "$1.done") & ` +
|
|
353
|
+
'else ' +
|
|
354
|
+
`(fc-match ${fcMatchArgs.map((_, i) => `"$a${i}"`).join(' ')} "$3" > "$1.out" 2> "$1.err"; ` +
|
|
355
|
+
'echo $? > "$1.done") & ' +
|
|
356
|
+
'fi; shift 3; done',
|
|
357
|
+
'wait'
|
|
358
|
+
].join('; ');
|
|
334
359
|
|
|
335
360
|
// The directory prewarms write into, made on first use: undefined until
|
|
336
361
|
// then, null where it cannot be (no shell, no writable tmpdir), which leaves
|
|
@@ -364,9 +389,9 @@ function prewarmBase() {
|
|
|
364
389
|
return prewarmDir === null ? null : path.join(prewarmDir, String(prewarmCount++));
|
|
365
390
|
}
|
|
366
391
|
|
|
367
|
-
/** The status
|
|
368
|
-
*
|
|
369
|
-
function
|
|
392
|
+
/** The exit status a prewarm left, once it has left it; null while it is
|
|
393
|
+
* still running. */
|
|
394
|
+
function prewarmStatus(base) {
|
|
370
395
|
const fs = builtin('node:fs');
|
|
371
396
|
let done;
|
|
372
397
|
try {
|
|
@@ -377,7 +402,15 @@ function readPrewarm(base) {
|
|
|
377
402
|
// `echo` writes the status and the newline in one go, but a read can land
|
|
378
403
|
// between the file appearing and its bytes arriving
|
|
379
404
|
if (!done.endsWith('\n')) return null;
|
|
380
|
-
|
|
405
|
+
return Number(done);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** The status and answer a prewarm left, once it has left them; null while
|
|
409
|
+
* it is still running. */
|
|
410
|
+
function readPrewarm(base) {
|
|
411
|
+
const fs = builtin('node:fs');
|
|
412
|
+
const status = prewarmStatus(base);
|
|
413
|
+
if (status === null) return null;
|
|
381
414
|
let out = '';
|
|
382
415
|
if (status === 0) {
|
|
383
416
|
try {
|
|
@@ -408,38 +441,56 @@ function removePrewarm(base) {
|
|
|
408
441
|
* that, so the layout it was started for spawned fc-match a second time and
|
|
409
442
|
* waited for that one instead.
|
|
410
443
|
*
|
|
411
|
-
*
|
|
444
|
+
* One child for all the patterns handed over, because what a spawn costs is
|
|
445
|
+
* node's: libuv forks the whole process to start one, and a fork copies page
|
|
446
|
+
* tables that grow with the heap. Four spawns took 4.7 ms of the main thread
|
|
447
|
+
* at 130 MB and 33 ms at 260 MB on a Linux desktop; one shell starting the
|
|
448
|
+
* same four took 1.4 and 8, since the shell's own forks cost next to nothing.
|
|
449
|
+
*
|
|
450
|
+
* The answers stay in their files until something asks for one
|
|
451
|
+
* (`answerSync`), so a face no text is set in — a family's italics, as often
|
|
452
|
+
* as not — is never read. Read and parsed as each child exited, a family's
|
|
453
|
+
* answers were 9 ms of the main thread while the connection was still being
|
|
454
|
+
* set up: 634 KB apiece, most of it the coverage of every face fontconfig
|
|
455
|
+
* sorts behind the first.
|
|
456
|
+
*
|
|
457
|
+
* An entry a pattern, in order; null where files cannot be used, and the
|
|
458
|
+
* caller prewarms the old way.
|
|
412
459
|
*/
|
|
413
|
-
function spawnToFiles(
|
|
460
|
+
function spawnToFiles(fcs, bests = []) {
|
|
414
461
|
const cp = childProcess();
|
|
415
|
-
const
|
|
416
|
-
|
|
462
|
+
const all = [...fcs, ...bests];
|
|
463
|
+
const bases = cp ? all.map(() => prewarmBase()) : [];
|
|
464
|
+
if (bases.length === 0 || bases[0] === null) return null;
|
|
465
|
+
const jobs = [];
|
|
466
|
+
all.forEach((fc, i) => jobs.push(bases[i], i < fcs.length ? 'sorted' : 'best', fc));
|
|
417
467
|
let child;
|
|
418
468
|
try {
|
|
419
|
-
child = cp.spawn('/bin/sh', ['-c', PREWARM_SCRIPT,
|
|
469
|
+
child = cp.spawn('/bin/sh', ['-c', PREWARM_SCRIPT, 'ntk-fc-match', ...fcMatchArgs, ...jobs], {
|
|
420
470
|
stdio: 'ignore'
|
|
421
471
|
});
|
|
422
472
|
} catch {
|
|
423
473
|
return null;
|
|
424
474
|
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
}
|
|
435
|
-
if (entry.answer !== null) resolve(entry.answer);
|
|
436
|
-
else reject(new Error(`fc-match for "${fc}" did not answer`));
|
|
437
|
-
};
|
|
438
|
-
child.once('error', settle);
|
|
439
|
-
child.once('exit', settle);
|
|
475
|
+
// `promise` resolves once the child is gone, with what it left in the
|
|
476
|
+
// files; `exited` tells a synchronous caller there is nothing to wait for
|
|
477
|
+
const entries = all.map((fc, i) => {
|
|
478
|
+
const entry = { promise: null, base: bases[i], answer: undefined, exited: false, gone: null };
|
|
479
|
+
entry.promise = new Promise((resolve) => {
|
|
480
|
+
entry.gone = resolve;
|
|
481
|
+
});
|
|
482
|
+
(i < fcs.length ? inflight : bestInflight).set(fc, entry);
|
|
483
|
+
return entry;
|
|
440
484
|
});
|
|
441
|
-
|
|
442
|
-
|
|
485
|
+
const gone = () => {
|
|
486
|
+
for (const entry of entries) {
|
|
487
|
+
entry.exited = true;
|
|
488
|
+
entry.gone();
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
child.once('error', gone);
|
|
492
|
+
child.once('exit', gone);
|
|
493
|
+
return entries;
|
|
443
494
|
}
|
|
444
495
|
|
|
445
496
|
// the synchronous path's nap while a prewarm finishes
|
|
@@ -451,14 +502,15 @@ const nap = new Int32Array(new SharedArrayBuffer(4));
|
|
|
451
502
|
const PREWARM_WAIT_MS = 3000;
|
|
452
503
|
|
|
453
504
|
/**
|
|
454
|
-
*
|
|
455
|
-
* null if it failed or took too long
|
|
456
|
-
* itself, which is also what reports why.
|
|
505
|
+
* A prewarm's answer, read from its files — waited for synchronously while
|
|
506
|
+
* the child still runs: its stdout, or null if it failed or took too long.
|
|
507
|
+
* The caller then spawns fc-match itself, which is also what reports why.
|
|
457
508
|
*/
|
|
458
509
|
function answerSync(fc, entry) {
|
|
459
510
|
const deadline = performance.now() + PREWARM_WAIT_MS;
|
|
460
511
|
let left = readPrewarm(entry.base);
|
|
461
|
-
|
|
512
|
+
// a child that is gone has left all it ever will
|
|
513
|
+
while (left === null && !entry.exited && performance.now() < deadline) {
|
|
462
514
|
Atomics.wait(nap, 0, 0, 1);
|
|
463
515
|
left = readPrewarm(entry.base);
|
|
464
516
|
}
|
|
@@ -470,6 +522,105 @@ function answerSync(fc, entry) {
|
|
|
470
522
|
return entry.answer;
|
|
471
523
|
}
|
|
472
524
|
|
|
525
|
+
/**
|
|
526
|
+
* The first candidate of a prewarm's answer that ntk can open, read from the
|
|
527
|
+
* head of its file — waited for synchronously while the child still runs.
|
|
528
|
+
* Null where the answer failed, took too long, or holds no such candidate;
|
|
529
|
+
* the caller then reads it whole (`matchSortedSync`), which also reports why.
|
|
530
|
+
*
|
|
531
|
+
* An answer is the whole fallback chain, each face with its coverage: 634 KB
|
|
532
|
+
* for `sans-serif` on a Linux desktop, 2-3 ms to read and parse, where a
|
|
533
|
+
* layout setting text in the face needs its first line. The rest waits in
|
|
534
|
+
* the file for the first character that falls back, which Latin text in a
|
|
535
|
+
* face that covers it never has.
|
|
536
|
+
*/
|
|
537
|
+
function firstOfPrewarm(entry) {
|
|
538
|
+
if (entry.first !== undefined) return entry.first;
|
|
539
|
+
const deadline = performance.now() + PREWARM_WAIT_MS;
|
|
540
|
+
let status = prewarmStatus(entry.base);
|
|
541
|
+
while (status === null && !entry.exited && performance.now() < deadline) {
|
|
542
|
+
Atomics.wait(nap, 0, 0, 1);
|
|
543
|
+
status = prewarmStatus(entry.base);
|
|
544
|
+
}
|
|
545
|
+
// not answered, or not yet: the whole read is what decides, and reports
|
|
546
|
+
if (status !== 0) return null;
|
|
547
|
+
entry.first = firstCandidate(`${entry.base}.out`);
|
|
548
|
+
return entry.first;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* The face a prewarm's `best` job found for a pattern, if ntk can open it —
|
|
553
|
+
* waited for synchronously while the job still runs. Null where it failed,
|
|
554
|
+
* took too long or named a face ntk cannot open; the caller then takes the
|
|
555
|
+
* chain's head (`firstOfPrewarm`).
|
|
556
|
+
*
|
|
557
|
+
* A `best` job asks fontconfig for the face it would pick, which it answers
|
|
558
|
+
* in about half the time the whole chain takes (15 ms against 30 on a Linux
|
|
559
|
+
* desktop): no sort, one line. A layout that asks for a family warmed while
|
|
560
|
+
* its component rendered waits on the prewarm, and the chain was most of
|
|
561
|
+
* that wait.
|
|
562
|
+
*/
|
|
563
|
+
function bestOfPrewarm(entry) {
|
|
564
|
+
if (entry.first !== undefined) return entry.first;
|
|
565
|
+
const deadline = performance.now() + PREWARM_WAIT_MS;
|
|
566
|
+
let left = readPrewarm(entry.base);
|
|
567
|
+
while (left === null && !entry.exited && performance.now() < deadline) {
|
|
568
|
+
Atomics.wait(nap, 0, 0, 1);
|
|
569
|
+
left = readPrewarm(entry.base);
|
|
570
|
+
}
|
|
571
|
+
if (left === null) return null;
|
|
572
|
+
removePrewarm(entry.base);
|
|
573
|
+
const [best] = left.status === 0 ? parseMatches(left.out) : [];
|
|
574
|
+
entry.first = best ?? null;
|
|
575
|
+
return entry.first;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// how much of an answer's head a read takes at a time: a line is a face's
|
|
579
|
+
// path, names and coverage, a few KB at most
|
|
580
|
+
const HEAD_CHUNK = 16384;
|
|
581
|
+
|
|
582
|
+
/** The first line of an fc-match answer naming a face ntk can open, parsed;
|
|
583
|
+
* null if the file has none, or cannot be read. */
|
|
584
|
+
function firstCandidate(file) {
|
|
585
|
+
const fs = builtin('node:fs');
|
|
586
|
+
let fd;
|
|
587
|
+
try {
|
|
588
|
+
fd = fs.openSync(file, 'r');
|
|
589
|
+
} catch {
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
const decoder = new TextDecoder();
|
|
593
|
+
try {
|
|
594
|
+
let pending = new Uint8Array(0);
|
|
595
|
+
const chunk = new Uint8Array(HEAD_CHUNK);
|
|
596
|
+
for (;;) {
|
|
597
|
+
const read = fs.readSync(fd, chunk, 0, HEAD_CHUNK, null);
|
|
598
|
+
let bytes = pending;
|
|
599
|
+
if (read > 0) {
|
|
600
|
+
bytes = new Uint8Array(pending.length + read);
|
|
601
|
+
bytes.set(pending);
|
|
602
|
+
bytes.set(chunk.subarray(0, read), pending.length);
|
|
603
|
+
}
|
|
604
|
+
let start = 0;
|
|
605
|
+
// a newline never falls inside a UTF-8 sequence, so whole lines decode
|
|
606
|
+
for (let nl = bytes.indexOf(10, start); nl !== -1; nl = bytes.indexOf(10, start)) {
|
|
607
|
+
const [candidate] = parseMatches(decoder.decode(bytes.subarray(start, nl)));
|
|
608
|
+
if (candidate) return candidate;
|
|
609
|
+
start = nl + 1;
|
|
610
|
+
}
|
|
611
|
+
if (read <= 0) {
|
|
612
|
+
const [candidate] = parseMatches(decoder.decode(bytes.subarray(start)));
|
|
613
|
+
return candidate ?? null;
|
|
614
|
+
}
|
|
615
|
+
pending = bytes.slice(start);
|
|
616
|
+
}
|
|
617
|
+
} catch {
|
|
618
|
+
return null;
|
|
619
|
+
} finally {
|
|
620
|
+
fs.closeSync(fd);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
473
624
|
/**
|
|
474
625
|
* Seed the match cache for a pattern ahead of time, off the event loop.
|
|
475
626
|
*
|
|
@@ -491,22 +642,54 @@ function answerSync(fc, entry) {
|
|
|
491
642
|
* `matchSorted` is the reporting variant: same spawn, same cache, but it
|
|
492
643
|
* awaits an answer and so has somewhere to put a failure.
|
|
493
644
|
*
|
|
494
|
-
* @returns {Promise<void>} resolves once the
|
|
495
|
-
*
|
|
645
|
+
* @returns {Promise<void>} resolves once the answer is ready for the first
|
|
646
|
+
* layout that asks — in the cache, or in the prewarm's files — or the
|
|
647
|
+
* attempt abandoned
|
|
496
648
|
*/
|
|
497
649
|
export function prewarm(pattern = {}) {
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
650
|
+
return warm([patternFor(pattern)])[0];
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* `prewarm` for several patterns at once, started together in one child: the
|
|
655
|
+
* faces a caller knows text will be set in besides a family's four — a menu's
|
|
656
|
+
* medium. Patterns already cached or running are not asked again.
|
|
657
|
+
*
|
|
658
|
+
* @returns {Promise<void>} once every pattern's answer is ready or given
|
|
659
|
+
* up on; never rejects
|
|
660
|
+
*/
|
|
661
|
+
export function prewarmPatterns(patterns) {
|
|
662
|
+
return Promise.all(warm(patterns.map(patternFor))).then(() => {});
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* `prewarm` for a list of fontconfig patterns: the ones neither cached nor
|
|
667
|
+
* already running start together, in one child (`spawnToFiles`). A promise a
|
|
668
|
+
* pattern, resolving once its answer is ready or given up on; none rejects.
|
|
669
|
+
*/
|
|
670
|
+
function warm(fcs, best = null) {
|
|
671
|
+
if (unavailable) return fcs.map(() => Promise.resolve());
|
|
672
|
+
const fresh = [...new Set(fcs)].filter((fc) => !sortedCache.has(fc) && !inflight.has(fc));
|
|
673
|
+
// the best face only beside its own chain: once that is running or read,
|
|
674
|
+
// the chain's head answers as soon as a best job would
|
|
675
|
+
const bests = best !== null && fresh.includes(best) && !bestInflight.has(best) ? [best] : [];
|
|
676
|
+
if (fresh.length > 0) spawnToFiles(fresh, bests);
|
|
677
|
+
return fcs.map((fc) => {
|
|
678
|
+
if (sortedCache.has(fc)) return Promise.resolve();
|
|
679
|
+
// a prewarm's answer waits in its files for the first ask
|
|
680
|
+
const running = inflight.get(fc);
|
|
681
|
+
if (running?.base) return running.promise;
|
|
682
|
+
const pending = running?.promise ?? runFcMatch(fc);
|
|
683
|
+
return pending.then(
|
|
684
|
+
(out) => {
|
|
685
|
+
if (!sortedCache.has(fc)) {
|
|
686
|
+
const list = parseMatches(out);
|
|
687
|
+
if (list.length > 0) sortedCache.set(fc, list);
|
|
688
|
+
}
|
|
689
|
+
},
|
|
690
|
+
() => {}
|
|
691
|
+
);
|
|
692
|
+
});
|
|
510
693
|
}
|
|
511
694
|
|
|
512
695
|
// the faces a family is asked for in: regular, bold, and both in italic
|
|
@@ -524,14 +707,20 @@ const facesWarmed = new Set();
|
|
|
524
707
|
* A document asks for its family's faces one at a time, as layout reaches
|
|
525
708
|
* the first bold word, the first emphasis, and each ask that misses is a
|
|
526
709
|
* synchronous fc-match on the way to the first frame: a Markdown document
|
|
527
|
-
* waited on five of them, 600 ms on XQuartz. Started together
|
|
528
|
-
* by side, and each later ask finds its answer there.
|
|
710
|
+
* waited on five of them, 600 ms on XQuartz. Started together, from one
|
|
711
|
+
* child, they run side by side, and each later ask finds its answer there.
|
|
529
712
|
*/
|
|
530
713
|
export function prewarmFaces(pattern = {}) {
|
|
531
714
|
const family = pattern.family || 'sans-serif';
|
|
532
715
|
if (facesWarmed.has(family) || unavailable) return;
|
|
533
716
|
facesWarmed.add(family);
|
|
534
|
-
|
|
717
|
+
// the face a layout will ask for first gets a `best` job beside the chains:
|
|
718
|
+
// the one being asked for, or, warmed ahead, the regular
|
|
719
|
+
const first = patternFor(pattern.weight === undefined && pattern.style === undefined ? { family, weight: 400, style: 'normal' } : pattern);
|
|
720
|
+
warm(
|
|
721
|
+
FACES.map(([weight, style]) => patternFor({ family, weight, style })),
|
|
722
|
+
first
|
|
723
|
+
);
|
|
535
724
|
}
|
|
536
725
|
|
|
537
726
|
/**
|
|
@@ -561,9 +750,10 @@ export async function matchSorted(pattern = {}) {
|
|
|
561
750
|
let out;
|
|
562
751
|
const pending = inflight.get(fc);
|
|
563
752
|
if (pending?.base) {
|
|
564
|
-
// a prewarm
|
|
565
|
-
// a spawn of our own, which is what reports why
|
|
566
|
-
|
|
753
|
+
// a prewarm for the pattern: its answer once the child is gone, or — if
|
|
754
|
+
// it left none — a spawn of our own, which is what reports why
|
|
755
|
+
await pending.promise;
|
|
756
|
+
out = answerSync(fc, pending) ?? undefined;
|
|
567
757
|
}
|
|
568
758
|
if (out === undefined) {
|
|
569
759
|
try {
|
|
@@ -617,6 +807,34 @@ export function matchSortedSync(pattern) {
|
|
|
617
807
|
return cacheMatches(fc, out);
|
|
618
808
|
}
|
|
619
809
|
|
|
810
|
+
/**
|
|
811
|
+
* The best face for a pattern: `matchSortedSync(pattern)[0]`, without reading
|
|
812
|
+
* the rest of the fallback chain where a prewarm's answer is waiting in its
|
|
813
|
+
* file (`firstOfPrewarm`). A layout setting text in a face asks for this;
|
|
814
|
+
* the first character it has to fall back for reads the chain whole.
|
|
815
|
+
*
|
|
816
|
+
* @returns {{path, postscriptName, family: string, families: string[],
|
|
817
|
+
* charset: string}}
|
|
818
|
+
*/
|
|
819
|
+
export function matchFirstSync(pattern) {
|
|
820
|
+
const fc = patternFor(pattern);
|
|
821
|
+
const cached = sortedCache.get(fc);
|
|
822
|
+
if (cached) return cached[0];
|
|
823
|
+
if (unavailable) throw noFontsError(unavailable);
|
|
824
|
+
prewarmFaces(pattern);
|
|
825
|
+
const best = bestInflight.get(fc);
|
|
826
|
+
if (best) {
|
|
827
|
+
const first = bestOfPrewarm(best);
|
|
828
|
+
if (first) return first;
|
|
829
|
+
}
|
|
830
|
+
const pending = inflight.get(fc);
|
|
831
|
+
if (pending?.base) {
|
|
832
|
+
const first = firstOfPrewarm(pending);
|
|
833
|
+
if (first) return first;
|
|
834
|
+
}
|
|
835
|
+
return matchSortedSync(pattern)[0];
|
|
836
|
+
}
|
|
837
|
+
|
|
620
838
|
/**
|
|
621
839
|
* Resolve a font pattern ({family, weight, style}) to the best matching font
|
|
622
840
|
* file. Returns { path, postscriptName, family, families } or throws if
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
clipRingToRect,
|
|
34
34
|
clipTrianglesToRect,
|
|
35
35
|
rectsOnSurface,
|
|
36
|
+
WIRE_REACH,
|
|
36
37
|
withinRect,
|
|
37
38
|
} from "./cliprect.js";
|
|
38
39
|
import {
|
|
@@ -1035,6 +1036,13 @@ class RenderingContext2d {
|
|
|
1035
1036
|
}
|
|
1036
1037
|
|
|
1037
1038
|
_stylePicture(value) {
|
|
1039
|
+
if (typeof value === "string") {
|
|
1040
|
+
// by the string, where the app keeps that index (App#solidPictureOf);
|
|
1041
|
+
// an app that only answers solidPicture — a host's own, a test's
|
|
1042
|
+
// stand-in — takes the long way below
|
|
1043
|
+
const app = this.window.app;
|
|
1044
|
+
if (app.solidPictureOf) return app.solidPictureOf(value);
|
|
1045
|
+
}
|
|
1038
1046
|
if (typeof value === "string" || Array.isArray(value)) {
|
|
1039
1047
|
const c = parseColor(value);
|
|
1040
1048
|
return this.createSolidPicture(c[0], c[1], c[2], c[3]);
|
|
@@ -1885,9 +1893,10 @@ class RenderingContext2d {
|
|
|
1885
1893
|
) {
|
|
1886
1894
|
if (!shapes.length) return;
|
|
1887
1895
|
// Trapezoids are 16.16 fixed point, and a coordinate past 32,767 throws
|
|
1888
|
-
// out of the paint (lib/cliprect.js). A shape that reaches
|
|
1889
|
-
//
|
|
1890
|
-
// outside the picture is ever composited.
|
|
1896
|
+
// out of the paint (lib/cliprect.js). A shape that reaches that far is
|
|
1897
|
+
// cut to the picture first, a pixel to spare on every side: nothing
|
|
1898
|
+
// outside the picture is ever composited. One that does not goes as it
|
|
1899
|
+
// came, for the server to clip.
|
|
1891
1900
|
const x0 = -dx - 1;
|
|
1892
1901
|
const y0 = -dy - 1;
|
|
1893
1902
|
const x1 = w - dx + 1;
|
|
@@ -1895,7 +1904,7 @@ class RenderingContext2d {
|
|
|
1895
1904
|
let fitted = null;
|
|
1896
1905
|
for (let i = 0; i < shapes.length; i++) {
|
|
1897
1906
|
const shape = shapes[i];
|
|
1898
|
-
if (withinRect(shape,
|
|
1907
|
+
if (withinRect(shape, -WIRE_REACH - dx, -WIRE_REACH - dy, WIRE_REACH - dx, WIRE_REACH - dy)) {
|
|
1899
1908
|
if (fitted) fitted.push(shape);
|
|
1900
1909
|
continue;
|
|
1901
1910
|
}
|
|
@@ -2498,28 +2507,44 @@ class RenderingContext2d {
|
|
|
2498
2507
|
// cuts above are meant to keep the extruder off that path; this keeps
|
|
2499
2508
|
// a future miss from being catastrophic. A sum is finite iff every
|
|
2500
2509
|
// term is.
|
|
2510
|
+
//
|
|
2511
|
+
// A triangle wholly off the surface is left out as well: it adds
|
|
2512
|
+
// nothing on either route — the rasterizer would walk its rows only for
|
|
2513
|
+
// its edges to cancel on the border column, and the server would be
|
|
2514
|
+
// sent it only to clip it away — and a zoomed graph has hundreds of
|
|
2515
|
+
// edges past the window every frame.
|
|
2516
|
+
const right = this.width + 1;
|
|
2517
|
+
const bottom = this.height + 1;
|
|
2501
2518
|
let w = 0;
|
|
2502
2519
|
for (let i = 0; i < tris.length; i += 6) {
|
|
2503
|
-
const
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
if (
|
|
2520
|
+
const ax = tris[i];
|
|
2521
|
+
const ay = tris[i + 1];
|
|
2522
|
+
const bx = tris[i + 2];
|
|
2523
|
+
const by = tris[i + 3];
|
|
2524
|
+
const cx = tris[i + 4];
|
|
2525
|
+
const cy = tris[i + 5];
|
|
2526
|
+
if (!Number.isFinite(ax + ay + bx + by + cx + cy)) continue;
|
|
2527
|
+
if (
|
|
2528
|
+
(ax < -1 && bx < -1 && cx < -1) ||
|
|
2529
|
+
(ay < -1 && by < -1 && cy < -1) ||
|
|
2530
|
+
(ax > right && bx > right && cx > right) ||
|
|
2531
|
+
(ay > bottom && by > bottom && cy > bottom)
|
|
2532
|
+
) {
|
|
2533
|
+
continue;
|
|
2534
|
+
}
|
|
2511
2535
|
if (w < i) for (let k = 0; k < 6; k++) tris[w + k] = tris[i + k];
|
|
2512
2536
|
w += 6;
|
|
2513
2537
|
}
|
|
2514
2538
|
if (w < tris.length) tris.length = w;
|
|
2515
2539
|
if (!tris.length) return;
|
|
2516
2540
|
// Triangles are 16.16 fixed point, and the source origin a direct stroke
|
|
2517
|
-
// hands the server is 16 bits: a stroke that reaches
|
|
2518
|
-
//
|
|
2519
|
-
// what the surface shows is drawn exactly as before.
|
|
2541
|
+
// hands the server is 16 bits: a stroke that reaches that far is cut to
|
|
2542
|
+
// the surface first (lib/cliprect.js), a pixel to spare on every side, so
|
|
2543
|
+
// what the surface shows is drawn exactly as before. One that does not
|
|
2544
|
+
// goes as it came, for the server to clip.
|
|
2520
2545
|
const edgeX = this.width + 1;
|
|
2521
2546
|
const edgeY = this.height + 1;
|
|
2522
|
-
if (!withinRect(tris, -
|
|
2547
|
+
if (!withinRect(tris, -WIRE_REACH, -WIRE_REACH, WIRE_REACH, WIRE_REACH)) {
|
|
2523
2548
|
const cut = clipTrianglesToRect(tris, -1, -1, edgeX, edgeY);
|
|
2524
2549
|
tris.length = 0;
|
|
2525
2550
|
for (let i = 0; i < cut.length; i++) tris.push(cut[i]);
|