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 +21 -1
- package/lib/renderingcontext_2d.js +43 -16
- package/lib/text/font.js +48 -13
- package/lib/text/sfnt.js +388 -0
- package/lib/widgets/svgview.js +333 -76
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
342
|
+
* that says what happened.
|
|
311
343
|
*
|
|
312
|
-
* `getVariation` rebuilds the face
|
|
313
|
-
*
|
|
314
|
-
* Brotli-compressed
|
|
315
|
-
*
|
|
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.
|
|
318
|
-
*
|
|
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
|
|
327
|
-
'
|
|
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;
|
package/lib/text/sfnt.js
ADDED
|
@@ -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
|
+
}
|
package/lib/widgets/svgview.js
CHANGED
|
@@ -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,
|
|
20
|
-
// <text>. See docs/svg.md for the
|
|
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', '
|
|
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 `
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
* target with the *use*
|
|
223
|
-
*
|
|
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,
|
|
382
|
+
const kids = (node, style, depth) => {
|
|
254
383
|
for (const child of node.children || []) {
|
|
255
|
-
if (child.type === 'tag') visit(child,
|
|
384
|
+
if (child.type === 'tag') visit(child, style, depth + 1);
|
|
256
385
|
}
|
|
257
386
|
};
|
|
258
387
|
|
|
259
|
-
const visit = (node,
|
|
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
|
|
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,
|
|
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,
|
|
277
|
-
else visit(target,
|
|
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
|
-
|
|
285
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
482
|
-
const
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
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
|
-
|
|
546
|
-
const nodeAlpha = alpha * (
|
|
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)) {
|