ntk 8.21.1 → 8.23.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/lib/text/fontmanager.js +10 -1
- package/lib/text/layout.js +91 -2
- package/lib/text/shape.js +16 -3
- package/lib/widgets/svgview.js +387 -23
- package/package.json +1 -1
package/lib/text/fontmanager.js
CHANGED
|
@@ -565,6 +565,15 @@ export default class FontManager {
|
|
|
565
565
|
return new TextLayout(this, content, style, options);
|
|
566
566
|
}
|
|
567
567
|
|
|
568
|
+
/**
|
|
569
|
+
* Whether `layout` takes `justify`, and fills a line with its word
|
|
570
|
+
* separators itself. True; a caller that asks before it passes the option
|
|
571
|
+
* keeps its own way of justifying for an engine without it.
|
|
572
|
+
*/
|
|
573
|
+
get justifies() {
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
|
|
568
577
|
/**
|
|
569
578
|
* Memoized shaping used by TextLayout and the canvas text path, bounded at
|
|
570
579
|
* two generations of `SHAPE_GENERATION` words. `levelsKey` is a compact
|
|
@@ -671,7 +680,7 @@ export default class FontManager {
|
|
|
671
680
|
*/
|
|
672
681
|
_shapeCachedWhole(text, style) {
|
|
673
682
|
const emb = embeddingLevels(text, style.direction);
|
|
674
|
-
const baseLevel = emb.paragraphs
|
|
683
|
+
const baseLevel = emb.paragraphs[0].level;
|
|
675
684
|
const shaped = this._shapeCached(text, style, normalizedLevels(emb.levels, 0, text.length));
|
|
676
685
|
return shaped.baseLevel === baseLevel ? shaped : { ...shaped, baseLevel };
|
|
677
686
|
}
|
package/lib/text/layout.js
CHANGED
|
@@ -12,6 +12,29 @@ const WS = new Set([0x20, 0x09]);
|
|
|
12
12
|
const HARD_BREAKS = /[\n\r\u2028\u2029]+$/;
|
|
13
13
|
const TRAILING_WS = /[ \t]+$/;
|
|
14
14
|
|
|
15
|
+
// The word separators a justified line's room is shared out at (CSS Text 3,
|
|
16
|
+
// 7.4, `text-justify: auto` as a browser has it for the scripts that space
|
|
17
|
+
// their words): a space and a no-break space.
|
|
18
|
+
const SEPARATORS = new Set([0x20, 0xa0]);
|
|
19
|
+
const separates = (g) => g.codePoints.length === 1 && SEPARATORS.has(g.codePoints[0]);
|
|
20
|
+
|
|
21
|
+
// What ends a line `justify` sets as it does a paragraph's last: a line feed,
|
|
22
|
+
// a carriage return or a paragraph separator, as a browser's forced break
|
|
23
|
+
// does. A line separator, U+2028, is not one, so a caller can ask a line to
|
|
24
|
+
// break there and be justified.
|
|
25
|
+
const JUSTIFY_STOPS = /[\n\r\u2029]$/;
|
|
26
|
+
|
|
27
|
+
// `justify`'s lines as bits: those that go on to another (REST), and the
|
|
28
|
+
// paragraph's last with those a forced break ends (LAST).
|
|
29
|
+
const REST = 1;
|
|
30
|
+
const LAST = 2;
|
|
31
|
+
function justifyBits(justify) {
|
|
32
|
+
if (justify === true || justify === 'rest') return REST;
|
|
33
|
+
if (justify === 'last') return LAST;
|
|
34
|
+
if (justify === 'all') return REST | LAST;
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
15
38
|
/** Scripts written without spaces between words, whose line breaking
|
|
16
39
|
* needs a dictionary (UAX #14's SA class, and Javanese, Balinese and
|
|
17
40
|
* Buginese, which are AL there): a token of theirs is a run of words. */
|
|
@@ -162,6 +185,44 @@ function isWsGlyph(g) {
|
|
|
162
185
|
return g.codePoints.length > 0 && g.codePoints.every((cp) => WS.has(cp));
|
|
163
186
|
}
|
|
164
187
|
|
|
188
|
+
/**
|
|
189
|
+
* A line set to fill `width`: what it leaves of it shared equally among its
|
|
190
|
+
* word separators (`SEPARATORS`), each glyph's advance widened by its share
|
|
191
|
+
* — the white space it ends on hangs, and takes none. Its runs are made
|
|
192
|
+
* anew, those with a separator in them over glyphs of their own, since a
|
|
193
|
+
* shaped run is the paragraph's and shared by every layout of it; caret
|
|
194
|
+
* positions, hit testing, drawing and coverage all read the advances, and
|
|
195
|
+
* agree. A line with no separator, or with no room left, keeps its
|
|
196
|
+
* alignment. Answers the line's width.
|
|
197
|
+
*/
|
|
198
|
+
function justifyLine(line, width) {
|
|
199
|
+
const free = width - line.width;
|
|
200
|
+
if (!(free > 0)) return line.width;
|
|
201
|
+
let count = 0;
|
|
202
|
+
for (const r of line.runs) {
|
|
203
|
+
for (const g of r.run.glyphs) if (separates(g)) count++;
|
|
204
|
+
}
|
|
205
|
+
if (count === 0) return line.width;
|
|
206
|
+
const extra = free / count;
|
|
207
|
+
let shift = 0;
|
|
208
|
+
line.runs = line.runs.map((r) => {
|
|
209
|
+
const moved = shift ? { ...r, x: r.x + shift } : r;
|
|
210
|
+
let k = 0;
|
|
211
|
+
for (const g of r.run.glyphs) if (separates(g)) k++;
|
|
212
|
+
if (k === 0) return moved;
|
|
213
|
+
shift += k * extra;
|
|
214
|
+
const glyphs = r.run.glyphs.map((g) => (separates(g) ? { ...g, ax: g.ax + extra } : g));
|
|
215
|
+
return {
|
|
216
|
+
...moved,
|
|
217
|
+
width: r.width + k * extra,
|
|
218
|
+
run: { ...r.run, glyphs, width: r.run.width + k * extra }
|
|
219
|
+
};
|
|
220
|
+
});
|
|
221
|
+
line.width += count * extra;
|
|
222
|
+
line.advance += count * extra;
|
|
223
|
+
return line.width;
|
|
224
|
+
}
|
|
225
|
+
|
|
165
226
|
/**
|
|
166
227
|
* Multi-line text layout: breaks (possibly styled) text into lines for a
|
|
167
228
|
* target container width, with full shaping — kerning, ligatures, complex
|
|
@@ -205,6 +266,13 @@ function isWsGlyph(g) {
|
|
|
205
266
|
* past the line's end, as CSS's `overflow-wrap: normal` does — but for
|
|
206
267
|
* text in a script written without spaces (Thai, Khmer, Javanese…), whose
|
|
207
268
|
* words the breaker cannot find, which is cut as a fallback
|
|
269
|
+
* - `justify` — the lines set to fill `maxWidth`, what each leaves of it
|
|
270
|
+
* shared equally among its word separators, a space and a no-break space:
|
|
271
|
+
* `true` (or `'rest'`) for every line but the paragraph's last and each a
|
|
272
|
+
* forced break ends, as CSS's `text-align: justify`; `'last'` for only
|
|
273
|
+
* those; `'all'` for every line. A line with no separator, or none of the
|
|
274
|
+
* width to spare, keeps `align`'s place, as do the lines not justified.
|
|
275
|
+
* The breaks are the layout's at its width; justifying moves none
|
|
208
276
|
* - `fit` — 'items' fits and measures a line as a browser does: each span's
|
|
209
277
|
* part of it rounded up to a 64th of a pixel before it is added, as Blink
|
|
210
278
|
* rounds an element's text on a line up to a LayoutUnit; a line's `width`
|
|
@@ -436,6 +504,11 @@ export class TextLayout {
|
|
|
436
504
|
natural = m.lineHeight;
|
|
437
505
|
}
|
|
438
506
|
if (runs.length) fitted += round(item);
|
|
507
|
+
// a line's place in the paragraph, for `justify`: its last, which a
|
|
508
|
+
// `maxLines` cut leaves no line, or one a forced break ends
|
|
509
|
+
const ends =
|
|
510
|
+
(li === lineTokens.length - 1 && !this.truncated) ||
|
|
511
|
+
JUSTIFY_STOPS.test(text.slice(Math.max(lineStart, lineEnd - 1), lineEnd));
|
|
439
512
|
if (fitted > layoutWidth) layoutWidth = fitted;
|
|
440
513
|
// Half-leading (CSS Inline Layout 3): the slack between the glyphs and
|
|
441
514
|
// the line box is split evenly above and below, rather than all of it
|
|
@@ -462,11 +535,27 @@ export class TextLayout {
|
|
|
462
535
|
start: lineStart,
|
|
463
536
|
end: lineEnd,
|
|
464
537
|
_contentEnd: contentEnd,
|
|
465
|
-
_trailing: trailing
|
|
538
|
+
_trailing: trailing,
|
|
539
|
+
_ends: ends,
|
|
540
|
+
_elided: !!ellipsis
|
|
466
541
|
});
|
|
467
542
|
y += box;
|
|
468
543
|
}
|
|
469
544
|
|
|
545
|
+
// ---- justification ----
|
|
546
|
+
// After the lines are broken, so a width the lines are set at is no
|
|
547
|
+
// shaping: a paragraph laid out again at another width breaks the glyphs
|
|
548
|
+
// it has into other lines and spaces them, where one that spaced its
|
|
549
|
+
// words through letter spacing was shaped again at every width.
|
|
550
|
+
const justify = justifyBits(options.justify);
|
|
551
|
+
if (justify && Number.isFinite(maxWidth)) {
|
|
552
|
+
for (const line of this.lines) {
|
|
553
|
+
if (line._elided || !(justify & (line._ends ? LAST : REST))) continue;
|
|
554
|
+
const width = justifyLine(line, maxWidth);
|
|
555
|
+
if (width > layoutWidth) layoutWidth = width;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
470
559
|
this.width = layoutWidth;
|
|
471
560
|
this.height = y;
|
|
472
561
|
|
|
@@ -537,7 +626,7 @@ export class TextLayout {
|
|
|
537
626
|
const text = spans.map((s) => s.text).join('');
|
|
538
627
|
const emb = embeddingLevels(text, direction);
|
|
539
628
|
const levels = emb.levels;
|
|
540
|
-
const baseLevel = emb.paragraphs
|
|
629
|
+
const baseLevel = emb.paragraphs[0].level & 1;
|
|
541
630
|
|
|
542
631
|
// span boundaries, for binary search by char position
|
|
543
632
|
const spanStarts = [];
|
package/lib/text/shape.js
CHANGED
|
@@ -21,17 +21,30 @@ function neverSwitches(cp) {
|
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* UAX#9 embedding levels for a string. `direction` is 'ltr', 'rtl' or 'auto'
|
|
24
|
-
* (first-strong detection). Returns bidi-js's result object
|
|
24
|
+
* (first-strong detection). Returns bidi-js's result object, which always
|
|
25
|
+
* has a first paragraph: its level is the text's base level.
|
|
25
26
|
*/
|
|
26
27
|
export function embeddingLevels(text, direction = 'auto') {
|
|
27
28
|
const explicit = direction === 'ltr' || direction === 'rtl' ? direction : null;
|
|
29
|
+
// An empty text is a paragraph too, at the level its direction names.
|
|
30
|
+
// bidi-js finds no paragraph in it, and a base level read as 0 for want
|
|
31
|
+
// of one set an empty right-to-left field's line, and the caret on it,
|
|
32
|
+
// at the left — where the same field holding only a line break had it
|
|
33
|
+
// at the right. Under 'auto' there is no strong character to find, and
|
|
34
|
+
// the paragraph is left-to-right (UAX#9 P3).
|
|
35
|
+
if (!text.length) {
|
|
36
|
+
return {
|
|
37
|
+
levels: new Uint8Array(0),
|
|
38
|
+
paragraphs: [{ start: 0, end: -1, level: explicit === 'rtl' ? 1 : 0 }]
|
|
39
|
+
};
|
|
40
|
+
}
|
|
28
41
|
// Text that nothing can make right-to-left is level 0 throughout, which
|
|
29
42
|
// is what the algorithm would work out a character at a time: every
|
|
30
43
|
// layout asks, and most text is Latin, CJK or digits with no bidi at all.
|
|
31
44
|
if (explicit !== 'rtl' && !MAY_REVERSE.test(text)) {
|
|
32
45
|
return {
|
|
33
46
|
levels: new Uint8Array(text.length),
|
|
34
|
-
paragraphs:
|
|
47
|
+
paragraphs: [{ start: 0, end: text.length - 1, level: 0 }]
|
|
35
48
|
};
|
|
36
49
|
}
|
|
37
50
|
return bidi.getEmbeddingLevels(text, explicit);
|
|
@@ -161,7 +174,7 @@ export function shapeText(fonts, text, style, levels) {
|
|
|
161
174
|
if (!levels) {
|
|
162
175
|
const emb = embeddingLevels(text, style.direction);
|
|
163
176
|
levels = emb.levels;
|
|
164
|
-
baseLevel = emb.paragraphs
|
|
177
|
+
baseLevel = emb.paragraphs[0].level;
|
|
165
178
|
} else {
|
|
166
179
|
baseLevel = levels.length ? levels[0] & ~1 : 0;
|
|
167
180
|
}
|
package/lib/widgets/svgview.js
CHANGED
|
@@ -18,13 +18,13 @@
|
|
|
18
18
|
// <use>, <switch>, linear/radial gradients (objectBoundingBox and
|
|
19
19
|
// userSpaceOnUse), transform lists, opacity/fill-opacity/stroke-opacity,
|
|
20
20
|
// display, visibility, nested <svg> viewports, fill-rule, <text> with its
|
|
21
|
-
// <tspan>s, and <
|
|
22
|
-
// limitations.
|
|
21
|
+
// <tspan>s, <mask> and <clipPath>. See docs/svg.md for the full surface
|
|
22
|
+
// and limitations.
|
|
23
23
|
|
|
24
24
|
import { parseDocument } from 'htmlparser2';
|
|
25
25
|
|
|
26
26
|
import { cssColorStraight } from '../color.js';
|
|
27
|
-
import { Path2D, flattenPath } from '../path.js';
|
|
27
|
+
import { MAT_IDENTITY, Path2D, flattenPath, matIsIdentity, matMultiply } from '../path.js';
|
|
28
28
|
|
|
29
29
|
const INHERITED = {
|
|
30
30
|
fill: '#000',
|
|
@@ -34,6 +34,7 @@ const INHERITED = {
|
|
|
34
34
|
lineJoin: 'miter',
|
|
35
35
|
miterLimit: 4,
|
|
36
36
|
fillRule: 'nonzero',
|
|
37
|
+
clipRule: 'nonzero',
|
|
37
38
|
fillOpacity: 1,
|
|
38
39
|
strokeOpacity: 1,
|
|
39
40
|
color: '#000',
|
|
@@ -61,6 +62,7 @@ const STYLE_ATTRS = {
|
|
|
61
62
|
'stroke-linejoin': 'lineJoin',
|
|
62
63
|
'stroke-miterlimit': 'miterLimit',
|
|
63
64
|
'fill-rule': 'fillRule',
|
|
65
|
+
'clip-rule': 'clipRule',
|
|
64
66
|
'fill-opacity': 'fillOpacity',
|
|
65
67
|
'stroke-opacity': 'strokeOpacity',
|
|
66
68
|
color: 'color',
|
|
@@ -145,7 +147,7 @@ function displayNone(node) {
|
|
|
145
147
|
// author's fallback is what to draw instead
|
|
146
148
|
const UNDRAWN_FEATURES = new Set(
|
|
147
149
|
[
|
|
148
|
-
'Extensibility', 'Image', '
|
|
150
|
+
'Extensibility', 'Image', 'Filter', 'BasicFilter',
|
|
149
151
|
'Pattern', 'Marker', 'Font', 'BasicFont', 'Script', 'Animation', 'ColorProfile',
|
|
150
152
|
'Cursor', 'View'
|
|
151
153
|
].map((name) => `http://www.w3.org/TR/SVG11/feature#${name}`)
|
|
@@ -540,6 +542,11 @@ function resolveStyle(node, parent) {
|
|
|
540
542
|
if (VISIBILITY.has(k)) value = k;
|
|
541
543
|
break;
|
|
542
544
|
}
|
|
545
|
+
case 'clipRule': {
|
|
546
|
+
const k = v.toLowerCase();
|
|
547
|
+
if (k === 'nonzero' || k === 'evenodd') value = k;
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
543
550
|
case 'fontSize':
|
|
544
551
|
value = fontSizeValue(v, parent.fontSize);
|
|
545
552
|
break;
|
|
@@ -704,8 +711,8 @@ function scanPaints(root, ids, languages) {
|
|
|
704
711
|
|
|
705
712
|
/**
|
|
706
713
|
* Widget rendering a static SVG document into a window (or any 2d context
|
|
707
|
-
* via `draw()`). Scripting, CSS stylesheets, filters
|
|
708
|
-
*
|
|
714
|
+
* via `draw()`). Scripting, CSS stylesheets, filters and external
|
|
715
|
+
* references are not supported — see docs/svg.md.
|
|
709
716
|
*/
|
|
710
717
|
export default class SvgView {
|
|
711
718
|
constructor(window, opts = {}) {
|
|
@@ -910,6 +917,13 @@ export default class SvgView {
|
|
|
910
917
|
*/
|
|
911
918
|
_ink(ctx, kind, value, style, path, alpha, draw) {
|
|
912
919
|
const key = kind === 'fill' ? 'fillStyle' : 'strokeStyle';
|
|
920
|
+
if (this._silhouetting) {
|
|
921
|
+
// a text in a clip path, which counts in its glyphs (`_silhouette`)
|
|
922
|
+
ctx[key] = '#000';
|
|
923
|
+
ctx.globalAlpha = 1;
|
|
924
|
+
draw();
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
913
927
|
if (!this._luminance) {
|
|
914
928
|
const paint = this._paint(ctx, value, style, path);
|
|
915
929
|
if (!paint) return;
|
|
@@ -1045,6 +1059,9 @@ export default class SvgView {
|
|
|
1045
1059
|
* `viewBox` is fitted in as `preserveAspectRatio` says, and what it draws
|
|
1046
1060
|
* is clipped to it unless its `overflow` is `visible` or `auto`. A zero
|
|
1047
1061
|
* or negative size, or a zero-sized `viewBox`, draws nothing.
|
|
1062
|
+
*
|
|
1063
|
+
* Its `clip-path` is set in the user space of what it holds, inside the
|
|
1064
|
+
* `viewBox`, as Chrome and WebKit set it.
|
|
1048
1065
|
*/
|
|
1049
1066
|
_renderViewport(node, ctx, style, alpha, depth) {
|
|
1050
1067
|
const outer = this._viewport;
|
|
@@ -1074,7 +1091,14 @@ export default class SvgView {
|
|
|
1074
1091
|
if (x || y) ctx.translate(x, y);
|
|
1075
1092
|
this._viewport = [w, h];
|
|
1076
1093
|
}
|
|
1077
|
-
this.
|
|
1094
|
+
const clip = this._clipOf(node);
|
|
1095
|
+
if (clip) {
|
|
1096
|
+
this._renderClipped(node, clip, ctx, style, alpha, depth, (c, a) =>
|
|
1097
|
+
this._renderChildren(node, c, style, a, depth + 1)
|
|
1098
|
+
);
|
|
1099
|
+
} else {
|
|
1100
|
+
this._renderChildren(node, ctx, style, alpha, depth + 1);
|
|
1101
|
+
}
|
|
1078
1102
|
this._viewport = outer;
|
|
1079
1103
|
ctx.restore();
|
|
1080
1104
|
}
|
|
@@ -1089,7 +1113,7 @@ export default class SvgView {
|
|
|
1089
1113
|
const nodeAlpha = alpha * (ownProperty(node, 'opacity', alphaValue) ?? 1);
|
|
1090
1114
|
if (nodeAlpha <= 0) return;
|
|
1091
1115
|
|
|
1092
|
-
const transforms =
|
|
1116
|
+
const transforms = ownTransforms(node);
|
|
1093
1117
|
const needsCtxState = transforms.length > 0;
|
|
1094
1118
|
if (needsCtxState) {
|
|
1095
1119
|
ctx.save();
|
|
@@ -1097,8 +1121,12 @@ export default class SvgView {
|
|
|
1097
1121
|
}
|
|
1098
1122
|
|
|
1099
1123
|
const mask = this._maskOf(node);
|
|
1100
|
-
|
|
1101
|
-
|
|
1124
|
+
const paint = (c, a) =>
|
|
1125
|
+
mask ? this._renderMasked(node, mask, c, style, a, depth) : this._renderElement(node, c, style, a, depth);
|
|
1126
|
+
// a nested <svg> is cut inside its viewport (`_renderViewport`)
|
|
1127
|
+
const clip = name === 'svg' ? null : this._clipOf(node);
|
|
1128
|
+
if (clip) this._renderClipped(node, clip, ctx, style, nodeAlpha, depth, paint);
|
|
1129
|
+
else paint(ctx, nodeAlpha);
|
|
1102
1130
|
|
|
1103
1131
|
if (needsCtxState) ctx.restore();
|
|
1104
1132
|
}
|
|
@@ -1122,15 +1150,11 @@ export default class SvgView {
|
|
|
1122
1150
|
break;
|
|
1123
1151
|
}
|
|
1124
1152
|
case 'use': {
|
|
1153
|
+
// its `x` and `y` are in its transforms (`ownTransforms`)
|
|
1125
1154
|
const target = this._useTarget(node);
|
|
1126
1155
|
if (target) {
|
|
1127
|
-
const ux = attrNum(node, 'x');
|
|
1128
|
-
const uy = attrNum(node, 'y');
|
|
1129
|
-
ctx.save();
|
|
1130
|
-
if (ux || uy) ctx.translate(ux, uy);
|
|
1131
1156
|
if (tag(target) === 'symbol') this._renderChildren(target, ctx, style, nodeAlpha, depth + 1);
|
|
1132
1157
|
else this._renderNode(target, ctx, style, nodeAlpha, depth + 1);
|
|
1133
|
-
ctx.restore();
|
|
1134
1158
|
}
|
|
1135
1159
|
break;
|
|
1136
1160
|
}
|
|
@@ -1347,6 +1371,299 @@ export default class SvgView {
|
|
|
1347
1371
|
ctx.restore();
|
|
1348
1372
|
}
|
|
1349
1373
|
|
|
1374
|
+
/** The `<clipPath>` an element's `clip-path` names, or null for none. One
|
|
1375
|
+
* that is not there, that names something else, or whose own `display`
|
|
1376
|
+
* is `none` clips nothing, as Chrome, Firefox and WebKit all have it,
|
|
1377
|
+
* where one inside something that is `display: none` clips as any other
|
|
1378
|
+
* does. */
|
|
1379
|
+
_clipOf(node) {
|
|
1380
|
+
const id = ownProperty(node, 'clip-path', urlReference);
|
|
1381
|
+
if (!id) return null;
|
|
1382
|
+
const clip = this._ids.get(id);
|
|
1383
|
+
return clip && tag(clip) === 'clippath' && !displayNone(clip) ? clip : null;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/** The clipPath a clip path's own `clip-path` names, or one on what is in
|
|
1387
|
+
* it, as `_clipOf` — and null for one that reaches itself through the
|
|
1388
|
+
* clip paths on it and in it, which Chrome and WebKit leave out, keeping
|
|
1389
|
+
* the clip that named it. */
|
|
1390
|
+
_nestedClip(node) {
|
|
1391
|
+
const clip = this._clipOf(node);
|
|
1392
|
+
return clip && !this._reachesItself(clip) ? clip : null;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
_reachesItself(clip) {
|
|
1396
|
+
const seen = new Set();
|
|
1397
|
+
const stack = [clip];
|
|
1398
|
+
while (stack.length) {
|
|
1399
|
+
const at = stack.pop();
|
|
1400
|
+
const next = [this._clipOf(at)];
|
|
1401
|
+
for (const child of at.children || []) {
|
|
1402
|
+
if (child.type !== 'tag') continue;
|
|
1403
|
+
next.push(this._clipOf(child));
|
|
1404
|
+
if (tag(child) === 'use') {
|
|
1405
|
+
const target = this._useTarget(child);
|
|
1406
|
+
if (target) next.push(this._clipOf(target));
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
for (const n of next) {
|
|
1410
|
+
if (n === clip) return true;
|
|
1411
|
+
if (n && !seen.has(n)) {
|
|
1412
|
+
seen.add(n);
|
|
1413
|
+
stack.push(n);
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
return false;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
/**
|
|
1421
|
+
* An element cut to the `<clipPath>` its `clip-path` names (CSS Masking
|
|
1422
|
+
* 1, 6), in the user space the element draws in: after its `transform`,
|
|
1423
|
+
* and a `use`'s `x` and `y`. `paint(ctx, alpha)` draws the element.
|
|
1424
|
+
*/
|
|
1425
|
+
_renderClipped(node, clip, ctx, style, alpha, depth, paint) {
|
|
1426
|
+
const region = this._clipRegion(clip, node, style, MAT_IDENTITY, ctx, depth, 0);
|
|
1427
|
+
if (region) this._drawCut(ctx, [region], alpha, paint);
|
|
1428
|
+
else paint(ctx, alpha);
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
/**
|
|
1432
|
+
* What a `<clipPath>` lets show of `ref`, in the user space `m` maps
|
|
1433
|
+
* `ref`'s to: `{ parts, own }`, the union of the parts, each cut to the
|
|
1434
|
+
* regions in its `clips`, and all of that cut to `own`, the region of the
|
|
1435
|
+
* clipPath's own `clip-path`, where it has one.
|
|
1436
|
+
*
|
|
1437
|
+
* In `objectBoundingBox` units the clipPath's user space is `ref`'s
|
|
1438
|
+
* bounding box, and a box of no area is a region of nothing. The
|
|
1439
|
+
* clipPath's `transform` goes outside that, and its own `clip-path` is set
|
|
1440
|
+
* in `ref`'s user space, as Chrome and Firefox set it.
|
|
1441
|
+
*
|
|
1442
|
+
* `depth` is how deep `ref` is drawn, and `nest` how many clip paths in
|
|
1443
|
+
* this one is: null past 32 of them, which cuts nothing.
|
|
1444
|
+
*/
|
|
1445
|
+
_clipRegion(clip, ref, refStyle, m, ctx, depth, nest) {
|
|
1446
|
+
if (nest > 32) return null;
|
|
1447
|
+
const region = { parts: [], own: null };
|
|
1448
|
+
let base = matMultiply(m, matrixOf(parseSvgTransform(attr(clip, 'transform'))));
|
|
1449
|
+
if ((attr(clip, 'clipPathUnits') || '').trim() === 'objectBoundingBox') {
|
|
1450
|
+
const bbox = this._bbox(ref, ctx, refStyle, depth);
|
|
1451
|
+
if (!bbox || !(bbox.w > 0 && bbox.h > 0)) return region;
|
|
1452
|
+
base = matMultiply(base, [bbox.w, 0, 0, bbox.h, bbox.x, bbox.y]);
|
|
1453
|
+
}
|
|
1454
|
+
const inherited = this._inheritedStyle(clip);
|
|
1455
|
+
for (const child of clip.children || []) {
|
|
1456
|
+
if (child.type !== 'tag') continue;
|
|
1457
|
+
const part = this._clipPart(child, inherited, base, ctx, depth, nest);
|
|
1458
|
+
if (part) region.parts.push(part);
|
|
1459
|
+
}
|
|
1460
|
+
const own = this._nestedClip(clip);
|
|
1461
|
+
if (own) region.own = this._clipRegion(own, ref, refStyle, m, ctx, depth, nest + 1);
|
|
1462
|
+
return region;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
/**
|
|
1466
|
+
* A child of a `<clipPath>` as a part of its region (CSS Masking 1, 6.3):
|
|
1467
|
+
* a shape — `{ path, rule, m }` — or a text — `{ text, style, m }` — with
|
|
1468
|
+
* `m` the matrix from its user space to the region's, `box` where it lies
|
|
1469
|
+
* there, and `clips` the regions its own `clip-path` cuts it to. A `use`
|
|
1470
|
+
* is the shape or text it names, through the use's transforms and its
|
|
1471
|
+
* own, and cut by both their clip paths.
|
|
1472
|
+
*
|
|
1473
|
+
* What counts is the shape as it is filled, whatever its paint, by its
|
|
1474
|
+
* `clip-rule`, which it inherits from the clipPath and what the clipPath
|
|
1475
|
+
* is in, and not from what is clipped. Anything else — a group, a `use`
|
|
1476
|
+
* of a group or a symbol — is no part, as Chrome and WebKit leave it,
|
|
1477
|
+
* and nor is one that is `display: none`, whose conditions do not hold,
|
|
1478
|
+
* or that is not visible.
|
|
1479
|
+
*/
|
|
1480
|
+
_clipPart(child, inherited, base, ctx, depth, nest) {
|
|
1481
|
+
if (!CLIPPING.has(tag(child)) || displayNone(child) || !conditionsHold(child, this.languages)) return null;
|
|
1482
|
+
let style = this._style(child, inherited);
|
|
1483
|
+
let m = matMultiply(base, matrixOf(ownTransforms(child)));
|
|
1484
|
+
const clips = [];
|
|
1485
|
+
const cutBy = (node, st, at) => {
|
|
1486
|
+
const clip = this._nestedClip(node);
|
|
1487
|
+
const region = clip && this._clipRegion(clip, node, st, at, ctx, depth, nest + 1);
|
|
1488
|
+
if (region) clips.push(region);
|
|
1489
|
+
};
|
|
1490
|
+
cutBy(child, style, m);
|
|
1491
|
+
let shape = child;
|
|
1492
|
+
if (tag(child) === 'use') {
|
|
1493
|
+
shape = this._useTarget(child);
|
|
1494
|
+
if (!shape || tag(shape) === 'use' || !CLIPPING.has(tag(shape))) return null;
|
|
1495
|
+
if (displayNone(shape) || !conditionsHold(shape, this.languages)) return null;
|
|
1496
|
+
style = this._style(shape, style);
|
|
1497
|
+
m = matMultiply(m, matrixOf(ownTransforms(shape)));
|
|
1498
|
+
cutBy(shape, style, m);
|
|
1499
|
+
}
|
|
1500
|
+
if (style.visibility !== 'visible') return null;
|
|
1501
|
+
if (tag(shape) === 'text') {
|
|
1502
|
+
const box = this._bbox(shape, ctx, style, depth);
|
|
1503
|
+
if (!box) return null;
|
|
1504
|
+
// and room for what a glyph draws past its advance and the font's
|
|
1505
|
+
// extent
|
|
1506
|
+
const pad = style.fontSize / 2;
|
|
1507
|
+
const inked = { x: box.x - pad, y: box.y - pad, w: box.w + 2 * pad, h: box.h + 2 * pad };
|
|
1508
|
+
return { text: shape, style, m, clips, box: transformedBox(inked, [m]) };
|
|
1509
|
+
}
|
|
1510
|
+
const path = shapePath(shape);
|
|
1511
|
+
if (!path) return null;
|
|
1512
|
+
return { path, rule: style.clipRule, m, clips, box: transformedBox(pathBBox(path), [m]) };
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
/**
|
|
1516
|
+
* Draw with `paint(ctx, alpha)` cut to every one of `regions`: with the
|
|
1517
|
+
* context's own clip, each that is one shape and what cuts it, and each
|
|
1518
|
+
* that is not through surfaces (`_drawThrough`).
|
|
1519
|
+
*/
|
|
1520
|
+
_drawCut(ctx, regions, alpha, paint) {
|
|
1521
|
+
ctx.save();
|
|
1522
|
+
try {
|
|
1523
|
+
const rest = [];
|
|
1524
|
+
for (const region of regions) {
|
|
1525
|
+
const cut = this._clipTo(ctx, region);
|
|
1526
|
+
if (cut === false) return;
|
|
1527
|
+
if (cut === null) rest.push(region);
|
|
1528
|
+
}
|
|
1529
|
+
if (!rest.length) paint(ctx, alpha);
|
|
1530
|
+
else this._drawThrough(ctx, rest[0], alpha, (c, a) => this._drawCut(c, rest.slice(1), a, paint));
|
|
1531
|
+
} finally {
|
|
1532
|
+
ctx.restore();
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
/**
|
|
1537
|
+
* Cut the context to a region with its own clip, where that can: true
|
|
1538
|
+
* where it did, false where nothing of the region shows, and null —
|
|
1539
|
+
* leaving the context as it was — where the region, or one that cuts it,
|
|
1540
|
+
* is more than one part, or a text, which a context's clip is not.
|
|
1541
|
+
*/
|
|
1542
|
+
_clipTo(ctx, region) {
|
|
1543
|
+
if (!region.parts.length) return false;
|
|
1544
|
+
if (!clipsAsOne(region)) return null;
|
|
1545
|
+
const cut = (r) => {
|
|
1546
|
+
const [part] = r.parts;
|
|
1547
|
+
if (!part) return false;
|
|
1548
|
+
ctx.clip(placedPath(part.path, part.m), part.rule);
|
|
1549
|
+
return part.clips.every(cut) && (!r.own || cut(r.own));
|
|
1550
|
+
};
|
|
1551
|
+
return cut(region);
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
/**
|
|
1555
|
+
* Draw with `paint` cut to a region the context's clip cannot make, as a
|
|
1556
|
+
* mask cuts (`_renderMasked`): on a surface, and the region's coverage on
|
|
1557
|
+
* a second, through the same transform — device pixels, the part of the
|
|
1558
|
+
* region the drawing can show — the second cutting the first with
|
|
1559
|
+
* `destination-in`, and the first composited at `alpha`.
|
|
1560
|
+
*
|
|
1561
|
+
* With no surface to draw on, or no `destination-in`, it is cut to the
|
|
1562
|
+
* outlines of the region's shapes as one path, which is their union
|
|
1563
|
+
* wherever they do not overlap, and to a text's box.
|
|
1564
|
+
*/
|
|
1565
|
+
_drawThrough(ctx, region, alpha, paint) {
|
|
1566
|
+
const extent = region.parts.reduce((box, part) => unionBox(box, part.box), null);
|
|
1567
|
+
const m = ctx.getTransform?.();
|
|
1568
|
+
const make = this._surface;
|
|
1569
|
+
const box = m && make && ctx.drawImage && ctx.setTransform ? surfaceBox(m, extent, this._bounds) : null;
|
|
1570
|
+
if (box === EMPTY) return;
|
|
1571
|
+
let content = null;
|
|
1572
|
+
let cover = null;
|
|
1573
|
+
let cctx = null;
|
|
1574
|
+
let mctx = null;
|
|
1575
|
+
let done = false;
|
|
1576
|
+
const bounds = this._bounds;
|
|
1577
|
+
const luminance = this._luminance;
|
|
1578
|
+
try {
|
|
1579
|
+
if (box) {
|
|
1580
|
+
content = make(box.w, box.h);
|
|
1581
|
+
cover = content && make(box.w, box.h);
|
|
1582
|
+
}
|
|
1583
|
+
if (cover) {
|
|
1584
|
+
cctx = content.getContext('2d');
|
|
1585
|
+
cctx.globalCompositeOperation = 'destination-in';
|
|
1586
|
+
if (cctx.globalCompositeOperation === 'destination-in') {
|
|
1587
|
+
cctx.globalCompositeOperation = 'source-over';
|
|
1588
|
+
const place = [m.a, m.b, m.c, m.d, m.e - box.x, m.f - box.y];
|
|
1589
|
+
// what the surfaces show is all of them
|
|
1590
|
+
this._bounds = { x: 0, y: 0, w: box.w, h: box.h };
|
|
1591
|
+
cctx.transform(...place);
|
|
1592
|
+
paint(cctx, 1);
|
|
1593
|
+
|
|
1594
|
+
mctx = cover.getContext('2d');
|
|
1595
|
+
mctx.transform(...place);
|
|
1596
|
+
this._luminance = false;
|
|
1597
|
+
this._cover(mctx, region);
|
|
1598
|
+
|
|
1599
|
+
cctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
1600
|
+
cctx.globalAlpha = 1;
|
|
1601
|
+
cctx.globalCompositeOperation = 'destination-in';
|
|
1602
|
+
cctx.drawImage(cover, 0, 0);
|
|
1603
|
+
ctx.save();
|
|
1604
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
1605
|
+
ctx.globalAlpha = alpha;
|
|
1606
|
+
ctx.drawImage(content, box.x, box.y);
|
|
1607
|
+
ctx.restore();
|
|
1608
|
+
done = true;
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
} finally {
|
|
1612
|
+
this._bounds = bounds;
|
|
1613
|
+
this._luminance = luminance;
|
|
1614
|
+
mctx?.destroy?.();
|
|
1615
|
+
cctx?.destroy?.();
|
|
1616
|
+
cover?.destroy?.();
|
|
1617
|
+
content?.destroy?.();
|
|
1618
|
+
}
|
|
1619
|
+
if (done) return;
|
|
1620
|
+
const outline = new Path2D();
|
|
1621
|
+
const rules = new Set();
|
|
1622
|
+
for (const part of region.parts) {
|
|
1623
|
+
if (part.text) {
|
|
1624
|
+
outline.rect(part.box.x, part.box.y, part.box.w, part.box.h);
|
|
1625
|
+
rules.add('nonzero');
|
|
1626
|
+
} else {
|
|
1627
|
+
outline.addPath(part.path, part.m);
|
|
1628
|
+
rules.add(part.rule);
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
ctx.save();
|
|
1632
|
+
ctx.clip(outline, rules.size === 1 && rules.has('evenodd') ? 'evenodd' : 'nonzero');
|
|
1633
|
+
paint(ctx, alpha);
|
|
1634
|
+
ctx.restore();
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
/** A region's coverage, drawn opaque: each part, cut to the regions that
|
|
1638
|
+
* cut it, and all of them to the region's own. */
|
|
1639
|
+
_cover(ctx, region) {
|
|
1640
|
+
const parts = (c) => {
|
|
1641
|
+
for (const part of region.parts) this._drawCut(c, part.clips, 1, (cc) => this._silhouette(cc, part));
|
|
1642
|
+
};
|
|
1643
|
+
if (region.own) this._drawCut(ctx, [region.own], 1, parts);
|
|
1644
|
+
else parts(ctx);
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
/** A part of a clip region filled opaque, whatever its paint: a shape by
|
|
1648
|
+
* its clip rule, a text in its glyphs. */
|
|
1649
|
+
_silhouette(ctx, part) {
|
|
1650
|
+
if (part.text) {
|
|
1651
|
+
ctx.save();
|
|
1652
|
+
ctx.transform(...part.m);
|
|
1653
|
+
this._silhouetting = true;
|
|
1654
|
+
try {
|
|
1655
|
+
this._renderText(part.text, ctx, part.style, 1);
|
|
1656
|
+
} finally {
|
|
1657
|
+
this._silhouetting = false;
|
|
1658
|
+
ctx.restore();
|
|
1659
|
+
}
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1662
|
+
ctx.fillStyle = '#000';
|
|
1663
|
+
ctx.globalAlpha = 1;
|
|
1664
|
+
ctx.fill(placedPath(part.path, part.m), part.rule);
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1350
1667
|
/** The style an element has where it stands in the document: what is in a
|
|
1351
1668
|
* `<mask>` inherits from the mask's ancestors, not from what it masks. */
|
|
1352
1669
|
_inheritedStyle(node) {
|
|
@@ -1359,9 +1676,10 @@ export default class SvgView {
|
|
|
1359
1676
|
|
|
1360
1677
|
/**
|
|
1361
1678
|
* An element's bounding box in its own user space (SVG 2, 8.10): the
|
|
1362
|
-
* geometry of its shapes and text, without strokes
|
|
1363
|
-
* transforms of what is in it but not its own
|
|
1364
|
-
*
|
|
1679
|
+
* geometry of its shapes and text, without strokes or what clips them,
|
|
1680
|
+
* through the transforms of what is in it but not its own, which for a
|
|
1681
|
+
* `use` are its `x` and `y` too (`ownTransforms`). Null for one that
|
|
1682
|
+
* has none.
|
|
1365
1683
|
*/
|
|
1366
1684
|
_bbox(node, ctx, style, depth) {
|
|
1367
1685
|
if (depth > 32) return null;
|
|
@@ -1377,7 +1695,7 @@ export default class SvgView {
|
|
|
1377
1695
|
if (!child || child.type !== 'tag') continue;
|
|
1378
1696
|
if (NON_RENDERED.has(tag(child)) || displayNone(child) || !conditionsHold(child, this.languages)) continue;
|
|
1379
1697
|
const inner = this._bbox(child, ctx, this._style(child, style), depth + 1);
|
|
1380
|
-
if (inner) box = unionBox(box, transformedBox(inner,
|
|
1698
|
+
if (inner) box = unionBox(box, transformedBox(inner, ownTransforms(child)));
|
|
1381
1699
|
}
|
|
1382
1700
|
return box;
|
|
1383
1701
|
}
|
|
@@ -1389,13 +1707,14 @@ export default class SvgView {
|
|
|
1389
1707
|
for (const child of target.children || []) {
|
|
1390
1708
|
if (child.type !== 'tag' || NON_RENDERED.has(tag(child)) || displayNone(child)) continue;
|
|
1391
1709
|
const inner = this._bbox(child, ctx, this._style(child, style), depth + 1);
|
|
1392
|
-
if (inner) box = unionBox(box, transformedBox(inner,
|
|
1710
|
+
if (inner) box = unionBox(box, transformedBox(inner, ownTransforms(child)));
|
|
1393
1711
|
}
|
|
1394
1712
|
} else {
|
|
1395
1713
|
const inner = this._bbox(target, ctx, this._style(target, style), depth + 1);
|
|
1396
|
-
if (inner) box = transformedBox(inner,
|
|
1714
|
+
if (inner) box = transformedBox(inner, ownTransforms(target));
|
|
1397
1715
|
}
|
|
1398
|
-
|
|
1716
|
+
// in the space its `x` and `y` move it to, which is its own
|
|
1717
|
+
return box;
|
|
1399
1718
|
}
|
|
1400
1719
|
case 'text': {
|
|
1401
1720
|
ctx.save();
|
|
@@ -1578,7 +1897,7 @@ export default class SvgView {
|
|
|
1578
1897
|
let bounds = null;
|
|
1579
1898
|
for (const p of pieces) {
|
|
1580
1899
|
const st = p.span.style;
|
|
1581
|
-
if (st.visibility !== 'visible' || st.fill === 'none' || !p.text.trim()) continue;
|
|
1900
|
+
if (st.visibility !== 'visible' || (st.fill === 'none' && !this._silhouetting) || !p.text.trim()) continue;
|
|
1582
1901
|
if (!bounds) {
|
|
1583
1902
|
bounds = new Path2D();
|
|
1584
1903
|
bounds.rect(box.x, box.y, box.w, box.h);
|
|
@@ -1685,6 +2004,51 @@ function urlReference(raw) {
|
|
|
1685
2004
|
return m ? m[1] : undefined;
|
|
1686
2005
|
}
|
|
1687
2006
|
|
|
2007
|
+
/**
|
|
2008
|
+
* The transforms an element sets for itself and what it holds: its
|
|
2009
|
+
* `transform`, and after that a `use`'s `x` and `y`, which SVG 1.1 puts at
|
|
2010
|
+
* the end of the transform of the group a use stands for (5.6), the group
|
|
2011
|
+
* its `clip-path` and its `mask` go on. So both are set in the space the
|
|
2012
|
+
* use's `x` and `y` move it to, as Chrome, Firefox and WebKit set them.
|
|
2013
|
+
*/
|
|
2014
|
+
function ownTransforms(node) {
|
|
2015
|
+
const list = parseSvgTransform(node.attribs?.transform);
|
|
2016
|
+
if (tag(node) === 'use') {
|
|
2017
|
+
const x = attrNum(node, 'x');
|
|
2018
|
+
const y = attrNum(node, 'y');
|
|
2019
|
+
if (x || y) list.push([1, 0, 0, 1, x, y]);
|
|
2020
|
+
}
|
|
2021
|
+
return list;
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
/** A transform list as the one matrix it comes to. */
|
|
2025
|
+
function matrixOf(transforms) {
|
|
2026
|
+
let m = MAT_IDENTITY;
|
|
2027
|
+
for (const t of transforms) m = matMultiply(m, t);
|
|
2028
|
+
return m;
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
/** What a `<clipPath>` may hold that is part of its region: the shapes,
|
|
2032
|
+
* text, and a `use` of either (CSS Masking 1, 6.3). */
|
|
2033
|
+
const CLIPPING = new Set(['path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'text', 'use']);
|
|
2034
|
+
|
|
2035
|
+
/** Whether a clip region is one shape at most, cut by regions that are one
|
|
2036
|
+
* at most themselves: what a context's own clip makes. */
|
|
2037
|
+
function clipsAsOne(region) {
|
|
2038
|
+
if (region.parts.length > 1) return false;
|
|
2039
|
+
const [part] = region.parts;
|
|
2040
|
+
if (part && (part.text || !part.clips.every(clipsAsOne))) return false;
|
|
2041
|
+
return !region.own || clipsAsOne(region.own);
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
/** A path in the space `m` maps its own to. */
|
|
2045
|
+
function placedPath(path, m) {
|
|
2046
|
+
if (matIsIdentity(m)) return path;
|
|
2047
|
+
const out = new Path2D();
|
|
2048
|
+
out.addPath(path, m);
|
|
2049
|
+
return out;
|
|
2050
|
+
}
|
|
2051
|
+
|
|
1688
2052
|
/** The shapes that take markers (SVG 2, 11.6): the ones with vertices. */
|
|
1689
2053
|
const MARKABLE = new Set(['path', 'line', 'polyline', 'polygon']);
|
|
1690
2054
|
|