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 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 rectangle is clipped: everything else goes out
8
- // as it came, so a drawing that fits is sent exactly as before.
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 with its answer in a file, then —
330
- // once that file is complete — the exit status in another, which is what a
331
- // synchronous caller waits for (`answerSync`). Nothing but shell builtins
332
- // runs after fc-match, so a PATH that holds fc-match alone is enough.
333
- const PREWARM_SCRIPT = 'fc-match "$@" > "$0.out" 2> "$0.err"; echo $? > "$0.done"';
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 and answer a prewarm left, once it has left them; null while
368
- * it is still running. */
369
- function readPrewarm(base) {
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
- const status = Number(done);
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
- * Null where files cannot be used; the caller prewarms the old way.
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(fc) {
460
+ function spawnToFiles(fcs, bests = []) {
414
461
  const cp = childProcess();
415
- const base = cp ? prewarmBase() : null;
416
- if (base === null) return null;
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, base, ...fcMatchArgs, fc], {
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
- const entry = { promise: null, base, answer: undefined };
426
- entry.promise = new Promise((resolve, reject) => {
427
- const settle = () => {
428
- if (inflight.get(fc) === entry) inflight.delete(fc);
429
- // answered already, by a synchronous caller that could not wait
430
- if (entry.answer === undefined) {
431
- const left = readPrewarm(base);
432
- removePrewarm(base);
433
- entry.answer = left && left.status === 0 ? left.out : null;
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
- inflight.set(fc, entry);
442
- return entry;
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
- * An in-flight prewarm's answer, waited for synchronously: its stdout, or
455
- * null if it failed or took too long — the caller then spawns fc-match
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
- while (left === null && performance.now() < deadline) {
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 cache is seeded or the attempt
495
- * abandoned
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
- const fc = patternFor(pattern);
499
- if (sortedCache.has(fc) || unavailable) return Promise.resolve();
500
- const pending = inflight.get(fc)?.promise ?? spawnToFiles(fc)?.promise ?? runFcMatch(fc);
501
- return pending.then(
502
- (out) => {
503
- if (!sortedCache.has(fc)) {
504
- const list = parseMatches(out);
505
- if (list.length > 0) sortedCache.set(fc, list);
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 they run side
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
- for (const [weight, style] of FACES) prewarm({ family, weight, style });
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 running for the pattern: its answer, or — if it has none —
565
- // a spawn of our own, which is what reports why
566
- out = await pending.promise.catch(() => undefined);
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 past the
1889
- // picture is cut to it first, a pixel to spare on every side: nothing
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, x0, y0, x1, y1)) {
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 sum =
2504
- tris[i] +
2505
- tris[i + 1] +
2506
- tris[i + 2] +
2507
- tris[i + 3] +
2508
- tris[i + 4] +
2509
- tris[i + 5];
2510
- if (!Number.isFinite(sum)) continue;
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 past the surface is
2518
- // cut to it first (lib/cliprect.js), a pixel to spare on every side, so
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, -1, -1, edgeX, edgeY)) {
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]);