ntk 8.17.1 → 8.17.3

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
@@ -901,11 +901,31 @@ export function charsetHas(candidate, codepoint) {
901
901
  return false;
902
902
  }
903
903
 
904
+ /**
905
+ * A CSS font weight on fontconfig's scale, which is not CSS's: regular is 80
906
+ * there and black is 210. The hundreds are the table's; a weight between two
907
+ * of them is as far between their fontconfig weights, so 450 is 90, halfway
908
+ * from regular to medium. CSS takes 1 to 1000, and what is outside the table
909
+ * is its nearest end.
910
+ *
911
+ * Handed over as it was, 450 was a weight past twice black, and fontconfig
912
+ * answered with the heaviest face a family has: Arial Bold, where a browser
913
+ * sets 450 in Arial Regular. At 90 fontconfig picks the face nearest in
914
+ * weight, which is the regular's 80 and not the bold's 200.
915
+ *
916
+ * A whole number, because a pattern is a cache key and an fc-match of its
917
+ * own: the weights an axis is animated through then share the ones they
918
+ * round to.
919
+ */
904
920
  function normalizeWeight(weight) {
905
921
  if (weight === undefined) return undefined;
906
922
  if (weight === 'normal') return cssToFcWeight[400];
907
923
  if (weight === 'bold') return cssToFcWeight[700];
908
924
  const n = parseInt(weight, 10);
909
925
  if (Number.isNaN(n)) return undefined;
910
- return cssToFcWeight[n] !== undefined ? cssToFcWeight[n] : n;
926
+ const css = Math.min(900, Math.max(100, n));
927
+ const below = Math.floor(css / 100) * 100;
928
+ const lighter = cssToFcWeight[below];
929
+ if (css === below) return lighter;
930
+ return Math.round(lighter + ((cssToFcWeight[below + 100] - lighter) * (css - below)) / 100);
911
931
  }
package/lib/text/font.js CHANGED
@@ -4,6 +4,7 @@ import { flatten, rasterizePath } from '../rasterize.js';
4
4
  import { NO_ANCHOR, tolerateAnchors, watchAnchors } from './anchors.js';
5
5
  import { marksCover, standInMarks } from './marks.js';
6
6
  import { pairGlyphs, pairsCover } from './pairs.js';
7
+ import { sfntOf } from './sfnt.js';
7
8
 
8
9
  // Axis coordinates are rounded to this many decimals before anything is
9
10
  // instantiated or cached. Every distinct coordinate is a font in its own
@@ -145,11 +146,20 @@ function statedOrGlyph(fk, field, codePoint) {
145
146
  * the space among them, and every layout in it threw, which left a whole
146
147
  * document blank. An empty glyph's box is its empty outline's, as the rest
147
148
  * of fontkit has it. Installed once, on the class every TrueType glyph is.
149
+ *
150
+ * Not on a WOFF2's glyphs, where its `glyf` table is transformed: those are
151
+ * decoded whole, with no header to read, and the file's `loca` is empty —
152
+ * fontkit reads the table after it in its place, which is wrong about which
153
+ * glyphs are empty (about 24 of Geist's 975). Asked, it sent a glyph to a
154
+ * header that is not there, and in an instance of a variable face, whose
155
+ * box is needed to make the outline, round and round until the stack ran
156
+ * out.
148
157
  */
149
158
  const MENDED = new WeakSet();
150
159
 
151
160
  function mendEmptyGlyphBoxes(fk) {
152
- if (!fk.directory?.tables?.glyf) return;
161
+ const glyf = fk.directory?.tables?.glyf;
162
+ if (!glyf || glyf.transformed) return;
153
163
  let glyph;
154
164
  try {
155
165
  glyph = fk.getGlyph(0);
@@ -169,6 +179,28 @@ function mendEmptyGlyphBoxes(fk) {
169
179
  };
170
180
  }
171
181
 
182
+ /**
183
+ * `fk`, or where it is a variable face read out of a WOFF or a WOFF2, the
184
+ * same font read from the sfnt inside the container (./sfnt.js): fontkit
185
+ * cuts an instance out of an uncompressed font only, and a variable web
186
+ * font is nearly always a WOFF2. A static face is left as it came — nothing
187
+ * is asked of it that a container does not answer.
188
+ *
189
+ * A container this cannot take apart is left too, and says so when an
190
+ * instance is asked of it (`_instantiate`).
191
+ */
192
+ function instantiable(fk) {
193
+ if (fk.type !== 'WOFF' && fk.type !== 'WOFF2') return fk;
194
+ if (!fk.directory?.tables?.fvar) return fk;
195
+ try {
196
+ const bytes = sfntOf(fk);
197
+ if (!bytes) return fk;
198
+ return fontkit.create(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength));
199
+ } catch {
200
+ return fk;
201
+ }
202
+ }
203
+
172
204
  export default class Font {
173
205
  constructor(fkFont, path, postscriptName) {
174
206
  answerWithCharacters(fkFont);
@@ -205,7 +237,7 @@ export default class Font {
205
237
  (postscriptName && fk.fonts.find((f) => f.postscriptName === postscriptName)) ||
206
238
  fk.fonts[0];
207
239
  }
208
- return new Font(fk, path, postscriptName);
240
+ return new Font(instantiable(fk), path, postscriptName);
209
241
  }
210
242
 
211
243
  /**
@@ -226,7 +258,7 @@ export default class Font {
226
258
  (postscriptName && fk.fonts.find((f) => f.postscriptName === postscriptName)) ||
227
259
  fk.fonts[0];
228
260
  }
229
- const font = new Font(fk, null, postscriptName);
261
+ const font = new Font(instantiable(fk), null, postscriptName);
230
262
  font.key = key ?? `mem:#${fk.postscriptName || ''}`;
231
263
  return font;
232
264
  }
@@ -307,15 +339,18 @@ export default class Font {
307
339
 
308
340
  /**
309
341
  * fontkit's instancing, with the one failure it has turned into something
310
- * that says what to do about it.
342
+ * that says what happened.
311
343
  *
312
- * `getVariation` rebuilds the face from the original stream at the
313
- * original directory offset. For a WOFF2 that stream is still
314
- * Brotli-compressed, so the copy comes back with a directory parsed out of
315
- * compressed bytes — no `cmap`, and a `Cannot read properties of undefined
344
+ * `getVariation` rebuilds the face as a plain sfnt from the original
345
+ * stream, which for a WOFF is another directory and for a WOFF2 is still
346
+ * Brotli-compressed: the copy comes back with a directory parsed out of
347
+ * the wrong bytes — no `cmap`, and a `Cannot read properties of undefined
316
348
  * (reading 'tables')` from somewhere deep in shaping, at the first
317
- * character drawn rather than here. Checking the instance is well-formed
318
- * costs one property read and moves the report to the call that caused it.
349
+ * character drawn rather than here. A variable face in either container
350
+ * is read from the sfnt inside it for that reason (`instantiable`), so
351
+ * this is a container that could not be taken apart. Checking the
352
+ * instance is well-formed costs one property read and moves the report to
353
+ * the call that caused it.
319
354
  */
320
355
  _instantiate(coords) {
321
356
  const instance = this.fk.getVariation(coords);
@@ -323,9 +358,9 @@ export default class Font {
323
358
  throw new Error(
324
359
  `ntk: cannot instantiate a variation of ${this.postscriptName || this.key}` +
325
360
  (this.fk.type ? ` (${this.fk.type})` : '') +
326
- '. fontkit can only instantiate axes from an uncompressed font — ' +
327
- 'ship the .ttf/.otf of a variable font rather than its .woff2. ' +
328
- 'See docs/fonts.md#variable-fonts'
361
+ '. fontkit instantiates axes from an uncompressed font only, and ' +
362
+ 'the sfnt inside this file could not be rebuilt — ship the ' +
363
+ '.ttf/.otf of the font. See docs/fonts.md#variable-fonts'
329
364
  );
330
365
  }
331
366
  return instance;
@@ -0,0 +1,388 @@
1
+ // A WOFF or a WOFF2, as the sfnt it was made from.
2
+ //
3
+ // fontkit reads a font out of either container, a table at a time, and that
4
+ // is all most faces are asked for. A variable face is asked for more: an
5
+ // instance, cut at a point of its design space, and fontkit (2.0.4) cuts
6
+ // one by reading the file again as a plain sfnt — a WOFF's directory is
7
+ // another, and a WOFF2 is Brotli-compressed — so the instance came back
8
+ // with no tables, and the first text set at a weight off the file's default
9
+ // threw. A variable web font is nearly always a WOFF2: a page set in one
10
+ // drew nothing.
11
+ //
12
+ // So a variable face that arrives in a container is handed to fontkit as
13
+ // the sfnt inside it (`Font`, text/font.js), and everything after that is
14
+ // the path an uncompressed font takes. Built here rather than fixed there,
15
+ // because there is nowhere to put a fixed fontkit: upstream releases
16
+ // rarely, and a fork has to be named by URL or by git, neither of which npm
17
+ // 12 installs for a dependency of a dependency (`allow-remote` and
18
+ // `allow-git` are `none` unless an application says otherwise).
19
+ //
20
+ // A WOFF is the sfnt's tables, each deflated on its own, and fontkit
21
+ // inflates them. A WOFF2 is the tables in one Brotli stream, which fontkit
22
+ // decompresses, with `glyf` and `loca` *transformed*: the glyphs' contour
23
+ // counts, point counts, flags, coordinates, composites, boxes and
24
+ // instructions are each a stream of their own, and `loca` is left out. The
25
+ // work here is writing those back as the two tables (WOFF2, 5.1 to 5.3).
26
+
27
+ /** Reads big-endian numbers off a byte array, from a position it keeps. */
28
+ class Reader {
29
+ constructor(bytes, pos = 0, end = bytes.length) {
30
+ this.bytes = bytes;
31
+ this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
32
+ this.pos = pos;
33
+ this.end = end;
34
+ }
35
+
36
+ need(n) {
37
+ if (this.pos + n > this.end) throw new RangeError('a stream of the WOFF2 glyf table ends short');
38
+ }
39
+
40
+ u8() {
41
+ this.need(1);
42
+ return this.bytes[this.pos++];
43
+ }
44
+
45
+ u16() {
46
+ this.need(2);
47
+ const value = this.view.getUint16(this.pos);
48
+ this.pos += 2;
49
+ return value;
50
+ }
51
+
52
+ i16() {
53
+ this.need(2);
54
+ const value = this.view.getInt16(this.pos);
55
+ this.pos += 2;
56
+ return value;
57
+ }
58
+
59
+ u32() {
60
+ this.need(4);
61
+ const value = this.view.getUint32(this.pos);
62
+ this.pos += 4;
63
+ return value;
64
+ }
65
+
66
+ take(n) {
67
+ this.need(n);
68
+ const out = this.bytes.subarray(this.pos, this.pos + n);
69
+ this.pos += n;
70
+ return out;
71
+ }
72
+
73
+ /** WOFF2's 255UInt16: a number up to 65535 in one to three bytes. */
74
+ u255() {
75
+ const code = this.u8();
76
+ if (code === 253) return this.u16();
77
+ if (code === 255) return this.u8() + 253;
78
+ if (code === 254) return this.u8() + 506;
79
+ return code;
80
+ }
81
+ }
82
+
83
+ const signed = (flag, value) => (flag & 1 ? value : -value);
84
+
85
+ // glyf: a simple glyph's flags, and a composite's
86
+ const ON_CURVE = 0x01;
87
+ const OVERLAP_SIMPLE = 0x40;
88
+ const ARGS_ARE_WORDS = 0x0001;
89
+ const HAS_SCALE = 0x0008;
90
+ const MORE_COMPONENTS = 0x0020;
91
+ const HAS_XY_SCALE = 0x0040;
92
+ const HAS_TWO_BY_TWO = 0x0080;
93
+ const HAS_INSTRUCTIONS = 0x0100;
94
+
95
+ /**
96
+ * The `glyf` and `loca` tables a WOFF2's transformed `glyf` stands for.
97
+ *
98
+ * A glyph comes out as the points it had, not the bytes: a coordinate is
99
+ * written as a 16-bit step from the one before, where the font it was made
100
+ * from had short forms and repeats. What reads the table reads the same
101
+ * outline, and nothing here is written to a file.
102
+ *
103
+ * @param {Uint8Array} table the transformed table
104
+ * @returns {{ glyf: Uint8Array, loca: Uint8Array }}
105
+ */
106
+ export function untransformGlyf(table) {
107
+ const head = new Reader(table);
108
+ head.u16(); // reserved
109
+ const options = head.u16();
110
+ const numGlyphs = head.u16();
111
+ head.u16(); // the loca format the font had: a long one is written
112
+ const sizes = [];
113
+ for (let i = 0; i < 7; i += 1) sizes.push(head.u32());
114
+ let at = head.pos;
115
+ const stream = (size) => {
116
+ const reader = new Reader(table, at, at + size);
117
+ at += size;
118
+ if (at > table.length) throw new RangeError('the WOFF2 glyf table is shorter than its streams');
119
+ return reader;
120
+ };
121
+ const contours = stream(sizes[0]);
122
+ const pointCounts = stream(sizes[1]);
123
+ const flags = stream(sizes[2]);
124
+ const glyphs = stream(sizes[3]);
125
+ const composites = stream(sizes[4]);
126
+ const boxes = stream(sizes[5]);
127
+ const instructions = stream(sizes[6]);
128
+ // a bit a glyph, set where its box is stored; the boxes follow the bits
129
+ const stored = boxes.take(((numGlyphs + 31) >> 5) << 2);
130
+ const overlaps = options & 1 ? new Reader(table, at).take((numGlyphs + 7) >> 3) : null;
131
+ const bit = (bits, i) => (bits[i >> 3] & (0x80 >> (i & 7))) !== 0;
132
+
133
+ const records = [];
134
+ let total = 0;
135
+ for (let id = 0; id < numGlyphs; id += 1) {
136
+ const count = contours.i16();
137
+ let record = null;
138
+ if (count > 0) {
139
+ record = simpleGlyph(count, {
140
+ pointCounts,
141
+ flags,
142
+ glyphs,
143
+ instructions,
144
+ box: bit(stored, id) ? boxes : null,
145
+ overlap: overlaps !== null && bit(overlaps, id)
146
+ });
147
+ } else if (count < 0) {
148
+ // a composite's box cannot be had from its points: it has to be stored
149
+ if (!bit(stored, id)) throw new RangeError('a composite glyph of the WOFF2 has no box');
150
+ record = compositeGlyph(count, { composites, glyphs, instructions, box: boxes });
151
+ }
152
+ records.push(record);
153
+ if (record) total += (record.length + 3) & ~3;
154
+ }
155
+
156
+ const glyf = new Uint8Array(total);
157
+ const loca = new Uint8Array((numGlyphs + 1) * 4);
158
+ const offsets = new DataView(loca.buffer);
159
+ let offset = 0;
160
+ for (let id = 0; id < numGlyphs; id += 1) {
161
+ offsets.setUint32(id * 4, offset);
162
+ const record = records[id];
163
+ if (!record) continue;
164
+ glyf.set(record, offset);
165
+ offset += (record.length + 3) & ~3;
166
+ }
167
+ offsets.setUint32(numGlyphs * 4, offset);
168
+ return { glyf, loca };
169
+ }
170
+
171
+ function simpleGlyph(count, { pointCounts, flags, glyphs, instructions, box, overlap }) {
172
+ const ends = [];
173
+ let points = 0;
174
+ for (let i = 0; i < count; i += 1) {
175
+ points += pointCounts.u255();
176
+ ends.push(points - 1);
177
+ }
178
+ const on = new Uint8Array(points);
179
+ const xs = new Int32Array(points);
180
+ const ys = new Int32Array(points);
181
+ let x = 0;
182
+ let y = 0;
183
+ let xMin = 0x7fff;
184
+ let yMin = 0x7fff;
185
+ let xMax = -0x8000;
186
+ let yMax = -0x8000;
187
+ for (let i = 0; i < points; i += 1) {
188
+ // a point is a flag and one to four bytes: which, and how they divide
189
+ // between the two steps, is the flag's (WOFF2, 5.2)
190
+ let flag = flags.u8();
191
+ on[i] = flag >> 7 ? 0 : 1;
192
+ flag &= 0x7f;
193
+ let dx = 0;
194
+ let dy = 0;
195
+ if (flag < 10) {
196
+ dy = signed(flag, ((flag & 14) << 7) + glyphs.u8());
197
+ } else if (flag < 20) {
198
+ dx = signed(flag, (((flag - 10) & 14) << 7) + glyphs.u8());
199
+ } else if (flag < 84) {
200
+ const b0 = flag - 20;
201
+ const b1 = glyphs.u8();
202
+ dx = signed(flag, 1 + (b0 & 0x30) + (b1 >> 4));
203
+ dy = signed(flag >> 1, 1 + ((b0 & 0x0c) << 2) + (b1 & 0x0f));
204
+ } else if (flag < 120) {
205
+ const b0 = flag - 84;
206
+ dx = signed(flag, 1 + (Math.floor(b0 / 12) << 8) + glyphs.u8());
207
+ dy = signed(flag >> 1, 1 + (((b0 % 12) >> 2) << 8) + glyphs.u8());
208
+ } else if (flag < 124) {
209
+ const b1 = glyphs.u8();
210
+ const b2 = glyphs.u8();
211
+ dx = signed(flag, (b1 << 4) + (b2 >> 4));
212
+ dy = signed(flag >> 1, ((b2 & 0x0f) << 8) + glyphs.u8());
213
+ } else {
214
+ dx = signed(flag, glyphs.u16());
215
+ dy = signed(flag >> 1, glyphs.u16());
216
+ }
217
+ xs[i] = dx;
218
+ ys[i] = dy;
219
+ x += dx;
220
+ y += dy;
221
+ if (x < xMin) xMin = x;
222
+ if (x > xMax) xMax = x;
223
+ if (y < yMin) yMin = y;
224
+ if (y > yMax) yMax = y;
225
+ }
226
+ const program = instructions.take(glyphs.u255());
227
+ if (box) {
228
+ xMin = box.i16();
229
+ yMin = box.i16();
230
+ xMax = box.i16();
231
+ yMax = box.i16();
232
+ }
233
+
234
+ const out = new Uint8Array(10 + count * 2 + 2 + program.length + points * 5);
235
+ const view = new DataView(out.buffer);
236
+ view.setInt16(0, count);
237
+ view.setInt16(2, xMin);
238
+ view.setInt16(4, yMin);
239
+ view.setInt16(6, xMax);
240
+ view.setInt16(8, yMax);
241
+ let pos = 10;
242
+ for (const end of ends) {
243
+ view.setUint16(pos, end);
244
+ pos += 2;
245
+ }
246
+ view.setUint16(pos, program.length);
247
+ pos += 2;
248
+ out.set(program, pos);
249
+ pos += program.length;
250
+ for (let i = 0; i < points; i += 1) out[pos + i] = on[i] ? ON_CURVE : 0;
251
+ if (overlap && points) out[pos] |= OVERLAP_SIMPLE;
252
+ pos += points;
253
+ for (let i = 0; i < points; i += 1) view.setInt16(pos + i * 2, xs[i]);
254
+ pos += points * 2;
255
+ for (let i = 0; i < points; i += 1) view.setInt16(pos + i * 2, ys[i]);
256
+ return out;
257
+ }
258
+
259
+ function compositeGlyph(count, { composites, glyphs, instructions, box }) {
260
+ // the components are in the stream as they are in a glyf table
261
+ const from = composites.pos;
262
+ let flags;
263
+ let programmed = false;
264
+ do {
265
+ flags = composites.u16();
266
+ let size = 2 + (flags & ARGS_ARE_WORDS ? 4 : 2);
267
+ if (flags & HAS_SCALE) size += 2;
268
+ else if (flags & HAS_XY_SCALE) size += 4;
269
+ else if (flags & HAS_TWO_BY_TWO) size += 8;
270
+ composites.take(size);
271
+ if (flags & HAS_INSTRUCTIONS) programmed = true;
272
+ } while (flags & MORE_COMPONENTS);
273
+ const components = composites.bytes.subarray(from, composites.pos);
274
+ const program = programmed ? instructions.take(glyphs.u255()) : null;
275
+
276
+ const out = new Uint8Array(10 + components.length + (program ? 2 + program.length : 0));
277
+ const view = new DataView(out.buffer);
278
+ view.setInt16(0, count);
279
+ for (let i = 0; i < 4; i += 1) view.setInt16(2 + i * 2, box.i16());
280
+ out.set(components, 10);
281
+ if (program) {
282
+ view.setUint16(10 + components.length, program.length);
283
+ out.set(program, 12 + components.length);
284
+ }
285
+ return out;
286
+ }
287
+
288
+ /** Where a `head` table says which of `loca`'s two formats the font uses. */
289
+ const LOCA_FORMAT = 50;
290
+
291
+ /** A table's checksum: its bytes as 32-bit numbers, the last one padded. */
292
+ function checksum(data) {
293
+ let sum = 0;
294
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
295
+ const whole = data.length & ~3;
296
+ for (let i = 0; i < whole; i += 4) sum = (sum + view.getUint32(i)) >>> 0;
297
+ let last = 0;
298
+ for (let i = whole; i < data.length; i += 1) last |= data[i] << (24 - (i - whole) * 8);
299
+ return (sum + (last >>> 0)) >>> 0;
300
+ }
301
+
302
+ /** An sfnt of `tables`, each `{ tag, data }`, under a version `flavor`. */
303
+ function assemble(flavor, tables) {
304
+ tables.sort((a, b) => (a.tag < b.tag ? -1 : a.tag > b.tag ? 1 : 0));
305
+ const count = tables.length;
306
+ let size = 12 + count * 16;
307
+ for (const table of tables) size += (table.data.length + 3) & ~3;
308
+ const out = new Uint8Array(size);
309
+ const view = new DataView(out.buffer);
310
+ const power = Math.floor(Math.log2(count));
311
+ view.setUint32(0, flavor);
312
+ view.setUint16(4, count);
313
+ view.setUint16(6, 16 << power);
314
+ view.setUint16(8, power);
315
+ view.setUint16(10, count * 16 - (16 << power));
316
+ let offset = 12 + count * 16;
317
+ tables.forEach(({ tag, data }, i) => {
318
+ const entry = 12 + i * 16;
319
+ for (let c = 0; c < 4; c += 1) out[entry + c] = tag.charCodeAt(c);
320
+ view.setUint32(entry + 4, checksum(data));
321
+ view.setUint32(entry + 8, offset);
322
+ view.setUint32(entry + 12, data.length);
323
+ out.set(data, offset);
324
+ offset += (data.length + 3) & ~3;
325
+ });
326
+ return out;
327
+ }
328
+
329
+ /** `ttcf`: a collection, which neither container is read as here. */
330
+ const COLLECTION = 0x74746366;
331
+
332
+ /**
333
+ * The sfnt inside a font fontkit read out of a WOFF or a WOFF2, or null
334
+ * where there is none to be had: a font that is not in a container, a
335
+ * collection, or a WOFF2 whose `hmtx` is transformed, which fontkit does
336
+ * not read either.
337
+ *
338
+ * Reads fontkit's own state — its directory, and the tables as it inflated
339
+ * or decompressed them — so the bytes are decompressed once, by the code
340
+ * that already does it.
341
+ *
342
+ * @param {object} fk a fontkit font
343
+ * @returns {Uint8Array|null}
344
+ */
345
+ export function sfntOf(fk) {
346
+ const entries = fk?.directory?.tables;
347
+ if (!entries || fk.directory.flavor === COLLECTION) return null;
348
+ const tables = [];
349
+
350
+ if (fk.type === 'WOFF') {
351
+ for (const tag of Object.keys(entries)) {
352
+ const entry = entries[tag];
353
+ const stream = fk._getTableStream(tag);
354
+ // a table deflated in the file is a stream of its own, and one that
355
+ // was stored is the file's, at the table
356
+ const data =
357
+ stream === fk.stream
358
+ ? fk.stream.buffer.subarray(entry.offset, entry.offset + entry.length)
359
+ : stream.buffer;
360
+ tables.push({ tag, data });
361
+ }
362
+ return assemble(fk.directory.flavor, tables);
363
+ }
364
+
365
+ if (fk.type !== 'WOFF2') return null;
366
+ fk._decompress();
367
+ const bytes = fk.stream.buffer;
368
+ const stored = (entry) =>
369
+ bytes.subarray(entry.offset, entry.offset + (entry.transformLength ?? entry.length));
370
+ for (const tag of Object.keys(entries)) {
371
+ const entry = entries[tag];
372
+ if (tag === 'loca' && entries.glyf?.transformed) continue;
373
+ if (tag === 'glyf' && entry.transformed) {
374
+ const { glyf, loca } = untransformGlyf(stored(entry));
375
+ tables.push({ tag: 'glyf', data: glyf }, { tag: 'loca', data: loca });
376
+ } else if (entry.transformed) {
377
+ return null;
378
+ } else if (tag === 'head' && entries.glyf?.transformed) {
379
+ // `loca` is written long, whatever it was
380
+ const head = stored(entry).slice();
381
+ new DataView(head.buffer).setInt16(LOCA_FORMAT, 1);
382
+ tables.push({ tag, data: head });
383
+ } else {
384
+ tables.push({ tag, data: stored(entry) });
385
+ }
386
+ }
387
+ return assemble(fk.directory.flavor, tables);
388
+ }
@@ -76,6 +76,26 @@ function attrNum(node, name, fallback = 0) {
76
76
  return Number.isFinite(n) ? n : fallback;
77
77
  }
78
78
 
79
+ /**
80
+ * Whether an element is `display: none`, by its attribute or in its
81
+ * `style`, which wins. Such an element is not rendered, and nor is anything
82
+ * in it, whatever that says of itself: `display` is not inherited, and a
83
+ * child's `display="inline"` brings nothing back (SVG 1.1, 11.5). That is
84
+ * how an editor writes a hidden layer — Illustrator's `<g display="none">`
85
+ * around shapes it marks `display="inline"`.
86
+ */
87
+ function displayNone(node) {
88
+ let v = attr(node, 'display');
89
+ const style = node.attribs?.style;
90
+ if (style && style.includes('display')) {
91
+ for (const decl of style.split(';')) {
92
+ const idx = decl.indexOf(':');
93
+ if (idx > 0 && decl.slice(0, idx).trim() === 'display') v = decl.slice(idx + 1);
94
+ }
95
+ }
96
+ return v !== undefined && String(v).trim().toLowerCase() === 'none';
97
+ }
98
+
79
99
  const NON_RENDERED = new Set([
80
100
  'defs', 'title', 'desc', 'metadata', 'symbol', 'style',
81
101
  'lineargradient', 'radialgradient', 'clippath', 'mask', 'filter', 'pattern', 'marker'
@@ -259,7 +279,7 @@ function scanPaints(root, ids) {
259
279
  const visit = (node, fill, stroke, depth) => {
260
280
  if (multi || depth > 32) return;
261
281
  const name = tag(node);
262
- if (NON_RENDERED.has(name)) return;
282
+ if (NON_RENDERED.has(name) || displayNone(node)) return;
263
283
  const f = paintOf(node, 'fill', fill);
264
284
  const s = paintOf(node, 'stroke', stroke);
265
285
  switch (name) {
@@ -477,9 +497,38 @@ export default class SvgView {
477
497
  return this._gradient(ctx, node, path);
478
498
  }
479
499
 
500
+ /**
501
+ * A gradient and the gradients it takes after: the one its `href` names,
502
+ * the one that names, and so on (SVG 1.1, 13.2.2). What a gradient does
503
+ * not set itself it takes from the first of these that sets it, and its
504
+ * stops, where it has none, from the first that has some. An editor
505
+ * writes a drawing's colours once, in a gradient of stops alone, and
506
+ * every gradient that uses them as one line with an `xlink:href` to it:
507
+ * read without it, each had no stops, and what it filled was not drawn.
508
+ */
509
+ _gradientChain(node) {
510
+ const chain = [node];
511
+ for (let at = node; chain.length < 32;) {
512
+ const href = attr(at, 'href') || attr(at, 'xlink:href') || '';
513
+ const next = href.startsWith('#') ? this._ids.get(href.slice(1)) : null;
514
+ if (!next || chain.includes(next)) break;
515
+ if (tag(next) !== 'lineargradient' && tag(next) !== 'radialgradient') break;
516
+ chain.push(next);
517
+ at = next;
518
+ }
519
+ return chain;
520
+ }
521
+
480
522
  _gradient(ctx, node, path) {
481
- const a = node.attribs || {};
482
- const units = attr(node, 'gradientUnits') || 'objectBoundingBox';
523
+ const chain = this._gradientChain(node);
524
+ const own = (name) => {
525
+ for (const from of chain) {
526
+ const value = attr(from, name);
527
+ if (value !== undefined) return value;
528
+ }
529
+ return undefined;
530
+ };
531
+ const units = own('gradientUnits') || 'objectBoundingBox';
483
532
  const bbox = units === 'objectBoundingBox' ? pathBBox(path) : null;
484
533
  const coord = (raw, fallback, axis) => {
485
534
  let v = raw === undefined ? fallback : parseFloat(raw);
@@ -488,26 +537,45 @@ export default class SvgView {
488
537
  if (!bbox) return v;
489
538
  return axis === 'x' ? bbox.x + v * bbox.w : bbox.y + v * bbox.h;
490
539
  };
540
+ // `gradientTransform` is set in the gradient's own space, the bounding
541
+ // box's where that is its units: here, as the matrix it comes to in
542
+ // the path's user space
543
+ const matrix = gradientMatrix(own('gradientTransform'), bbox);
491
544
  // gradient coordinates are user space, like the path's own — the
492
545
  // context resolves them against the transform in force when it paints,
493
546
  // which is this one (issue #271)
494
547
  let gradient;
495
548
  if (tag(node) === 'lineargradient') {
496
- const x1 = coord(a.x1, 0, 'x');
497
- const y1 = coord(a.y1, 0, 'y');
498
- const x2 = coord(a.x2, 1, 'x');
499
- const y2 = coord(a.y2, 0, 'y');
549
+ let x1 = coord(own('x1'), 0, 'x');
550
+ let y1 = coord(own('y1'), 0, 'y');
551
+ let x2 = coord(own('x2'), 1, 'x');
552
+ let y2 = coord(own('y2'), 0, 'y');
553
+ if (matrix) [x1, y1, x2, y2] = transformedLine(matrix, x1, y1, x2, y2);
500
554
  gradient = ctx.createLinearGradient(x1, y1, x2, y2);
501
555
  } else {
502
- const cx = coord(a.cx, 0.5, 'x');
503
- const cy = coord(a.cy, 0.5, 'y');
504
- let r = a.r === undefined ? 0.5 : parseFloat(a.r);
505
- if (String(a.r ?? '').endsWith('%')) r /= 100;
556
+ let cx = coord(own('cx'), 0.5, 'x');
557
+ let cy = coord(own('cy'), 0.5, 'y');
558
+ const raw = own('r');
559
+ let r = raw === undefined ? 0.5 : parseFloat(raw);
560
+ if (String(raw ?? '').endsWith('%')) r /= 100;
506
561
  if (bbox) r *= (bbox.w + bbox.h) / 2;
562
+ if (matrix) {
563
+ // a circle's centre goes where the matrix takes it, and its radius
564
+ // grows by what the matrix scales an area by: exact for a matrix
565
+ // that turns, moves and scales evenly, and a circle of the same
566
+ // area where one stretches it into an ellipse, which a context's
567
+ // radial gradient is not
568
+ const [a, b, c, d, e, f] = matrix;
569
+ [cx, cy] = [a * cx + c * cy + e, b * cx + d * cy + f];
570
+ r *= Math.sqrt(Math.abs(a * d - b * c));
571
+ }
507
572
  gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
508
573
  }
509
574
 
510
- for (const stop of node.children || []) {
575
+ const stops = chain.find((from) =>
576
+ (from.children || []).some((child) => child.type === 'tag' && tag(child) === 'stop')
577
+ );
578
+ for (const stop of stops?.children || []) {
511
579
  if (stop.type !== 'tag' || tag(stop) !== 'stop') continue;
512
580
  const sa = stop.attribs || {};
513
581
  const decls = {};
@@ -539,7 +607,7 @@ export default class SvgView {
539
607
  _renderNode(node, ctx, parentStyle, alpha, depth) {
540
608
  if (depth > 32) return;
541
609
  const name = tag(node);
542
- if (NON_RENDERED.has(name)) return;
610
+ if (NON_RENDERED.has(name) || displayNone(node)) return;
543
611
 
544
612
  const style = this._style(node, parentStyle);
545
613
  const opacity = parseFloat(node.attribs?.opacity ?? '1');
@@ -627,6 +695,58 @@ export default class SvgView {
627
695
  }
628
696
 
629
697
  // fold an opacity into a CSS color by going through rgba()
698
+ /**
699
+ * A `gradientTransform` as one matrix in the path's user space, or null
700
+ * where there is none. With `objectBoundingBox` units the transform is set
701
+ * in the box's own 0-to-1 space, so it is carried out of it and back:
702
+ * `B · M · B⁻¹`, for the matrix `B` that takes that space to the box.
703
+ */
704
+ function gradientMatrix(value, bbox) {
705
+ const list = parseSvgTransform(value);
706
+ if (!list.length) return null;
707
+ const mul = (m, n) => [
708
+ m[0] * n[0] + m[2] * n[1],
709
+ m[1] * n[0] + m[3] * n[1],
710
+ m[0] * n[2] + m[2] * n[3],
711
+ m[1] * n[2] + m[3] * n[3],
712
+ m[0] * n[4] + m[2] * n[5] + m[4],
713
+ m[1] * n[4] + m[3] * n[5] + m[5]
714
+ ];
715
+ let matrix = list.reduce(mul);
716
+ if (bbox) {
717
+ if (!(bbox.w > 0 && bbox.h > 0)) return null;
718
+ const box = [bbox.w, 0, 0, bbox.h, bbox.x, bbox.y];
719
+ const back = [1 / bbox.w, 0, 0, 1 / bbox.h, -bbox.x / bbox.w, -bbox.y / bbox.h];
720
+ matrix = mul(mul(box, matrix), back);
721
+ }
722
+ return matrix;
723
+ }
724
+
725
+ /**
726
+ * A linear gradient's line under a matrix. Its start goes where the matrix
727
+ * takes it. Its end does only where the matrix keeps right angles: the
728
+ * lines of one colour cross the gradient's line squarely, and go where the
729
+ * matrix takes *them*, so the new line is the one square to those, as long
730
+ * as takes it from the start's line of colour to the end's.
731
+ */
732
+ function transformedLine([a, b, c, d, e, f], x1, y1, x2, y2) {
733
+ const px = a * x1 + c * y1 + e;
734
+ const py = b * x1 + d * y1 + f;
735
+ const qx = a * x2 + c * y2 + e;
736
+ const qy = b * x2 + d * y2 + f;
737
+ // a line of one colour runs square to the gradient's, (-dy, dx)
738
+ const dx = x2 - x1;
739
+ const dy = y2 - y1;
740
+ const lx = a * -dy + c * dx;
741
+ const ly = b * -dy + d * dx;
742
+ // the new line is square to that, (ly, -lx), and as long as (q - p) is
743
+ // along it
744
+ const n2 = lx * lx + ly * ly;
745
+ if (!(n2 > 0)) return [px, py, qx, qy];
746
+ const along = ((qx - px) * ly + (qy - py) * -lx) / n2;
747
+ return [px, py, px + ly * along, py - lx * along];
748
+ }
749
+
630
750
  function rgbaWithAlpha(color, alpha) {
631
751
  // cheap path for #rrggbb / #rgb; anything else goes through rgba() string
632
752
  if (/^#([0-9a-f]{6})$/i.test(color)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.17.1",
3
+ "version": "8.17.3",
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",