ntk 8.19.0 → 8.21.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.
@@ -17,12 +17,13 @@
17
17
  // Supported: path/rect/circle/ellipse/line/polyline/polygon, <g>, <defs>,
18
18
  // <use>, <switch>, linear/radial gradients (objectBoundingBox and
19
19
  // userSpaceOnUse), transform lists, opacity/fill-opacity/stroke-opacity,
20
- // display, visibility, nested <svg> viewports, fill-rule, basic <text>. See
21
- // docs/svg.md for the full surface and limitations.
20
+ // display, visibility, nested <svg> viewports, fill-rule, <text> with its
21
+ // <tspan>s, and <mask>. See docs/svg.md for the full surface and
22
+ // limitations.
22
23
 
23
- import { textContent } from 'domutils';
24
24
  import { parseDocument } from 'htmlparser2';
25
25
 
26
+ import { cssColorStraight } from '../color.js';
26
27
  import { Path2D, flattenPath } from '../path.js';
27
28
 
28
29
  const INHERITED = {
@@ -38,8 +39,18 @@ const INHERITED = {
38
39
  color: '#000',
39
40
  fontFamily: 'sans-serif',
40
41
  fontSize: 16,
42
+ fontWeight: 400,
43
+ fontStyle: 'normal',
44
+ letterSpacing: 0,
45
+ wordSpacing: 0,
46
+ textTransform: 'none',
47
+ whiteSpace: 'normal',
48
+ dominantBaseline: 'auto',
41
49
  textAnchor: 'start',
42
- visibility: 'visible'
50
+ visibility: 'visible',
51
+ markerStart: 'none',
52
+ markerMid: 'none',
53
+ markerEnd: 'none'
43
54
  };
44
55
 
45
56
  const STYLE_ATTRS = {
@@ -55,11 +66,26 @@ const STYLE_ATTRS = {
55
66
  color: 'color',
56
67
  'font-family': 'fontFamily',
57
68
  'font-size': 'fontSize',
69
+ 'font-weight': 'fontWeight',
70
+ 'font-style': 'fontStyle',
71
+ 'letter-spacing': 'letterSpacing',
72
+ 'word-spacing': 'wordSpacing',
73
+ 'text-transform': 'textTransform',
74
+ 'white-space': 'whiteSpace',
75
+ 'xml:space': 'whiteSpace',
76
+ 'dominant-baseline': 'dominantBaseline',
58
77
  'text-anchor': 'textAnchor',
59
- visibility: 'visibility'
78
+ visibility: 'visibility',
79
+ 'marker-start': 'markerStart',
80
+ 'marker-mid': 'markerMid',
81
+ 'marker-end': 'markerEnd'
60
82
  };
61
83
 
62
- const NUMERIC = new Set(['strokeWidth', 'miterLimit', 'fontSize']);
84
+ /** The three a `marker` declaration in a `style` sets, which is a CSS
85
+ * shorthand and no presentation attribute (SVG 2, 11.6.3). */
86
+ const MARKER_PROPERTIES = ['marker-start', 'marker-mid', 'marker-end'];
87
+
88
+ const NUMERIC = new Set(['strokeWidth', 'miterLimit']);
63
89
  const ALPHA = new Set(['fillOpacity', 'strokeOpacity']);
64
90
  const VISIBILITY = new Set(['visible', 'hidden', 'collapse']);
65
91
 
@@ -119,7 +145,7 @@ function displayNone(node) {
119
145
  // author's fallback is what to draw instead
120
146
  const UNDRAWN_FEATURES = new Set(
121
147
  [
122
- 'Extensibility', 'Image', 'Clip', 'BasicClip', 'Mask', 'Filter', 'BasicFilter',
148
+ 'Extensibility', 'Image', 'Clip', 'BasicClip', 'Filter', 'BasicFilter',
123
149
  'Pattern', 'Marker', 'Font', 'BasicFont', 'Script', 'Animation', 'ColorProfile',
124
150
  'Cursor', 'View'
125
151
  ].map((name) => `http://www.w3.org/TR/SVG11/feature#${name}`)
@@ -373,42 +399,193 @@ function pathBBox(path) {
373
399
  return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
374
400
  }
375
401
 
402
+ const ABSOLUTE_UNITS = { '': 1, px: 1, pt: 4 / 3, pc: 16, in: 96, cm: 96 / 2.54, mm: 96 / 25.4, q: 96 / 101.6 };
403
+ const LENGTH = /^([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)([a-z%]*)$/i;
404
+
405
+ /**
406
+ * A length in user units, which are CSS pixels: a number, a length in an
407
+ * absolute unit, `em` and `ex` of `fontSize`, `rem` of the initial 16px,
408
+ * and a percentage of `percent` — undefined where a percentage means
409
+ * nothing, and for anything it cannot read.
410
+ */
411
+ function userLength(raw, fontSize, percent) {
412
+ const m = LENGTH.exec(String(raw ?? '').trim());
413
+ if (!m) return undefined;
414
+ const n = parseFloat(m[1]);
415
+ const unit = m[2].toLowerCase();
416
+ if (unit in ABSOLUTE_UNITS) return n * ABSOLUTE_UNITS[unit];
417
+ if (unit === 'em') return n * fontSize;
418
+ if (unit === 'ex') return (n * fontSize) / 2;
419
+ if (unit === 'rem') return n * 16;
420
+ if (unit === '%' && percent !== undefined) return (n / 100) * percent;
421
+ return undefined;
422
+ }
423
+
424
+ /** A list of lengths — `x="10 20 30"`, `dy="1em"` — or null for none. */
425
+ function userLengths(raw, fontSize, percent) {
426
+ if (raw === undefined) return null;
427
+ const out = [];
428
+ for (const part of String(raw).trim().split(/[\s,]+/)) {
429
+ if (part === '') continue;
430
+ const n = userLength(part, fontSize, percent);
431
+ if (n === undefined) return null;
432
+ out.push(n);
433
+ }
434
+ return out.length ? out : null;
435
+ }
436
+
437
+ const FONT_SIZES = {
438
+ 'xx-small': 9,
439
+ 'x-small': 10,
440
+ small: 13,
441
+ medium: 16,
442
+ large: 18,
443
+ 'x-large': 24,
444
+ 'xx-large': 32,
445
+ 'xxx-large': 48
446
+ };
447
+
448
+ /** `font-size`, against the size it inherits (CSS Fonts 4, 2.5). */
449
+ function fontSizeValue(v, inherited) {
450
+ const k = v.toLowerCase();
451
+ if (k in FONT_SIZES) return FONT_SIZES[k];
452
+ if (k === 'larger') return inherited * 1.2;
453
+ if (k === 'smaller') return inherited / 1.2;
454
+ const n = userLength(v, inherited, inherited);
455
+ return n !== undefined && n >= 0 ? n : undefined;
456
+ }
457
+
458
+ /** `font-weight` as a number, `bolder` and `lighter` against the weight it
459
+ * inherits (CSS Fonts 4, 2.2). */
460
+ function fontWeightValue(v, inherited) {
461
+ const k = v.toLowerCase();
462
+ if (k === 'normal') return 400;
463
+ if (k === 'bold') return 700;
464
+ if (k === 'bolder') return inherited < 350 ? 400 : inherited < 550 ? 700 : Math.max(900, inherited);
465
+ if (k === 'lighter') return inherited < 100 ? inherited : inherited < 550 ? 100 : inherited < 750 ? 400 : 700;
466
+ const n = Number(k);
467
+ return Number.isFinite(n) && n >= 1 && n <= 1000 ? n : undefined;
468
+ }
469
+
470
+ /** `letter-spacing` and `word-spacing`: `normal` is none, and a length in
471
+ * `em` is of the element's own font size. */
472
+ function spacingValue(v, fontSize) {
473
+ if (v.toLowerCase() === 'normal') return 0;
474
+ return userLength(v, fontSize, undefined);
475
+ }
476
+
477
+ const TEXT_TRANSFORMS = new Set(['none', 'uppercase', 'lowercase', 'capitalize']);
478
+
479
+ /**
480
+ * Whether white space is kept as written: `xml:space="preserve"`, or a CSS
481
+ * `white-space` that does not collapse it (SVG 2, 11.7). Either way a line
482
+ * break and a tab are a space, since an SVG text chunk never wraps.
483
+ */
484
+ function whiteSpaceValue(name, v) {
485
+ const k = v.toLowerCase();
486
+ if (name === 'xml:space') return k === 'preserve' ? 'pre' : k === 'default' ? 'normal' : undefined;
487
+ if (k === 'pre' || k === 'pre-wrap' || k === 'break-spaces') return 'pre';
488
+ if (k === 'normal' || k === 'nowrap' || k === 'pre-line') return 'normal';
489
+ return undefined;
490
+ }
491
+
376
492
  /**
377
493
  * The inherited properties an element ends up with: its parent's, where
378
494
  * the element sets them neither by presentation attribute nor in its inline
379
495
  * `style`, which wins over the attribute. A value it cannot read is dropped,
380
- * as CSS drops an invalid declaration. Returns `parent` itself where
381
- * nothing changes.
496
+ * as CSS drops an invalid declaration — and so is one that leans on a
497
+ * custom property, which there are none of here: `font-family="var(--sans)"`
498
+ * is the family the element inherits. Returns `parent` itself where nothing
499
+ * changes.
500
+ *
501
+ * `font-size` is read first, whichever way round the element writes them:
502
+ * a `letter-spacing` in `em` is of the element's own size.
382
503
  */
383
504
  function resolveStyle(node, parent) {
384
505
  let style = parent;
385
506
  const own = () => (style === parent ? (style = { ...parent }) : style);
507
+ const decls = [];
508
+ for (const [name, value] of Object.entries(node.attribs || {})) {
509
+ if (STYLE_ATTRS[name]) decls.push([name, value]);
510
+ }
511
+ // inline style="" wins over presentation attributes
512
+ const inline = node.attribs?.style;
513
+ if (inline) {
514
+ for (const decl of inline.split(';')) {
515
+ const idx = decl.indexOf(':');
516
+ if (idx <= 0) continue;
517
+ const name = decl.slice(0, idx).trim().toLowerCase();
518
+ if (STYLE_ATTRS[name]) decls.push([name, decl.slice(idx + 1)]);
519
+ else if (name === 'marker') {
520
+ for (const each of MARKER_PROPERTIES) decls.push([each, decl.slice(idx + 1)]);
521
+ }
522
+ }
523
+ }
524
+ if (!decls.length) return parent;
386
525
  const apply = (name, raw) => {
387
526
  const key = STYLE_ATTRS[name];
388
- if (!key || raw === undefined || raw === '' || raw === 'inherit') return;
389
- const v = String(raw).trim();
527
+ if (raw === undefined) return;
528
+ const v = String(raw).trim().replace(/\s*!important$/i, '');
529
+ if (v === '' || v === 'inherit') return;
530
+ let value;
390
531
  if (NUMERIC.has(key)) {
391
532
  const n = parseFloat(v);
392
- if (!Number.isFinite(n)) return;
393
- own()[key] = n;
533
+ if (Number.isFinite(n)) value = n;
394
534
  } else if (ALPHA.has(key)) {
395
- const n = alphaValue(v);
396
- if (n === undefined) return;
397
- own()[key] = n;
398
- } else if (key === 'visibility') {
399
- const k = v.toLowerCase();
400
- if (!VISIBILITY.has(k)) return;
401
- own()[key] = k;
535
+ value = alphaValue(v);
402
536
  } else {
403
- own()[key] = v;
537
+ switch (key) {
538
+ case 'visibility': {
539
+ const k = v.toLowerCase();
540
+ if (VISIBILITY.has(k)) value = k;
541
+ break;
542
+ }
543
+ case 'fontSize':
544
+ value = fontSizeValue(v, parent.fontSize);
545
+ break;
546
+ case 'fontWeight':
547
+ value = fontWeightValue(v, parent.fontWeight);
548
+ break;
549
+ case 'fontStyle': {
550
+ const k = v.toLowerCase();
551
+ if (k === 'normal' || k === 'italic') value = k;
552
+ else if (k.startsWith('oblique')) value = 'oblique';
553
+ break;
554
+ }
555
+ case 'fontFamily':
556
+ if (!/\b(?:var|env)\(/i.test(v)) value = v;
557
+ break;
558
+ case 'letterSpacing':
559
+ case 'wordSpacing':
560
+ value = spacingValue(v, (style ?? parent).fontSize);
561
+ break;
562
+ case 'textTransform': {
563
+ const k = v.toLowerCase();
564
+ if (TEXT_TRANSFORMS.has(k)) value = k;
565
+ break;
566
+ }
567
+ case 'whiteSpace':
568
+ value = whiteSpaceValue(name, v);
569
+ break;
570
+ case 'dominantBaseline':
571
+ case 'textAnchor':
572
+ value = v.toLowerCase();
573
+ break;
574
+ case 'markerStart':
575
+ case 'markerMid':
576
+ case 'markerEnd':
577
+ // `none`, or the id a `url(#id)` names; anything else leaves it
578
+ value = urlReference(v);
579
+ if (value === '') value = 'none';
580
+ break;
581
+ default:
582
+ value = v;
583
+ }
404
584
  }
585
+ if (value !== undefined) own()[key] = value;
405
586
  };
406
- for (const [name, value] of Object.entries(node.attribs || {})) apply(name, value);
407
- // inline style="" wins over presentation attributes
408
- for (const decl of (node.attribs?.style || '').split(';')) {
409
- const idx = decl.indexOf(':');
410
- if (idx > 0) apply(decl.slice(0, idx).trim(), decl.slice(idx + 1).trim());
411
- }
587
+ for (const [name, raw] of decls) if (name === 'font-size') apply(name, raw);
588
+ for (const [name, raw] of decls) if (name !== 'font-size') apply(name, raw);
412
589
  return style;
413
590
  }
414
591
 
@@ -483,14 +660,40 @@ function scanPaints(root, ids, languages) {
483
660
  else visit(target, style, depth + 1);
484
661
  return;
485
662
  }
663
+ case 'text': {
664
+ // each span of it fills in its own paint, and a span of a hidden
665
+ // text may be shown again
666
+ const spans = (el, st, d) => {
667
+ if (st.visibility === 'visible') note(st.fill);
668
+ for (const child of el.children || []) {
669
+ if (child.type !== 'tag' || !TEXT_SPANS.has(tag(child)) || displayNone(child) || d > 32) continue;
670
+ spans(child, resolveStyle(child, st), d + 1);
671
+ }
672
+ };
673
+ spans(node, style, 0);
674
+ return;
675
+ }
486
676
  default:
487
677
  // a hidden shape paints nothing, though what is in a hidden
488
678
  // group may show itself again
489
679
  if (style.visibility !== 'visible') return;
490
680
  if (name !== 'line') note(style.fill);
491
- if (name !== 'text') note(style.stroke);
681
+ note(style.stroke);
682
+ // what its markers paint is its drawing's too, in the style each
683
+ // marker has where it stands
684
+ if (MARKABLE.has(name)) {
685
+ for (const id of markerIds(style)) {
686
+ const marker = ids.get(id);
687
+ if (marker && tag(marker) === 'marker' && !inMarker.has(marker)) {
688
+ inMarker.add(marker);
689
+ kids(marker, styleWhereItStands(marker, root), depth);
690
+ inMarker.delete(marker);
691
+ }
692
+ }
693
+ }
492
694
  }
493
695
  };
696
+ const inMarker = new Set();
494
697
 
495
698
  visit(root, INHERITED, 0);
496
699
  return {
@@ -501,8 +704,8 @@ function scanPaints(root, ids, languages) {
501
704
 
502
705
  /**
503
706
  * Widget rendering a static SVG document into a window (or any 2d context
504
- * via `draw()`). Scripting, CSS stylesheets, filters, masks and external
505
- * references are not supported — see docs/svg.md.
707
+ * via `draw()`). Scripting, CSS stylesheets, filters, clip paths and
708
+ * external references are not supported — see docs/svg.md.
506
709
  */
507
710
  export default class SvgView {
508
711
  constructor(window, opts = {}) {
@@ -627,9 +830,27 @@ export default class SvgView {
627
830
  * names no colour, so the same parsed document paints in whatever the
628
831
  * caller is using, and a caller caching the result can recolour a cached
629
832
  * `paintKind === 'mono'` drawing without re-rendering it.
833
+ *
834
+ * `opts.surface(width, height)` makes an offscreen surface to draw a
835
+ * masked element on: something with a `getContext('2d')`, a `destroy()`,
836
+ * that `ctx.drawImage` takes — or null where there is none. Where it is
837
+ * not given, a context of ntk's own makes an ntk `Surface` on its app, and
838
+ * a window-mode view one on its window's; a context that is neither's —
839
+ * the macOS one react-x11 draws with — needs it given. With no surface, a
840
+ * masked element is drawn unmasked, cut to the mask's region.
841
+ *
842
+ * `opts.font` is what the document's text inherits where it names no font
843
+ * of its own — `{ family, size, weight, style }`, any of them, the size in
844
+ * user units: an inline `<svg>` takes its font from the page around it,
845
+ * as it does in a browser, where on its own it starts from 16px
846
+ * sans-serif.
630
847
  */
631
848
  draw(ctx, x = 0, y = 0, w = this.naturalWidth, h = this.naturalHeight, opts = {}) {
632
849
  if (!this._root) return;
850
+ // what a masked element's surface is made with, and where it can show
851
+ this._surface = opts.surface ?? surfaceMaker(ctx) ?? surfaceMaker(this._ctx);
852
+ this._bounds = deviceBox(ctx, x, y, w, h);
853
+ this._masking = new Set();
633
854
  ctx.save();
634
855
  ctx.translate(x, y);
635
856
  // the user-space size of the viewport, what a nested <svg>'s
@@ -646,14 +867,13 @@ export default class SvgView {
646
867
  ctx.scale(w / this.naturalWidth, h / this.naturalHeight);
647
868
  this._viewport = [this.naturalWidth, this.naturalHeight];
648
869
  }
649
- this._renderChildren(
650
- this._root,
651
- ctx,
652
- this._style(this._root, { ...INHERITED, color: opts.color ?? this.color }),
653
- 1,
654
- 0
655
- );
656
- ctx.restore();
870
+ this._initial = { ...INHERITED, color: opts.color ?? this.color, ...inheritedFont(opts.font) };
871
+ try {
872
+ this._renderChildren(this._root, ctx, this._style(this._root, this._initial), 1, 0);
873
+ } finally {
874
+ this._surface = null;
875
+ ctx.restore();
876
+ }
657
877
  }
658
878
 
659
879
  // ------------------------------------------------------------------
@@ -662,13 +882,55 @@ export default class SvgView {
662
882
  return resolveStyle(node, parent);
663
883
  }
664
884
 
665
- _paint(ctx, value, style, path) {
666
- if (value === 'currentColor') return style.color;
885
+ /**
886
+ * What a fill or a stroke paints with: a colour, `currentColor`'s, or a
887
+ * gradient for `path`. `tint` maps each colour it is made of — a luminance
888
+ * mask's content is drawn as the coverage it makes (`_ink`).
889
+ */
890
+ _paint(ctx, value, style, path, tint = null) {
891
+ if (value === 'currentColor') value = style.color;
667
892
  const url = /^url\(['"]?#([^'")]+)['"]?\)/.exec(value);
668
- if (!url) return value;
893
+ if (!url) return tint ? tint(value) : value;
669
894
  const node = this._ids.get(url[1]);
670
895
  if (!node || (tag(node) !== 'lineargradient' && tag(node) !== 'radialgradient')) return null;
671
- return this._gradient(ctx, node, path);
896
+ return this._gradient(ctx, node, path, tint);
897
+ }
898
+
899
+ /**
900
+ * Fill or stroke with a paint: `draw` makes the marks, in `fillStyle` or
901
+ * `strokeStyle` as `kind` says, at `alpha`.
902
+ *
903
+ * Inside a luminance mask (`_renderMasked`), what is drawn is the mask's
904
+ * value, into the alpha of the surface: CSS Masking 1 takes a pixel's
905
+ * luminance times its alpha, as composited. A mark of luminance `L` at
906
+ * alpha `a` leaves `L·a + m·(1 − a)` where it falls on a mask of `m` —
907
+ * `destination-out` at `a`, then `lighter` at `L·a` — so a black shape
908
+ * over a white one hides what is under it, as the colours composited would
909
+ * have. Where the context has neither op, the mark is drawn at `L·a`.
910
+ */
911
+ _ink(ctx, kind, value, style, path, alpha, draw) {
912
+ const key = kind === 'fill' ? 'fillStyle' : 'strokeStyle';
913
+ if (!this._luminance) {
914
+ const paint = this._paint(ctx, value, style, path);
915
+ if (!paint) return;
916
+ ctx[key] = paint;
917
+ ctx.globalAlpha = alpha;
918
+ draw();
919
+ return;
920
+ }
921
+ const erase = this._paint(ctx, value, style, path, coverageOf);
922
+ const add = this._paint(ctx, value, style, path, luminanceOf);
923
+ if (!erase || !add) return;
924
+ ctx.globalAlpha = alpha;
925
+ ctx.globalCompositeOperation = 'destination-out';
926
+ if (ctx.globalCompositeOperation === 'destination-out') {
927
+ ctx[key] = erase;
928
+ draw();
929
+ ctx.globalCompositeOperation = 'lighter';
930
+ }
931
+ ctx[key] = add;
932
+ draw();
933
+ ctx.globalCompositeOperation = 'source-over';
672
934
  }
673
935
 
674
936
  /**
@@ -693,7 +955,7 @@ export default class SvgView {
693
955
  return chain;
694
956
  }
695
957
 
696
- _gradient(ctx, node, path) {
958
+ _gradient(ctx, node, path, tint = null) {
697
959
  const chain = this._gradientChain(node);
698
960
  const own = (name) => {
699
961
  for (const from of chain) {
@@ -762,11 +1024,9 @@ export default class SvgView {
762
1024
  if (!Number.isFinite(offset)) offset = 0;
763
1025
  const color = decls['stop-color'] || sa['stop-color'] || '#000';
764
1026
  const so = parseFloat(decls['stop-opacity'] ?? sa['stop-opacity'] ?? '1');
765
- if (Number.isFinite(so) && so < 1) {
766
- gradient.addColorStop(offset, rgbaWithAlpha(color, so));
767
- } else {
768
- gradient.addColorStop(offset, color);
769
- }
1027
+ const stopColor = Number.isFinite(so) && so < 1 ? rgbaWithAlpha(color, so) : color;
1028
+ const painted = tint ? tint(stopColor) : stopColor;
1029
+ if (painted) gradient.addColorStop(offset, painted);
770
1030
  }
771
1031
  return gradient;
772
1032
  }
@@ -836,6 +1096,16 @@ export default class SvgView {
836
1096
  for (const t of transforms) ctx.transform(t[0], t[1], t[2], t[3], t[4], t[5]);
837
1097
  }
838
1098
 
1099
+ const mask = this._maskOf(node);
1100
+ if (mask) this._renderMasked(node, mask, ctx, style, nodeAlpha, depth);
1101
+ else this._renderElement(node, ctx, style, nodeAlpha, depth);
1102
+
1103
+ if (needsCtxState) ctx.restore();
1104
+ }
1105
+
1106
+ /** What an element draws, in the user space its `transform` makes. */
1107
+ _renderElement(node, ctx, style, nodeAlpha, depth) {
1108
+ const name = tag(node);
839
1109
  switch (name) {
840
1110
  case 'svg':
841
1111
  this._renderViewport(node, ctx, style, nodeAlpha, depth);
@@ -852,8 +1122,7 @@ export default class SvgView {
852
1122
  break;
853
1123
  }
854
1124
  case 'use': {
855
- const href = node.attribs?.href || node.attribs?.['xlink:href'] || '';
856
- const target = href.startsWith('#') ? this._ids.get(href.slice(1)) : null;
1125
+ const target = this._useTarget(node);
857
1126
  if (target) {
858
1127
  const ux = attrNum(node, 'x');
859
1128
  const uy = attrNum(node, 'y');
@@ -866,7 +1135,9 @@ export default class SvgView {
866
1135
  break;
867
1136
  }
868
1137
  case 'text':
869
- if (style.visibility === 'visible') this._renderText(node, ctx, style, nodeAlpha);
1138
+ // `visibility` is asked of each run, since a span of a hidden
1139
+ // `<text>` may be shown again
1140
+ this._renderText(node, ctx, style, nodeAlpha);
870
1141
  break;
871
1142
  default: {
872
1143
  // `visibility` hides what an element paints itself, and is
@@ -876,50 +1147,815 @@ export default class SvgView {
876
1147
  const path = shapePath(node);
877
1148
  if (!path) break;
878
1149
  if (name !== 'line' && style.fill !== 'none') {
879
- const paint = this._paint(ctx, style.fill, style, path);
880
- if (paint) {
881
- ctx.fillStyle = paint;
882
- ctx.globalAlpha = nodeAlpha * style.fillOpacity;
883
- ctx.fill(path, style.fillRule === 'evenodd' ? 'evenodd' : 'nonzero');
884
- }
1150
+ const rule = style.fillRule === 'evenodd' ? 'evenodd' : 'nonzero';
1151
+ this._ink(ctx, 'fill', style.fill, style, path, nodeAlpha * style.fillOpacity, () => ctx.fill(path, rule));
885
1152
  }
886
1153
  if (style.stroke !== 'none' && style.strokeWidth > 0) {
887
- const paint = this._paint(ctx, style.stroke, style, path);
888
- if (paint) {
889
- ctx.strokeStyle = paint;
890
- ctx.globalAlpha = nodeAlpha * style.strokeOpacity;
891
- ctx.lineWidth = style.strokeWidth;
892
- ctx.lineCap = style.lineCap;
893
- ctx.lineJoin = style.lineJoin;
894
- ctx.miterLimit = style.miterLimit;
895
- ctx.stroke(path);
896
- }
1154
+ ctx.lineWidth = style.strokeWidth;
1155
+ ctx.lineCap = style.lineCap;
1156
+ ctx.lineJoin = style.lineJoin;
1157
+ ctx.miterLimit = style.miterLimit;
1158
+ this._ink(ctx, 'stroke', style.stroke, style, path, nodeAlpha * style.strokeOpacity, () => ctx.stroke(path));
897
1159
  }
898
1160
  ctx.globalAlpha = 1;
1161
+ // over its fill and its stroke, which is the default paint order
1162
+ if (MARKABLE.has(name)) this._renderMarkers(path, ctx, style, nodeAlpha, depth);
899
1163
  break;
900
1164
  }
901
1165
  }
1166
+ }
902
1167
 
903
- if (needsCtxState) ctx.restore();
1168
+ /**
1169
+ * The markers a shape's `marker-start`, `marker-mid` and `marker-end`
1170
+ * name, drawn at its vertices (SVG 2, 11.6): the first vertex of the
1171
+ * path, every one between, and the last. Each is a `<marker>`'s content
1172
+ * in a viewport of its own — `markerWidth` by `markerHeight`, scaled by
1173
+ * the stroke width unless `markerUnits` is `userSpaceOnUse` — with its
1174
+ * `viewBox` fitted in, the point `refX`,`refY` of it on the vertex,
1175
+ * turned to the direction the path runs there where `orient` is `auto`
1176
+ * (and the other way at the start for `auto-start-reverse`) or by its
1177
+ * angle, and clipped to the viewport unless its `overflow` is `visible`
1178
+ * or `auto`. What is in a marker inherits from the marker's ancestors,
1179
+ * not from the shape, and a marker that is being drawn already draws
1180
+ * nothing.
1181
+ */
1182
+ _renderMarkers(path, ctx, style, alpha, depth) {
1183
+ if (style.markerStart === 'none' && style.markerMid === 'none' && style.markerEnd === 'none') return;
1184
+ const vertices = pathVertices(path);
1185
+ if (!vertices.length) return;
1186
+ const last = vertices.length - 1;
1187
+ vertices.forEach((v, i) => {
1188
+ const id = i === 0 ? style.markerStart : i === last ? style.markerEnd : style.markerMid;
1189
+ if (id !== 'none') this._renderMarker(id, v, i === 0, ctx, style, alpha, depth);
1190
+ });
904
1191
  }
905
1192
 
906
- _renderText(node, ctx, style, alpha) {
907
- const text = textContent(node).trim();
908
- if (!text || style.fill === 'none') return;
909
- const x = attrNum(node, 'x');
910
- const y = attrNum(node, 'y');
911
- // glyphs are not scaled by the canvas transform — bake the average
912
- // scale factor into the font size instead
913
- const m = ctx.getTransform();
914
- const scale = Math.sqrt(Math.abs(m.a * m.d - m.b * m.c)) || 1;
1193
+ _renderMarker(id, vertex, first, ctx, style, alpha, depth) {
1194
+ const marker = this._ids.get(id);
1195
+ if (!marker || tag(marker) !== 'marker' || displayNone(marker)) return;
1196
+ if ((this._marking ??= new Set()).has(marker)) return;
1197
+ const mw = viewportLength(marker, 'markerWidth', 0, 3);
1198
+ const mh = viewportLength(marker, 'markerHeight', 0, 3);
1199
+ if (!(mw > 0) || !(mh > 0)) return;
1200
+ let vb = parseViewBox(marker);
1201
+ if (vb && !(vb[2] > 0 && vb[3] > 0)) return;
1202
+ const angle = markerAngle(attr(marker, 'orient'), vertex, first);
1203
+ const units = (attr(marker, 'markerUnits') || '').trim() === 'userSpaceOnUse' ? 1 : style.strokeWidth;
1204
+ if (!(units > 0)) return;
1205
+ const [sx, sy, tx, ty] = vb ? viewBoxFit(vb, 0, 0, mw, mh, attr(marker, 'preserveAspectRatio')) : [1, 1, 0, 0];
1206
+ const box = vb ?? [0, 0, mw, mh];
1207
+ const refX = refPoint(attr(marker, 'refX'), box[0], box[2]);
1208
+ const refY = refPoint(attr(marker, 'refY'), box[1], box[3]);
1209
+
1210
+ const outer = this._viewport;
1211
+ ctx.save();
1212
+ this._marking.add(marker);
1213
+ try {
1214
+ ctx.transform(1, 0, 0, 1, vertex.x, vertex.y);
1215
+ if (angle) {
1216
+ const cos = Math.cos(angle);
1217
+ const sin = Math.sin(angle);
1218
+ ctx.transform(cos, sin, -sin, cos, 0, 0);
1219
+ }
1220
+ if (units !== 1) ctx.transform(units, 0, 0, units, 0, 0);
1221
+ // the viewport's corner, so that the reference point is on the vertex
1222
+ ctx.transform(1, 0, 0, 1, -(sx * refX + tx), -(sy * refY + ty));
1223
+ const overflow = ownProperty(marker, 'overflow', keyword);
1224
+ if (overflow !== 'visible' && overflow !== 'auto') {
1225
+ const clip = new Path2D();
1226
+ clip.rect(0, 0, mw, mh);
1227
+ ctx.clip(clip);
1228
+ }
1229
+ if (vb) ctx.transform(sx, 0, 0, sy, tx, ty);
1230
+ this._viewport = [box[2], box[3]];
1231
+ this._renderChildren(marker, ctx, this._inheritedStyle(marker), alpha, depth + 1);
1232
+ } finally {
1233
+ this._viewport = outer;
1234
+ this._marking.delete(marker);
1235
+ ctx.restore();
1236
+ }
1237
+ }
1238
+
1239
+ _useTarget(node) {
1240
+ const href = node.attribs?.href || node.attribs?.['xlink:href'] || '';
1241
+ return href.startsWith('#') ? (this._ids.get(href.slice(1)) ?? null) : null;
1242
+ }
1243
+
1244
+ /** The `<mask>` an element's `mask` names, or null for none — and for one
1245
+ * that is being drawn already, which a mask that reaches itself is. */
1246
+ _maskOf(node) {
1247
+ const id = ownProperty(node, 'mask', urlReference);
1248
+ if (!id) return null;
1249
+ const mask = this._ids.get(id);
1250
+ if (!mask || tag(mask) !== 'mask' || this._masking?.has(mask)) return null;
1251
+ return mask;
1252
+ }
1253
+
1254
+ /**
1255
+ * A masked element (CSS Masking 1, 7): drawn on a surface of its own, and
1256
+ * the mask's content on a second, through the same transform — device
1257
+ * pixels, the part of the mask's region the drawing can show. The second
1258
+ * cuts the first with `destination-in`, and the first is composited in
1259
+ * the element's place at its opacity, which applies to what the mask
1260
+ * leaves of it as a group. A `mask-type` of `alpha` takes the mask's
1261
+ * alpha as it is; the default, `luminance`, has its content drawn as the
1262
+ * luminance it makes (`_ink`).
1263
+ *
1264
+ * With no surface to draw on, or no `destination-in` to cut with, the
1265
+ * element is drawn as it is, cut to the mask's region.
1266
+ */
1267
+ _renderMasked(node, mask, ctx, style, alpha, depth) {
1268
+ const byBox = (attr(mask, 'maskUnits') || '').trim() !== 'userSpaceOnUse';
1269
+ const contentByBox = (attr(mask, 'maskContentUnits') || '').trim() === 'objectBoundingBox';
1270
+ let bbox = null;
1271
+ if (byBox || contentByBox) {
1272
+ bbox = this._bbox(node, ctx, style, depth);
1273
+ // with no area there is nothing to measure the mask against, and the
1274
+ // element is not drawn
1275
+ if (!bbox || !(bbox.w > 0 && bbox.h > 0)) return;
1276
+ }
1277
+ const region = maskRegion(mask, byBox ? bbox : null, this._viewport);
1278
+ if (!region) return;
1279
+ const m = ctx.getTransform?.();
1280
+ const make = this._surface;
1281
+ const box = m && make && ctx.drawImage && ctx.setTransform ? surfaceBox(m, region, this._bounds) : null;
1282
+ if (box === EMPTY) return;
1283
+ let content = null;
1284
+ let cover = null;
1285
+ let cctx = null;
1286
+ let mctx = null;
1287
+ let done = false;
1288
+ const bounds = this._bounds;
1289
+ const luminance = this._luminance;
1290
+ try {
1291
+ if (box) {
1292
+ content = make(box.w, box.h);
1293
+ cover = content && make(box.w, box.h);
1294
+ }
1295
+ if (cover) {
1296
+ cctx = content.getContext('2d');
1297
+ cctx.globalCompositeOperation = 'destination-in';
1298
+ if (cctx.globalCompositeOperation === 'destination-in') {
1299
+ cctx.globalCompositeOperation = 'source-over';
1300
+ const place = [m.a, m.b, m.c, m.d, m.e - box.x, m.f - box.y];
1301
+ // what the surfaces show is all of them
1302
+ this._bounds = { x: 0, y: 0, w: box.w, h: box.h };
1303
+ this._luminance = false;
1304
+ cctx.transform(...place);
1305
+ this._renderElement(node, cctx, style, 1, depth);
1306
+
1307
+ mctx = cover.getContext('2d');
1308
+ mctx.transform(...place);
1309
+ const clip = new Path2D();
1310
+ clip.rect(region.x, region.y, region.w, region.h);
1311
+ mctx.clip(clip);
1312
+ if (contentByBox) mctx.transform(bbox.w, 0, 0, bbox.h, bbox.x, bbox.y);
1313
+ this._luminance = ownProperty(mask, 'mask-type', keyword) !== 'alpha';
1314
+ this._masking.add(mask);
1315
+ try {
1316
+ this._renderChildren(mask, mctx, this._inheritedStyle(mask), 1, depth + 1);
1317
+ } finally {
1318
+ this._masking.delete(mask);
1319
+ }
1320
+
1321
+ cctx.setTransform(1, 0, 0, 1, 0, 0);
1322
+ cctx.globalAlpha = 1;
1323
+ cctx.globalCompositeOperation = 'destination-in';
1324
+ cctx.drawImage(cover, 0, 0);
1325
+ ctx.save();
1326
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
1327
+ ctx.globalAlpha = alpha;
1328
+ ctx.drawImage(content, box.x, box.y);
1329
+ ctx.restore();
1330
+ done = true;
1331
+ }
1332
+ }
1333
+ } finally {
1334
+ this._bounds = bounds;
1335
+ this._luminance = luminance;
1336
+ mctx?.destroy?.();
1337
+ cctx?.destroy?.();
1338
+ cover?.destroy?.();
1339
+ content?.destroy?.();
1340
+ }
1341
+ if (done) return;
915
1342
  ctx.save();
916
- ctx.font = `${Math.max(1, style.fontSize * scale)}px ${style.fontFamily}`;
917
- ctx.fillStyle = style.fill === 'currentColor' ? style.color : style.fill;
918
- ctx.globalAlpha = alpha * style.fillOpacity;
919
- ctx.textAlign = style.textAnchor === 'middle' ? 'center' : style.textAnchor === 'end' ? 'right' : 'left';
920
- ctx.fillText(text, x, y);
1343
+ const clip = new Path2D();
1344
+ clip.rect(region.x, region.y, region.w, region.h);
1345
+ ctx.clip(clip);
1346
+ this._renderElement(node, ctx, style, alpha, depth);
921
1347
  ctx.restore();
922
1348
  }
1349
+
1350
+ /** The style an element has where it stands in the document: what is in a
1351
+ * `<mask>` inherits from the mask's ancestors, not from what it masks. */
1352
+ _inheritedStyle(node) {
1353
+ const chain = [];
1354
+ for (let at = node; at && at.type === 'tag' && at !== this._root; at = at.parent) chain.push(at);
1355
+ let style = this._style(this._root, this._initial ?? INHERITED);
1356
+ for (let i = chain.length - 1; i >= 0; i--) style = this._style(chain[i], style);
1357
+ return style;
1358
+ }
1359
+
1360
+ /**
1361
+ * An element's bounding box in its own user space (SVG 2, 8.10): the
1362
+ * geometry of its shapes and text, without strokes, through the
1363
+ * transforms of what is in it but not its own. Null for one that has
1364
+ * none.
1365
+ */
1366
+ _bbox(node, ctx, style, depth) {
1367
+ if (depth > 32) return null;
1368
+ const name = tag(node);
1369
+ switch (name) {
1370
+ case 'svg':
1371
+ case 'g':
1372
+ case 'a':
1373
+ case 'switch': {
1374
+ const kids = name === 'switch' ? [switchChoice(node, this.languages)] : node.children || [];
1375
+ let box = null;
1376
+ for (const child of kids) {
1377
+ if (!child || child.type !== 'tag') continue;
1378
+ if (NON_RENDERED.has(tag(child)) || displayNone(child) || !conditionsHold(child, this.languages)) continue;
1379
+ const inner = this._bbox(child, ctx, this._style(child, style), depth + 1);
1380
+ if (inner) box = unionBox(box, transformedBox(inner, parseSvgTransform(child.attribs?.transform)));
1381
+ }
1382
+ return box;
1383
+ }
1384
+ case 'use': {
1385
+ const target = this._useTarget(node);
1386
+ if (!target || displayNone(target)) return null;
1387
+ let box = null;
1388
+ if (tag(target) === 'symbol') {
1389
+ for (const child of target.children || []) {
1390
+ if (child.type !== 'tag' || NON_RENDERED.has(tag(child)) || displayNone(child)) continue;
1391
+ const inner = this._bbox(child, ctx, this._style(child, style), depth + 1);
1392
+ if (inner) box = unionBox(box, transformedBox(inner, parseSvgTransform(child.attribs?.transform)));
1393
+ }
1394
+ } else {
1395
+ const inner = this._bbox(target, ctx, this._style(target, style), depth + 1);
1396
+ if (inner) box = transformedBox(inner, parseSvgTransform(target.attribs?.transform));
1397
+ }
1398
+ return box && { ...box, x: box.x + attrNum(node, 'x'), y: box.y + attrNum(node, 'y') };
1399
+ }
1400
+ case 'text': {
1401
+ ctx.save();
1402
+ try {
1403
+ return this._layoutText(node, ctx, style, 1).box;
1404
+ } finally {
1405
+ ctx.restore();
1406
+ }
1407
+ }
1408
+ default: {
1409
+ const path = shapePath(node);
1410
+ return path ? pathBBox(path) : null;
1411
+ }
1412
+ }
1413
+ }
1414
+
1415
+ /**
1416
+ * A `<text>` laid out (SVG 2, 11.8): its characters, after white space is
1417
+ * collapsed, in runs of one style that nothing places apart, each where
1418
+ * the positions before it put it, with each text chunk — what starts at
1419
+ * an absolute `x` or `y` — moved as its `text-anchor` says. A run is
1420
+ * `{ text, x, y, font, span }`, `y` its baseline, and `box` is what the
1421
+ * runs cover.
1422
+ *
1423
+ * The anchor is measured, not left to `textAlign`: a chunk is more than
1424
+ * one run as soon as a `tspan` changes its size, and a context that
1425
+ * draws through CoreText or DirectWrite has no `textAlign` at all.
1426
+ *
1427
+ * Lengths are user units, and the glyphs are set at the size they are
1428
+ * drawn: where the context does not scale text with its transform — ntk's
1429
+ * own, whose glyphs are rasterized at the size they were shaped at — the
1430
+ * transform's scale goes into the font size, and what is measured comes
1431
+ * back out of it.
1432
+ */
1433
+ _layoutText(node, ctx, style, alpha) {
1434
+ const chars = [];
1435
+ const owner = [];
1436
+ const spans = [];
1437
+ // whether a space here would follow another, or nothing: one that
1438
+ // would is collapsed away
1439
+ let space = true;
1440
+ const collect = (el, st, a, depth) => {
1441
+ const index = spans.length;
1442
+ const span = { node: el, style: st, alpha: a, start: chars.length, end: chars.length };
1443
+ spans.push(span);
1444
+ const keep = st.whiteSpace === 'pre';
1445
+ const walk = (list) => {
1446
+ for (const child of list || []) {
1447
+ if (child.type === 'text') {
1448
+ for (const ch of child.data) {
1449
+ if (ch === '\r') continue;
1450
+ const c = ch === '\n' || ch === '\t' ? ' ' : ch;
1451
+ if (c === ' ' && space && !keep) continue;
1452
+ chars.push(c);
1453
+ owner.push(index);
1454
+ space = c === ' ';
1455
+ }
1456
+ } else if (child.type === 'cdata') {
1457
+ walk(child.children);
1458
+ } else if (child.type === 'tag' && depth < 32) {
1459
+ if (!TEXT_SPANS.has(tag(child)) || displayNone(child) || !conditionsHold(child, this.languages)) continue;
1460
+ const ca = a * (ownProperty(child, 'opacity', alphaValue) ?? 1);
1461
+ collect(child, this._style(child, st), ca, depth + 1);
1462
+ }
1463
+ }
1464
+ };
1465
+ walk(el.children);
1466
+ span.end = chars.length;
1467
+ };
1468
+ collect(node, style, alpha, 0);
1469
+ // and a space at the end goes, as one at the start did
1470
+ while (chars.length && chars[chars.length - 1] === ' ' && spans[owner[owner.length - 1]].style.whiteSpace !== 'pre') {
1471
+ chars.pop();
1472
+ owner.pop();
1473
+ }
1474
+ const n = chars.length;
1475
+ if (!n) return { pieces: [], box: null };
1476
+ for (const span of spans) {
1477
+ span.start = Math.min(span.start, n);
1478
+ span.end = Math.min(span.end, n);
1479
+ }
1480
+
1481
+ // Each character's position, from the innermost element that gives
1482
+ // one for it: an element's lists are of its own characters, in order,
1483
+ // and an element comes after the ones it is in.
1484
+ const [vw, vh] = this._viewport;
1485
+ const X = [];
1486
+ const Y = [];
1487
+ const DX = [];
1488
+ const DY = [];
1489
+ const place = (into, span, list) => {
1490
+ if (!list) return;
1491
+ for (let k = 0; k < list.length && span.start + k < span.end; k++) into[span.start + k] = list[k];
1492
+ };
1493
+ for (const span of spans) {
1494
+ if (span.start === span.end) continue;
1495
+ const size = span.style.fontSize;
1496
+ place(X, span, userLengths(attr(span.node, 'x'), size, vw));
1497
+ place(Y, span, userLengths(attr(span.node, 'y'), size, vh));
1498
+ place(DX, span, userLengths(attr(span.node, 'dx'), size, vw));
1499
+ place(DY, span, userLengths(attr(span.node, 'dy'), size, vh));
1500
+ }
1501
+
1502
+ const m = ctx.getTransform?.();
1503
+ const scale = ctx.scalesText || !m ? 1 : Math.sqrt(Math.abs(m.a * m.d - m.b * m.c)) || 1;
1504
+ const pieces = [];
1505
+ const chunks = [];
1506
+ let chunk = null;
1507
+ let cx = 0;
1508
+ let cy = 0;
1509
+ for (let i = 0; i < n; ) {
1510
+ const span = spans[owner[i]];
1511
+ const st = span.style;
1512
+ if (chunk === null || X[i] !== undefined || Y[i] !== undefined) {
1513
+ if (X[i] !== undefined) cx = X[i];
1514
+ if (Y[i] !== undefined) cy = Y[i];
1515
+ chunk = { anchor: st.textAnchor, start: Infinity, end: -Infinity, pieces: [] };
1516
+ chunks.push(chunk);
1517
+ }
1518
+ cx += DX[i] ?? 0;
1519
+ cy += DY[i] ?? 0;
1520
+ // spacing goes after each character, so each is a run of its own
1521
+ const spaced = st.letterSpacing !== 0 || st.wordSpacing !== 0;
1522
+ let j = i + 1;
1523
+ if (!spaced) {
1524
+ while (
1525
+ j < n &&
1526
+ owner[j] === owner[i] &&
1527
+ X[j] === undefined &&
1528
+ Y[j] === undefined &&
1529
+ DX[j] === undefined &&
1530
+ DY[j] === undefined
1531
+ ) {
1532
+ j++;
1533
+ }
1534
+ }
1535
+ const raw = chars.slice(i, j).join('');
1536
+ const text = transformText(raw, st.textTransform, i === 0 || /\s/.test(chars[i - 1]));
1537
+ const font = fontOf(st, scale);
1538
+ ctx.font = font;
1539
+ const metrics = ctx.measureText?.(text);
1540
+ const width = (metrics?.width ?? 0) / scale;
1541
+ // the font's own extent where the context says, and an em's
1542
+ // proportions where it does not
1543
+ const fa = metrics?.fontBoundingBoxAscent;
1544
+ const fd = metrics?.fontBoundingBoxDescent;
1545
+ const ascent = Number.isFinite(fa) ? fa / scale : 0.8 * st.fontSize;
1546
+ const descent = Number.isFinite(fd) ? fd / scale : 0.2 * st.fontSize;
1547
+ const align = ownProperty(span.node, 'alignment-baseline', keyword);
1548
+ const baseline = align && align !== 'auto' && align !== 'baseline' ? align : st.dominantBaseline;
1549
+ const shift = baselineShift(baseline, ascent, descent, st.fontSize);
1550
+ const piece = { text, x: cx, y: cy + shift, width, ascent, descent, font, span };
1551
+ pieces.push(piece);
1552
+ chunk.pieces.push(piece);
1553
+ chunk.start = Math.min(chunk.start, cx);
1554
+ cx += width;
1555
+ if (spaced) cx += st.letterSpacing + (raw === ' ' ? st.wordSpacing : 0);
1556
+ chunk.end = Math.max(chunk.end, cx);
1557
+ i = j;
1558
+ }
1559
+ let box = null;
1560
+ for (const c of chunks) {
1561
+ const extent = c.end - c.start;
1562
+ const by = c.anchor === 'middle' ? -extent / 2 : c.anchor === 'end' ? -extent : 0;
1563
+ for (const p of c.pieces) {
1564
+ p.x += by;
1565
+ box = unionBox(box, { x: p.x, y: p.y - p.ascent, w: p.width, h: p.ascent + p.descent });
1566
+ }
1567
+ }
1568
+ return { pieces, box };
1569
+ }
1570
+
1571
+ _renderText(node, ctx, style, alpha) {
1572
+ ctx.save();
1573
+ try {
1574
+ ctx.textAlign = 'left';
1575
+ ctx.textBaseline = 'alphabetic';
1576
+ const { pieces, box } = this._layoutText(node, ctx, style, alpha);
1577
+ // what a gradient's bounding box is: the whole text's
1578
+ let bounds = null;
1579
+ for (const p of pieces) {
1580
+ const st = p.span.style;
1581
+ if (st.visibility !== 'visible' || st.fill === 'none' || !p.text.trim()) continue;
1582
+ if (!bounds) {
1583
+ bounds = new Path2D();
1584
+ bounds.rect(box.x, box.y, box.w, box.h);
1585
+ }
1586
+ ctx.font = p.font;
1587
+ this._ink(ctx, 'fill', st.fill, st, bounds, p.span.alpha * st.fillOpacity, () => ctx.fillText(p.text, p.x, p.y));
1588
+ }
1589
+ } finally {
1590
+ ctx.restore();
1591
+ }
1592
+ }
1593
+ }
1594
+
1595
+ /** The font properties a caller's `opts.font` gives the root to inherit,
1596
+ * as far as they are ones it can use. */
1597
+ function inheritedFont(font) {
1598
+ const out = {};
1599
+ if (!font) return out;
1600
+ if (typeof font.family === 'string' && font.family.trim() && !/\b(?:var|env)\(/i.test(font.family)) {
1601
+ out.fontFamily = font.family.trim();
1602
+ }
1603
+ if (Number.isFinite(font.size) && font.size >= 0) out.fontSize = font.size;
1604
+ if (Number.isFinite(font.weight) && font.weight >= 1 && font.weight <= 1000) out.fontWeight = font.weight;
1605
+ if (font.style === 'normal' || font.style === 'italic' || font.style === 'oblique') out.fontStyle = font.style;
1606
+ return out;
1607
+ }
1608
+
1609
+ /** What a `<text>` holds that is text of its own: a span of it. */
1610
+ const TEXT_SPANS = new Set(['tspan', 'a', 'textpath']);
1611
+
1612
+ /**
1613
+ * The font a run is set in, as a CSS `font` shorthand — `italic` and the
1614
+ * weight before the size, which is the order both ntk's parser and
1615
+ * react-x11's native contexts read. The weight is a number between 100
1616
+ * and 999 to keep to three digits, which the native contexts ask of one.
1617
+ */
1618
+ function fontOf(style, scale) {
1619
+ const size = Math.round(Math.max(0, style.fontSize * scale) * 1000) / 1000;
1620
+ const w = Math.min(999, Math.max(100, Math.round(style.fontWeight)));
1621
+ const weight = w === 400 ? '' : w === 700 ? 'bold ' : `${w} `;
1622
+ const slant = style.fontStyle === 'normal' ? '' : `${style.fontStyle} `;
1623
+ return `${slant}${weight}${size}px ${style.fontFamily}`;
1624
+ }
1625
+
1626
+ /** `text-transform` on a run; `atWordStart` says whether it starts a word,
1627
+ * for `capitalize`. */
1628
+ function transformText(text, how, atWordStart) {
1629
+ switch (how) {
1630
+ case 'uppercase':
1631
+ return text.toUpperCase();
1632
+ case 'lowercase':
1633
+ return text.toLowerCase();
1634
+ case 'capitalize': {
1635
+ let out = '';
1636
+ let start = atWordStart;
1637
+ for (const ch of text) {
1638
+ out += start ? ch.toUpperCase() : ch;
1639
+ start = /\s/.test(ch);
1640
+ }
1641
+ return out;
1642
+ }
1643
+ default:
1644
+ return text;
1645
+ }
1646
+ }
1647
+
1648
+ /**
1649
+ * How far below the `y` it is given a run's alphabetic baseline goes, for
1650
+ * the baseline `dominant-baseline` or `alignment-baseline` names (CSS
1651
+ * Inline 3, 4): `central` is half way down the font's extent, `middle`
1652
+ * half an x-height above the baseline — the em's half of one where the
1653
+ * font's is not known — and the edges are the font's ascent and descent.
1654
+ */
1655
+ function baselineShift(baseline, ascent, descent, fontSize) {
1656
+ switch (baseline) {
1657
+ case 'central':
1658
+ return (ascent - descent) / 2;
1659
+ case 'middle':
1660
+ return 0.25 * fontSize;
1661
+ case 'hanging':
1662
+ return 0.8 * ascent;
1663
+ case 'mathematical':
1664
+ return ascent / 2;
1665
+ case 'text-before-edge':
1666
+ case 'before-edge':
1667
+ case 'text-top':
1668
+ return ascent;
1669
+ case 'text-after-edge':
1670
+ case 'after-edge':
1671
+ case 'text-bottom':
1672
+ case 'ideographic':
1673
+ return -descent;
1674
+ default:
1675
+ return 0;
1676
+ }
1677
+ }
1678
+
1679
+ /** The id a `mask` names — `url(#id)` — `''` for `none`, and undefined for
1680
+ * anything else, which leaves what the attribute said. */
1681
+ function urlReference(raw) {
1682
+ const v = String(raw ?? '').trim();
1683
+ if (v.toLowerCase() === 'none') return '';
1684
+ const m = /^url\(\s*['"]?#([^'")\s]+)['"]?\s*\)/i.exec(v);
1685
+ return m ? m[1] : undefined;
1686
+ }
1687
+
1688
+ /** The shapes that take markers (SVG 2, 11.6): the ones with vertices. */
1689
+ const MARKABLE = new Set(['path', 'line', 'polyline', 'polygon']);
1690
+
1691
+ /** The ids of the markers a style names, each once. */
1692
+ function markerIds(style) {
1693
+ const ids = new Set([style.markerStart, style.markerMid, style.markerEnd]);
1694
+ ids.delete('none');
1695
+ return ids;
1696
+ }
1697
+
1698
+ /** The style an element has where it stands, from the root down — what a
1699
+ * marker's content inherits, the walk `_inheritedStyle` makes for a
1700
+ * drawing. */
1701
+ function styleWhereItStands(node, root) {
1702
+ const chain = [];
1703
+ for (let at = node; at && at.type === 'tag' && at !== root; at = at.parent) chain.push(at);
1704
+ let style = resolveStyle(root, INHERITED);
1705
+ for (let i = chain.length - 1; i >= 0; i--) style = resolveStyle(chain[i], style);
1706
+ return style;
1707
+ }
1708
+
1709
+ /**
1710
+ * A path's vertices, for its markers (SVG 2, 11.6.2): where each subpath
1711
+ * starts and where each of its segments ends, a closing segment's at the
1712
+ * subpath's start, with the direction the path comes in by and goes out
1713
+ * by, in radians — null where it does neither. A segment's direction at an
1714
+ * end is its tangent there, a curve's toward the nearest control point
1715
+ * that is not on that end. An arc, which the parser turns into curves, is
1716
+ * one segment: the curves it was cut into make no vertices between them.
1717
+ */
1718
+ function pathVertices(path) {
1719
+ const out = [];
1720
+ const cmds = path?._cmds;
1721
+ if (!Array.isArray(cmds)) return out;
1722
+ let x = 0;
1723
+ let y = 0;
1724
+ let start = -1;
1725
+ const toward = (fx, fy, points) => {
1726
+ for (const [px, py] of points) {
1727
+ if (px !== fx || py !== fy) return Math.atan2(py - fy, px - fx);
1728
+ }
1729
+ return null;
1730
+ };
1731
+ const segment = (startDir, endDir, nx, ny, inner) => {
1732
+ const from = out[out.length - 1];
1733
+ if (from && from.out === null) from.out = startDir;
1734
+ x = nx;
1735
+ y = ny;
1736
+ if (!inner) out.push({ x, y, in: endDir, out: null });
1737
+ };
1738
+ for (const c of cmds) {
1739
+ switch (c.type) {
1740
+ case 'M':
1741
+ x = c.x;
1742
+ y = c.y;
1743
+ start = out.length;
1744
+ out.push({ x, y, in: null, out: null });
1745
+ break;
1746
+ case 'L': {
1747
+ const dir = toward(x, y, [[c.x, c.y]]);
1748
+ segment(dir, dir, c.x, c.y, false);
1749
+ break;
1750
+ }
1751
+ case 'C': {
1752
+ const back = toward(c.x, c.y, [[c.x2, c.y2], [c.x1, c.y1], [x, y]]);
1753
+ const ahead = toward(x, y, [[c.x1, c.y1], [c.x2, c.y2], [c.x, c.y]]);
1754
+ segment(ahead, back === null ? null : back + Math.PI, c.x, c.y, c.arcPart === true);
1755
+ break;
1756
+ }
1757
+ case 'Q': {
1758
+ const back = toward(c.x, c.y, [[c.x1, c.y1], [x, y]]);
1759
+ segment(toward(x, y, [[c.x1, c.y1], [c.x, c.y]]), back === null ? null : back + Math.PI, c.x, c.y, false);
1760
+ break;
1761
+ }
1762
+ case 'Z': {
1763
+ if (start < 0) break;
1764
+ const head = out[start];
1765
+ const dir = toward(x, y, [[head.x, head.y]]);
1766
+ segment(dir, dir, head.x, head.y, false);
1767
+ const closing = out[out.length - 1];
1768
+ // a closed subpath comes back into its start by its closing segment,
1769
+ // and its closing vertex goes on out the way the start did
1770
+ head.in = closing.in;
1771
+ closing.out = head.out;
1772
+ break;
1773
+ }
1774
+ }
1775
+ }
1776
+ return out;
1777
+ }
1778
+
1779
+ /**
1780
+ * The angle a marker is turned by at a vertex: `orient`'s angle, in
1781
+ * degrees unless it names another unit; or the direction of the path
1782
+ * there for `auto` — the one it comes in by and the one it goes out by
1783
+ * halved, at a vertex with both — and at the start the other way round
1784
+ * for `auto-start-reverse`. 0 where it says neither.
1785
+ */
1786
+ function markerAngle(raw, vertex, first) {
1787
+ const v = String(raw ?? '').trim().toLowerCase();
1788
+ if (v === 'auto' || v === 'auto-start-reverse') {
1789
+ const { in: a, out: b } = vertex;
1790
+ let angle = 0;
1791
+ if (a !== null && b !== null) {
1792
+ const dx = Math.cos(a) + Math.cos(b);
1793
+ const dy = Math.sin(a) + Math.sin(b);
1794
+ angle = Math.abs(dx) < 1e-9 && Math.abs(dy) < 1e-9 ? a : Math.atan2(dy, dx);
1795
+ } else angle = a ?? b ?? 0;
1796
+ return v === 'auto-start-reverse' && first ? angle + Math.PI : angle;
1797
+ }
1798
+ const m = /^([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)(deg|rad|grad|turn)?$/.exec(v);
1799
+ if (!m) return 0;
1800
+ const n = parseFloat(m[1]);
1801
+ const unit = m[2] ?? 'deg';
1802
+ return unit === 'rad' ? n : unit === 'grad' ? (n * Math.PI) / 200 : unit === 'turn' ? n * 2 * Math.PI : (n * Math.PI) / 180;
1803
+ }
1804
+
1805
+ /** `refX` or `refY`: a number in the marker's own units, or `left`,
1806
+ * `center` or `right` (`top`, `center`, `bottom`) of the box it is in. */
1807
+ function refPoint(raw, min, size) {
1808
+ const v = String(raw ?? '').trim().toLowerCase();
1809
+ if (v === 'left' || v === 'top') return min;
1810
+ if (v === 'center') return min + size / 2;
1811
+ if (v === 'right' || v === 'bottom') return min + size;
1812
+ const n = parseFloat(v);
1813
+ return Number.isFinite(n) ? n : 0;
1814
+ }
1815
+
1816
+ /**
1817
+ * A mask's region (CSS Masking 1, 10.1): `x`, `y`, `width` and `height` as
1818
+ * fractions of the element's bounding box where `bbox` is given —
1819
+ * `maskUnits="objectBoundingBox"`, the default — and lengths in user space
1820
+ * otherwise, percentages of the viewport. -10%, -10%, 120% and 120% where
1821
+ * they are not set. Null for a region of no area, which shows nothing.
1822
+ */
1823
+ function maskRegion(mask, bbox, [vw, vh]) {
1824
+ const read = (name, fallback, size) => {
1825
+ const raw = attr(mask, name);
1826
+ if (raw === undefined) return fallback;
1827
+ const v = String(raw).trim();
1828
+ if (bbox) {
1829
+ const n = parseFloat(v);
1830
+ if (!Number.isFinite(n)) return fallback;
1831
+ return v.endsWith('%') ? n / 100 : n;
1832
+ }
1833
+ return userLength(v, 16, size) ?? fallback;
1834
+ };
1835
+ let region;
1836
+ if (bbox) {
1837
+ const fx = read('x', -0.1);
1838
+ const fy = read('y', -0.1);
1839
+ const fw = read('width', 1.2);
1840
+ const fh = read('height', 1.2);
1841
+ region = { x: bbox.x + fx * bbox.w, y: bbox.y + fy * bbox.h, w: fw * bbox.w, h: fh * bbox.h };
1842
+ } else {
1843
+ region = {
1844
+ x: read('x', -0.1 * vw, vw),
1845
+ y: read('y', -0.1 * vh, vh),
1846
+ w: read('width', 1.2 * vw, vw),
1847
+ h: read('height', 1.2 * vh, vh)
1848
+ };
1849
+ }
1850
+ return region.w > 0 && region.h > 0 ? region : null;
1851
+ }
1852
+
1853
+ /** A rectangle under a transform list, as the rectangle it lands in. */
1854
+ function transformedBox(box, transforms) {
1855
+ if (!transforms.length) return box;
1856
+ let [a, b, c, d, e, f] = transforms[0];
1857
+ for (let i = 1; i < transforms.length; i++) {
1858
+ const [a2, b2, c2, d2, e2, f2] = transforms[i];
1859
+ [a, b, c, d, e, f] = [
1860
+ a * a2 + c * b2,
1861
+ b * a2 + d * b2,
1862
+ a * c2 + c * d2,
1863
+ b * c2 + d * d2,
1864
+ a * e2 + c * f2 + e,
1865
+ b * e2 + d * f2 + f
1866
+ ];
1867
+ }
1868
+ return matrixBox({ a, b, c, d, e, f }, box);
1869
+ }
1870
+
1871
+ function matrixBox(m, { x, y, w, h }) {
1872
+ let minX = Infinity;
1873
+ let minY = Infinity;
1874
+ let maxX = -Infinity;
1875
+ let maxY = -Infinity;
1876
+ for (const [px, py] of [
1877
+ [x, y],
1878
+ [x + w, y],
1879
+ [x, y + h],
1880
+ [x + w, y + h]
1881
+ ]) {
1882
+ const tx = m.a * px + m.c * py + m.e;
1883
+ const ty = m.b * px + m.d * py + m.f;
1884
+ if (tx < minX) minX = tx;
1885
+ if (tx > maxX) maxX = tx;
1886
+ if (ty < minY) minY = ty;
1887
+ if (ty > maxY) maxY = ty;
1888
+ }
1889
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
1890
+ }
1891
+
1892
+ function unionBox(a, b) {
1893
+ if (!a) return b;
1894
+ if (!b) return a;
1895
+ const x = Math.min(a.x, b.x);
1896
+ const y = Math.min(a.y, b.y);
1897
+ return { x, y, w: Math.max(a.x + a.w, b.x + b.w) - x, h: Math.max(a.y + a.h, b.y + b.h) - y };
1898
+ }
1899
+
1900
+ /** Where a drawing `w` by `h` at `x`,`y` lands on a context, in device
1901
+ * pixels, or null where the context cannot say. */
1902
+ function deviceBox(ctx, x, y, w, h) {
1903
+ const m = ctx.getTransform?.();
1904
+ return m ? matrixBox(m, { x, y, w, h }) : null;
1905
+ }
1906
+
1907
+ const EMPTY = Symbol('empty');
1908
+
1909
+ /** The largest surface a mask is drawn on: 4096 by 4096. */
1910
+ const MAX_SURFACE = 4096 * 4096;
1911
+
1912
+ /**
1913
+ * The device-pixel rectangle a mask's region lands in, under `m` and
1914
+ * inside what the drawing can show, whole pixels out: `EMPTY` where none of
1915
+ * it shows, and null where it is too large to draw on a surface.
1916
+ */
1917
+ function surfaceBox(m, region, bounds) {
1918
+ let box = matrixBox(m, region);
1919
+ if (![box.x, box.y, box.w, box.h].every(Number.isFinite)) return null;
1920
+ if (bounds) {
1921
+ const x = Math.max(box.x, bounds.x);
1922
+ const y = Math.max(box.y, bounds.y);
1923
+ box = { x, y, w: Math.min(box.x + box.w, bounds.x + bounds.w) - x, h: Math.min(box.y + box.h, bounds.y + bounds.h) - y };
1924
+ }
1925
+ const x0 = Math.floor(box.x);
1926
+ const y0 = Math.floor(box.y);
1927
+ const x1 = Math.ceil(box.x + box.w);
1928
+ const y1 = Math.ceil(box.y + box.h);
1929
+ if (!(x1 > x0 && y1 > y0)) return EMPTY;
1930
+ if ((x1 - x0) * (y1 - y0) > MAX_SURFACE) return null;
1931
+ return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
1932
+ }
1933
+
1934
+ /** What makes an ntk `Surface` for a context of ntk's own: on its app.
1935
+ * Through the app, so that `ntk/svg` loads no X11 client of its own. */
1936
+ function surfaceMaker(ctx) {
1937
+ const app = ctx?.window?.app;
1938
+ if (typeof app?.createSurface !== 'function' || !app.display?.Render) return null;
1939
+ return (width, height) => {
1940
+ try {
1941
+ return app.createSurface({ width, height });
1942
+ } catch {
1943
+ return null;
1944
+ }
1945
+ };
1946
+ }
1947
+
1948
+ /** A colour as the coverage it makes: its alpha, in black. */
1949
+ function coverageOf(color) {
1950
+ const c = cssColorStraight(color);
1951
+ return c ? `rgba(0, 0, 0, ${c[3]})` : null;
1952
+ }
1953
+
1954
+ /** A colour as the luminance mask value it makes: its luminance times its
1955
+ * alpha (CSS Masking 1, 8.2), as an alpha, in black. */
1956
+ function luminanceOf(color) {
1957
+ const c = cssColorStraight(color);
1958
+ return c ? `rgba(0, 0, 0, ${(0.2125 * c[0] + 0.7154 * c[1] + 0.0721 * c[2]) * c[3]})` : null;
923
1959
  }
924
1960
 
925
1961
  // fold an opacity into a CSS color by going through rgba()