ntk 8.12.3 → 8.12.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/fontconfig.js CHANGED
@@ -255,6 +255,9 @@ function cacheMatches(fc, out) {
255
255
  return list;
256
256
  }
257
257
 
258
+ // fc -> { promise, base }: one child per pattern however many callers ask.
259
+ // `base` is where a prewarm writes its answer (`spawnToFiles`), null for a
260
+ // child whose answer only reaches the event loop.
258
261
  const inflight = new Map();
259
262
 
260
263
  /**
@@ -270,13 +273,14 @@ const inflight = new Map();
270
273
  */
271
274
  function runFcMatch(fc) {
272
275
  const pending = inflight.get(fc);
273
- if (pending) return pending;
276
+ if (pending) return pending.promise;
274
277
  const cp = childProcess();
275
278
  if (!cp) return Promise.reject(noFontsError(NOT_NODE));
276
279
 
277
- const promise = new Promise((resolve, reject) => {
280
+ const entry = { promise: null, base: null };
281
+ entry.promise = new Promise((resolve, reject) => {
278
282
  cp.execFile('fc-match', [...fcMatchArgs, fc], fcMatchOpts, (err, out, stderr) => {
279
- inflight.delete(fc);
283
+ if (inflight.get(fc) === entry) inflight.delete(fc);
280
284
  if (!err) return resolve(out);
281
285
  // execFile hands stderr to the callback; execFileSync hangs it on the
282
286
  // error, which is where the shared diagnosis reads it from
@@ -284,8 +288,152 @@ function runFcMatch(fc) {
284
288
  reject(err);
285
289
  });
286
290
  });
287
- inflight.set(fc, promise);
288
- return promise;
291
+ inflight.set(fc, entry);
292
+ return entry.promise;
293
+ }
294
+
295
+ // What a prewarm's child runs: fc-match with its answer in a file, then —
296
+ // once that file is complete — the exit status in another, which is what a
297
+ // synchronous caller waits for (`answerSync`). Nothing but shell builtins
298
+ // runs after fc-match, so a PATH that holds fc-match alone is enough.
299
+ const PREWARM_SCRIPT = 'fc-match "$@" > "$0.out" 2> "$0.err"; echo $? > "$0.done"';
300
+
301
+ // The directory prewarms write into, made on first use: undefined until
302
+ // then, null where it cannot be (no shell, no writable tmpdir), which leaves
303
+ // prewarms on the event loop the way they always were.
304
+ let prewarmDir;
305
+ let prewarmCount = 0;
306
+
307
+ function prewarmBase() {
308
+ const fs = builtin('node:fs');
309
+ const path = builtin('node:path');
310
+ if (prewarmDir === undefined) {
311
+ prewarmDir = null;
312
+ const p = globalThis.process;
313
+ const os = builtin('node:os');
314
+ if (p && p.platform !== 'win32' && fs && os && path && fs.existsSync('/bin/sh')) {
315
+ try {
316
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ntk-fc-'));
317
+ p.once('exit', () => {
318
+ try {
319
+ fs.rmSync(dir, { recursive: true, force: true });
320
+ } catch {
321
+ // a tmpdir cleaned up under us is already clean
322
+ }
323
+ });
324
+ prewarmDir = dir;
325
+ } catch {
326
+ // a read-only or missing tmpdir: prewarm the old way
327
+ }
328
+ }
329
+ }
330
+ return prewarmDir === null ? null : path.join(prewarmDir, String(prewarmCount++));
331
+ }
332
+
333
+ /** The status and answer a prewarm left, once it has left them; null while
334
+ * it is still running. */
335
+ function readPrewarm(base) {
336
+ const fs = builtin('node:fs');
337
+ let done;
338
+ try {
339
+ done = fs.readFileSync(`${base}.done`, 'utf8');
340
+ } catch {
341
+ return null;
342
+ }
343
+ // `echo` writes the status and the newline in one go, but a read can land
344
+ // between the file appearing and its bytes arriving
345
+ if (!done.endsWith('\n')) return null;
346
+ const status = Number(done);
347
+ let out = '';
348
+ if (status === 0) {
349
+ try {
350
+ out = fs.readFileSync(`${base}.out`, 'utf8');
351
+ } catch {
352
+ return { status: -1, out: '' };
353
+ }
354
+ }
355
+ return { status, out };
356
+ }
357
+
358
+ function removePrewarm(base) {
359
+ const fs = builtin('node:fs');
360
+ for (const ext of ['.out', '.err', '.done']) {
361
+ try {
362
+ fs.unlinkSync(base + ext);
363
+ } catch {
364
+ // never written, or already gone
365
+ }
366
+ }
367
+ }
368
+
369
+ /**
370
+ * A prewarm's child, answering through files so that the synchronous path
371
+ * can take its answer without the event loop (`answerSync`) — which the
372
+ * first text layout is holding, from inside a render, for as long as the
373
+ * first frame takes. A prewarm answered through `execFile` alone lands after
374
+ * that, so the layout it was started for spawned fc-match a second time and
375
+ * waited for that one instead.
376
+ *
377
+ * Null where files cannot be used; the caller prewarms the old way.
378
+ */
379
+ function spawnToFiles(fc) {
380
+ const cp = childProcess();
381
+ const base = cp ? prewarmBase() : null;
382
+ if (base === null) return null;
383
+ let child;
384
+ try {
385
+ child = cp.spawn('/bin/sh', ['-c', PREWARM_SCRIPT, base, ...fcMatchArgs, fc], {
386
+ stdio: 'ignore'
387
+ });
388
+ } catch {
389
+ return null;
390
+ }
391
+ const entry = { promise: null, base, answer: undefined };
392
+ entry.promise = new Promise((resolve, reject) => {
393
+ const settle = () => {
394
+ if (inflight.get(fc) === entry) inflight.delete(fc);
395
+ // answered already, by a synchronous caller that could not wait
396
+ if (entry.answer === undefined) {
397
+ const left = readPrewarm(base);
398
+ removePrewarm(base);
399
+ entry.answer = left && left.status === 0 ? left.out : null;
400
+ }
401
+ if (entry.answer !== null) resolve(entry.answer);
402
+ else reject(new Error(`fc-match for "${fc}" did not answer`));
403
+ };
404
+ child.once('error', settle);
405
+ child.once('exit', settle);
406
+ });
407
+ inflight.set(fc, entry);
408
+ return entry;
409
+ }
410
+
411
+ // the synchronous path's nap while a prewarm finishes
412
+ const nap = new Int32Array(new SharedArrayBuffer(4));
413
+
414
+ // How long the synchronous path waits on a prewarm before asking fc-match
415
+ // itself. fc-match answers in tens of milliseconds, and a prewarm that is
416
+ // running has a head start on any spawn that could replace it.
417
+ const PREWARM_WAIT_MS = 3000;
418
+
419
+ /**
420
+ * An in-flight prewarm's answer, waited for synchronously: its stdout, or
421
+ * null if it failed or took too long — the caller then spawns fc-match
422
+ * itself, which is also what reports why.
423
+ */
424
+ function answerSync(fc, entry) {
425
+ const deadline = performance.now() + PREWARM_WAIT_MS;
426
+ let left = readPrewarm(entry.base);
427
+ while (left === null && performance.now() < deadline) {
428
+ Atomics.wait(nap, 0, 0, 1);
429
+ left = readPrewarm(entry.base);
430
+ }
431
+ if (inflight.get(fc) === entry) inflight.delete(fc);
432
+ if (entry.answer === undefined) {
433
+ entry.answer = left && left.status === 0 ? left.out : null;
434
+ if (left !== null) removePrewarm(entry.base);
435
+ }
436
+ return entry.answer;
289
437
  }
290
438
 
291
439
  /**
@@ -294,8 +442,10 @@ function runFcMatch(fc) {
294
442
  * matchSortedSync is deliberately synchronous — it answers from inside text
295
443
  * layout — so the first layout for a pattern pays the fc-match spawn (~50ms)
296
444
  * as a first-paint stall. Starting the same command here with a non-blocking
297
- * execFile, while the X connection is still being set up, moves that cost off
298
- * the critical path (issue #182).
445
+ * spawn, while the X connection is still being set up, moves that cost off
446
+ * the critical path (issue #182). The child answers through files as well as
447
+ * through the event loop, so a layout that asks for the pattern before the
448
+ * loop has run takes the prewarm's answer rather than spawning its own.
299
449
  *
300
450
  * Never rejects and never reports: a prewarm is an optimization, and an app
301
451
  * that never renders text must not crash — or even warn — over a missing
@@ -304,9 +454,6 @@ function runFcMatch(fc) {
304
454
  * `unavailable`: the first sync throw keeps its original spawn error as
305
455
  * `cause`.
306
456
  *
307
- * A sync call racing this one wins — the child's result is discarded
308
- * whenever the pattern is already cached by the time it exits.
309
- *
310
457
  * `matchSorted` is the reporting variant: same spawn, same cache, but it
311
458
  * awaits an answer and so has somewhere to put a failure.
312
459
  *
@@ -316,7 +463,8 @@ function runFcMatch(fc) {
316
463
  export function prewarm(pattern = {}) {
317
464
  const fc = patternFor(pattern);
318
465
  if (sortedCache.has(fc) || unavailable) return Promise.resolve();
319
- return runFcMatch(fc).then(
466
+ const pending = inflight.get(fc)?.promise ?? spawnToFiles(fc)?.promise ?? runFcMatch(fc);
467
+ return pending.then(
320
468
  (out) => {
321
469
  if (!sortedCache.has(fc)) {
322
470
  const list = parseMatches(out);
@@ -327,6 +475,31 @@ export function prewarm(pattern = {}) {
327
475
  );
328
476
  }
329
477
 
478
+ // the faces a family is asked for in: regular, bold, and both in italic
479
+ const FACES = [
480
+ [400, 'normal'],
481
+ [700, 'normal'],
482
+ [400, 'italic'],
483
+ [700, 'italic']
484
+ ];
485
+ const facesWarmed = new Set();
486
+
487
+ /**
488
+ * Prewarm a family in the four faces text is set in — once per family.
489
+ *
490
+ * A document asks for its family's faces one at a time, as layout reaches
491
+ * the first bold word, the first emphasis, and each ask that misses is a
492
+ * synchronous fc-match on the way to the first frame: a Markdown document
493
+ * waited on five of them, 600 ms on XQuartz. Started together they run side
494
+ * by side, and each later ask finds its answer there.
495
+ */
496
+ export function prewarmFaces(pattern = {}) {
497
+ const family = pattern.family || 'sans-serif';
498
+ if (facesWarmed.has(family) || unavailable) return;
499
+ facesWarmed.add(family);
500
+ for (const [weight, style] of FACES) prewarm({ family, weight, style });
501
+ }
502
+
330
503
  /**
331
504
  * The same match list as `matchSortedSync`, without blocking for it.
332
505
  *
@@ -352,10 +525,18 @@ export async function matchSorted(pattern = {}) {
352
525
  if (unavailable) throw noFontsError(unavailable);
353
526
 
354
527
  let out;
355
- try {
356
- out = await runFcMatch(fc);
357
- } catch (err) {
358
- throw fcMatchError(err);
528
+ const pending = inflight.get(fc);
529
+ if (pending?.base) {
530
+ // a prewarm running for the pattern: its answer, or — if it has none —
531
+ // a spawn of our own, which is what reports why
532
+ out = await pending.promise.catch(() => undefined);
533
+ }
534
+ if (out === undefined) {
535
+ try {
536
+ out = await runFcMatch(fc);
537
+ } catch (err) {
538
+ throw fcMatchError(err);
539
+ }
359
540
  }
360
541
  // A sync call may have answered this pattern while the child ran. Its list
361
542
  // is the cached one, and candidates memoize their parsed charset, so hand
@@ -385,6 +566,14 @@ export function matchSortedSync(pattern) {
385
566
  if (cached) return cached;
386
567
  if (unavailable) throw noFontsError(unavailable);
387
568
 
569
+ // The family's other faces are the next asks (`prewarmFaces`): started
570
+ // now, they run beside this one instead of after it.
571
+ prewarmFaces(pattern);
572
+ const pending = inflight.get(fc);
573
+ if (pending?.base) {
574
+ const out = answerSync(fc, pending);
575
+ if (out !== null) return sortedCache.get(fc) ?? cacheMatches(fc, out);
576
+ }
388
577
  let out;
389
578
  try {
390
579
  out = execFileSync('fc-match', [...fcMatchArgs, fc], fcMatchOpts);
@@ -63,6 +63,60 @@ function shapingKeyOf(style) {
63
63
  return `${feats}|${language ?? ''}|${letterSpacing || 0}`;
64
64
  }
65
65
 
66
+ /**
67
+ * How many shaped words a generation of the memo holds. Two are kept, so a
68
+ * working set up to twice this survives a relayout; what was asked for in
69
+ * neither generation is dropped when the current one fills.
70
+ */
71
+ const SHAPE_GENERATION = 4000;
72
+
73
+ // The part of a shaping key a style decides, per style object. Built once a
74
+ // span rather than once a word: TextLayout asks for every word of a span with
75
+ // the same object, and the 2D context builds one object per `font` it is
76
+ // given. Neither changes an object after handing it over, so the only field
77
+ // checked again is the resolved face, the one a caller sets after building
78
+ // the rest.
79
+ const shapePrefixes = new WeakMap();
80
+
81
+ /** The key a style's words are memoized under, with the embedding levels
82
+ * they were shaped at: one string per style and level, kept, so a lookup
83
+ * hashes nothing it has not hashed before. */
84
+ function shapeGroupOf(style, levelsKey) {
85
+ const font = style.font;
86
+ let known = shapePrefixes.get(style);
87
+ if (known === undefined || known.font !== font) {
88
+ known = { font, prefix: shapePrefixOf(style), levels: new Map() };
89
+ shapePrefixes.set(style, known);
90
+ }
91
+ // a fragment's levels are one number in the common case, a list only
92
+ // for mixed-direction text; a list is not worth keeping
93
+ if (levelsKey.length > 2) return `${known.prefix}|${levelsKey}`;
94
+ let group = known.levels.get(levelsKey);
95
+ if (group === undefined) {
96
+ group = `${known.prefix}|${levelsKey}`;
97
+ known.levels.set(levelsKey, group);
98
+ }
99
+ return group;
100
+ }
101
+
102
+ function shapePrefixOf(style) {
103
+ const font = style.font;
104
+ // A resolved `font` already carries its coordinates in its key, so the
105
+ // variations and optical-size fragments only earn their keep on the
106
+ // family path — where two points of one axis would otherwise share a
107
+ // shaped run, and the second would be drawn with the first's advances.
108
+ // Features, language and spacing change the glyphs a word shapes to, so
109
+ // they are part of the key on both paths: without them a word shaped once
110
+ // plain answered every later request for it with the plain glyphs, and a
111
+ // `tnum` asked for after that point was silently ignored.
112
+ const shaping = shapingKeyOf(style);
113
+ return font
114
+ ? `${font.key}|${style.size}|${style.weight}|${style.style}|${shaping}`
115
+ : `${style.family}|${style.size}|${style.weight}|${style.style}|${variationsKeyOf(
116
+ style.variations
117
+ )}|${opticalKeyOf(style)}|${shaping}`;
118
+ }
119
+
66
120
  /** Cache-key fragment for the optical size, `''` when the axis is left alone. */
67
121
  function opticalKeyOf(style) {
68
122
  return opticalSizeFor(style) ?? '';
@@ -136,7 +190,11 @@ export default class FontManager {
136
190
  this._matches = new Map(); // family|weight|style -> Font
137
191
  this._fallbacks = new Map(); // family|weight|style -> Map(codepoint -> Font|null)
138
192
  this._registered = []; // { font, family (lowercase), weight, italic }
139
- this._shapeCache = new Map(); // word-level shaping memo (bounded, LRU)
193
+ // word-level shaping memo, in two generations (`_shapeCached`): each a
194
+ // map from a style's key to the words shaped in it
195
+ this._shapeCache = new Map();
196
+ this._shapeCacheBefore = new Map();
197
+ this._shapeCount = 0;
140
198
  }
141
199
 
142
200
  /** the FontSource in effect (explicit, else the process-wide default) */
@@ -355,53 +413,45 @@ export default class FontManager {
355
413
  }
356
414
 
357
415
  /**
358
- * Memoized shaping used by TextLayout and the canvas text path (bounded
359
- * LRU). `levelsKey` is a compact embedding-levels encoding: a single
360
- * number when uniform for the whole fragment (the common case), else
361
- * comma-separated per-char levels.
416
+ * Memoized shaping used by TextLayout and the canvas text path, bounded at
417
+ * two generations of `SHAPE_GENERATION` words. `levelsKey` is a compact
418
+ * embedding-levels encoding: a single number when uniform for the whole
419
+ * fragment (the common case), else comma-separated per-char levels.
362
420
  */
363
421
  _shapeCached(text, style, levelsKey = '0') {
364
- const font = style.font;
365
- // A resolved `font` already carries its coordinates in its key, so the
366
- // variations and optical-size fragments only earn their keep on the
367
- // family path — where two points of one axis would otherwise share a
368
- // shaped run, and the second would be drawn with the first's advances.
369
- // Features, language and spacing change the glyphs a word shapes to, so
370
- // they are part of the key on both paths: without them a word shaped once
371
- // plain answered every later request for it with the plain glyphs, and a
372
- // `tnum` asked for after that point was silently ignored.
373
- const shaping = shapingKeyOf(style);
374
- const key = font
375
- ? `${font.key}|${style.size}|${style.weight}|${style.style}|${shaping}|${levelsKey}|${text}`
376
- : `${style.family}|${style.size}|${style.weight}|${style.style}|${variationsKeyOf(
377
- style.variations
378
- )}|${opticalKeyOf(style)}|${shaping}|${levelsKey}|${text}`;
379
- let shaped = this._shapeCache.get(key);
380
- if (shaped) {
381
- // Map iterates in insertion order: re-inserting a hit moves it to the
382
- // tail, so the eviction sweep below walks least-recently-used first
383
- this._shapeCache.delete(key);
384
- this._shapeCache.set(key, shaped);
385
- return shaped;
422
+ // A hit in this generation is the whole cost of a word a paragraph has
423
+ // shaped before — a relayout at a new width asks for every word again,
424
+ // a hundred thousand of them in a long document — so it is two lookups
425
+ // and nothing built: the style's key is the same string every time
426
+ // (`shapeGroupOf`), and the word is the only thing hashed. What the
427
+ // generation before holds is carried over when asked for; what neither
428
+ // was asked for goes when the generation turns.
429
+ const group = shapeGroupOf(style, levelsKey);
430
+ let words = this._shapeCache.get(group);
431
+ let shaped = words?.get(text);
432
+ if (shaped) return shaped;
433
+ shaped = this._shapeCacheBefore.get(group)?.get(text);
434
+ if (!shaped) {
435
+ let levels;
436
+ if (levelsKey.includes(',')) {
437
+ levels = levelsKey.split(',').map(Number);
438
+ } else {
439
+ levels = new Uint8Array(text.length).fill(Number(levelsKey));
440
+ }
441
+ shaped = shapeText(this, text, style, levels);
386
442
  }
387
- let levels;
388
- if (levelsKey.includes(',')) {
389
- levels = levelsKey.split(',').map(Number);
390
- } else {
391
- levels = new Uint8Array(text.length).fill(Number(levelsKey));
443
+ if (this._shapeCount >= SHAPE_GENERATION) {
444
+ this._shapeCacheBefore = this._shapeCache;
445
+ this._shapeCache = new Map();
446
+ this._shapeCount = 0;
447
+ words = undefined;
392
448
  }
393
- shaped = shapeText(this, text, style, levels);
394
- if (this._shapeCache.size > 4000) {
395
- // drop the stale half in one sweep rather than one entry per insert —
396
- // a live UI's working set sits at the recent end and survives, where
397
- // the old wholesale clear() re-shaped everything on screen
398
- let drop = this._shapeCache.size >> 1;
399
- for (const k of this._shapeCache.keys()) {
400
- if (drop-- <= 0) break;
401
- this._shapeCache.delete(k);
402
- }
449
+ if (!words) {
450
+ words = new Map();
451
+ this._shapeCache.set(group, words);
403
452
  }
404
- this._shapeCache.set(key, shaped);
453
+ words.set(text, shaped);
454
+ this._shapeCount++;
405
455
  return shaped;
406
456
  }
407
457
 
@@ -51,7 +51,7 @@ import {
51
51
  matchSorted,
52
52
  matchSortedSync,
53
53
  noFontsError,
54
- prewarm,
54
+ prewarmFaces,
55
55
  supported
56
56
  } from '../fontconfig.js';
57
57
  import Font from './font.js';
@@ -116,13 +116,13 @@ export function parseFamilies(family) {
116
116
  export class FontconfigFontSource {
117
117
  constructor() {
118
118
  // Construction is the moment fontconfig is chosen as the lookup path, so
119
- // the fc-match spawn for the pattern every widget default resolves to —
119
+ // the fc-match spawns for the pattern every widget default resolves to —
120
120
  // sans-serif at regular weight, exactly as FontManager.match normalizes
121
- // it — starts here, asynchronously, instead of stalling the first text
122
- // layout for ~50ms. Fire-and-forget: prewarm never rejects, and a source
123
- // constructed where fc-match is missing stays silent until (unless) text
124
- // is actually rendered.
125
- prewarm({ family: 'sans-serif', weight: 400, style: 'normal' });
121
+ // it — and for its bold and italic start here, asynchronously, instead of
122
+ // stalling the first text layout for ~50ms each. Fire-and-forget:
123
+ // prewarm never rejects, and a source constructed where fc-match is
124
+ // missing stays silent until (unless) text is actually rendered.
125
+ prewarmFaces({ family: 'sans-serif' });
126
126
  }
127
127
 
128
128
  matchSorted(pattern) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.12.3",
3
+ "version": "8.12.5",
4
4
  "description": "Desktop UI toolkit for X11 with canvas-like 2d and OpenGL rendering",
5
5
  "author": "Andrey Sidorov <sidorares@yandex.ru>",
6
6
  "license": "MIT",