ntk 8.12.7 → 8.12.8

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
@@ -16,6 +16,40 @@ function execFileSync(...args) {
16
16
  return cp.execFileSync(...args);
17
17
  }
18
18
 
19
+ let fcMatchFound = null;
20
+
21
+ /**
22
+ * fc-match's path, looked up once per PATH. Spawned by its bare name, the
23
+ * system searches PATH for it, and on macOS that is `posix_spawnp`, which
24
+ * attempts a spawn in every directory ahead of the one that has it: with
25
+ * fc-match seventh on a developer's PATH, each spawn took 45 ms where the
26
+ * path itself takes 15 — and the synchronous ones stall the first text
27
+ * layout. A directory search here is a few stats. Not found, the bare name
28
+ * is spawned as before, so a missing fontconfig reports exactly as it did.
29
+ */
30
+ export function fcMatchFile() {
31
+ const p = globalThis.process;
32
+ const path = p?.env?.PATH ?? '';
33
+ if (fcMatchFound !== null && fcMatchFound.path === path) return fcMatchFound.file;
34
+ let file = 'fc-match';
35
+ const fs = p?.platform === 'win32' ? undefined : builtin('node:fs');
36
+ if (fs) {
37
+ for (const dir of path.split(':')) {
38
+ const candidate = `${dir || '.'}/fc-match`;
39
+ try {
40
+ if (!fs.statSync(candidate).isFile()) continue;
41
+ fs.accessSync(candidate, fs.constants.X_OK);
42
+ file = candidate;
43
+ break;
44
+ } catch {
45
+ // not here, or not ours to run: the next directory
46
+ }
47
+ }
48
+ }
49
+ fcMatchFound = { path, file };
50
+ return file;
51
+ }
52
+
19
53
  const DOCS = 'https://github.com/sidorares/ntk/blob/master/docs/fonts.md#environments-without-fontconfig';
20
54
 
21
55
  /**
@@ -279,7 +313,7 @@ function runFcMatch(fc) {
279
313
 
280
314
  const entry = { promise: null, base: null };
281
315
  entry.promise = new Promise((resolve, reject) => {
282
- cp.execFile('fc-match', [...fcMatchArgs, fc], fcMatchOpts, (err, out, stderr) => {
316
+ cp.execFile(fcMatchFile(), [...fcMatchArgs, fc], fcMatchOpts, (err, out, stderr) => {
283
317
  if (inflight.get(fc) === entry) inflight.delete(fc);
284
318
  if (!err) return resolve(out);
285
319
  // execFile hands stderr to the callback; execFileSync hangs it on the
@@ -576,7 +610,7 @@ export function matchSortedSync(pattern) {
576
610
  }
577
611
  let out;
578
612
  try {
579
- out = execFileSync('fc-match', [...fcMatchArgs, fc], fcMatchOpts);
613
+ out = execFileSync(fcMatchFile(), [...fcMatchArgs, fc], fcMatchOpts);
580
614
  } catch (err) {
581
615
  throw fcMatchError(err);
582
616
  }
@@ -182,6 +182,27 @@ function instantiate(font, weight, variations, opticalSize) {
182
182
  * fontconfig, e.g. a StaticFontSource in a browser bundle. `source` also
183
183
  * takes a font spec: a path, a directory of faces, or font bytes.
184
184
  */
185
+ /** A CSS family list's names, unquoted. */
186
+ function familiesOf(family) {
187
+ return String(family)
188
+ .split(',')
189
+ .map((f) => f.trim().replace(/^["']|["']$/g, ''))
190
+ .filter(Boolean);
191
+ }
192
+
193
+ /**
194
+ * The pattern a family and face are matched with — one spelling for both
195
+ * `match` and `fallbackFor`, since a source caches its lists by pattern and
196
+ * the two ask about the same face.
197
+ */
198
+ function patternOf(family, weight, italic) {
199
+ return {
200
+ family: familiesOf(family).join(','),
201
+ weight,
202
+ style: italic ? 'italic' : 'normal'
203
+ };
204
+ }
205
+
185
206
  export default class FontManager {
186
207
  constructor({ source } = {}) {
187
208
  // coerced here rather than in the getter, which is on the match path;
@@ -304,10 +325,7 @@ export default class FontManager {
304
325
  return instantiate(face, weight, opts.variations, opticalSizeFor(opts));
305
326
  }
306
327
 
307
- const families = String(family)
308
- .split(',')
309
- .map((f) => f.trim().replace(/^["']|["']$/g, ''))
310
- .filter(Boolean);
328
+ const families = familiesOf(family);
311
329
 
312
330
  face = this._matchRegistered(
313
331
  families.map((f) => f.toLowerCase()),
@@ -316,11 +334,7 @@ export default class FontManager {
316
334
  );
317
335
  if (!face) {
318
336
  // sources understand comma-separated family lists natively
319
- const candidates = this.source.matchSorted({
320
- family: families.join(','),
321
- weight,
322
- style: italic ? 'italic' : 'normal'
323
- });
337
+ const candidates = this.source.matchSorted(patternOf(family, weight, italic));
324
338
  face = this._open(candidates[0]);
325
339
  }
326
340
  this._matches.set(cacheKey, face);
@@ -369,7 +383,12 @@ export default class FontManager {
369
383
  const source = this.source;
370
384
  let candidates;
371
385
  try {
372
- candidates = source.matchSorted({ family, weight: opts.weight, style: opts.style });
386
+ // The pattern `match` asks for this family and face, so the list the
387
+ // match or a prewarm already fetched answers: asked with the span's
388
+ // raw weight — undefined for regular text — it was another pattern,
389
+ // and the first character to need a fallback waited on a fc-match
390
+ // of its own.
391
+ candidates = source.matchSorted(patternOf(family, numWeight(opts.weight), !!opts.style?.includes('italic')));
373
392
  } catch (err) {
374
393
  // no system fonts to fall back to is an answer, not a crash; a source
375
394
  // that failed for any other reason is a real bug and still propagates
@@ -3,6 +3,7 @@ import LineBreaker from 'linebreak';
3
3
  import { rasterizePolys } from '../rasterize.js';
4
4
  import { drawGlyphRuns } from './glyphs.js';
5
5
  import { embeddingLevels, normalizedLevels, reorderRuns } from './shape.js';
6
+ import { worthKeeping } from './paragraphs.js';
6
7
 
7
8
  const WS = new Set([0x20, 0x09, 0xa0]);
8
9
  const HARD_BREAKS = /[\n\r\u2028\u2029]+$/;
@@ -55,7 +56,9 @@ export class TextLayout {
55
56
  // Everything up to the line fill is the same at every width, and a
56
57
  // font manager keeps it (text/paragraphs.js): a relayout at a new width
57
58
  // starts at the fill.
58
- const kept = fonts._paragraphs;
59
+ // A short text skips it (`PARAGRAPH_MIN_CHARS`): a label is cheap to
60
+ // prepare and seldom laid out at another width.
61
+ const kept = worthKeeping(content) ? fonts._paragraphs : undefined;
59
62
  const key = kept ? kept.keyOf(content) : null;
60
63
  let paragraph = kept?.find(key, options.direction, content, style);
61
64
  if (!paragraph) {
@@ -21,6 +21,29 @@
21
21
  /** How many characters of paragraphs a generation holds. */
22
22
  export const PARAGRAPH_GENERATION = 1 << 19;
23
23
 
24
+ /**
25
+ * The shortest paragraph worth keeping, in code units. Shorter text does not
26
+ * ask the cache at all: a label's preparation is a few microseconds and the
27
+ * lookup and copies about one of them, while a label is seldom laid out at
28
+ * a second width. Kept, a UI's labels mounted a quarter slower and a ticking
29
+ * counter redrew a quarter slower, to save nothing; the paragraphs a resize
30
+ * lays out again are a document's, and those are longer than this.
31
+ */
32
+ export const PARAGRAPH_MIN_CHARS = 64;
33
+
34
+ /** Whether `content` is long enough to keep (`PARAGRAPH_MIN_CHARS`), counted
35
+ * without building anything. */
36
+ export function worthKeeping(content) {
37
+ if (typeof content === 'string') return content.length >= PARAGRAPH_MIN_CHARS;
38
+ let chars = 0;
39
+ for (const span of content) {
40
+ const text = span.text;
41
+ chars += typeof text === 'string' ? text.length : String(text ?? '').length;
42
+ if (chars >= PARAGRAPH_MIN_CHARS) return true;
43
+ }
44
+ return false;
45
+ }
46
+
24
47
  export class ParagraphCache {
25
48
  constructor(limit = PARAGRAPH_GENERATION) {
26
49
  this._limit = limit;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.12.7",
3
+ "version": "8.12.8",
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",