ntk 8.13.1 → 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,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
+ }
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.13.1",
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",