ntk 8.13.0 → 8.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/fontconfig.js CHANGED
@@ -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
@@ -29,6 +29,13 @@ import { formatForDepth } from "./pictformat.js";
29
29
  import Pixmap from "./pixmap.js";
30
30
  import { REGION_DOCS, regionId } from "./region.js";
31
31
  import { routeRaster } from "./rasterize.js";
32
+ import {
33
+ clipRingToRect,
34
+ clipTrianglesToRect,
35
+ rectsOnSurface,
36
+ WIRE_REACH,
37
+ withinRect,
38
+ } from "./cliprect.js";
32
39
  import {
33
40
  blurCoverage,
34
41
  cachedShadow,
@@ -1029,6 +1036,13 @@ class RenderingContext2d {
1029
1036
  }
1030
1037
 
1031
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
+ }
1032
1046
  if (typeof value === "string" || Array.isArray(value)) {
1033
1047
  const c = parseColor(value);
1034
1048
  return this.createSolidPicture(c[0], c[1], c[2], c[3]);
@@ -1868,9 +1882,37 @@ class RenderingContext2d {
1868
1882
  });
1869
1883
  }
1870
1884
 
1871
- _rasterizePolys(picture, shapes, rule, dx = 0, dy = 0) {
1885
+ _rasterizePolys(
1886
+ picture,
1887
+ shapes,
1888
+ rule,
1889
+ dx = 0,
1890
+ dy = 0,
1891
+ w = this.width,
1892
+ h = this.height,
1893
+ ) {
1872
1894
  if (!shapes.length) return;
1873
- const traps = trapezoidize(shapes, dx, dy, [], rule);
1895
+ // Trapezoids are 16.16 fixed point, and a coordinate past 32,767 throws
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.
1900
+ const x0 = -dx - 1;
1901
+ const y0 = -dy - 1;
1902
+ const x1 = w - dx + 1;
1903
+ const y1 = h - dy + 1;
1904
+ let fitted = null;
1905
+ for (let i = 0; i < shapes.length; i++) {
1906
+ const shape = shapes[i];
1907
+ if (withinRect(shape, -WIRE_REACH - dx, -WIRE_REACH - dy, WIRE_REACH - dx, WIRE_REACH - dy)) {
1908
+ if (fitted) fitted.push(shape);
1909
+ continue;
1910
+ }
1911
+ if (!fitted) fitted = shapes.slice(0, i);
1912
+ const ring = clipRingToRect(shape, x0, y0, x1, y1);
1913
+ if (ring.length >= 6) fitted.push(ring);
1914
+ }
1915
+ const traps = trapezoidize(fitted ?? shapes, dx, dy, [], rule);
1874
1916
  // stay under the server's maximum request size
1875
1917
  const chunk = 4000 * 6;
1876
1918
  for (let i = 0; i < traps.length; i += chunk) {
@@ -2465,21 +2507,49 @@ class RenderingContext2d {
2465
2507
  // cuts above are meant to keep the extruder off that path; this keeps
2466
2508
  // a future miss from being catastrophic. A sum is finite iff every
2467
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;
2468
2518
  let w = 0;
2469
2519
  for (let i = 0; i < tris.length; i += 6) {
2470
- const sum =
2471
- tris[i] +
2472
- tris[i + 1] +
2473
- tris[i + 2] +
2474
- tris[i + 3] +
2475
- tris[i + 4] +
2476
- tris[i + 5];
2477
- 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
+ }
2478
2535
  if (w < i) for (let k = 0; k < 6; k++) tris[w + k] = tris[i + k];
2479
2536
  w += 6;
2480
2537
  }
2481
2538
  if (w < tris.length) tris.length = w;
2482
2539
  if (!tris.length) return;
2540
+ // Triangles are 16.16 fixed point, and the source origin a direct stroke
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.
2545
+ const edgeX = this.width + 1;
2546
+ const edgeY = this.height + 1;
2547
+ if (!withinRect(tris, -WIRE_REACH, -WIRE_REACH, WIRE_REACH, WIRE_REACH)) {
2548
+ const cut = clipTrianglesToRect(tris, -1, -1, edgeX, edgeY);
2549
+ tris.length = 0;
2550
+ for (let i = 0; i < cut.length; i++) tris.push(cut[i]);
2551
+ if (!tris.length) return;
2552
+ }
2483
2553
 
2484
2554
  const op = this._op();
2485
2555
  // round-cap/join disks overlap the stroke body; overlapping coverage
@@ -3018,6 +3088,20 @@ class RenderingContext2d {
3018
3088
  * for the whole list and then draws the rectangles through here */
3019
3089
  _fillRect(x, y, w, h) {
3020
3090
  if (matIsIdentity(this._m)) {
3091
+ // X coordinates and sizes are 16 bits, and a coordinate past 32,767
3092
+ // throws out of the paint (lib/cliprect.js): a rectangle that reaches
3093
+ // past the surface is filled as the part of it the surface has.
3094
+ if (x < 0 || y < 0 || x + w > this.width || y + h > this.height) {
3095
+ const x0 = Math.max(x, 0);
3096
+ const y0 = Math.max(y, 0);
3097
+ const x1 = Math.min(x + w, this.width);
3098
+ const y1 = Math.min(y + h, this.height);
3099
+ if (!(x1 > x0 && y1 > y0)) return;
3100
+ x = x0;
3101
+ y = y0;
3102
+ w = x1 - x0;
3103
+ h = y1 - y0;
3104
+ }
3021
3105
  if (!prepareStyle(this._backgroundPicture, this._m)) return;
3022
3106
  const op = this._op();
3023
3107
  // A rectangular clip is a smaller rectangle to fill, not a mask — this
@@ -3113,6 +3197,10 @@ class RenderingContext2d {
3113
3197
  }
3114
3198
  if (this._hasPolyClip) return;
3115
3199
  if (clip && (clip.w === 0 || clip.h === 0)) return; // clipped away
3200
+ // the part of each the surface has, as `_fillRect` fills it: the wire's
3201
+ // coordinates are 16 bits
3202
+ const onSurface = rectsOnSurface(flat, this.width, this.height);
3203
+ if (!onSurface.length) return;
3116
3204
  const R = this.Render;
3117
3205
  if (clip) this._setPictureClip(clip);
3118
3206
  // globalAlpha folds into the premultiplied colour — scaling all four
@@ -3122,12 +3210,14 @@ class RenderingContext2d {
3122
3210
  const color = this._foldedColor(this._fillStyle);
3123
3211
  // stay under the server's maximum request size
3124
3212
  const chunk = 10000 * 4;
3125
- for (let i = 0; i < flat.length; i += chunk) {
3213
+ for (let i = 0; i < onSurface.length; i += chunk) {
3126
3214
  R.FillRectangles(
3127
3215
  this._op(),
3128
3216
  this._dst(),
3129
3217
  color,
3130
- i === 0 && flat.length <= chunk ? flat : flat.slice(i, i + chunk),
3218
+ i === 0 && onSurface.length <= chunk
3219
+ ? onSurface
3220
+ : onSurface.slice(i, i + chunk),
3131
3221
  );
3132
3222
  }
3133
3223
  if (clip) this._invalidatePictureClip();
@@ -3166,10 +3256,13 @@ class RenderingContext2d {
3166
3256
  let x1 = -Infinity;
3167
3257
  let y1 = -Infinity;
3168
3258
  for (let i = 0; i < flat.length; i += 4) {
3169
- const x = Math.trunc(flat[i]);
3170
- const y = Math.trunc(flat[i + 1]);
3171
- const w = Math.trunc(flat[i + 2]);
3172
- const h = Math.trunc(flat[i + 3]);
3259
+ // the part of it on the surface: the wire's coordinates are 16 bits
3260
+ const left = Math.trunc(flat[i]);
3261
+ const top = Math.trunc(flat[i + 1]);
3262
+ const x = Math.max(0, left);
3263
+ const y = Math.max(0, top);
3264
+ const w = Math.min(this.width, left + Math.trunc(flat[i + 2])) - x;
3265
+ const h = Math.min(this.height, top + Math.trunc(flat[i + 3])) - y;
3173
3266
  if (w <= 0 || h <= 0) continue;
3174
3267
  rects.push(x, y, w, h);
3175
3268
  if (x < x0) x0 = x;
@@ -3953,6 +4046,8 @@ class RenderingContext2d {
3953
4046
  entry.rule,
3954
4047
  -bb.x,
3955
4048
  -bb.y,
4049
+ bb.w,
4050
+ bb.h,
3956
4051
  );
3957
4052
  }
3958
4053
  R.Composite(