ntk 8.13.1 → 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/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/font.js +31 -1
- package/lib/text/fontmanager.js +34 -3
- package/lib/text/fontsource.js +25 -0
- package/lib/text/layout.js +67 -23
- package/lib/text/marks.js +212 -0
- 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]);
|
package/lib/text/font.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fontkit from 'fontkit';
|
|
2
2
|
|
|
3
3
|
import { flatten, rasterizePath } from '../rasterize.js';
|
|
4
|
+
import { marksCover, standInMarks } from './marks.js';
|
|
4
5
|
|
|
5
6
|
// Axis coordinates are rounded to this many decimals before anything is
|
|
6
7
|
// instantiated or cached. Every distinct coordinate is a font in its own
|
|
@@ -122,6 +123,10 @@ export default class Font {
|
|
|
122
123
|
this._upem = 0;
|
|
123
124
|
this._vertical = null;
|
|
124
125
|
this._space = -1;
|
|
126
|
+
// the face's mark lookups while they are stood in for (./marks.js), set
|
|
127
|
+
// at its first shaping; null once the real ones are back, or never were
|
|
128
|
+
// stood in for
|
|
129
|
+
this._marks = undefined;
|
|
125
130
|
}
|
|
126
131
|
|
|
127
132
|
static loadSync(path, postscriptName) {
|
|
@@ -339,7 +344,7 @@ export default class Font {
|
|
|
339
344
|
* ax = advance, dx/dy = drawing offset from pen position (y up = positive dy)
|
|
340
345
|
*/
|
|
341
346
|
shape(text, size, opts = {}) {
|
|
342
|
-
const run = this.
|
|
347
|
+
const run = this._layout(text, opts);
|
|
343
348
|
const s = this.scale(size);
|
|
344
349
|
const glyphs = new Array(run.glyphs.length);
|
|
345
350
|
let width = 0;
|
|
@@ -358,6 +363,31 @@ export default class Font {
|
|
|
358
363
|
return { font: this, size, direction: run.direction, width, glyphs };
|
|
359
364
|
}
|
|
360
365
|
|
|
366
|
+
/**
|
|
367
|
+
* fontkit's layout, with the face's mark lookups stood in for until a run
|
|
368
|
+
* holds a glyph they act at.
|
|
369
|
+
*
|
|
370
|
+
* fontkit decodes a feature's lookups the first time a run asks for it,
|
|
371
|
+
* and a face's mark attachment can be most of its tables: 14 ms of Noto
|
|
372
|
+
* Sans' first shaping, in the first frame. Until a run needs them the
|
|
373
|
+
* lookups are empty stand-ins (./marks.js), which a run holding none of
|
|
374
|
+
* their glyphs comes out the same with. A run that holds one puts the
|
|
375
|
+
* real ones back and is shaped again, and so is every run after it.
|
|
376
|
+
*/
|
|
377
|
+
_layout(text, opts) {
|
|
378
|
+
const { features, script, language, direction } = opts;
|
|
379
|
+
if (this._marks === undefined) this._marks = standInMarks(this.fk);
|
|
380
|
+
const marks = this._marks;
|
|
381
|
+
// fontkit adds to the features object it is handed: a second shaping
|
|
382
|
+
// gets what the caller asked for, not what the first left there
|
|
383
|
+
const again = marks === null || features == null ? features : Array.isArray(features) ? [...features] : { ...features };
|
|
384
|
+
const run = this.fk.layout(text, features, script, language, direction);
|
|
385
|
+
if (marks === null || !run.glyphs.some((glyph) => marksCover(marks.bits, glyph.id))) return run;
|
|
386
|
+
marks.restore();
|
|
387
|
+
this._marks = null;
|
|
388
|
+
return this.fk.layout(text, again, script, language, direction);
|
|
389
|
+
}
|
|
390
|
+
|
|
361
391
|
/** nominal (unshaped) advance of a glyph id, in pixels */
|
|
362
392
|
advanceOf(glyphId, size) {
|
|
363
393
|
return this.fk.getGlyph(glyphId).advanceWidth * this.scale(size);
|
package/lib/text/fontmanager.js
CHANGED
|
@@ -227,6 +227,32 @@ export default class FontManager {
|
|
|
227
227
|
return this._source ?? defaultFontSource();
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Have `family` ready before a layout asks for it: a source that resolves
|
|
232
|
+
* families by asking the system (fontconfig) starts on its faces now, off
|
|
233
|
+
* the event loop, so the first layout in that family takes the answer
|
|
234
|
+
* instead of stalling for it. For a family the caller knows is coming — a
|
|
235
|
+
* code editor's monospace, set before it renders. A source with nothing to
|
|
236
|
+
* look up ignores it, and it never throws.
|
|
237
|
+
*
|
|
238
|
+
* `faces`, each `{ weight, style }`, names the faces to start on instead
|
|
239
|
+
* of the family's regular, bold, italic and bold italic: a face outside
|
|
240
|
+
* those that the caller knows text will be set in, such as a menu's
|
|
241
|
+
* medium. The family is spelled the way `match` spells it, so a list or a
|
|
242
|
+
* quoted name warms the pattern a layout will ask for.
|
|
243
|
+
*/
|
|
244
|
+
prewarm(family, faces) {
|
|
245
|
+
const spelled = familiesOf(family).join(',');
|
|
246
|
+
if (!spelled) return;
|
|
247
|
+
this.source.prewarm?.(
|
|
248
|
+
spelled,
|
|
249
|
+
faces?.map(({ weight, style }) => ({
|
|
250
|
+
weight: numWeight(weight),
|
|
251
|
+
style: style?.includes('italic') ? 'italic' : 'normal'
|
|
252
|
+
}))
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
230
256
|
/** open (and cache) a match candidate — see fontsource.js for the shape */
|
|
231
257
|
_open(candidate) {
|
|
232
258
|
const key = candidate.key ?? `${candidate.path}#${candidate.postscriptName || ''}`;
|
|
@@ -333,9 +359,14 @@ export default class FontManager {
|
|
|
333
359
|
italic
|
|
334
360
|
);
|
|
335
361
|
if (!face) {
|
|
336
|
-
// sources understand comma-separated family lists natively
|
|
337
|
-
|
|
338
|
-
face
|
|
362
|
+
// sources understand comma-separated family lists natively; one that
|
|
363
|
+
// can answer the best face alone saves reading the fallback chain the
|
|
364
|
+
// face may never need
|
|
365
|
+
const pattern = patternOf(family, weight, italic);
|
|
366
|
+
const source = this.source;
|
|
367
|
+
face = this._open(
|
|
368
|
+
typeof source.matchFirst === 'function' ? source.matchFirst(pattern) : source.matchSorted(pattern)[0]
|
|
369
|
+
);
|
|
339
370
|
}
|
|
340
371
|
this._matches.set(cacheKey, face);
|
|
341
372
|
// A long-lived app can name a lot of families. Same sweep as the shaping
|
package/lib/text/fontsource.js
CHANGED
|
@@ -48,10 +48,12 @@
|
|
|
48
48
|
import { builtin } from '../builtin.js';
|
|
49
49
|
import {
|
|
50
50
|
charsetHas,
|
|
51
|
+
matchFirstSync,
|
|
51
52
|
matchSorted,
|
|
52
53
|
matchSortedSync,
|
|
53
54
|
noFontsError,
|
|
54
55
|
prewarmFaces,
|
|
56
|
+
prewarmPatterns,
|
|
55
57
|
supported
|
|
56
58
|
} from '../fontconfig.js';
|
|
57
59
|
import Font from './font.js';
|
|
@@ -129,6 +131,29 @@ export class FontconfigFontSource {
|
|
|
129
131
|
return matchSortedSync(pattern);
|
|
130
132
|
}
|
|
131
133
|
|
|
134
|
+
/**
|
|
135
|
+
* `matchSorted(pattern)[0]`, reading only the head of a prewarm's answer
|
|
136
|
+
* where one is waiting (`matchFirstSync` in fontconfig.js): a face's
|
|
137
|
+
* match needs its first candidate, and the fallback chain behind it is
|
|
138
|
+
* read when a character first falls back.
|
|
139
|
+
*/
|
|
140
|
+
matchFirst(pattern) {
|
|
141
|
+
return matchFirstSync(pattern);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The family's faces — regular, bold, and both in italic, or the `faces`
|
|
146
|
+
* named, each `{ weight, style }` — matched off the event loop, as the
|
|
147
|
+
* constructor does for sans-serif, and answerable by a layout that asks
|
|
148
|
+
* before the loop runs again (see `prewarm` in fontconfig.js). A pattern
|
|
149
|
+
* already cached or running is not asked again; never rejects and never
|
|
150
|
+
* reports.
|
|
151
|
+
*/
|
|
152
|
+
prewarm(family, faces) {
|
|
153
|
+
if (faces) prewarmPatterns(faces.map(({ weight, style }) => ({ family, weight, style })));
|
|
154
|
+
else prewarmFaces({ family });
|
|
155
|
+
}
|
|
156
|
+
|
|
132
157
|
/**
|
|
133
158
|
* Non-blocking sibling: the fc-match spawn runs off the event loop and
|
|
134
159
|
* seeds the same cache, so a later synchronous layout for the pattern is
|
package/lib/text/layout.js
CHANGED
|
@@ -407,7 +407,7 @@ export class TextLayout {
|
|
|
407
407
|
const shaped = this.fonts._shapeCached(fragText, shaping, fragLevels);
|
|
408
408
|
// the levels ride along so a later split can re-shape a piece at the
|
|
409
409
|
// level it actually has, rather than assuming ltr
|
|
410
|
-
fragments.push({ text: fragText, span, shaped, start: pos, levels: fragLevels });
|
|
410
|
+
fragments.push({ text: fragText, span, shaping, shaped, start: pos, levels: fragLevels });
|
|
411
411
|
width += shaped.width;
|
|
412
412
|
}
|
|
413
413
|
pos = fragEnd;
|
|
@@ -497,33 +497,47 @@ export class TextLayout {
|
|
|
497
497
|
used += frag.shaped.width;
|
|
498
498
|
continue;
|
|
499
499
|
}
|
|
500
|
-
//
|
|
500
|
+
// shaped at the bidi level this text actually has: assuming level 0
|
|
501
|
+
// here re-shapes an rtl word as ltr, which lays its glyphs out
|
|
502
|
+
// backwards and hands reorderRuns an even level that stops it from
|
|
503
|
+
// being reordered at all
|
|
504
|
+
const shaping = frag.shaping ?? frag.span;
|
|
505
|
+
const shape = (text, from, to) =>
|
|
506
|
+
this.fonts._shapeCached(text, shaping, sliceLevels(frag.levels, from, to));
|
|
507
|
+
let best = null;
|
|
501
508
|
// Graphemes rather than code points: cutting between a base character
|
|
502
509
|
// and its combining mark, or inside an emoji ZWJ sequence, leaves a
|
|
503
510
|
// dotted circle or a pair of half-emoji on the two sides of the break.
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
hi = mid - 1;
|
|
511
|
+
const lead = firstGrapheme(frag.text);
|
|
512
|
+
if (used + shape(lead, 0, lead.length).width <= maxWidth) {
|
|
513
|
+
// binary search the longest grapheme prefix of this fragment that fits
|
|
514
|
+
const cps = graphemes(frag.text);
|
|
515
|
+
let lo = 0;
|
|
516
|
+
let hi = cps.length - 1;
|
|
517
|
+
while (lo <= hi) {
|
|
518
|
+
const mid = (lo + hi) >> 1;
|
|
519
|
+
const prefix = cps.slice(0, mid + 1).join('');
|
|
520
|
+
const shaped = shape(prefix, 0, prefix.length);
|
|
521
|
+
if (used + shaped.width <= maxWidth) {
|
|
522
|
+
best = { len: prefix.length, shaped, text: prefix };
|
|
523
|
+
lo = mid + 1;
|
|
524
|
+
} else {
|
|
525
|
+
hi = mid - 1;
|
|
526
|
+
}
|
|
521
527
|
}
|
|
528
|
+
} else if (!headFrags.length) {
|
|
529
|
+
// Not even its first cluster fits, so nothing of the token does: the
|
|
530
|
+
// caller lets it overflow whole. That is every word at width 0, where
|
|
531
|
+
// a layout is asked for the narrowest it can be, and the search found
|
|
532
|
+
// it out by segmenting the whole word and shaping a dozen prefixes of
|
|
533
|
+
// it, each a word the memo had never seen.
|
|
534
|
+
return [null, token];
|
|
522
535
|
}
|
|
523
536
|
if (best) {
|
|
524
537
|
headFrags.push({
|
|
525
538
|
text: best.text,
|
|
526
539
|
span: frag.span,
|
|
540
|
+
shaping,
|
|
527
541
|
shaped: best.shaped,
|
|
528
542
|
start: frag.start,
|
|
529
543
|
levels: sliceLevels(frag.levels, 0, best.len)
|
|
@@ -538,7 +552,8 @@ export class TextLayout {
|
|
|
538
552
|
restFrags.push({
|
|
539
553
|
text: restText,
|
|
540
554
|
span: frag.span,
|
|
541
|
-
|
|
555
|
+
shaping,
|
|
556
|
+
shaped: this.fonts._shapeCached(restText, shaping, restLevels),
|
|
542
557
|
start: frag.start + cut,
|
|
543
558
|
levels: restLevels
|
|
544
559
|
});
|
|
@@ -958,19 +973,48 @@ export class TextLayout {
|
|
|
958
973
|
// somehow absent, code points are the old behaviour and still safe for the
|
|
959
974
|
// scripts that reach a force-break most often.
|
|
960
975
|
let segmenter;
|
|
961
|
-
function
|
|
976
|
+
function graphemeSegmenter() {
|
|
962
977
|
if (segmenter === undefined) {
|
|
963
978
|
segmenter =
|
|
964
979
|
typeof Intl !== 'undefined' && Intl.Segmenter
|
|
965
980
|
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
966
981
|
: null;
|
|
967
982
|
}
|
|
968
|
-
|
|
983
|
+
return segmenter;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Two ASCII characters are never one cluster, bar a CR before a LF (UAX#29
|
|
987
|
+
// GB3): whatever extends a cluster or joins into one — marks, joiners,
|
|
988
|
+
// spacing marks, prepends — sits above U+02FF. So text of nothing else,
|
|
989
|
+
// which is most of what a document cuts, needs no segmenter: it costs a
|
|
990
|
+
// microsecond a call, and the width floors ask once a word.
|
|
991
|
+
function plainAscii(text) {
|
|
992
|
+
for (let i = 0; i < text.length; i++) {
|
|
993
|
+
const c = text.charCodeAt(i);
|
|
994
|
+
if (c >= 0x80 || (c === 13 && text.charCodeAt(i + 1) === 10)) return false;
|
|
995
|
+
}
|
|
996
|
+
return true;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function graphemes(text) {
|
|
1000
|
+
if (plainAscii(text)) return text.split('');
|
|
1001
|
+
const segments = graphemeSegmenter();
|
|
1002
|
+
if (!segments) return Array.from(text);
|
|
969
1003
|
const out = [];
|
|
970
|
-
for (const { segment } of
|
|
1004
|
+
for (const { segment } of segments.segment(text)) out.push(segment);
|
|
971
1005
|
return out;
|
|
972
1006
|
}
|
|
973
1007
|
|
|
1008
|
+
/** The first grapheme cluster of `text`, without segmenting the rest. */
|
|
1009
|
+
function firstGrapheme(text) {
|
|
1010
|
+
const c0 = text.charCodeAt(0);
|
|
1011
|
+
if (c0 < 0x80 && c0 !== 13 && !(text.charCodeAt(1) >= 0x80)) return text.slice(0, 1);
|
|
1012
|
+
const segments = graphemeSegmenter();
|
|
1013
|
+
if (!segments) return String.fromCodePoint(text.codePointAt(0));
|
|
1014
|
+
for (const { segment } of segments.segment(text)) return segment;
|
|
1015
|
+
return '';
|
|
1016
|
+
}
|
|
1017
|
+
|
|
974
1018
|
// UTF-16 length of a glyph cluster's codePoints array
|
|
975
1019
|
function cuLength(codePoints) {
|
|
976
1020
|
let len = 0;
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// The glyphs a face's mark positioning can move, read from the GPOS table's
|
|
2
|
+
// own bytes, so that its lookups need not be decoded until a run holds one.
|
|
3
|
+
//
|
|
4
|
+
// fontkit decodes a lookup whole the first time a run asks for its feature,
|
|
5
|
+
// and `mark` is the one that costs: Noto Sans' mark-to-base subtable holds an
|
|
6
|
+
// anchor for every base glyph in every mark class, and decoding it took 14 ms
|
|
7
|
+
// of each face's first shaping (21 ms in all), inside the first frame of
|
|
8
|
+
// every app whose sans-serif is Noto. A line of Latin UI text has no glyph
|
|
9
|
+
// any of those lookups acts on.
|
|
10
|
+
//
|
|
11
|
+
// So each `mark` and `mkmk` lookup is stood in for, in fontkit's cache of the
|
|
12
|
+
// face's lookups, by an empty one (`standInMarks`): a run applies the stand-in
|
|
13
|
+
// and decodes nothing, and fontkit is handed the caller's features as they
|
|
14
|
+
// are. A run that turns out to hold a glyph one of the real lookups acts at
|
|
15
|
+
// puts them back and is shaped again (`Font#_layout`).
|
|
16
|
+
//
|
|
17
|
+
// That is exact rather than a guess about scripts. Every GPOS lookup acts
|
|
18
|
+
// only at a glyph in its first coverage — the mark coverage of a mark
|
|
19
|
+
// attachment, the first glyph's of a pair, the first input's of a context —
|
|
20
|
+
// wherever it is reached from: its feature, another feature that names it,
|
|
21
|
+
// the feature table a variation swaps in, a context that nests it. A run
|
|
22
|
+
// holding no glyph from the coverage of any stood-in lookup is one the real
|
|
23
|
+
// lookups would have left as it is.
|
|
24
|
+
//
|
|
25
|
+
// An earlier cut left the two features out through fontkit's own feature
|
|
26
|
+
// overrides instead. That was exact too, and cost every shaping a pass over
|
|
27
|
+
// the overrides that deletes the tags from the plan: 1-3 us a word, 6% of a
|
|
28
|
+
// frame scrolling a long document into text it had not shaped.
|
|
29
|
+
//
|
|
30
|
+
// Null — shape as fontkit shapes — for a face that has nothing to stand in
|
|
31
|
+
// for, that fontkit shapes through AAT's morx rather than GPOS, or whose
|
|
32
|
+
// tables this cannot read to the end.
|
|
33
|
+
|
|
34
|
+
const TAGS = new Set(['mark', 'mkmk']);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {object} fk a fontkit font
|
|
38
|
+
* @returns {{ bits: Uint8Array, lookups: Set<number> }|null} the `mark` and
|
|
39
|
+
* `mkmk` lookups, and a bit per glyph id, set for every glyph one of them
|
|
40
|
+
* can act at; null where none can be stood in for
|
|
41
|
+
*/
|
|
42
|
+
export function markGlyphs(fk) {
|
|
43
|
+
try {
|
|
44
|
+
if (fk.directory?.tables?.morx) return null;
|
|
45
|
+
const gpos = table(fk, 'GPOS');
|
|
46
|
+
if (!gpos) return null;
|
|
47
|
+
const lookups = markLookups(gpos);
|
|
48
|
+
if (lookups.size === 0) return null;
|
|
49
|
+
const bits = new Uint8Array(8192);
|
|
50
|
+
const lookupList = gpos.offset(8, 0);
|
|
51
|
+
for (const index of lookups) {
|
|
52
|
+
if (!coverLookup(gpos, lookupList, index, bits)) return null;
|
|
53
|
+
}
|
|
54
|
+
return { bits, lookups };
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Stand an empty lookup in for each `mark` and `mkmk` lookup of a face, in
|
|
62
|
+
* fontkit's cache of its GPOS lookups (restructure's lazy array, which
|
|
63
|
+
* decodes an index the first time it is asked and keeps it in `items`).
|
|
64
|
+
* Applied, a stand-in has no subtables and moves nothing.
|
|
65
|
+
*
|
|
66
|
+
* @param {object} fk a fontkit font, before its first layout
|
|
67
|
+
* @returns {{ bits: Uint8Array, restore: () => void }|null} `restore` puts the
|
|
68
|
+
* real lookups back, to be decoded by the next run that asks; null where
|
|
69
|
+
* there is nothing to stand in for, where fontkit's cache is not the shape
|
|
70
|
+
* this knows, or where a lookup was decoded already
|
|
71
|
+
*/
|
|
72
|
+
export function standInMarks(fk) {
|
|
73
|
+
const marks = markGlyphs(fk);
|
|
74
|
+
if (marks === null) return null;
|
|
75
|
+
let list;
|
|
76
|
+
try {
|
|
77
|
+
list = fk.GPOS?.lookupList;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
if (!list || typeof list.get !== 'function' || !Array.isArray(list.items)) return null;
|
|
82
|
+
for (const index of marks.lookups) if (list.items[index] != null) return null;
|
|
83
|
+
const standIn = { lookupType: 0, flags: {}, subTableCount: 0, subTables: [] };
|
|
84
|
+
for (const index of marks.lookups) list.items[index] = standIn;
|
|
85
|
+
return {
|
|
86
|
+
bits: marks.bits,
|
|
87
|
+
restore() {
|
|
88
|
+
for (const index of marks.lookups) {
|
|
89
|
+
if (list.items[index] === standIn) list.items[index] = null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Whether `bits` (from `markGlyphs`) has glyph `id`. */
|
|
96
|
+
export function marksCover(bits, id) {
|
|
97
|
+
return (bits[id >> 3] & (1 << (id & 7))) !== 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A table's bytes, read big-endian at positions from its start. `offset(pos,
|
|
102
|
+
* base)` reads the 16-bit offset stored at `pos` and answers where it
|
|
103
|
+
* points, `base` being what the format measures it from.
|
|
104
|
+
*/
|
|
105
|
+
function table(fk, tag) {
|
|
106
|
+
const entry = fk.directory?.tables?.[tag];
|
|
107
|
+
if (!entry) return null;
|
|
108
|
+
const stream = fk._getTableStream(tag);
|
|
109
|
+
if (!stream) return null;
|
|
110
|
+
const buf = stream.buffer;
|
|
111
|
+
const start = stream.pos;
|
|
112
|
+
const end = Math.min(start + entry.length, buf.length);
|
|
113
|
+
const u16 = (pos) => {
|
|
114
|
+
const p = start + pos;
|
|
115
|
+
if (pos < 0 || p + 2 > end) throw new RangeError(`${tag} read past its end`);
|
|
116
|
+
return (buf[p] << 8) | buf[p + 1];
|
|
117
|
+
};
|
|
118
|
+
const u32 = (pos) => u16(pos) * 65536 + u16(pos + 2);
|
|
119
|
+
const offset = (pos, base) => base + u16(pos);
|
|
120
|
+
return { u16, u32, offset };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The lookup indices of every `mark` and `mkmk` feature record, in any
|
|
125
|
+
* script: a superset of what fontkit applies, which only widens what
|
|
126
|
+
* counts as a mark.
|
|
127
|
+
*/
|
|
128
|
+
function markLookups(t) {
|
|
129
|
+
const list = t.offset(6, 0);
|
|
130
|
+
const lookups = new Set();
|
|
131
|
+
for (let i = 0, n = t.u16(list); i < n; i++) {
|
|
132
|
+
const rec = list + 2 + i * 6;
|
|
133
|
+
const tag = String.fromCharCode(t.u16(rec) >> 8, t.u16(rec) & 255, t.u16(rec + 2) >> 8, t.u16(rec + 2) & 255);
|
|
134
|
+
if (!TAGS.has(tag)) continue;
|
|
135
|
+
const feature = t.offset(rec + 4, list);
|
|
136
|
+
for (let j = 0, m = t.u16(feature + 2); j < m; j++) lookups.add(t.u16(feature + 4 + j * 2));
|
|
137
|
+
}
|
|
138
|
+
return lookups;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Adds the first coverage of each of a lookup's subtables; false for one it cannot read. */
|
|
142
|
+
function coverLookup(t, lookupList, index, bits) {
|
|
143
|
+
if (index >= t.u16(lookupList)) return false;
|
|
144
|
+
const lookup = t.offset(lookupList + 2 + index * 2, lookupList);
|
|
145
|
+
const type = t.u16(lookup);
|
|
146
|
+
for (let i = 0, n = t.u16(lookup + 4); i < n; i++) {
|
|
147
|
+
let sub = t.offset(lookup + 6 + i * 2, lookup);
|
|
148
|
+
let subType = type;
|
|
149
|
+
if (subType === 9) {
|
|
150
|
+
// an extension: the real subtable, at a 32-bit offset from here
|
|
151
|
+
if (t.u16(sub) !== 1) return false;
|
|
152
|
+
subType = t.u16(sub + 2);
|
|
153
|
+
sub += t.u32(sub + 4);
|
|
154
|
+
}
|
|
155
|
+
const coverage = firstCoverage(t, subType, sub);
|
|
156
|
+
if (coverage < 0) return false;
|
|
157
|
+
addCoverage(t, coverage, bits);
|
|
158
|
+
}
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Where the coverage a subtable is entered from starts, or -1 for a
|
|
164
|
+
* subtable this does not know.
|
|
165
|
+
*/
|
|
166
|
+
function firstCoverage(t, type, sub) {
|
|
167
|
+
const format = t.u16(sub);
|
|
168
|
+
switch (type) {
|
|
169
|
+
case 1: // single adjustment
|
|
170
|
+
case 2: // pair adjustment: the pair's first glyph
|
|
171
|
+
return format === 1 || format === 2 ? t.offset(sub + 2, sub) : -1;
|
|
172
|
+
case 3: // cursive attachment
|
|
173
|
+
case 4: // mark to base: the mark
|
|
174
|
+
case 5: // mark to ligature: the mark
|
|
175
|
+
case 6: // mark to mark: the mark that attaches
|
|
176
|
+
return format === 1 ? t.offset(sub + 2, sub) : -1;
|
|
177
|
+
case 7: // context
|
|
178
|
+
if (format === 1 || format === 2) return t.offset(sub + 2, sub);
|
|
179
|
+
// the first input glyph's, after the glyph and lookup counts
|
|
180
|
+
return format === 3 && t.u16(sub + 2) > 0 ? t.offset(sub + 6, sub) : -1;
|
|
181
|
+
case 8: {
|
|
182
|
+
// chained context
|
|
183
|
+
if (format === 1 || format === 2) return t.offset(sub + 2, sub);
|
|
184
|
+
if (format !== 3) return -1;
|
|
185
|
+
// past the backtrack coverages to the input's count, then its first
|
|
186
|
+
const input = sub + 4 + t.u16(sub + 2) * 2;
|
|
187
|
+
return t.u16(input) > 0 ? t.offset(input + 2, sub) : -1;
|
|
188
|
+
}
|
|
189
|
+
default:
|
|
190
|
+
return -1;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function addCoverage(t, coverage, bits) {
|
|
195
|
+
const format = t.u16(coverage);
|
|
196
|
+
const count = t.u16(coverage + 2);
|
|
197
|
+
if (format === 1) {
|
|
198
|
+
for (let i = 0; i < count; i++) set(bits, t.u16(coverage + 4 + i * 2));
|
|
199
|
+
} else if (format === 2) {
|
|
200
|
+
for (let i = 0; i < count; i++) {
|
|
201
|
+
const rec = coverage + 4 + i * 6;
|
|
202
|
+
const last = t.u16(rec + 2);
|
|
203
|
+
for (let id = t.u16(rec); id <= last; id++) set(bits, id);
|
|
204
|
+
}
|
|
205
|
+
} else {
|
|
206
|
+
throw new RangeError(`coverage format ${format}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function set(bits, id) {
|
|
211
|
+
bits[id >> 3] |= 1 << (id & 7);
|
|
212
|
+
}
|