ntk 8.14.0 → 8.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,81 @@
1
+ // fontkit's mark attachment, for a face whose tables leave an anchor out.
2
+ //
3
+ // A mark-to-base, mark-to-ligature or mark-to-mark subtable holds an anchor
4
+ // for each base (ligature component, earlier mark) and mark class, and the
5
+ // OpenType spec lets one be NULL: that subtable attaches no mark of that
6
+ // class to that glyph. HarfBuzz answers "not applied", and the lookup's next
7
+ // subtable gets its turn. fontkit reads the NULL's coordinates and throws
8
+ // `Cannot read properties of null (reading 'xCoordinate')` out of a layout,
9
+ // and faces ship them: Noto Sans Bold holds 2,822, DejaVu Sans Mono 198 (a
10
+ // Lithuanian Į̃ reaches one), Amiri, Noto Naskh Arabic and FreeSerif
11
+ // thousands. Upstream that is foliojs/fontkit#367; #374 returns early
12
+ // instead, which ends the crash but still counts the subtable as applied.
13
+ //
14
+ // Answering "not applied" means wrapping the processor's `applyLookup`,
15
+ // which runs for every glyph at every lookup: 2% of shaping a word the
16
+ // first time. So a face pays it only from the first NULL its text reaches.
17
+ // Until then `applyAnchor` alone is watched — it runs when a mark attaches —
18
+ // and a NULL there abandons the layout (`NO_ANCHOR`), which `Font#_layout`
19
+ // shapes again with the wrapper in. Abandoning loses nothing: fontkit keeps
20
+ // no state from a layout but the tables it decoded, which a second one
21
+ // reads the same.
22
+
23
+ /**
24
+ * Thrown through fontkit's layout from a NULL anchor, and caught here or in
25
+ * `Font#_layout`. One error, made once: a face can reach NULLs many times a
26
+ * word, and a fresh error would take a stack trace each time. It says what
27
+ * happened to anything that calls a watched face's `layout` directly.
28
+ */
29
+ export const NO_ANCHOR = new Error(
30
+ 'a mark attachment subtable has a NULL anchor for this glyph and mark class: ' +
31
+ "shaped through ntk's Font, that is a subtable that did not apply"
32
+ );
33
+
34
+ /** The face's GPOS processor, where fontkit shapes it through GPOS; else null. */
35
+ function processorOf(fk) {
36
+ try {
37
+ return fk._layoutEngine?.engine?.GPOSProcessor ?? null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Watch a face's mark attachment for a NULL anchor, which then abandons the
45
+ * layout it is reached in with `NO_ANCHOR`.
46
+ *
47
+ * @param {object} fk a fontkit font
48
+ * @returns {boolean} whether there is anything to watch: false for a face
49
+ * fontkit does not position through GPOS
50
+ */
51
+ export function watchAnchors(fk) {
52
+ const gpos = processorOf(fk);
53
+ if (!gpos || typeof gpos.applyAnchor !== 'function') return false;
54
+ if (Object.hasOwn(gpos, 'applyAnchor')) return true;
55
+ const applyAnchor = gpos.applyAnchor;
56
+ gpos.applyAnchor = function (markRecord, baseAnchor, baseGlyphIndex) {
57
+ if (baseAnchor == null || markRecord.markAnchor == null) throw NO_ANCHOR;
58
+ return applyAnchor.call(this, markRecord, baseAnchor, baseGlyphIndex);
59
+ };
60
+ return true;
61
+ }
62
+
63
+ /**
64
+ * From here on a subtable that reaches a NULL anchor is one that did not
65
+ * apply, so the lookup's next subtable is tried, as HarfBuzz tries it.
66
+ *
67
+ * @param {object} fk a fontkit font `watchAnchors` has been handed
68
+ */
69
+ export function tolerateAnchors(fk) {
70
+ const gpos = processorOf(fk);
71
+ if (!gpos || Object.hasOwn(gpos, 'applyLookup')) return;
72
+ const applyLookup = gpos.applyLookup;
73
+ gpos.applyLookup = function (lookupType, table) {
74
+ try {
75
+ return applyLookup.call(this, lookupType, table);
76
+ } catch (err) {
77
+ if (err === NO_ANCHOR) return false;
78
+ throw err;
79
+ }
80
+ };
81
+ }
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 { NO_ANCHOR, tolerateAnchors, watchAnchors } from './anchors.js';
4
5
  import { marksCover, standInMarks } from './marks.js';
5
6
 
6
7
  // Axis coordinates are rounded to this many decimals before anything is
@@ -127,6 +128,10 @@ export default class Font {
127
128
  // at its first shaping; null once the real ones are back, or never were
128
129
  // stood in for
129
130
  this._marks = undefined;
131
+ this._drawable = undefined;
132
+ // whether the face's mark attachment is watched for a NULL anchor
133
+ // (./anchors.js): set at its first shaping, false once one has been met
134
+ this._anchors = false;
130
135
  }
131
136
 
132
137
  static loadSync(path, postscriptName) {
@@ -308,8 +313,29 @@ export default class Font {
308
313
  return this._space * this.scale(size);
309
314
  }
310
315
 
316
+ /**
317
+ * Whether fontkit can make a glyph of this face at all. It makes one from
318
+ * `glyf`, `CFF ` or `CFF2` outlines, or an `sbix` or `COLR`/`CPAL` colour
319
+ * glyph, and answers null for anything else — and its shaper throws on the
320
+ * null. A bitmap-only colour font (`CBDT`/`CBLC`) is that face: Noto Color
321
+ * Emoji and EmojiOne as most Linux desktops ship them, which fontconfig
322
+ * answers first for an emoji. Its cmap says it has the character and
323
+ * nothing can be shaped or drawn from it, so it covers nothing here
324
+ * (`hasGlyph`), and `FontManager` hands it out neither as a match nor as a
325
+ * fallback.
326
+ */
327
+ get drawable() {
328
+ if (this._drawable === undefined) {
329
+ const t = this.fk.directory?.tables ?? {};
330
+ this._drawable = Boolean(
331
+ t.glyf || t['CFF '] || t.CFF2 || t.sbix || (t.COLR && t.CPAL)
332
+ );
333
+ }
334
+ return this._drawable;
335
+ }
336
+
311
337
  hasGlyph(codepoint) {
312
- return this.fk.hasGlyphForCodePoint(codepoint);
338
+ return this.drawable && this.fk.hasGlyphForCodePoint(codepoint);
313
339
  }
314
340
 
315
341
  /**
@@ -329,7 +355,7 @@ export default class Font {
329
355
  * @returns {number|null} font glyph id, as `shape()` would report in `glyphs[].id`
330
356
  */
331
357
  glyphIdFor(codepoint) {
332
- if (!this.fk.hasGlyphForCodePoint(codepoint)) return null;
358
+ if (!this.hasGlyph(codepoint)) return null;
333
359
  return this.fk.glyphForCodePoint(codepoint).id;
334
360
  }
335
361
 
@@ -376,16 +402,38 @@ export default class Font {
376
402
  */
377
403
  _layout(text, opts) {
378
404
  const { features, script, language, direction } = opts;
379
- if (this._marks === undefined) this._marks = standInMarks(this.fk);
405
+ if (this._marks === undefined) {
406
+ this._marks = standInMarks(this.fk);
407
+ this._anchors = watchAnchors(this.fk);
408
+ }
380
409
  const marks = this._marks;
381
410
  // fontkit adds to the features object it is handed: a second shaping
382
411
  // gets what the caller asked for, not what the first left there
383
412
  const again = marks === null || features == null ? features : Array.isArray(features) ? [...features] : { ...features };
384
- const run = this.fk.layout(text, features, script, language, direction);
413
+ const run = this._fkLayout(text, features, script, language, direction);
385
414
  if (marks === null || !run.glyphs.some((glyph) => marksCover(marks.bits, glyph.id))) return run;
386
415
  marks.restore();
387
416
  this._marks = null;
388
- return this.fk.layout(text, again, script, language, direction);
417
+ return this._fkLayout(text, again, script, language, direction);
418
+ }
419
+
420
+ /**
421
+ * fontkit's layout, shaped once more where a NULL anchor abandoned it: a
422
+ * face's mark attachment is watched for one until its text reaches the
423
+ * first, and takes one as a subtable that did not apply from then on
424
+ * (./anchors.js).
425
+ */
426
+ _fkLayout(text, features, script, language, direction) {
427
+ if (!this._anchors) return this.fk.layout(text, features, script, language, direction);
428
+ const again = features == null ? features : Array.isArray(features) ? [...features] : { ...features };
429
+ try {
430
+ return this.fk.layout(text, features, script, language, direction);
431
+ } catch (err) {
432
+ if (err !== NO_ANCHOR) throw err;
433
+ tolerateAnchors(this.fk);
434
+ this._anchors = false;
435
+ return this.fk.layout(text, again, script, language, direction);
436
+ }
389
437
  }
390
438
 
391
439
  /** nominal (unshaped) advance of a glyph id, in pixels */
@@ -111,8 +111,12 @@ function shapePrefixOf(style) {
111
111
  // plain answered every later request for it with the plain glyphs, and a
112
112
  // `tnum` asked for after that point was silently ignored.
113
113
  const shaping = shapingKeyOf(style);
114
+ // The family list too where a face is given, since the letters that face
115
+ // lacks are set in the families after it (`fallbackFor`): a word shaped
116
+ // under `Ahem, Times New Roman` answered the same word under `Ahem,
117
+ // Arial`, in Times.
114
118
  return font
115
- ? `${font.key}|${style.size}|${style.weight}|${style.style}|${shaping}`
119
+ ? `${font.key}|${style.family ?? ''}|${style.size}|${style.weight}|${style.style}|${shaping}`
116
120
  : `${style.family}|${style.size}|${style.weight}|${style.style}|${variationsKeyOf(
117
121
  style.variations
118
122
  )}|${opticalKeyOf(style)}|${shaping}`;
@@ -253,6 +257,28 @@ export default class FontManager {
253
257
  );
254
258
  }
255
259
 
260
+ /**
261
+ * The best of `candidates` a glyph can be made from (`Font.drawable`):
262
+ * `emoji`, or a stylesheet's `"Noto Color Emoji"`, is a bitmap-only face
263
+ * on most Linux desktops, and a base face nothing can be shaped in threw
264
+ * on the first character of its text. The first candidate still, where
265
+ * none is — there is nothing better to hand out.
266
+ */
267
+ _firstDrawable(candidates) {
268
+ let first = null;
269
+ for (const c of candidates) {
270
+ let font;
271
+ try {
272
+ font = this._open(c);
273
+ } catch {
274
+ continue; // unparseable candidate — try the next one
275
+ }
276
+ if (font.drawable) return font;
277
+ first ??= font;
278
+ }
279
+ return first ?? this._open(candidates[0]);
280
+ }
281
+
256
282
  /** open (and cache) a match candidate — see fontsource.js for the shape */
257
283
  _open(candidate) {
258
284
  const key = candidate.key ?? `${candidate.path}#${candidate.postscriptName || ''}`;
@@ -309,7 +335,7 @@ export default class FontManager {
309
335
  let bestScore = Infinity;
310
336
  for (const family of families) {
311
337
  for (const r of this._registered) {
312
- if (r.family !== family) continue;
338
+ if (r.family !== family || !r.font.drawable) continue;
313
339
  const score = Math.abs(r.weight - weight) + (r.italic !== italic ? 1000 : 0);
314
340
  if (score < bestScore) {
315
341
  bestScore = score;
@@ -361,12 +387,19 @@ export default class FontManager {
361
387
  if (!face) {
362
388
  // sources understand comma-separated family lists natively; one that
363
389
  // can answer the best face alone saves reading the fallback chain the
364
- // face may never need
390
+ // face may never need — unless no glyph can be made from that face
365
391
  const pattern = patternOf(family, weight, italic);
366
392
  const source = this.source;
367
- face = this._open(
368
- typeof source.matchFirst === 'function' ? source.matchFirst(pattern) : source.matchSorted(pattern)[0]
369
- );
393
+ let head = null;
394
+ if (typeof source.matchFirst === 'function') {
395
+ const candidate = source.matchFirst(pattern);
396
+ try {
397
+ head = this._open(candidate);
398
+ } catch {
399
+ // unparseable: the chain has the next candidate
400
+ }
401
+ }
402
+ face = head?.drawable ? head : this._firstDrawable(source.matchSorted(pattern));
370
403
  }
371
404
  this._matches.set(cacheKey, face);
372
405
  // A long-lived app can name a lot of families. Same sweep as the shaping
@@ -383,9 +416,17 @@ export default class FontManager {
383
416
 
384
417
  /**
385
418
  * Find a font that has a glyph for `codepoint`, for use when the primary
386
- * font doesn't. Registered fonts first, then the source's fallback chain
387
- * (filtered by the source's coverage data — font files are only opened to
388
- * confirm). Returns null when nothing on the system covers the codepoint.
419
+ * font doesn't. The registered faces of the families the style names
420
+ * first, in the order it names them, then any registered face, then the
421
+ * source's fallback chain (filtered by the source's coverage data — font
422
+ * files are only opened to confirm). Returns null when nothing on the
423
+ * system covers the codepoint.
424
+ *
425
+ * The families first because that is what a list of them is for: CSS's
426
+ * `font-family: Icons, Arial` sets a letter the icon face lacks in Arial,
427
+ * as a browser does. Walking every registered face instead gave it to
428
+ * whichever was registered first — an app's serif — however far down the
429
+ * list, or off it, that face was.
389
430
  *
390
431
  * "Nothing covers it" includes "this environment has no system fonts at
391
432
  * all". An app that loaded its own faces still reaches here for the first
@@ -404,10 +445,23 @@ export default class FontManager {
404
445
  if (perCp.has(codepoint)) return perCp.get(codepoint);
405
446
 
406
447
  let found = null;
407
- for (const r of this._registered) {
408
- if (r.font.hasGlyph(codepoint)) {
409
- found = r.font;
410
- break;
448
+ if (this._registered.length) {
449
+ const weight = numWeight(opts.weight);
450
+ const italic = !!opts.style?.includes('italic');
451
+ for (const name of familiesOf(family)) {
452
+ const font = this._matchRegistered([name.toLowerCase()], weight, italic);
453
+ if (font && font.hasGlyph(codepoint)) {
454
+ found = font;
455
+ break;
456
+ }
457
+ }
458
+ }
459
+ if (!found) {
460
+ for (const r of this._registered) {
461
+ if (r.font.hasGlyph(codepoint)) {
462
+ found = r.font;
463
+ break;
464
+ }
411
465
  }
412
466
  }
413
467
  if (!found) {
package/lib/text/shape.js CHANGED
@@ -74,23 +74,32 @@ export function normalizedLevels(levels, start, end) {
74
74
  */
75
75
  const OPTIONAL_LIGATURES = ['liga', 'clig', 'dlig', 'hlig'];
76
76
 
77
- /**
78
- * The features a letter-spaced run shapes with: the optional ligatures off,
79
- * underneath whatever the caller asked for — a style that names `liga`
80
- * itself still gets it. `features` is either form fontkit takes, an array of
81
- * tags to turn on or an object of tag → on/off.
82
- */
83
77
  /**
84
78
  * A copy of `features` for fontkit, which **adds to the object it is handed**
85
79
  * — `rvrn` and the like, as it plans the shaping. Handed a caller's own
86
80
  * object that is a style quietly growing keys, which changes the memo key it
87
81
  * is filed under next time, and a frozen one throws.
82
+ *
83
+ * A feature a 0 turns off, as CSS writes one (`font-feature-settings: "kern"
84
+ * 0`, and `font-kerning: none`), goes to fontkit as `false`. It reads a 0 as
85
+ * off where it plans the OpenType features, but where a face keeps its
86
+ * kerning in the older `kern` table — Times New Roman's, and DejaVu's beside
87
+ * its GPOS pairs — it asks `kern !== false`, and a 0 kerned all the same.
88
88
  */
89
89
  function ownFeatures(features) {
90
90
  if (!features) return features;
91
- return Array.isArray(features) ? [...features] : { ...features };
91
+ if (Array.isArray(features)) return [...features];
92
+ const out = {};
93
+ for (const tag in features) out[tag] = features[tag] === 0 ? false : features[tag];
94
+ return out;
92
95
  }
93
96
 
97
+ /**
98
+ * The features a letter-spaced run shapes with: the optional ligatures off,
99
+ * underneath whatever the caller asked for — a style that names `liga`
100
+ * itself still gets it. `features` is either form fontkit takes, an array of
101
+ * tags to turn on or an object of tag → on/off.
102
+ */
94
103
  function spacedFeatures(features) {
95
104
  const off = Object.fromEntries(OPTIONAL_LIGATURES.map((tag) => [tag, false]));
96
105
  if (!features) return off;
@@ -98,7 +107,7 @@ function spacedFeatures(features) {
98
107
  for (const tag of features) off[tag] = true;
99
108
  return off;
100
109
  }
101
- return { ...off, ...features };
110
+ return { ...off, ...ownFeatures(features) };
102
111
  }
103
112
 
104
113
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.14.0",
3
+ "version": "8.14.1",
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",