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.
- package/README.md +1 -1
- package/lib/app.js +11 -0
- package/lib/image.js +125 -4
- package/lib/index.js +2 -1
- package/lib/path.js +4 -1
- package/lib/widgets/svgview.js +1118 -82
- package/package.json +1 -1
package/lib/widgets/svgview.js
CHANGED
|
@@ -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,
|
|
21
|
-
// docs/svg.md for the full surface and
|
|
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
|
-
|
|
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', '
|
|
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
|
|
381
|
-
*
|
|
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 (
|
|
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 (
|
|
393
|
-
own()[key] = n;
|
|
533
|
+
if (Number.isFinite(n)) value = n;
|
|
394
534
|
} else if (ALPHA.has(key)) {
|
|
395
|
-
|
|
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
|
-
|
|
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,
|
|
407
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
650
|
-
|
|
651
|
-
ctx,
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
-
|
|
666
|
-
|
|
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
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
880
|
-
|
|
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
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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
|
-
|
|
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
|
-
|
|
907
|
-
const
|
|
908
|
-
if (!
|
|
909
|
-
|
|
910
|
-
const
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
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
|
-
|
|
917
|
-
|
|
918
|
-
ctx.
|
|
919
|
-
|
|
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()
|