ntk 2.2.0 → 3.1.0
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/README.md +156 -151
- package/lib/app.js +64 -21
- package/lib/cleanup.js +14 -0
- package/lib/drawable.js +12 -18
- package/lib/events_map.js +92 -98
- package/lib/fontconfig.js +123 -17
- package/lib/glyphset.js +40 -21
- package/lib/image.js +138 -0
- package/lib/index.js +129 -57
- package/lib/path.js +734 -0
- package/lib/picture.js +84 -41
- package/lib/pixmap.js +50 -39
- package/lib/rasterize.js +178 -0
- package/lib/renderingcontext_2d.js +1078 -695
- package/lib/renderingcontext_opengl.js +53 -46
- package/lib/renderingcontext_x11.js +59 -67
- package/lib/text/font.js +196 -0
- package/lib/text/fontmanager.js +233 -0
- package/lib/text/fontsource.js +179 -0
- package/lib/text/glyphs.js +456 -0
- package/lib/text/layout.js +356 -0
- package/lib/text/shape.js +141 -0
- package/lib/trapezoid.js +131 -0
- package/lib/widgets/css.js +676 -0
- package/lib/widgets/highlight.js +97 -0
- package/lib/widgets/htmlview.js +879 -0
- package/lib/widgets/markdown.js +148 -0
- package/lib/widgets/markdownview.js +487 -0
- package/lib/widgets/mermaid.js +720 -0
- package/lib/widgets/svgview.js +528 -0
- package/lib/widgets/tex.js +681 -0
- package/lib/window.js +691 -308
- package/package.json +60 -56
- package/examples/example-await.js +0 -23
- package/examples/example-co.js +0 -13
- package/examples/example.js +0 -14
- package/examples/fill-styles.js +0 -39
- package/examples/glclock.js +0 -100
- package/examples/glxpixmap.js +0 -12
- package/examples/gradient.js +0 -41
- package/examples/package.json +0 -17
- package/examples/simpletex.js +0 -72
- package/examples/teapot.js +0 -104
- package/examples/teapot.json +0 -6
- package/examples/text-rendering.js +0 -44
- package/lib/fontface.js +0 -103
- package/lib/widgets/webview.js +0 -33
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
// Static SVG rendering on top of the ntk 2d context.
|
|
2
|
+
//
|
|
3
|
+
// Documents are parsed with htmlparser2 (XML mode) and rendered through the
|
|
4
|
+
// canvas API — Path2D for geometry, ctx transforms for <g transform="…">,
|
|
5
|
+
// canvas gradients for paint servers — so everything ends up as server-side
|
|
6
|
+
// XRender composites like any other 2d drawing.
|
|
7
|
+
//
|
|
8
|
+
// const view = new SvgView(wnd);
|
|
9
|
+
// view.setSvg('<svg viewBox="0 0 24 24">…</svg>');
|
|
10
|
+
// wnd.map();
|
|
11
|
+
//
|
|
12
|
+
// Standalone (windowless) use mirrors HtmlView/MarkdownView:
|
|
13
|
+
// const view = new SvgView(null);
|
|
14
|
+
// view.setSvg(svgText);
|
|
15
|
+
// view.draw(ctx, x, y, width, height);
|
|
16
|
+
//
|
|
17
|
+
// Supported: path/rect/circle/ellipse/line/polyline/polygon, <g>, <defs>,
|
|
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.
|
|
21
|
+
|
|
22
|
+
import { textContent } from 'domutils';
|
|
23
|
+
import { parseDocument } from 'htmlparser2';
|
|
24
|
+
|
|
25
|
+
import { Path2D, flattenPath, matApply } from '../path.js';
|
|
26
|
+
|
|
27
|
+
const INHERITED = {
|
|
28
|
+
fill: '#000',
|
|
29
|
+
stroke: 'none',
|
|
30
|
+
strokeWidth: 1,
|
|
31
|
+
lineCap: 'butt',
|
|
32
|
+
lineJoin: 'miter',
|
|
33
|
+
miterLimit: 4,
|
|
34
|
+
fillRule: 'nonzero',
|
|
35
|
+
fillOpacity: 1,
|
|
36
|
+
strokeOpacity: 1,
|
|
37
|
+
color: '#000',
|
|
38
|
+
fontFamily: 'sans-serif',
|
|
39
|
+
fontSize: 16,
|
|
40
|
+
textAnchor: 'start'
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const STYLE_ATTRS = {
|
|
44
|
+
fill: 'fill',
|
|
45
|
+
stroke: 'stroke',
|
|
46
|
+
'stroke-width': 'strokeWidth',
|
|
47
|
+
'stroke-linecap': 'lineCap',
|
|
48
|
+
'stroke-linejoin': 'lineJoin',
|
|
49
|
+
'stroke-miterlimit': 'miterLimit',
|
|
50
|
+
'fill-rule': 'fillRule',
|
|
51
|
+
'fill-opacity': 'fillOpacity',
|
|
52
|
+
'stroke-opacity': 'strokeOpacity',
|
|
53
|
+
color: 'color',
|
|
54
|
+
'font-family': 'fontFamily',
|
|
55
|
+
'font-size': 'fontSize',
|
|
56
|
+
'text-anchor': 'textAnchor'
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const NUMERIC = new Set(['strokeWidth', 'miterLimit', 'fillOpacity', 'strokeOpacity', 'fontSize']);
|
|
60
|
+
|
|
61
|
+
// documents may come from an XML parse (setSvg: exact case) or from an HTML
|
|
62
|
+
// parse (HtmlView inline <svg>: tag/attribute names lowercased) — compare
|
|
63
|
+
// names lowercased and look attributes up by their lowercase form too
|
|
64
|
+
const tag = (node) => (node.name || '').toLowerCase();
|
|
65
|
+
|
|
66
|
+
function attr(node, name) {
|
|
67
|
+
const a = node.attribs;
|
|
68
|
+
if (!a) return undefined;
|
|
69
|
+
return a[name] ?? a[name.toLowerCase()];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function attrNum(node, name, fallback = 0) {
|
|
73
|
+
const v = attr(node, name);
|
|
74
|
+
if (v === undefined || v === '') return fallback;
|
|
75
|
+
const n = parseFloat(v);
|
|
76
|
+
return Number.isFinite(n) ? n : fallback;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const NON_RENDERED = new Set([
|
|
80
|
+
'defs', 'title', 'desc', 'metadata', 'symbol', 'style',
|
|
81
|
+
'lineargradient', 'radialgradient', 'clippath', 'mask', 'filter', 'pattern', 'marker'
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
/** parse an SVG transform list into ctx.transform() calls */
|
|
85
|
+
export function parseSvgTransform(str) {
|
|
86
|
+
const out = [];
|
|
87
|
+
if (!str) return out;
|
|
88
|
+
const re = /(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)/g;
|
|
89
|
+
let m;
|
|
90
|
+
while ((m = re.exec(str))) {
|
|
91
|
+
const args = m[2]
|
|
92
|
+
.split(/[\s,]+/)
|
|
93
|
+
.filter((s) => s !== '')
|
|
94
|
+
.map(parseFloat);
|
|
95
|
+
if (args.some((a) => !Number.isFinite(a))) continue;
|
|
96
|
+
switch (m[1]) {
|
|
97
|
+
case 'matrix':
|
|
98
|
+
if (args.length === 6) out.push(args);
|
|
99
|
+
break;
|
|
100
|
+
case 'translate':
|
|
101
|
+
out.push([1, 0, 0, 1, args[0] || 0, args[1] || 0]);
|
|
102
|
+
break;
|
|
103
|
+
case 'scale': {
|
|
104
|
+
const sx = args[0] ?? 1;
|
|
105
|
+
const sy = args.length > 1 ? args[1] : sx;
|
|
106
|
+
out.push([sx, 0, 0, sy, 0, 0]);
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
case 'rotate': {
|
|
110
|
+
const a = ((args[0] || 0) * Math.PI) / 180;
|
|
111
|
+
const cos = Math.cos(a);
|
|
112
|
+
const sin = Math.sin(a);
|
|
113
|
+
if (args.length > 2) {
|
|
114
|
+
const [, cx, cy] = args;
|
|
115
|
+
out.push([1, 0, 0, 1, cx, cy], [cos, sin, -sin, cos, 0, 0], [1, 0, 0, 1, -cx, -cy]);
|
|
116
|
+
} else {
|
|
117
|
+
out.push([cos, sin, -sin, cos, 0, 0]);
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
case 'skewX':
|
|
122
|
+
out.push([1, 0, Math.tan(((args[0] || 0) * Math.PI) / 180), 1, 0, 0]);
|
|
123
|
+
break;
|
|
124
|
+
case 'skewY':
|
|
125
|
+
out.push([1, Math.tan(((args[0] || 0) * Math.PI) / 180), 0, 1, 0, 0]);
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function shapePath(node) {
|
|
133
|
+
const a = node.attribs || {};
|
|
134
|
+
const path = new Path2D();
|
|
135
|
+
switch (tag(node)) {
|
|
136
|
+
case 'path':
|
|
137
|
+
return a.d ? new Path2D(a.d) : null;
|
|
138
|
+
case 'rect': {
|
|
139
|
+
const x = attrNum(node, 'x');
|
|
140
|
+
const y = attrNum(node, 'y');
|
|
141
|
+
const w = attrNum(node, 'width');
|
|
142
|
+
const h = attrNum(node, 'height');
|
|
143
|
+
if (!(w > 0) || !(h > 0)) return null;
|
|
144
|
+
let rx = a.rx !== undefined ? attrNum(node, 'rx') : undefined;
|
|
145
|
+
let ry = a.ry !== undefined ? attrNum(node, 'ry') : undefined;
|
|
146
|
+
if (rx === undefined) rx = ry;
|
|
147
|
+
if (ry === undefined) ry = rx;
|
|
148
|
+
if (rx || ry) path.roundRect(x, y, w, h, [{ x: rx || 0, y: ry || 0 }]);
|
|
149
|
+
else path.rect(x, y, w, h);
|
|
150
|
+
return path;
|
|
151
|
+
}
|
|
152
|
+
case 'circle': {
|
|
153
|
+
const r = attrNum(node, 'r');
|
|
154
|
+
if (!(r > 0)) return null;
|
|
155
|
+
path.arc(attrNum(node, 'cx'), attrNum(node, 'cy'), r, 0, Math.PI * 2);
|
|
156
|
+
path.closePath();
|
|
157
|
+
return path;
|
|
158
|
+
}
|
|
159
|
+
case 'ellipse': {
|
|
160
|
+
const rx = attrNum(node, 'rx');
|
|
161
|
+
const ry = attrNum(node, 'ry');
|
|
162
|
+
if (!(rx > 0) || !(ry > 0)) return null;
|
|
163
|
+
path.ellipse(attrNum(node, 'cx'), attrNum(node, 'cy'), rx, ry, 0, 0, Math.PI * 2);
|
|
164
|
+
path.closePath();
|
|
165
|
+
return path;
|
|
166
|
+
}
|
|
167
|
+
case 'line':
|
|
168
|
+
path.moveTo(attrNum(node, 'x1'), attrNum(node, 'y1'));
|
|
169
|
+
path.lineTo(attrNum(node, 'x2'), attrNum(node, 'y2'));
|
|
170
|
+
return path;
|
|
171
|
+
case 'polyline':
|
|
172
|
+
case 'polygon': {
|
|
173
|
+
const nums = (a.points || '')
|
|
174
|
+
.split(/[\s,]+/)
|
|
175
|
+
.filter((s) => s !== '')
|
|
176
|
+
.map(parseFloat);
|
|
177
|
+
if (nums.length < 4) return null;
|
|
178
|
+
path.moveTo(nums[0], nums[1]);
|
|
179
|
+
for (let i = 2; i + 1 < nums.length; i += 2) path.lineTo(nums[i], nums[i + 1]);
|
|
180
|
+
if (node.name === 'polygon') path.closePath();
|
|
181
|
+
return path;
|
|
182
|
+
}
|
|
183
|
+
default:
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function pathBBox(path) {
|
|
189
|
+
let minX = Infinity;
|
|
190
|
+
let minY = Infinity;
|
|
191
|
+
let maxX = -Infinity;
|
|
192
|
+
let maxY = -Infinity;
|
|
193
|
+
for (const poly of flattenPath(path._cmds, null, 1)) {
|
|
194
|
+
for (let i = 0; i < poly.pts.length; i += 2) {
|
|
195
|
+
if (poly.pts[i] < minX) minX = poly.pts[i];
|
|
196
|
+
if (poly.pts[i] > maxX) maxX = poly.pts[i];
|
|
197
|
+
if (poly.pts[i + 1] < minY) minY = poly.pts[i + 1];
|
|
198
|
+
if (poly.pts[i + 1] > maxY) maxY = poly.pts[i + 1];
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (minX > maxX) return { x: 0, y: 0, w: 0, h: 0 };
|
|
202
|
+
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Widget rendering a static SVG document into a window (or any 2d context
|
|
207
|
+
* via `draw()`). Scripting, CSS stylesheets, filters, masks and external
|
|
208
|
+
* references are not supported — see docs/svg.md.
|
|
209
|
+
*/
|
|
210
|
+
export default class SvgView {
|
|
211
|
+
constructor(window, opts = {}) {
|
|
212
|
+
this.window = window ?? null;
|
|
213
|
+
this.theme = { background: 'white', ...(opts.theme || {}) };
|
|
214
|
+
/** fit mode in window mode: 'contain' (default) | 'fill' */
|
|
215
|
+
this.fit = opts.fit || 'contain';
|
|
216
|
+
|
|
217
|
+
this._root = null;
|
|
218
|
+
this._ids = new Map();
|
|
219
|
+
this.naturalWidth = 0;
|
|
220
|
+
this.naturalHeight = 0;
|
|
221
|
+
this.viewBox = null;
|
|
222
|
+
|
|
223
|
+
if (this.window) {
|
|
224
|
+
this._ctx = this.window.getContext('2d');
|
|
225
|
+
this.window.on('expose', () => this.render());
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** parse and adopt a new document (a string containing an <svg> element) */
|
|
230
|
+
setSvg(svg) {
|
|
231
|
+
const doc = parseDocument(String(svg), { xmlMode: true });
|
|
232
|
+
const findSvg = (nodes) => {
|
|
233
|
+
for (const n of nodes || []) {
|
|
234
|
+
if (n.type === 'tag' && tag(n) === 'svg') return n;
|
|
235
|
+
const inner = findSvg(n.children);
|
|
236
|
+
if (inner) return inner;
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
};
|
|
240
|
+
const root = findSvg(doc.children);
|
|
241
|
+
if (!root) throw new Error('SvgView: no <svg> element found');
|
|
242
|
+
return this.setSvgDom(root);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Adopt an already-parsed `<svg>` element (htmlparser2 DOM node) — used
|
|
247
|
+
* by HtmlView for inline SVG. HTML-mode parses (lowercased tag/attribute
|
|
248
|
+
* names) are handled.
|
|
249
|
+
*/
|
|
250
|
+
setSvgDom(element) {
|
|
251
|
+
if (!element || element.type !== 'tag' || tag(element) !== 'svg') {
|
|
252
|
+
throw new Error('SvgView: expected an <svg> element');
|
|
253
|
+
}
|
|
254
|
+
this._root = element;
|
|
255
|
+
|
|
256
|
+
this._ids = new Map();
|
|
257
|
+
const collect = (node) => {
|
|
258
|
+
for (const child of node.children || []) {
|
|
259
|
+
if (child.type !== 'tag') continue;
|
|
260
|
+
if (child.attribs?.id) this._ids.set(child.attribs.id, child);
|
|
261
|
+
collect(child);
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
collect(this._root);
|
|
265
|
+
|
|
266
|
+
const a = this._root.attribs || {};
|
|
267
|
+
const vb = (attr(this._root, 'viewBox') || '')
|
|
268
|
+
.split(/[\s,]+/)
|
|
269
|
+
.filter((s) => s !== '')
|
|
270
|
+
.map(parseFloat);
|
|
271
|
+
this.viewBox = vb.length === 4 && vb.every(Number.isFinite) ? vb : null;
|
|
272
|
+
const w = parseFloat(a.width);
|
|
273
|
+
const h = parseFloat(a.height);
|
|
274
|
+
this.naturalWidth = Number.isFinite(w) && !String(a.width || '').endsWith('%') ? w : this.viewBox ? this.viewBox[2] : 300;
|
|
275
|
+
this.naturalHeight = Number.isFinite(h) && !String(a.height || '').endsWith('%') ? h : this.viewBox ? this.viewBox[3] : 150;
|
|
276
|
+
|
|
277
|
+
if (this.window) this.render();
|
|
278
|
+
return this;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** window mode: clear the background and draw fitted + centered */
|
|
282
|
+
render() {
|
|
283
|
+
if (!this.window) throw new Error('SvgView.render() needs a window');
|
|
284
|
+
const ctx = this._ctx;
|
|
285
|
+
const ww = this.window.width;
|
|
286
|
+
const wh = this.window.height;
|
|
287
|
+
ctx.fillStyle = this.theme.background;
|
|
288
|
+
ctx.fillRect(0, 0, ww, wh);
|
|
289
|
+
if (!this._root) return this;
|
|
290
|
+
let w = ww;
|
|
291
|
+
let h = wh;
|
|
292
|
+
let x = 0;
|
|
293
|
+
let y = 0;
|
|
294
|
+
if (this.fit === 'contain' && this.naturalWidth > 0 && this.naturalHeight > 0) {
|
|
295
|
+
const s = Math.min(ww / this.naturalWidth, wh / this.naturalHeight);
|
|
296
|
+
w = this.naturalWidth * s;
|
|
297
|
+
h = this.naturalHeight * s;
|
|
298
|
+
x = (ww - w) / 2;
|
|
299
|
+
y = (wh - h) / 2;
|
|
300
|
+
}
|
|
301
|
+
this.draw(ctx, x, y, w, h);
|
|
302
|
+
return this;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Draw the document into any 2d context. `w`/`h` default to the
|
|
307
|
+
* document's natural size; the viewBox (when present) is scaled to fit.
|
|
308
|
+
*/
|
|
309
|
+
draw(ctx, x = 0, y = 0, w = this.naturalWidth, h = this.naturalHeight) {
|
|
310
|
+
if (!this._root) return;
|
|
311
|
+
ctx.save();
|
|
312
|
+
ctx.translate(x, y);
|
|
313
|
+
if (this.viewBox) {
|
|
314
|
+
const [minX, minY, vbW, vbH] = this.viewBox;
|
|
315
|
+
if (vbW > 0 && vbH > 0) {
|
|
316
|
+
ctx.scale(w / vbW, h / vbH);
|
|
317
|
+
ctx.translate(-minX, -minY);
|
|
318
|
+
}
|
|
319
|
+
} else if (this.naturalWidth > 0 && this.naturalHeight > 0) {
|
|
320
|
+
ctx.scale(w / this.naturalWidth, h / this.naturalHeight);
|
|
321
|
+
}
|
|
322
|
+
this._renderChildren(this._root, ctx, { ...INHERITED }, 1, 0);
|
|
323
|
+
ctx.restore();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
_style(node, parent) {
|
|
329
|
+
let style = parent;
|
|
330
|
+
const own = () => (style === parent ? (style = { ...parent }) : style);
|
|
331
|
+
const apply = (name, raw) => {
|
|
332
|
+
const key = STYLE_ATTRS[name];
|
|
333
|
+
if (!key || raw === undefined || raw === '' || raw === 'inherit') return;
|
|
334
|
+
let v = String(raw).trim();
|
|
335
|
+
if (NUMERIC.has(key)) {
|
|
336
|
+
const n = parseFloat(v);
|
|
337
|
+
if (!Number.isFinite(n)) return;
|
|
338
|
+
own()[key] = n;
|
|
339
|
+
} else {
|
|
340
|
+
own()[key] = v;
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
for (const [name, value] of Object.entries(node.attribs || {})) apply(name, value);
|
|
344
|
+
// inline style="" wins over presentation attributes
|
|
345
|
+
for (const decl of (node.attribs?.style || '').split(';')) {
|
|
346
|
+
const idx = decl.indexOf(':');
|
|
347
|
+
if (idx > 0) apply(decl.slice(0, idx).trim(), decl.slice(idx + 1).trim());
|
|
348
|
+
}
|
|
349
|
+
return style;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
_paint(ctx, value, style, path) {
|
|
353
|
+
if (value === 'currentColor') return style.color;
|
|
354
|
+
const url = /^url\(['"]?#([^'")]+)['"]?\)/.exec(value);
|
|
355
|
+
if (!url) return value;
|
|
356
|
+
const node = this._ids.get(url[1]);
|
|
357
|
+
if (!node || (tag(node) !== 'lineargradient' && tag(node) !== 'radialgradient')) return null;
|
|
358
|
+
return this._gradient(ctx, node, path);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
_gradient(ctx, node, path) {
|
|
362
|
+
const a = node.attribs || {};
|
|
363
|
+
const units = attr(node, 'gradientUnits') || 'objectBoundingBox';
|
|
364
|
+
const bbox = units === 'objectBoundingBox' ? pathBBox(path) : null;
|
|
365
|
+
const coord = (raw, fallback, axis) => {
|
|
366
|
+
let v = raw === undefined ? fallback : parseFloat(raw);
|
|
367
|
+
if (String(raw ?? '').endsWith('%')) v /= 100;
|
|
368
|
+
if (!Number.isFinite(v)) v = fallback;
|
|
369
|
+
if (!bbox) return v;
|
|
370
|
+
return axis === 'x' ? bbox.x + v * bbox.w : bbox.y + v * bbox.h;
|
|
371
|
+
};
|
|
372
|
+
// ntk gradients live in device space: map user-space endpoints through
|
|
373
|
+
// the current transform
|
|
374
|
+
const m = ctx.getTransform();
|
|
375
|
+
const mat = [m.a, m.b, m.c, m.d, m.e, m.f];
|
|
376
|
+
const dev = (px, py) => matApply(mat, px, py);
|
|
377
|
+
|
|
378
|
+
let gradient;
|
|
379
|
+
if (tag(node) === 'lineargradient') {
|
|
380
|
+
const [x1, y1] = dev(coord(a.x1, 0, 'x'), coord(a.y1, 0, 'y'));
|
|
381
|
+
const [x2, y2] = dev(coord(a.x2, 1, 'x'), coord(a.y2, 0, 'y'));
|
|
382
|
+
gradient = ctx.createLinearGradient(x1, y1, x2, y2);
|
|
383
|
+
} else {
|
|
384
|
+
const cx = coord(a.cx, 0.5, 'x');
|
|
385
|
+
const cy = coord(a.cy, 0.5, 'y');
|
|
386
|
+
let r = a.r === undefined ? 0.5 : parseFloat(a.r);
|
|
387
|
+
if (String(a.r ?? '').endsWith('%')) r /= 100;
|
|
388
|
+
if (bbox) r *= (bbox.w + bbox.h) / 2;
|
|
389
|
+
const [dcx, dcy] = dev(cx, cy);
|
|
390
|
+
const scale = Math.sqrt(Math.abs(mat[0] * mat[3] - mat[1] * mat[2])) || 1;
|
|
391
|
+
gradient = ctx.createRadialGradient(dcx, dcy, 0, dcx, dcy, r * scale);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
for (const stop of node.children || []) {
|
|
395
|
+
if (stop.type !== 'tag' || tag(stop) !== 'stop') continue;
|
|
396
|
+
const sa = stop.attribs || {};
|
|
397
|
+
const decls = {};
|
|
398
|
+
for (const decl of (sa.style || '').split(';')) {
|
|
399
|
+
const idx = decl.indexOf(':');
|
|
400
|
+
if (idx > 0) decls[decl.slice(0, idx).trim()] = decl.slice(idx + 1).trim();
|
|
401
|
+
}
|
|
402
|
+
let offset = parseFloat(sa.offset ?? '0');
|
|
403
|
+
if (String(sa.offset ?? '').endsWith('%')) offset /= 100;
|
|
404
|
+
if (!Number.isFinite(offset)) offset = 0;
|
|
405
|
+
const color = decls['stop-color'] || sa['stop-color'] || '#000';
|
|
406
|
+
const so = parseFloat(decls['stop-opacity'] ?? sa['stop-opacity'] ?? '1');
|
|
407
|
+
if (Number.isFinite(so) && so < 1) {
|
|
408
|
+
gradient.addColorStop(offset, rgbaWithAlpha(color, so));
|
|
409
|
+
} else {
|
|
410
|
+
gradient.addColorStop(offset, color);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return gradient;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
_renderChildren(node, ctx, style, alpha, depth) {
|
|
417
|
+
for (const child of node.children || []) {
|
|
418
|
+
if (child.type !== 'tag') continue;
|
|
419
|
+
this._renderNode(child, ctx, style, alpha, depth);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
_renderNode(node, ctx, parentStyle, alpha, depth) {
|
|
424
|
+
if (depth > 32) return;
|
|
425
|
+
const name = tag(node);
|
|
426
|
+
if (NON_RENDERED.has(name)) return;
|
|
427
|
+
|
|
428
|
+
const style = this._style(node, parentStyle);
|
|
429
|
+
const opacity = parseFloat(node.attribs?.opacity ?? '1');
|
|
430
|
+
const nodeAlpha = alpha * (Number.isFinite(opacity) ? Math.min(1, Math.max(0, opacity)) : 1);
|
|
431
|
+
if (nodeAlpha <= 0) return;
|
|
432
|
+
|
|
433
|
+
const transforms = parseSvgTransform(node.attribs?.transform);
|
|
434
|
+
const needsCtxState = transforms.length > 0;
|
|
435
|
+
if (needsCtxState) {
|
|
436
|
+
ctx.save();
|
|
437
|
+
for (const t of transforms) ctx.transform(t[0], t[1], t[2], t[3], t[4], t[5]);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
switch (name) {
|
|
441
|
+
case 'svg':
|
|
442
|
+
case 'g':
|
|
443
|
+
case 'a':
|
|
444
|
+
this._renderChildren(node, ctx, style, nodeAlpha, depth + 1);
|
|
445
|
+
break;
|
|
446
|
+
case 'use': {
|
|
447
|
+
const href = node.attribs?.href || node.attribs?.['xlink:href'] || '';
|
|
448
|
+
const target = href.startsWith('#') ? this._ids.get(href.slice(1)) : null;
|
|
449
|
+
if (target) {
|
|
450
|
+
const ux = attrNum(node, 'x');
|
|
451
|
+
const uy = attrNum(node, 'y');
|
|
452
|
+
ctx.save();
|
|
453
|
+
if (ux || uy) ctx.translate(ux, uy);
|
|
454
|
+
if (tag(target) === 'symbol') this._renderChildren(target, ctx, style, nodeAlpha, depth + 1);
|
|
455
|
+
else this._renderNode(target, ctx, style, nodeAlpha, depth + 1);
|
|
456
|
+
ctx.restore();
|
|
457
|
+
}
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
case 'text':
|
|
461
|
+
this._renderText(node, ctx, style, nodeAlpha);
|
|
462
|
+
break;
|
|
463
|
+
default: {
|
|
464
|
+
const path = shapePath(node);
|
|
465
|
+
if (!path) break;
|
|
466
|
+
if (name !== 'line' && style.fill !== 'none') {
|
|
467
|
+
const paint = this._paint(ctx, style.fill, style, path);
|
|
468
|
+
if (paint) {
|
|
469
|
+
ctx.fillStyle = paint;
|
|
470
|
+
ctx.globalAlpha = nodeAlpha * style.fillOpacity;
|
|
471
|
+
ctx.fill(path, style.fillRule === 'evenodd' ? 'evenodd' : 'nonzero');
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (style.stroke !== 'none' && style.strokeWidth > 0) {
|
|
475
|
+
const paint = this._paint(ctx, style.stroke, style, path);
|
|
476
|
+
if (paint) {
|
|
477
|
+
ctx.strokeStyle = paint;
|
|
478
|
+
ctx.globalAlpha = nodeAlpha * style.strokeOpacity;
|
|
479
|
+
ctx.lineWidth = style.strokeWidth;
|
|
480
|
+
ctx.lineCap = style.lineCap;
|
|
481
|
+
ctx.lineJoin = style.lineJoin;
|
|
482
|
+
ctx.miterLimit = style.miterLimit;
|
|
483
|
+
ctx.stroke(path);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
ctx.globalAlpha = 1;
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (needsCtxState) ctx.restore();
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
_renderText(node, ctx, style, alpha) {
|
|
495
|
+
const text = textContent(node).trim();
|
|
496
|
+
if (!text || style.fill === 'none') return;
|
|
497
|
+
const x = attrNum(node, 'x');
|
|
498
|
+
const y = attrNum(node, 'y');
|
|
499
|
+
// glyphs are not scaled by the canvas transform — bake the average
|
|
500
|
+
// scale factor into the font size instead
|
|
501
|
+
const m = ctx.getTransform();
|
|
502
|
+
const scale = Math.sqrt(Math.abs(m.a * m.d - m.b * m.c)) || 1;
|
|
503
|
+
ctx.save();
|
|
504
|
+
ctx.font = `${Math.max(1, style.fontSize * scale)}px ${style.fontFamily}`;
|
|
505
|
+
ctx.fillStyle = style.fill === 'currentColor' ? style.color : style.fill;
|
|
506
|
+
ctx.globalAlpha = alpha * style.fillOpacity;
|
|
507
|
+
ctx.textAlign = style.textAnchor === 'middle' ? 'center' : style.textAnchor === 'end' ? 'right' : 'left';
|
|
508
|
+
ctx.fillText(text, x, y);
|
|
509
|
+
ctx.restore();
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// fold an opacity into a CSS color by going through rgba()
|
|
514
|
+
function rgbaWithAlpha(color, alpha) {
|
|
515
|
+
// cheap path for #rrggbb / #rgb; anything else goes through rgba() string
|
|
516
|
+
if (/^#([0-9a-f]{6})$/i.test(color)) {
|
|
517
|
+
const v = parseInt(color.slice(1), 16);
|
|
518
|
+
return `rgba(${(v >> 16) & 255}, ${(v >> 8) & 255}, ${v & 255}, ${alpha})`;
|
|
519
|
+
}
|
|
520
|
+
if (/^#([0-9a-f]{3})$/i.test(color)) {
|
|
521
|
+
const v = parseInt(color.slice(1), 16);
|
|
522
|
+
const r = ((v >> 8) & 15) * 17;
|
|
523
|
+
const g = ((v >> 4) & 15) * 17;
|
|
524
|
+
const b = (v & 15) * 17;
|
|
525
|
+
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
526
|
+
}
|
|
527
|
+
return color;
|
|
528
|
+
}
|