ntk 8.17.1 → 8.17.4

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
  }
@@ -396,6 +396,17 @@ function prepareStyle(src, m) {
396
396
  : true;
397
397
  }
398
398
 
399
+ /**
400
+ * Whether every entry can be written as XRender's 16.16 fixed point, whose
401
+ * whole part is a signed 16-bit number. A picture transform is written that
402
+ * way, and the request encoder throws on an entry past it — out of the paint
403
+ * that asked for it, and out of everything drawn after it in the same frame.
404
+ * Not finite fails too.
405
+ */
406
+ function fitsFixed(values) {
407
+ return values.every((v) => Math.abs(v) < 32768);
408
+ }
409
+
399
410
  function isPictureSource(image) {
400
411
  return (
401
412
  image instanceof Image ||
@@ -5503,6 +5514,11 @@ class RenderingContext2d {
5503
5514
  const M = matMultiply(this._m, [dw / sw, 0, 0, dh / sh, dx, dy]);
5504
5515
  const inv = matInvert(M);
5505
5516
  if (!inv) return;
5517
+ // The inverse's linear part is how many source pixels one device pixel
5518
+ // steps across, and it goes on the wire as 16.16 fixed point. Past
5519
+ // 32,767 the image is drawn at under 1/32,768 of its size: less than a
5520
+ // pixel across for any image X can hold, and nothing to see.
5521
+ if (!fitsFixed(inv.slice(0, 4))) return;
5506
5522
 
5507
5523
  // device bbox of the transformed dest rect, clamped to the canvas
5508
5524
  const corners = [
@@ -5523,6 +5539,24 @@ class RenderingContext2d {
5523
5539
  );
5524
5540
  if (x1 <= x0 || y1 <= y0) return;
5525
5541
 
5542
+ // Device -> source sample, measured from the bounding box's corner: the
5543
+ // corner's device position is folded into the transform, and the
5544
+ // composite's source origin is its box's offset from that corner. With
5545
+ // the source origin at device coordinates instead, the translation was
5546
+ // the device position times the downscale — a 50x30 thumbnail of a
5547
+ // 2000x1200 image at x 2,200 is 88,000, past what 16.16 carries, and
5548
+ // the encoder threw. From the corner it is the source coordinate the
5549
+ // corner samples: the image's own size or so, wherever on the surface it
5550
+ // is drawn. The corner is the unclipped one, so a clip that narrows the
5551
+ // box below samples the same points the mask route does.
5552
+ const [a, b, c, d] = inv;
5553
+ const e = a * x0 + c * y0 + inv[4] + sx;
5554
+ const f = b * x0 + d * y0 + inv[5] + sy;
5555
+ // A corner out of reach even so is a draw near the limit above, or an
5556
+ // image over 20,000 pixels across under a rotation; it is not drawn,
5557
+ // rather than thrown out of the paint.
5558
+ if (!fitsFixed([e, f])) return;
5559
+
5526
5560
  // A rectangular clip shrinks the box instead of becoming a mask (issue
5527
5561
  // #307). Decided before the picture transform is installed, so a draw
5528
5562
  // the clip rejects does not have to put it back.
@@ -5531,27 +5565,15 @@ class RenderingContext2d {
5531
5565
  if (direct && !direct.box) return;
5532
5566
  const box = direct ? direct.box : bbox;
5533
5567
 
5534
- // device -> source sample (adding the source-rect origin)
5535
- const srcM = [inv[0], inv[1], inv[2], inv[3], inv[4] + sx, inv[5] + sy];
5536
- this.Render.SetPictureTransform(picture.id, [
5537
- srcM[0],
5538
- srcM[2],
5539
- srcM[4],
5540
- srcM[1],
5541
- srcM[3],
5542
- srcM[5],
5543
- 0,
5544
- 0,
5545
- 1,
5546
- ]);
5568
+ this.Render.SetPictureTransform(picture.id, [a, c, e, b, d, f, 0, 0, 1]);
5547
5569
  picture.setFilter("bilinear");
5548
5570
  this.Render.Composite(
5549
5571
  op,
5550
5572
  picture.id,
5551
5573
  direct ? direct.mask : this._compositeMask(box),
5552
5574
  this._dst(),
5553
- box.x,
5554
- box.y,
5575
+ box.x - x0,
5576
+ box.y - y0,
5555
5577
  box.x,
5556
5578
  box.y,
5557
5579
  box.x,
@@ -5701,7 +5723,7 @@ class CanvasGradient {
5701
5723
  if (k !== 1) inv = inv.map((v) => v / k);
5702
5724
  // the transform is 16.16 fixed point too: one that cannot be carried
5703
5725
  // fills nothing, rather than throwing the rest of the paint away
5704
- if (!inv.every((v) => Math.abs(v) < 32767)) return false;
5726
+ if (!fitsFixed(inv)) return false;
5705
5727
  const a = this._applied;
5706
5728
  if (
5707
5729
  inv[0] !== a[0] ||
@@ -5831,6 +5853,11 @@ class CanvasPattern {
5831
5853
  const m = matMultiply(ctm, this._m);
5832
5854
  const inv = matInvert(m);
5833
5855
  if (!inv) return false;
5856
+ // 16.16 fixed point, as a gradient's is: a tile scaled down far from
5857
+ // the origin puts its device position times the downscale into the
5858
+ // translation, and one that cannot be carried fills nothing rather than
5859
+ // throwing the rest of the paint away
5860
+ if (!fitsFixed(inv)) return false;
5834
5861
  const a = this._applied;
5835
5862
  if (
5836
5863
  inv[0] !== a[0] ||
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
+ }
@@ -16,8 +16,9 @@
16
16
  //
17
17
  // Supported: path/rect/circle/ellipse/line/polyline/polygon, <g>, <defs>,
18
18
  // <use>, linear/radial gradients (objectBoundingBox and userSpaceOnUse),
19
- // transform lists, opacity/fill-opacity/stroke-opacity, fill-rule, basic
20
- // <text>. See docs/svg.md for the full surface and limitations.
19
+ // transform lists, opacity/fill-opacity/stroke-opacity, display, visibility,
20
+ // nested <svg> viewports, fill-rule, basic <text>. See docs/svg.md for the
21
+ // full surface and limitations.
21
22
 
22
23
  import { textContent } from 'domutils';
23
24
  import { parseDocument } from 'htmlparser2';
@@ -37,7 +38,8 @@ const INHERITED = {
37
38
  color: '#000',
38
39
  fontFamily: 'sans-serif',
39
40
  fontSize: 16,
40
- textAnchor: 'start'
41
+ textAnchor: 'start',
42
+ visibility: 'visible'
41
43
  };
42
44
 
43
45
  const STYLE_ATTRS = {
@@ -53,10 +55,13 @@ const STYLE_ATTRS = {
53
55
  color: 'color',
54
56
  'font-family': 'fontFamily',
55
57
  'font-size': 'fontSize',
56
- 'text-anchor': 'textAnchor'
58
+ 'text-anchor': 'textAnchor',
59
+ visibility: 'visibility'
57
60
  };
58
61
 
59
- const NUMERIC = new Set(['strokeWidth', 'miterLimit', 'fillOpacity', 'strokeOpacity', 'fontSize']);
62
+ const NUMERIC = new Set(['strokeWidth', 'miterLimit', 'fontSize']);
63
+ const ALPHA = new Set(['fillOpacity', 'strokeOpacity']);
64
+ const VISIBILITY = new Set(['visible', 'hidden', 'collapse']);
60
65
 
61
66
  // documents may come from an XML parse (setSvg: exact case) or from an HTML
62
67
  // parse (inline <svg>: tag/attribute names lowercased) — compare
@@ -76,6 +81,99 @@ function attrNum(node, name, fallback = 0) {
76
81
  return Number.isFinite(n) ? n : fallback;
77
82
  }
78
83
 
84
+ /**
85
+ * A property that is not inherited, as an element sets it itself: in its
86
+ * `style`, the last declaration of it there that `read` makes something
87
+ * of, or else by its presentation attribute — the order `resolveStyle`
88
+ * applies the inherited ones in. `undefined` where it sets it neither way.
89
+ */
90
+ function ownProperty(node, name, read) {
91
+ const raw = attr(node, name);
92
+ let v = raw === undefined ? undefined : read(raw);
93
+ const style = node.attribs?.style;
94
+ if (style && style.includes(name)) {
95
+ for (const decl of style.split(';')) {
96
+ const idx = decl.indexOf(':');
97
+ if (idx > 0 && decl.slice(0, idx).trim() === name) v = read(decl.slice(idx + 1)) ?? v;
98
+ }
99
+ }
100
+ return v;
101
+ }
102
+
103
+ const keyword = (raw) => String(raw).trim().toLowerCase() || undefined;
104
+
105
+ /**
106
+ * Whether an element is `display: none`, by its attribute or in its
107
+ * `style`, which wins. Such an element is not rendered, and nor is anything
108
+ * in it, whatever that says of itself: `display` is not inherited, and a
109
+ * child's `display="inline"` brings nothing back (SVG 1.1, 11.5). That is
110
+ * how an editor writes a hidden layer — Illustrator's `<g display="none">`
111
+ * around shapes it marks `display="inline"`.
112
+ */
113
+ function displayNone(node) {
114
+ return ownProperty(node, 'display', keyword) === 'none';
115
+ }
116
+
117
+ /**
118
+ * An `<alpha-value>` — `opacity`, `fill-opacity`, `stroke-opacity`: a number
119
+ * or a percentage, clamped to 0..1 (CSS Color 4, 5.2). `undefined` for
120
+ * anything else, so the caller keeps what it had.
121
+ */
122
+ function alphaValue(raw) {
123
+ const v = String(raw ?? '').trim();
124
+ let n = parseFloat(v);
125
+ if (!Number.isFinite(n)) return undefined;
126
+ if (v.endsWith('%')) n /= 100;
127
+ return Math.min(1, Math.max(0, n));
128
+ }
129
+
130
+ /** A `viewBox` as `[minX, minY, width, height]`, or null where it has none. */
131
+ function parseViewBox(node) {
132
+ const vb = (attr(node, 'viewBox') || '')
133
+ .split(/[\s,]+/)
134
+ .filter((s) => s !== '')
135
+ .map(parseFloat);
136
+ return vb.length === 4 && vb.every(Number.isFinite) ? vb : null;
137
+ }
138
+
139
+ /**
140
+ * A length on a viewport-establishing element — `x`, `y`, `width`, `height`
141
+ * — in the user units of the viewport it sits in, `size` along its axis;
142
+ * a percentage is of that. `auto` and a missing or unreadable value are the
143
+ * `fallback`.
144
+ */
145
+ function viewportLength(node, name, size, fallback) {
146
+ const v = String(attr(node, name) ?? '').trim();
147
+ const n = parseFloat(v);
148
+ if (!Number.isFinite(n)) return fallback;
149
+ return v.endsWith('%') ? (n / 100) * size : n;
150
+ }
151
+
152
+ const ALIGN = /^x(Min|Mid|Max)Y(Min|Mid|Max)$/;
153
+ const ALONG = { Min: 0, Mid: 0.5, Max: 1 };
154
+
155
+ /**
156
+ * The transform that fits a `viewBox` into a viewport `w` by `h` at `x`,`y`,
157
+ * as `preserveAspectRatio` asks (SVG 2, 8.8): scaled evenly to fit inside
158
+ * (`meet`, the default) or to cover it (`slice`), and set where its
159
+ * alignment says, `xMidYMid` by default; or stretched to it for `none`.
160
+ *
161
+ * @returns {[number, number, number, number]} `[sx, sy, tx, ty]`
162
+ */
163
+ function viewBoxFit([minX, minY, vbW, vbH], x, y, w, h, par) {
164
+ const words = String(par || '').trim().split(/\s+/);
165
+ if (words[0] === 'defer') words.shift();
166
+ const [align, meetOrSlice] = words;
167
+ let sx = w / vbW;
168
+ let sy = h / vbH;
169
+ if (align === 'none') return [sx, sy, x - minX * sx, y - minY * sy];
170
+ // a value it cannot read is the default as a whole, xMidYMid meet
171
+ const m = ALIGN.exec(align || '');
172
+ const [fx, fy] = m ? [ALONG[m[1]], ALONG[m[2]]] : [0.5, 0.5];
173
+ sx = sy = m && meetOrSlice === 'slice' ? Math.max(sx, sy) : Math.min(sx, sy);
174
+ return [sx, sy, x - minX * sx + (w - vbW * sx) * fx, y - minY * sy + (h - vbH * sy) * fy];
175
+ }
176
+
79
177
  const NON_RENDERED = new Set([
80
178
  'defs', 'title', 'desc', 'metadata', 'symbol', 'style',
81
179
  'lineargradient', 'radialgradient', 'clippath', 'mask', 'filter', 'pattern', 'marker'
@@ -202,6 +300,45 @@ function pathBBox(path) {
202
300
  return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
203
301
  }
204
302
 
303
+ /**
304
+ * The inherited properties an element ends up with: its parent's, where
305
+ * the element sets them neither by presentation attribute nor in its inline
306
+ * `style`, which wins over the attribute. A value it cannot read is dropped,
307
+ * as CSS drops an invalid declaration. Returns `parent` itself where
308
+ * nothing changes.
309
+ */
310
+ function resolveStyle(node, parent) {
311
+ let style = parent;
312
+ const own = () => (style === parent ? (style = { ...parent }) : style);
313
+ const apply = (name, raw) => {
314
+ const key = STYLE_ATTRS[name];
315
+ if (!key || raw === undefined || raw === '' || raw === 'inherit') return;
316
+ const v = String(raw).trim();
317
+ if (NUMERIC.has(key)) {
318
+ const n = parseFloat(v);
319
+ if (!Number.isFinite(n)) return;
320
+ own()[key] = n;
321
+ } else if (ALPHA.has(key)) {
322
+ const n = alphaValue(v);
323
+ if (n === undefined) return;
324
+ own()[key] = n;
325
+ } else if (key === 'visibility') {
326
+ const k = v.toLowerCase();
327
+ if (!VISIBILITY.has(k)) return;
328
+ own()[key] = k;
329
+ } else {
330
+ own()[key] = v;
331
+ }
332
+ };
333
+ for (const [name, value] of Object.entries(node.attribs || {})) apply(name, value);
334
+ // inline style="" wins over presentation attributes
335
+ for (const decl of (node.attribs?.style || '').split(';')) {
336
+ const idx = decl.indexOf(':');
337
+ if (idx > 0) apply(decl.slice(0, idx).trim(), decl.slice(idx + 1).trim());
338
+ }
339
+ return style;
340
+ }
341
+
205
342
  /**
206
343
  * How many distinct colours a document actually commits to, decided once at
207
344
  * parse time.
@@ -216,11 +353,13 @@ function pathBBox(path) {
216
353
  * Opacity does not enter into it: `opacity`, `fill-opacity` and
217
354
  * `stroke-opacity` scale coverage, which a mask carries perfectly well.
218
355
  *
219
- * The walk mirrors `_style` and `_renderNode` — fill and stroke inherit,
220
- * inline `style` beats the presentation attribute, the initial fill is black
221
- * and the initial stroke is none, `<line>` never fills, `<use>` paints its
222
- * target with the *use* element's style, and non-rendered subtrees
223
- * contribute nothing. It starts at the root element itself, because `draw`
356
+ * The walk mirrors `_renderNode`, and reads style with the same
357
+ * `resolveStyle` — fill and stroke inherit, inline `style` beats the
358
+ * presentation attribute, the initial fill is black and the initial stroke
359
+ * is none, `<line>` never fills, `<use>` paints its target with the *use*
360
+ * element's style, a shape whose inherited `visibility` is not `visible`
361
+ * paints nothing, and non-rendered and `display: none` subtrees contribute
362
+ * nothing. It starts at the root element itself, because `draw`
224
363
  * does: the root `<svg>` is an ordinary element for inheritance, and every
225
364
  * mainstream icon set puts `fill`/`stroke` there (issue #306). The two walks
226
365
  * have to agree — a scan that skipped the root would disagree with what is
@@ -234,59 +373,48 @@ function scanPaints(root, ids) {
234
373
  const paints = new Set();
235
374
  let multi = false;
236
375
 
237
- const paintOf = (node, name, inherited) => {
238
- let v = attr(node, name);
239
- for (const decl of (node.attribs?.style || '').split(';')) {
240
- const idx = decl.indexOf(':');
241
- if (idx > 0 && decl.slice(0, idx).trim() === name) v = decl.slice(idx + 1);
242
- }
243
- if (v === undefined || v === '' || v === 'inherit') return inherited;
244
- return String(v).trim();
245
- };
246
-
247
376
  const note = (paint) => {
248
377
  if (paint === 'none') return;
249
378
  if (/^url\(/i.test(paint)) multi = true;
250
379
  else paints.add(paint);
251
380
  };
252
381
 
253
- const kids = (node, fill, stroke, depth) => {
382
+ const kids = (node, style, depth) => {
254
383
  for (const child of node.children || []) {
255
- if (child.type === 'tag') visit(child, fill, stroke, depth + 1);
384
+ if (child.type === 'tag') visit(child, style, depth + 1);
256
385
  }
257
386
  };
258
387
 
259
- const visit = (node, fill, stroke, depth) => {
388
+ const visit = (node, parent, depth) => {
260
389
  if (multi || depth > 32) return;
261
390
  const name = tag(node);
262
- if (NON_RENDERED.has(name)) return;
263
- const f = paintOf(node, 'fill', fill);
264
- const s = paintOf(node, 'stroke', stroke);
391
+ if (NON_RENDERED.has(name) || displayNone(node)) return;
392
+ const style = resolveStyle(node, parent);
265
393
  switch (name) {
266
394
  case 'svg':
267
395
  case 'g':
268
396
  case 'a':
269
- kids(node, f, s, depth);
397
+ kids(node, style, depth);
270
398
  return;
271
399
  case 'use': {
272
400
  const href = node.attribs?.href || node.attribs?.['xlink:href'] || '';
273
401
  const target = href.startsWith('#') ? ids.get(href.slice(1)) : null;
274
402
  if (!target) return;
275
403
  // <symbol> is non-rendered on its own but renders through <use>
276
- if (tag(target) === 'symbol') kids(target, f, s, depth);
277
- else visit(target, f, s, depth + 1);
404
+ if (tag(target) === 'symbol') kids(target, style, depth);
405
+ else visit(target, style, depth + 1);
278
406
  return;
279
407
  }
280
- case 'text':
281
- note(f);
282
- return;
283
408
  default:
284
- if (name !== 'line') note(f);
285
- note(s);
409
+ // a hidden shape paints nothing, though what is in a hidden
410
+ // group may show itself again
411
+ if (style.visibility !== 'visible') return;
412
+ if (name !== 'line') note(style.fill);
413
+ if (name !== 'text') note(style.stroke);
286
414
  }
287
415
  };
288
416
 
289
- visit(root, INHERITED.fill, INHERITED.stroke, 0);
417
+ visit(root, INHERITED, 0);
290
418
  return {
291
419
  kind: multi || paints.size > 1 ? 'multi' : 'mono',
292
420
  solo: paints.size === 1 ? [...paints][0] : null
@@ -313,6 +441,7 @@ export default class SvgView {
313
441
 
314
442
  this._root = null;
315
443
  this._ids = new Map();
444
+ this._viewport = [0, 0];
316
445
  this.naturalWidth = 0;
317
446
  this.naturalHeight = 0;
318
447
  this.viewBox = null;
@@ -370,11 +499,7 @@ export default class SvgView {
370
499
  this.soloPaint = scanned.solo;
371
500
 
372
501
  const a = this._root.attribs || {};
373
- const vb = (attr(this._root, 'viewBox') || '')
374
- .split(/[\s,]+/)
375
- .filter((s) => s !== '')
376
- .map(parseFloat);
377
- this.viewBox = vb.length === 4 && vb.every(Number.isFinite) ? vb : null;
502
+ this.viewBox = parseViewBox(this._root);
378
503
  const w = parseFloat(a.width);
379
504
  const h = parseFloat(a.height);
380
505
  this.naturalWidth = Number.isFinite(w) && !String(a.width || '').endsWith('%') ? w : this.viewBox ? this.viewBox[2] : 300;
@@ -423,14 +548,19 @@ export default class SvgView {
423
548
  if (!this._root) return;
424
549
  ctx.save();
425
550
  ctx.translate(x, y);
551
+ // the user-space size of the viewport, what a nested <svg>'s
552
+ // percentages are of
553
+ this._viewport = [w, h];
426
554
  if (this.viewBox) {
427
555
  const [minX, minY, vbW, vbH] = this.viewBox;
428
556
  if (vbW > 0 && vbH > 0) {
429
557
  ctx.scale(w / vbW, h / vbH);
430
558
  ctx.translate(-minX, -minY);
559
+ this._viewport = [vbW, vbH];
431
560
  }
432
561
  } else if (this.naturalWidth > 0 && this.naturalHeight > 0) {
433
562
  ctx.scale(w / this.naturalWidth, h / this.naturalHeight);
563
+ this._viewport = [this.naturalWidth, this.naturalHeight];
434
564
  }
435
565
  this._renderChildren(
436
566
  this._root,
@@ -445,27 +575,7 @@ export default class SvgView {
445
575
  // ------------------------------------------------------------------
446
576
 
447
577
  _style(node, parent) {
448
- let style = parent;
449
- const own = () => (style === parent ? (style = { ...parent }) : style);
450
- const apply = (name, raw) => {
451
- const key = STYLE_ATTRS[name];
452
- if (!key || raw === undefined || raw === '' || raw === 'inherit') return;
453
- let v = String(raw).trim();
454
- if (NUMERIC.has(key)) {
455
- const n = parseFloat(v);
456
- if (!Number.isFinite(n)) return;
457
- own()[key] = n;
458
- } else {
459
- own()[key] = v;
460
- }
461
- };
462
- for (const [name, value] of Object.entries(node.attribs || {})) apply(name, value);
463
- // inline style="" wins over presentation attributes
464
- for (const decl of (node.attribs?.style || '').split(';')) {
465
- const idx = decl.indexOf(':');
466
- if (idx > 0) apply(decl.slice(0, idx).trim(), decl.slice(idx + 1).trim());
467
- }
468
- return style;
578
+ return resolveStyle(node, parent);
469
579
  }
470
580
 
471
581
  _paint(ctx, value, style, path) {
@@ -477,9 +587,38 @@ export default class SvgView {
477
587
  return this._gradient(ctx, node, path);
478
588
  }
479
589
 
590
+ /**
591
+ * A gradient and the gradients it takes after: the one its `href` names,
592
+ * the one that names, and so on (SVG 1.1, 13.2.2). What a gradient does
593
+ * not set itself it takes from the first of these that sets it, and its
594
+ * stops, where it has none, from the first that has some. An editor
595
+ * writes a drawing's colours once, in a gradient of stops alone, and
596
+ * every gradient that uses them as one line with an `xlink:href` to it:
597
+ * read without it, each had no stops, and what it filled was not drawn.
598
+ */
599
+ _gradientChain(node) {
600
+ const chain = [node];
601
+ for (let at = node; chain.length < 32;) {
602
+ const href = attr(at, 'href') || attr(at, 'xlink:href') || '';
603
+ const next = href.startsWith('#') ? this._ids.get(href.slice(1)) : null;
604
+ if (!next || chain.includes(next)) break;
605
+ if (tag(next) !== 'lineargradient' && tag(next) !== 'radialgradient') break;
606
+ chain.push(next);
607
+ at = next;
608
+ }
609
+ return chain;
610
+ }
611
+
480
612
  _gradient(ctx, node, path) {
481
- const a = node.attribs || {};
482
- const units = attr(node, 'gradientUnits') || 'objectBoundingBox';
613
+ const chain = this._gradientChain(node);
614
+ const own = (name) => {
615
+ for (const from of chain) {
616
+ const value = attr(from, name);
617
+ if (value !== undefined) return value;
618
+ }
619
+ return undefined;
620
+ };
621
+ const units = own('gradientUnits') || 'objectBoundingBox';
483
622
  const bbox = units === 'objectBoundingBox' ? pathBBox(path) : null;
484
623
  const coord = (raw, fallback, axis) => {
485
624
  let v = raw === undefined ? fallback : parseFloat(raw);
@@ -488,26 +627,45 @@ export default class SvgView {
488
627
  if (!bbox) return v;
489
628
  return axis === 'x' ? bbox.x + v * bbox.w : bbox.y + v * bbox.h;
490
629
  };
630
+ // `gradientTransform` is set in the gradient's own space, the bounding
631
+ // box's where that is its units: here, as the matrix it comes to in
632
+ // the path's user space
633
+ const matrix = gradientMatrix(own('gradientTransform'), bbox);
491
634
  // gradient coordinates are user space, like the path's own — the
492
635
  // context resolves them against the transform in force when it paints,
493
636
  // which is this one (issue #271)
494
637
  let gradient;
495
638
  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');
639
+ let x1 = coord(own('x1'), 0, 'x');
640
+ let y1 = coord(own('y1'), 0, 'y');
641
+ let x2 = coord(own('x2'), 1, 'x');
642
+ let y2 = coord(own('y2'), 0, 'y');
643
+ if (matrix) [x1, y1, x2, y2] = transformedLine(matrix, x1, y1, x2, y2);
500
644
  gradient = ctx.createLinearGradient(x1, y1, x2, y2);
501
645
  } 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;
646
+ let cx = coord(own('cx'), 0.5, 'x');
647
+ let cy = coord(own('cy'), 0.5, 'y');
648
+ const raw = own('r');
649
+ let r = raw === undefined ? 0.5 : parseFloat(raw);
650
+ if (String(raw ?? '').endsWith('%')) r /= 100;
506
651
  if (bbox) r *= (bbox.w + bbox.h) / 2;
652
+ if (matrix) {
653
+ // a circle's centre goes where the matrix takes it, and its radius
654
+ // grows by what the matrix scales an area by: exact for a matrix
655
+ // that turns, moves and scales evenly, and a circle of the same
656
+ // area where one stretches it into an ellipse, which a context's
657
+ // radial gradient is not
658
+ const [a, b, c, d, e, f] = matrix;
659
+ [cx, cy] = [a * cx + c * cy + e, b * cx + d * cy + f];
660
+ r *= Math.sqrt(Math.abs(a * d - b * c));
661
+ }
507
662
  gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
508
663
  }
509
664
 
510
- for (const stop of node.children || []) {
665
+ const stops = chain.find((from) =>
666
+ (from.children || []).some((child) => child.type === 'tag' && tag(child) === 'stop')
667
+ );
668
+ for (const stop of stops?.children || []) {
511
669
  if (stop.type !== 'tag' || tag(stop) !== 'stop') continue;
512
670
  const sa = stop.attribs || {};
513
671
  const decls = {};
@@ -536,14 +694,55 @@ export default class SvgView {
536
694
  }
537
695
  }
538
696
 
697
+ /**
698
+ * A nested `<svg>`, which is a new viewport and not a group (SVG 2, 8.2):
699
+ * `width` by `height` at `x`,`y` of the user space it sits in, all of the
700
+ * viewport around it where they are unset, and percentages of that. Its
701
+ * `viewBox` is fitted in as `preserveAspectRatio` says, and what it draws
702
+ * is clipped to it unless its `overflow` is `visible` or `auto`. A zero
703
+ * or negative size, or a zero-sized `viewBox`, draws nothing.
704
+ */
705
+ _renderViewport(node, ctx, style, alpha, depth) {
706
+ const outer = this._viewport;
707
+ const [pw, ph] = outer;
708
+ const x = viewportLength(node, 'x', pw, 0);
709
+ const y = viewportLength(node, 'y', ph, 0);
710
+ const w = viewportLength(node, 'width', pw, pw);
711
+ const h = viewportLength(node, 'height', ph, ph);
712
+ if (!(w > 0) || !(h > 0)) return;
713
+ // a negative size makes the viewBox an error, and it is ignored
714
+ let vb = parseViewBox(node);
715
+ if (vb && (vb[2] === 0 || vb[3] === 0)) return;
716
+ if (vb && (vb[2] < 0 || vb[3] < 0)) vb = null;
717
+
718
+ ctx.save();
719
+ const overflow = ownProperty(node, 'overflow', keyword);
720
+ if (overflow !== 'visible' && overflow !== 'auto') {
721
+ const clip = new Path2D();
722
+ clip.rect(x, y, w, h);
723
+ ctx.clip(clip);
724
+ }
725
+ if (vb) {
726
+ const [sx, sy, tx, ty] = viewBoxFit(vb, x, y, w, h, attr(node, 'preserveAspectRatio'));
727
+ ctx.transform(sx, 0, 0, sy, tx, ty);
728
+ this._viewport = [vb[2], vb[3]];
729
+ } else {
730
+ if (x || y) ctx.translate(x, y);
731
+ this._viewport = [w, h];
732
+ }
733
+ this._renderChildren(node, ctx, style, alpha, depth + 1);
734
+ this._viewport = outer;
735
+ ctx.restore();
736
+ }
737
+
539
738
  _renderNode(node, ctx, parentStyle, alpha, depth) {
540
739
  if (depth > 32) return;
541
740
  const name = tag(node);
542
- if (NON_RENDERED.has(name)) return;
741
+ if (NON_RENDERED.has(name) || displayNone(node)) return;
543
742
 
544
743
  const style = this._style(node, parentStyle);
545
- const opacity = parseFloat(node.attribs?.opacity ?? '1');
546
- const nodeAlpha = alpha * (Number.isFinite(opacity) ? Math.min(1, Math.max(0, opacity)) : 1);
744
+ // not inherited: a group's multiplies down through what is in it
745
+ const nodeAlpha = alpha * (ownProperty(node, 'opacity', alphaValue) ?? 1);
547
746
  if (nodeAlpha <= 0) return;
548
747
 
549
748
  const transforms = parseSvgTransform(node.attribs?.transform);
@@ -555,6 +754,8 @@ export default class SvgView {
555
754
 
556
755
  switch (name) {
557
756
  case 'svg':
757
+ this._renderViewport(node, ctx, style, nodeAlpha, depth);
758
+ break;
558
759
  case 'g':
559
760
  case 'a':
560
761
  this._renderChildren(node, ctx, style, nodeAlpha, depth + 1);
@@ -574,9 +775,13 @@ export default class SvgView {
574
775
  break;
575
776
  }
576
777
  case 'text':
577
- this._renderText(node, ctx, style, nodeAlpha);
778
+ if (style.visibility === 'visible') this._renderText(node, ctx, style, nodeAlpha);
578
779
  break;
579
780
  default: {
781
+ // `visibility` hides what an element paints itself, and is
782
+ // inherited: a group walks on, since what is in it may say
783
+ // `visible` again
784
+ if (style.visibility !== 'visible') break;
580
785
  const path = shapePath(node);
581
786
  if (!path) break;
582
787
  if (name !== 'line' && style.fill !== 'none') {
@@ -627,6 +832,58 @@ export default class SvgView {
627
832
  }
628
833
 
629
834
  // fold an opacity into a CSS color by going through rgba()
835
+ /**
836
+ * A `gradientTransform` as one matrix in the path's user space, or null
837
+ * where there is none. With `objectBoundingBox` units the transform is set
838
+ * in the box's own 0-to-1 space, so it is carried out of it and back:
839
+ * `B · M · B⁻¹`, for the matrix `B` that takes that space to the box.
840
+ */
841
+ function gradientMatrix(value, bbox) {
842
+ const list = parseSvgTransform(value);
843
+ if (!list.length) return null;
844
+ const mul = (m, n) => [
845
+ m[0] * n[0] + m[2] * n[1],
846
+ m[1] * n[0] + m[3] * n[1],
847
+ m[0] * n[2] + m[2] * n[3],
848
+ m[1] * n[2] + m[3] * n[3],
849
+ m[0] * n[4] + m[2] * n[5] + m[4],
850
+ m[1] * n[4] + m[3] * n[5] + m[5]
851
+ ];
852
+ let matrix = list.reduce(mul);
853
+ if (bbox) {
854
+ if (!(bbox.w > 0 && bbox.h > 0)) return null;
855
+ const box = [bbox.w, 0, 0, bbox.h, bbox.x, bbox.y];
856
+ const back = [1 / bbox.w, 0, 0, 1 / bbox.h, -bbox.x / bbox.w, -bbox.y / bbox.h];
857
+ matrix = mul(mul(box, matrix), back);
858
+ }
859
+ return matrix;
860
+ }
861
+
862
+ /**
863
+ * A linear gradient's line under a matrix. Its start goes where the matrix
864
+ * takes it. Its end does only where the matrix keeps right angles: the
865
+ * lines of one colour cross the gradient's line squarely, and go where the
866
+ * matrix takes *them*, so the new line is the one square to those, as long
867
+ * as takes it from the start's line of colour to the end's.
868
+ */
869
+ function transformedLine([a, b, c, d, e, f], x1, y1, x2, y2) {
870
+ const px = a * x1 + c * y1 + e;
871
+ const py = b * x1 + d * y1 + f;
872
+ const qx = a * x2 + c * y2 + e;
873
+ const qy = b * x2 + d * y2 + f;
874
+ // a line of one colour runs square to the gradient's, (-dy, dx)
875
+ const dx = x2 - x1;
876
+ const dy = y2 - y1;
877
+ const lx = a * -dy + c * dx;
878
+ const ly = b * -dy + d * dx;
879
+ // the new line is square to that, (ly, -lx), and as long as (q - p) is
880
+ // along it
881
+ const n2 = lx * lx + ly * ly;
882
+ if (!(n2 > 0)) return [px, py, qx, qy];
883
+ const along = ((qx - px) * ly + (qy - py) * -lx) / n2;
884
+ return [px, py, px + ly * along, py - lx * along];
885
+ }
886
+
630
887
  function rgbaWithAlpha(color, alpha) {
631
888
  // cheap path for #rrggbb / #rgb; anything else goes through rgba() string
632
889
  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.4",
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",