ntk 8.17.3 → 8.17.5

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.
@@ -16,8 +16,9 @@
16
16
  //
17
17
  // Supported: path/rect/circle/ellipse/line/polyline/polygon, <g>, <defs>,
18
18
  // <use>, linear/radial gradients (objectBoundingBox and userSpaceOnUse),
19
- // transform lists, opacity/fill-opacity/stroke-opacity, fill-rule, basic
20
- // <text>. See docs/svg.md for the full surface and limitations.
19
+ // transform lists, opacity/fill-opacity/stroke-opacity, display, visibility,
20
+ // nested <svg> viewports, fill-rule, basic <text>. See docs/svg.md for the
21
+ // full surface and limitations.
21
22
 
22
23
  import { textContent } from 'domutils';
23
24
  import { parseDocument } from 'htmlparser2';
@@ -37,7 +38,8 @@ const INHERITED = {
37
38
  color: '#000',
38
39
  fontFamily: 'sans-serif',
39
40
  fontSize: 16,
40
- textAnchor: 'start'
41
+ textAnchor: 'start',
42
+ visibility: 'visible'
41
43
  };
42
44
 
43
45
  const STYLE_ATTRS = {
@@ -53,10 +55,13 @@ const STYLE_ATTRS = {
53
55
  color: 'color',
54
56
  'font-family': 'fontFamily',
55
57
  'font-size': 'fontSize',
56
- 'text-anchor': 'textAnchor'
58
+ 'text-anchor': 'textAnchor',
59
+ visibility: 'visibility'
57
60
  };
58
61
 
59
- const NUMERIC = new Set(['strokeWidth', 'miterLimit', 'fillOpacity', 'strokeOpacity', 'fontSize']);
62
+ const NUMERIC = new Set(['strokeWidth', 'miterLimit', 'fontSize']);
63
+ const ALPHA = new Set(['fillOpacity', 'strokeOpacity']);
64
+ const VISIBILITY = new Set(['visible', 'hidden', 'collapse']);
60
65
 
61
66
  // documents may come from an XML parse (setSvg: exact case) or from an HTML
62
67
  // parse (inline <svg>: tag/attribute names lowercased) — compare
@@ -76,6 +81,27 @@ 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
+
79
105
  /**
80
106
  * Whether an element is `display: none`, by its attribute or in its
81
107
  * `style`, which wins. Such an element is not rendered, and nor is anything
@@ -85,15 +111,67 @@ function attrNum(node, name, fallback = 0) {
85
111
  * around shapes it marks `display="inline"`.
86
112
  */
87
113
  function displayNone(node) {
88
- let v = attr(node, 'display');
89
- const style = node.attribs?.style;
90
- if (style && style.includes('display')) {
91
- for (const decl of style.split(';')) {
92
- const idx = decl.indexOf(':');
93
- if (idx > 0 && decl.slice(0, idx).trim() === 'display') v = decl.slice(idx + 1);
94
- }
95
- }
96
- return v !== undefined && String(v).trim().toLowerCase() === 'none';
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];
97
175
  }
98
176
 
99
177
  const NON_RENDERED = new Set([
@@ -222,6 +300,45 @@ function pathBBox(path) {
222
300
  return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
223
301
  }
224
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
+
225
342
  /**
226
343
  * How many distinct colours a document actually commits to, decided once at
227
344
  * parse time.
@@ -236,11 +353,13 @@ function pathBBox(path) {
236
353
  * Opacity does not enter into it: `opacity`, `fill-opacity` and
237
354
  * `stroke-opacity` scale coverage, which a mask carries perfectly well.
238
355
  *
239
- * The walk mirrors `_style` and `_renderNode` — fill and stroke inherit,
240
- * inline `style` beats the presentation attribute, the initial fill is black
241
- * and the initial stroke is none, `<line>` never fills, `<use>` paints its
242
- * target with the *use* element's style, and non-rendered subtrees
243
- * contribute nothing. It starts at the root element itself, because `draw`
356
+ * The walk mirrors `_renderNode`, and reads style with the same
357
+ * `resolveStyle` — fill and stroke inherit, inline `style` beats the
358
+ * presentation attribute, the initial fill is black and the initial stroke
359
+ * is none, `<line>` never fills, `<use>` paints its target with the *use*
360
+ * element's style, a shape whose inherited `visibility` is not `visible`
361
+ * paints nothing, and non-rendered and `display: none` subtrees contribute
362
+ * nothing. It starts at the root element itself, because `draw`
244
363
  * does: the root `<svg>` is an ordinary element for inheritance, and every
245
364
  * mainstream icon set puts `fill`/`stroke` there (issue #306). The two walks
246
365
  * have to agree — a scan that skipped the root would disagree with what is
@@ -254,59 +373,48 @@ function scanPaints(root, ids) {
254
373
  const paints = new Set();
255
374
  let multi = false;
256
375
 
257
- const paintOf = (node, name, inherited) => {
258
- let v = attr(node, name);
259
- for (const decl of (node.attribs?.style || '').split(';')) {
260
- const idx = decl.indexOf(':');
261
- if (idx > 0 && decl.slice(0, idx).trim() === name) v = decl.slice(idx + 1);
262
- }
263
- if (v === undefined || v === '' || v === 'inherit') return inherited;
264
- return String(v).trim();
265
- };
266
-
267
376
  const note = (paint) => {
268
377
  if (paint === 'none') return;
269
378
  if (/^url\(/i.test(paint)) multi = true;
270
379
  else paints.add(paint);
271
380
  };
272
381
 
273
- const kids = (node, fill, stroke, depth) => {
382
+ const kids = (node, style, depth) => {
274
383
  for (const child of node.children || []) {
275
- if (child.type === 'tag') visit(child, fill, stroke, depth + 1);
384
+ if (child.type === 'tag') visit(child, style, depth + 1);
276
385
  }
277
386
  };
278
387
 
279
- const visit = (node, fill, stroke, depth) => {
388
+ const visit = (node, parent, depth) => {
280
389
  if (multi || depth > 32) return;
281
390
  const name = tag(node);
282
391
  if (NON_RENDERED.has(name) || displayNone(node)) return;
283
- const f = paintOf(node, 'fill', fill);
284
- const s = paintOf(node, 'stroke', stroke);
392
+ const style = resolveStyle(node, parent);
285
393
  switch (name) {
286
394
  case 'svg':
287
395
  case 'g':
288
396
  case 'a':
289
- kids(node, f, s, depth);
397
+ kids(node, style, depth);
290
398
  return;
291
399
  case 'use': {
292
400
  const href = node.attribs?.href || node.attribs?.['xlink:href'] || '';
293
401
  const target = href.startsWith('#') ? ids.get(href.slice(1)) : null;
294
402
  if (!target) return;
295
403
  // <symbol> is non-rendered on its own but renders through <use>
296
- if (tag(target) === 'symbol') kids(target, f, s, depth);
297
- else visit(target, f, s, depth + 1);
404
+ if (tag(target) === 'symbol') kids(target, style, depth);
405
+ else visit(target, style, depth + 1);
298
406
  return;
299
407
  }
300
- case 'text':
301
- note(f);
302
- return;
303
408
  default:
304
- if (name !== 'line') note(f);
305
- note(s);
409
+ // a hidden shape paints nothing, though what is in a hidden
410
+ // group may show itself again
411
+ if (style.visibility !== 'visible') return;
412
+ if (name !== 'line') note(style.fill);
413
+ if (name !== 'text') note(style.stroke);
306
414
  }
307
415
  };
308
416
 
309
- visit(root, INHERITED.fill, INHERITED.stroke, 0);
417
+ visit(root, INHERITED, 0);
310
418
  return {
311
419
  kind: multi || paints.size > 1 ? 'multi' : 'mono',
312
420
  solo: paints.size === 1 ? [...paints][0] : null
@@ -333,6 +441,7 @@ export default class SvgView {
333
441
 
334
442
  this._root = null;
335
443
  this._ids = new Map();
444
+ this._viewport = [0, 0];
336
445
  this.naturalWidth = 0;
337
446
  this.naturalHeight = 0;
338
447
  this.viewBox = null;
@@ -390,11 +499,7 @@ export default class SvgView {
390
499
  this.soloPaint = scanned.solo;
391
500
 
392
501
  const a = this._root.attribs || {};
393
- const vb = (attr(this._root, 'viewBox') || '')
394
- .split(/[\s,]+/)
395
- .filter((s) => s !== '')
396
- .map(parseFloat);
397
- this.viewBox = vb.length === 4 && vb.every(Number.isFinite) ? vb : null;
502
+ this.viewBox = parseViewBox(this._root);
398
503
  const w = parseFloat(a.width);
399
504
  const h = parseFloat(a.height);
400
505
  this.naturalWidth = Number.isFinite(w) && !String(a.width || '').endsWith('%') ? w : this.viewBox ? this.viewBox[2] : 300;
@@ -443,14 +548,19 @@ export default class SvgView {
443
548
  if (!this._root) return;
444
549
  ctx.save();
445
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];
446
554
  if (this.viewBox) {
447
555
  const [minX, minY, vbW, vbH] = this.viewBox;
448
556
  if (vbW > 0 && vbH > 0) {
449
557
  ctx.scale(w / vbW, h / vbH);
450
558
  ctx.translate(-minX, -minY);
559
+ this._viewport = [vbW, vbH];
451
560
  }
452
561
  } else if (this.naturalWidth > 0 && this.naturalHeight > 0) {
453
562
  ctx.scale(w / this.naturalWidth, h / this.naturalHeight);
563
+ this._viewport = [this.naturalWidth, this.naturalHeight];
454
564
  }
455
565
  this._renderChildren(
456
566
  this._root,
@@ -465,27 +575,7 @@ export default class SvgView {
465
575
  // ------------------------------------------------------------------
466
576
 
467
577
  _style(node, parent) {
468
- let style = parent;
469
- const own = () => (style === parent ? (style = { ...parent }) : style);
470
- const apply = (name, raw) => {
471
- const key = STYLE_ATTRS[name];
472
- if (!key || raw === undefined || raw === '' || raw === 'inherit') return;
473
- let v = String(raw).trim();
474
- if (NUMERIC.has(key)) {
475
- const n = parseFloat(v);
476
- if (!Number.isFinite(n)) return;
477
- own()[key] = n;
478
- } else {
479
- own()[key] = v;
480
- }
481
- };
482
- for (const [name, value] of Object.entries(node.attribs || {})) apply(name, value);
483
- // inline style="" wins over presentation attributes
484
- for (const decl of (node.attribs?.style || '').split(';')) {
485
- const idx = decl.indexOf(':');
486
- if (idx > 0) apply(decl.slice(0, idx).trim(), decl.slice(idx + 1).trim());
487
- }
488
- return style;
578
+ return resolveStyle(node, parent);
489
579
  }
490
580
 
491
581
  _paint(ctx, value, style, path) {
@@ -604,14 +694,55 @@ export default class SvgView {
604
694
  }
605
695
  }
606
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
+
607
738
  _renderNode(node, ctx, parentStyle, alpha, depth) {
608
739
  if (depth > 32) return;
609
740
  const name = tag(node);
610
741
  if (NON_RENDERED.has(name) || displayNone(node)) return;
611
742
 
612
743
  const style = this._style(node, parentStyle);
613
- const opacity = parseFloat(node.attribs?.opacity ?? '1');
614
- const nodeAlpha = alpha * (Number.isFinite(opacity) ? Math.min(1, Math.max(0, opacity)) : 1);
744
+ // not inherited: a group's multiplies down through what is in it
745
+ const nodeAlpha = alpha * (ownProperty(node, 'opacity', alphaValue) ?? 1);
615
746
  if (nodeAlpha <= 0) return;
616
747
 
617
748
  const transforms = parseSvgTransform(node.attribs?.transform);
@@ -623,6 +754,8 @@ export default class SvgView {
623
754
 
624
755
  switch (name) {
625
756
  case 'svg':
757
+ this._renderViewport(node, ctx, style, nodeAlpha, depth);
758
+ break;
626
759
  case 'g':
627
760
  case 'a':
628
761
  this._renderChildren(node, ctx, style, nodeAlpha, depth + 1);
@@ -642,9 +775,13 @@ export default class SvgView {
642
775
  break;
643
776
  }
644
777
  case 'text':
645
- this._renderText(node, ctx, style, nodeAlpha);
778
+ if (style.visibility === 'visible') this._renderText(node, ctx, style, nodeAlpha);
646
779
  break;
647
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;
648
785
  const path = shapePath(node);
649
786
  if (!path) break;
650
787
  if (name !== 'line' && style.fill !== 'none') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.17.3",
3
+ "version": "8.17.5",
4
4
  "description": "Desktop UI toolkit for X11 with canvas-like 2d and OpenGL rendering",
5
5
  "author": "Andrey Sidorov <sidorares@yandex.ru>",
6
6
  "license": "MIT",