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.
@@ -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.length ? emb.paragraphs[0].level : 0;
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
  }
@@ -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.length ? emb.paragraphs[0].level & 1 : 0;
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: text.length ? [{ start: 0, end: text.length - 1, level: 0 }] : []
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.length ? emb.paragraphs[0].level : 0;
177
+ baseLevel = emb.paragraphs[0].level;
165
178
  } else {
166
179
  baseLevel = levels.length ? levels[0] & ~1 : 0;
167
180
  }
@@ -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 <mask>. See docs/svg.md for the full surface 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', 'Clip', 'BasicClip', 'Filter', 'BasicFilter',
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, clip paths and
708
- * external references are not supported — see docs/svg.md.
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._renderChildren(node, ctx, style, alpha, depth + 1);
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 = parseSvgTransform(node.attribs?.transform);
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
- if (mask) this._renderMasked(node, mask, ctx, style, nodeAlpha, depth);
1101
- else this._renderElement(node, ctx, style, nodeAlpha, depth);
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, through the
1363
- * transforms of what is in it but not its own. Null for one that has
1364
- * none.
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, parseSvgTransform(child.attribs?.transform)));
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, parseSvgTransform(child.attribs?.transform)));
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, parseSvgTransform(target.attribs?.transform));
1714
+ if (inner) box = transformedBox(inner, ownTransforms(target));
1397
1715
  }
1398
- return box && { ...box, x: box.x + attrNum(node, 'x'), y: box.y + attrNum(node, 'y') };
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.21.1",
3
+ "version": "8.23.0",
4
4
  "description": "Desktop UI toolkit for X11 with canvas-like 2d and OpenGL rendering",
5
5
  "author": "Andrey Sidorov <sidorares@yandex.ru>",
6
6
  "license": "MIT",