ntk 8.24.0 → 8.26.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.
@@ -33,6 +33,8 @@ const INHERITED = {
33
33
  lineCap: 'butt',
34
34
  lineJoin: 'miter',
35
35
  miterLimit: 4,
36
+ dashArray: 'none',
37
+ dashOffset: 0,
36
38
  fillRule: 'nonzero',
37
39
  clipRule: 'nonzero',
38
40
  fillOpacity: 1,
@@ -61,6 +63,8 @@ const STYLE_ATTRS = {
61
63
  'stroke-linecap': 'lineCap',
62
64
  'stroke-linejoin': 'lineJoin',
63
65
  'stroke-miterlimit': 'miterLimit',
66
+ 'stroke-dasharray': 'dashArray',
67
+ 'stroke-dashoffset': 'dashOffset',
64
68
  'fill-rule': 'fillRule',
65
69
  'clip-rule': 'clipRule',
66
70
  'fill-opacity': 'fillOpacity',
@@ -175,6 +179,8 @@ function userLanguages() {
175
179
  return (defaultLanguages = out);
176
180
  }
177
181
 
182
+ const EXTENSIBILITY = 'http://www.w3.org/TR/SVG11/feature#Extensibility';
183
+
178
184
  /**
179
185
  * Whether an element's conditional processing attributes hold (SVG 1.1,
180
186
  * 5.8): where one does not, the element and all of it are not rendered, and
@@ -186,14 +192,21 @@ function userLanguages() {
186
192
  * of. SVG 2 dropped the attribute and browsers hold every one, but the
187
193
  * one they would pick, a `foreignObject`, draws nothing here, and the
188
194
  * `<text>` an exporter writes after it for renderers like this one does.
195
+ * `extensible` is whether a `foreignObject` is drawn — the draw was handed
196
+ * `opts.foreignObject` — and then `#Extensibility` holds.
189
197
  * - `systemLanguage` holds where one of its comma-separated tags is one of
190
198
  * `languages`, or that narrowed by a subtag, or the other way round.
191
199
  */
192
- function conditionsHold(node, languages) {
200
+ function conditionsHold(node, languages, extensible = false) {
193
201
  if (!node.attribs) return true;
194
202
  if (attr(node, 'requiredExtensions') !== undefined) return false;
195
203
  const features = attr(node, 'requiredFeatures');
196
- if (features !== undefined && features.split(/\s+/).some((f) => UNDRAWN_FEATURES.has(f))) return false;
204
+ if (
205
+ features !== undefined &&
206
+ features.split(/\s+/).some((f) => UNDRAWN_FEATURES.has(f) && !(extensible && f === EXTENSIBILITY))
207
+ ) {
208
+ return false;
209
+ }
197
210
  const system = attr(node, 'systemLanguage');
198
211
  if (system !== undefined) {
199
212
  const tags = system.split(',').map((t) => t.trim().toLowerCase());
@@ -208,9 +221,9 @@ function conditionsHold(node, languages) {
208
221
  * (SVG 1.1, 5.8.2), whatever that is — a `display: none` one is chosen, and
209
222
  * draws nothing — or null where none does.
210
223
  */
211
- function switchChoice(node, languages) {
224
+ function switchChoice(node, languages, extensible = false) {
212
225
  for (const child of node.children || []) {
213
- if (child.type === 'tag' && conditionsHold(child, languages)) return child;
226
+ if (child.type === 'tag' && conditionsHold(child, languages, extensible)) return child;
214
227
  }
215
228
  return null;
216
229
  }
@@ -250,6 +263,24 @@ function viewportLength(node, name, size, fallback) {
250
263
  return v.endsWith('%') ? (n / 100) * size : n;
251
264
  }
252
265
 
266
+ /**
267
+ * Where a `<foreignObject>` is in the user space it is drawn in (SVG 2,
268
+ * 23.2): its `x` and `y`, 0 where unset, and its `width` and `height`, 0
269
+ * where unset or `auto` — which draws nothing — each a length or a
270
+ * percentage of the viewport, `[width, height]`. A negative size is as
271
+ * none. Its bounding box, which `objectBoundingBox` units are of.
272
+ */
273
+ function foreignBox(node, [pw, ph]) {
274
+ const width = viewportLength(node, 'width', pw, 0);
275
+ const height = viewportLength(node, 'height', ph, 0);
276
+ return {
277
+ x: viewportLength(node, 'x', pw, 0),
278
+ y: viewportLength(node, 'y', ph, 0),
279
+ width: width > 0 ? width : 0,
280
+ height: height > 0 ? height : 0
281
+ };
282
+ }
283
+
253
284
  const ALIGN = /^x(Min|Mid|Max)Y(Min|Mid|Max)$/;
254
285
  const ALONG = { Min: 0, Mid: 0.5, Max: 1 };
255
286
 
@@ -578,6 +609,12 @@ function resolveStyle(node, parent) {
578
609
  case 'textAnchor':
579
610
  value = v.toLowerCase();
580
611
  break;
612
+ case 'dashArray':
613
+ value = dashArrayValue(v);
614
+ break;
615
+ case 'dashOffset':
616
+ value = dashLength(v, true) ?? undefined;
617
+ break;
581
618
  case 'markerStart':
582
619
  case 'markerMid':
583
620
  case 'markerEnd':
@@ -596,6 +633,42 @@ function resolveStyle(node, parent) {
596
633
  return style;
597
634
  }
598
635
 
636
+ /**
637
+ * A length in a dash pattern or its offset, in user units: a number, or one
638
+ * in `px` (SVG 2, 13.5.6). A percentage of the viewport's diagonal, or a
639
+ * length in another unit, is none this reads. A negative one is an error in
640
+ * a pattern, and fine in an offset (`signed`).
641
+ */
642
+ function dashLength(value, signed = false) {
643
+ const m = /^([-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?)(px)?$/i.exec(value.trim());
644
+ if (!m) return null;
645
+ const n = Number(m[1]);
646
+ if (!Number.isFinite(n) || (!signed && n < 0)) return null;
647
+ return n;
648
+ }
649
+
650
+ /**
651
+ * `stroke-dasharray` as `setLineDash` takes it (SVG 2, 13.5.5): `none`, or
652
+ * its lengths, split on commas and white space. One that is not a length,
653
+ * or is negative, makes the value an error, which leaves the property as it
654
+ * was (undefined); lengths that come to nothing draw the stroke solid, as
655
+ * `none` does. An odd count is repeated by `setLineDash`, as the property
656
+ * has it.
657
+ */
658
+ function dashArrayValue(value) {
659
+ const v = value.trim();
660
+ if (v.toLowerCase() === 'none') return 'none';
661
+ const parts = v.split(/[\s,]+/).filter(Boolean);
662
+ if (!parts.length) return undefined;
663
+ const out = [];
664
+ for (const part of parts) {
665
+ const n = dashLength(part);
666
+ if (n === null) return undefined;
667
+ out.push(n);
668
+ }
669
+ return out.some((n) => n > 0) ? out : 'none';
670
+ }
671
+
599
672
  /**
600
673
  * How many distinct colours a document actually commits to, decided once at
601
674
  * parse time.
@@ -658,6 +731,10 @@ function scanPaints(root, ids, languages) {
658
731
  if (chosen) visit(chosen, style, depth + 1);
659
732
  return;
660
733
  }
734
+ case 'foreignobject':
735
+ // it paints nothing of its own: what is in it is the caller's to
736
+ // draw (`opts.foreignObject`), in the caller's colours
737
+ return;
661
738
  case 'use': {
662
739
  const href = node.attribs?.href || node.attribs?.['xlink:href'] || '';
663
740
  const target = href.startsWith('#') ? ids.get(href.slice(1)) : null;
@@ -736,6 +813,10 @@ export default class SvgView {
736
813
  this._root = null;
737
814
  this._ids = new Map();
738
815
  this._viewport = [0, 0];
816
+ /** what draws a `<foreignObject>` in this draw (`opts.foreignObject`),
817
+ * and whether there is one, which `#Extensibility` holds where */
818
+ this._foreignObject = null;
819
+ this._extensible = false;
739
820
  this.naturalWidth = 0;
740
821
  this.naturalHeight = 0;
741
822
  this.viewBox = null;
@@ -851,9 +932,23 @@ export default class SvgView {
851
932
  * user units: an inline `<svg>` takes its font from the page around it,
852
933
  * as it does in a browser, where on its own it starts from 16px
853
934
  * sans-serif.
935
+ *
936
+ * `opts.foreignObject(element, ctx, box)` draws what a `<foreignObject>`
937
+ * holds, which is the caller's — HTML, in a host document's inline
938
+ * `<svg>` — where the drawing reaches the object in its order: over what
939
+ * is drawn before it and under what is drawn after. `ctx` is in the
940
+ * object's user space, its `transform` and every one around it in force
941
+ * and cut to the `clip-path` on it and on what it is in, its `globalAlpha`
942
+ * what the opacities down to it come to. `box` is `{ x, y, width, height,
943
+ * opacity }`: where the object is in that space, its bounding box, and
944
+ * that opacity. The context is restored after it. A hidden object, or one
945
+ * of no width or height, is not handed over; and where it is given,
946
+ * `#Extensibility` holds in a `requiredFeatures`.
854
947
  */
855
948
  draw(ctx, x = 0, y = 0, w = this.naturalWidth, h = this.naturalHeight, opts = {}) {
856
949
  if (!this._root) return;
950
+ this._foreignObject = typeof opts.foreignObject === 'function' ? opts.foreignObject : null;
951
+ this._extensible = this._foreignObject !== null;
857
952
  // what a masked element's surface is made with, and where it can show
858
953
  this._surface = opts.surface ?? surfaceMaker(ctx) ?? surfaceMaker(this._ctx);
859
954
  this._bounds = deviceBox(ctx, x, y, w, h);
@@ -879,6 +974,8 @@ export default class SvgView {
879
974
  this._renderChildren(this._root, ctx, this._style(this._root, this._initial), 1, 0);
880
975
  } finally {
881
976
  this._surface = null;
977
+ this._foreignObject = null;
978
+ this._extensible = false;
882
979
  ctx.restore();
883
980
  }
884
981
  }
@@ -1106,7 +1203,7 @@ export default class SvgView {
1106
1203
  _renderNode(node, ctx, parentStyle, alpha, depth) {
1107
1204
  if (depth > 32) return;
1108
1205
  const name = tag(node);
1109
- if (NON_RENDERED.has(name) || displayNone(node) || !conditionsHold(node, this.languages)) return;
1206
+ if (NON_RENDERED.has(name) || displayNone(node) || !conditionsHold(node, this.languages, this._extensible)) return;
1110
1207
 
1111
1208
  const style = this._style(node, parentStyle);
1112
1209
  // not inherited: a group's multiplies down through what is in it
@@ -1145,7 +1242,7 @@ export default class SvgView {
1145
1242
  case 'switch': {
1146
1243
  // a group of one: its transform and opacity are a group's, and it
1147
1244
  // draws the first child whose conditions hold and none of the rest
1148
- const chosen = switchChoice(node, this.languages);
1245
+ const chosen = switchChoice(node, this.languages, this._extensible);
1149
1246
  if (chosen) this._renderNode(chosen, ctx, style, nodeAlpha, depth + 1);
1150
1247
  break;
1151
1248
  }
@@ -1163,6 +1260,9 @@ export default class SvgView {
1163
1260
  // `<text>` may be shown again
1164
1261
  this._renderText(node, ctx, style, nodeAlpha);
1165
1262
  break;
1263
+ case 'foreignobject':
1264
+ this._renderForeign(node, ctx, style, nodeAlpha);
1265
+ break;
1166
1266
  default: {
1167
1267
  // `visibility` hides what an element paints itself, and is
1168
1268
  // inherited: a group walks on, since what is in it may say
@@ -1179,7 +1279,17 @@ export default class SvgView {
1179
1279
  ctx.lineCap = style.lineCap;
1180
1280
  ctx.lineJoin = style.lineJoin;
1181
1281
  ctx.miterLimit = style.miterLimit;
1282
+ // dashed where it says so, and nothing after it is
1283
+ const dashed = style.dashArray !== 'none' && typeof ctx.setLineDash === 'function';
1284
+ if (dashed) {
1285
+ ctx.setLineDash(style.dashArray);
1286
+ ctx.lineDashOffset = style.dashOffset;
1287
+ }
1182
1288
  this._ink(ctx, 'stroke', style.stroke, style, path, nodeAlpha * style.strokeOpacity, () => ctx.stroke(path));
1289
+ if (dashed) {
1290
+ ctx.setLineDash([]);
1291
+ ctx.lineDashOffset = 0;
1292
+ }
1183
1293
  }
1184
1294
  ctx.globalAlpha = 1;
1185
1295
  // over its fill and its stroke, which is the default paint order
@@ -1189,6 +1299,26 @@ export default class SvgView {
1189
1299
  }
1190
1300
  }
1191
1301
 
1302
+ /**
1303
+ * A `<foreignObject>` (SVG 2, 23.2), handed to `opts.foreignObject` to
1304
+ * draw what it holds, in its user space and at its opacity — the caller's
1305
+ * own drawing, so the context is put back as it was after it, whatever
1306
+ * it set. Nothing is drawn of one where the draw was handed none.
1307
+ */
1308
+ _renderForeign(node, ctx, style, alpha) {
1309
+ const draw = this._foreignObject;
1310
+ if (!draw || style.visibility !== 'visible') return;
1311
+ const box = foreignBox(node, this._viewport);
1312
+ if (!(box.width > 0 && box.height > 0)) return;
1313
+ ctx.save();
1314
+ try {
1315
+ ctx.globalAlpha = alpha;
1316
+ draw(node, ctx, { ...box, opacity: alpha });
1317
+ } finally {
1318
+ ctx.restore();
1319
+ }
1320
+ }
1321
+
1192
1322
  /**
1193
1323
  * The markers a shape's `marker-start`, `marker-mid` and `marker-end`
1194
1324
  * name, drawn at its vertices (SVG 2, 11.6): the first vertex of the
@@ -1478,7 +1608,7 @@ export default class SvgView {
1478
1608
  * or that is not visible.
1479
1609
  */
1480
1610
  _clipPart(child, inherited, base, ctx, depth, nest) {
1481
- if (!CLIPPING.has(tag(child)) || displayNone(child) || !conditionsHold(child, this.languages)) return null;
1611
+ if (!CLIPPING.has(tag(child)) || displayNone(child) || !conditionsHold(child, this.languages, this._extensible)) return null;
1482
1612
  let style = this._style(child, inherited);
1483
1613
  let m = matMultiply(base, matrixOf(ownTransforms(child)));
1484
1614
  const clips = [];
@@ -1492,7 +1622,7 @@ export default class SvgView {
1492
1622
  if (tag(child) === 'use') {
1493
1623
  shape = this._useTarget(child);
1494
1624
  if (!shape || tag(shape) === 'use' || !CLIPPING.has(tag(shape))) return null;
1495
- if (displayNone(shape) || !conditionsHold(shape, this.languages)) return null;
1625
+ if (displayNone(shape) || !conditionsHold(shape, this.languages, this._extensible)) return null;
1496
1626
  style = this._style(shape, style);
1497
1627
  m = matMultiply(m, matrixOf(ownTransforms(shape)));
1498
1628
  cutBy(shape, style, m);
@@ -1689,11 +1819,11 @@ export default class SvgView {
1689
1819
  case 'g':
1690
1820
  case 'a':
1691
1821
  case 'switch': {
1692
- const kids = name === 'switch' ? [switchChoice(node, this.languages)] : node.children || [];
1822
+ const kids = name === 'switch' ? [switchChoice(node, this.languages, this._extensible)] : node.children || [];
1693
1823
  let box = null;
1694
1824
  for (const child of kids) {
1695
1825
  if (!child || child.type !== 'tag') continue;
1696
- if (NON_RENDERED.has(tag(child)) || displayNone(child) || !conditionsHold(child, this.languages)) continue;
1826
+ if (NON_RENDERED.has(tag(child)) || displayNone(child) || !conditionsHold(child, this.languages, this._extensible)) continue;
1697
1827
  const inner = this._bbox(child, ctx, this._style(child, style), depth + 1);
1698
1828
  if (inner) box = unionBox(box, transformedBox(inner, ownTransforms(child)));
1699
1829
  }
@@ -1724,6 +1854,11 @@ export default class SvgView {
1724
1854
  ctx.restore();
1725
1855
  }
1726
1856
  }
1857
+ case 'foreignobject': {
1858
+ // its rectangle, whether or not anything draws what is in it
1859
+ const { x, y, width, height } = foreignBox(node, this._viewport);
1860
+ return { x, y, w: width, h: height };
1861
+ }
1727
1862
  default: {
1728
1863
  const path = shapePath(node);
1729
1864
  return path ? pathBBox(path) : null;
@@ -1775,7 +1910,7 @@ export default class SvgView {
1775
1910
  } else if (child.type === 'cdata') {
1776
1911
  walk(child.children);
1777
1912
  } else if (child.type === 'tag' && depth < 32) {
1778
- if (!TEXT_SPANS.has(tag(child)) || displayNone(child) || !conditionsHold(child, this.languages)) continue;
1913
+ if (!TEXT_SPANS.has(tag(child)) || displayNone(child) || !conditionsHold(child, this.languages, this._extensible)) continue;
1779
1914
  const ca = a * (ownProperty(child, 'opacity', alphaValue) ?? 1);
1780
1915
  collect(child, this._style(child, st), ca, depth + 1);
1781
1916
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.24.0",
3
+ "version": "8.26.0",
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",
@@ -68,7 +68,7 @@
68
68
  "x11-dri": ">=0.5.0 <1"
69
69
  },
70
70
  "scripts": {
71
- "test": "NTK_STRICT_COLORS=1 node --test",
71
+ "test": "NTK_STRICT_COLORS=1 node --import ./test/helpers/exit-watchdog.js --test",
72
72
  "check-release-message": "node scripts/check-release-message.mjs"
73
73
  },
74
74
  "devDependencies": {