ntk 2.1.1 → 3.0.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.
Files changed (48) hide show
  1. package/README.md +115 -110
  2. package/lib/app.js +48 -15
  3. package/lib/cleanup.js +14 -0
  4. package/lib/drawable.js +10 -16
  5. package/lib/events_map.js +81 -87
  6. package/lib/fontconfig.js +122 -14
  7. package/lib/glyphset.js +36 -18
  8. package/lib/image.js +138 -0
  9. package/lib/index.js +110 -52
  10. package/lib/path.js +734 -0
  11. package/lib/picture.js +76 -38
  12. package/lib/pixmap.js +34 -25
  13. package/lib/rasterize.js +178 -0
  14. package/lib/renderingcontext_2d.js +994 -517
  15. package/lib/renderingcontext_opengl.js +38 -31
  16. package/lib/renderingcontext_x11.js +56 -64
  17. package/lib/text/font.js +196 -0
  18. package/lib/text/fontmanager.js +233 -0
  19. package/lib/text/fontsource.js +179 -0
  20. package/lib/text/glyphs.js +456 -0
  21. package/lib/text/layout.js +356 -0
  22. package/lib/text/shape.js +141 -0
  23. package/lib/trapezoid.js +131 -0
  24. package/lib/widgets/css.js +676 -0
  25. package/lib/widgets/highlight.js +97 -0
  26. package/lib/widgets/htmlview.js +879 -0
  27. package/lib/widgets/markdown.js +148 -0
  28. package/lib/widgets/markdownview.js +487 -0
  29. package/lib/widgets/mermaid.js +720 -0
  30. package/lib/widgets/svgview.js +528 -0
  31. package/lib/widgets/tex.js +681 -0
  32. package/lib/window.js +568 -246
  33. package/package.json +35 -32
  34. package/.npmignore +0 -15
  35. package/examples/example-await.js +0 -23
  36. package/examples/example-co.js +0 -13
  37. package/examples/example.js +0 -14
  38. package/examples/fill-styles.js +0 -39
  39. package/examples/glclock.js +0 -100
  40. package/examples/glxpixmap.js +0 -12
  41. package/examples/gradient.js +0 -41
  42. package/examples/package.json +0 -17
  43. package/examples/simpletex.js +0 -72
  44. package/examples/teapot.js +0 -104
  45. package/examples/teapot.json +0 -6
  46. package/examples/text-rendering.js +0 -44
  47. package/lib/fontface.js +0 -109
  48. 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
+ }