ntk 8.14.6 → 8.15.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.
@@ -1899,11 +1899,12 @@ class RenderingContext2d {
1899
1899
  * well as the AddTraps: two requests become one, and the one that is left
1900
1900
  * touches only the bounding box.
1901
1901
  */
1902
- _uploadCoverage(job, b, out, edges) {
1902
+ _uploadCoverage(job, b, out, edges, route) {
1903
1903
  const rasterizer = this.window.app.rasterizer;
1904
1904
  if (!rasterizer) return false;
1905
- if (routeRaster(b.w, b.h, edges, this.window.app.rasterPolicy) !== "local")
1906
- return false;
1905
+ const where =
1906
+ route ?? routeRaster(b.w, b.h, edges, this.window.app.rasterPolicy);
1907
+ if (where !== "local") return false;
1907
1908
 
1908
1909
  // Rasterized over the whole box, as always, so a drawing that clips
1909
1910
  // split across passes — a pan's strips, a full repaint — gets the same
@@ -1978,6 +1979,84 @@ class RenderingContext2d {
1978
1979
  return out;
1979
1980
  }
1980
1981
 
1982
+ /**
1983
+ * A drawing's own extent: its box with the same pixel of slack as
1984
+ * `_clampBBox`, and *not* clamped to the surface — what its masks are
1985
+ * clustered and routed by (`_fillPolys`, the stroke's mask path).
1986
+ *
1987
+ * Which rasterizer draws a mask — ours or the server's — has to be a
1988
+ * property of the drawing, never of where it lands. The two antialias
1989
+ * differently, so a route taken from the part the surface shows changed
1990
+ * as the drawing moved: a stroke whose cap had just left the surface had
1991
+ * three triangles fewer to count, went to the other rasterizer, and came
1992
+ * out up to ten levels different along its whole length — a pan's copy
1993
+ * of it one way, the strip beside it the other. Clamping and culling
1994
+ * decide how much work a mask is, never how it is drawn.
1995
+ */
1996
+ _rawBox(minX, minY, maxX, maxY) {
1997
+ const x = Math.floor(minX) - 1;
1998
+ const y = Math.floor(minY) - 1;
1999
+ return { x, y, w: Math.ceil(maxX) + 1 - x, h: Math.ceil(maxY) + 1 - y };
2000
+ }
2001
+
2002
+ /** `_rawBox` of one flat `[x0, y0, …]` point list; null for none. */
2003
+ _pointsExtent(pts) {
2004
+ let minX = Infinity;
2005
+ let minY = Infinity;
2006
+ let maxX = -Infinity;
2007
+ let maxY = -Infinity;
2008
+ for (let i = 0; i < pts.length; i += 2) {
2009
+ if (pts[i] < minX) minX = pts[i];
2010
+ if (pts[i] > maxX) maxX = pts[i];
2011
+ if (pts[i + 1] < minY) minY = pts[i + 1];
2012
+ if (pts[i + 1] > maxY) maxY = pts[i + 1];
2013
+ }
2014
+ if (!(maxX >= minX && maxY >= minY)) return null;
2015
+ return this._rawBox(minX, minY, maxX, maxY);
2016
+ }
2017
+
2018
+ /** `_rawBox` over every subpath of a flattened path; null for none. */
2019
+ _polysExtent(polys) {
2020
+ let out = null;
2021
+ for (const poly of polys) {
2022
+ const b = this._pointsExtent(poly.pts);
2023
+ if (!b) continue;
2024
+ out = out ? unionBox(out, b) : b;
2025
+ }
2026
+ return out;
2027
+ }
2028
+
2029
+ /**
2030
+ * Where a drawing whose own box is `box` is rasterized (`routeRaster`),
2031
+ * sized by as much of it as the surface could ever show — each side no
2032
+ * longer than the surface's, and its edges in the same proportion —
2033
+ * rather than by the part it shows now: a route that depends only on the
2034
+ * drawing's size and complexity is the same wherever the drawing lands
2035
+ * (`_rawBox`). Counting every edge of a stroke that runs far past the
2036
+ * surface sent it through the mask more often than the part it shows
2037
+ * earned: a fifth more bytes a frame for a hundred such strokes.
2038
+ */
2039
+ _routeFor(box, edges) {
2040
+ const w = Math.min(box.w, this.width);
2041
+ const h = Math.min(box.h, this.height);
2042
+ return routeRaster(
2043
+ w,
2044
+ h,
2045
+ (edges * w * h) / (box.w * box.h),
2046
+ this.window.app.rasterPolicy,
2047
+ );
2048
+ }
2049
+
2050
+ /** the part of a box on the surface; null where there is none */
2051
+ _onSurface(b) {
2052
+ const x = Math.max(0, b.x);
2053
+ const y = Math.max(0, b.y);
2054
+ const w = Math.min(this.width, b.x + b.w) - x;
2055
+ const h = Math.min(this.height, b.y + b.h) - y;
2056
+ if (w <= 0 || h <= 0) return null;
2057
+ return { x, y, w, h };
2058
+ }
2059
+
1981
2060
  // a pixel of slack for the antialiased edge, clamped to the surface
1982
2061
  _clampBBox(minX, minY, maxX, maxY) {
1983
2062
  const x = Math.max(0, Math.floor(minX) - 1);
@@ -2030,7 +2109,7 @@ class RenderingContext2d {
2030
2109
  *
2031
2110
  * @returns {boolean} whether anything was composited
2032
2111
  */
2033
- _paintThroughMask(b, job, { edges, src, op, alpha, server }) {
2112
+ _paintThroughMask(b, job, { edges, route, src, op, alpha, server }) {
2034
2113
  const R = this.Render;
2035
2114
  // Everything is bounded to the part of the box the clip lets through:
2036
2115
  // the clip's rectangle, or the extents of a stack with a path in it —
@@ -2049,7 +2128,9 @@ class RenderingContext2d {
2049
2128
  if (!out) return false;
2050
2129
  }
2051
2130
  if (this._hasPolyClip) this._requireClipMask();
2052
- if (!this._uploadCoverage({ ...job, dx: -b.x, dy: -b.y }, b, out, edges)) {
2131
+ if (
2132
+ !this._uploadCoverage({ ...job, dx: -b.x, dy: -b.y }, b, out, edges, route)
2133
+ ) {
2053
2134
  R.FillRectangles(
2054
2135
  R.PictOp.Src,
2055
2136
  this.fillMask.id,
@@ -2118,12 +2199,13 @@ class RenderingContext2d {
2118
2199
  if (!prepareStyle(src, this._m)) return;
2119
2200
 
2120
2201
  // one box per subpath, so a path holding disjoint ones can be masked as
2121
- // the pieces it is rather than as the box around all of them
2202
+ // the pieces it is rather than as the box around all of them — each the
2203
+ // subpath's own box, on the surface or not (`_rawBox`)
2122
2204
  const shapes = [];
2123
2205
  const pieces = [];
2124
2206
  for (const p of polys) {
2125
2207
  if (p.pts.length < 6) continue;
2126
- const b = this._pointsBBox(p.pts);
2208
+ const b = this._pointsExtent(p.pts);
2127
2209
  if (!b) continue;
2128
2210
  shapes.push(p.pts);
2129
2211
  pieces.push(b);
@@ -2133,18 +2215,30 @@ class RenderingContext2d {
2133
2215
  this._ensureFillMask();
2134
2216
  let painted = false;
2135
2217
  for (const cluster of this._maskClusters(pieces, op)) {
2218
+ // Routed by every subpath of the cluster, and rasterized from the
2219
+ // ones the surface shows, over the part of them it shows: a subpath
2220
+ // wholly off it adds nothing to the winding of a pixel outside its
2221
+ // own box.
2136
2222
  const flat = [];
2223
+ let box = null;
2137
2224
  let edges = 0;
2225
+ let drawn = 0;
2138
2226
  for (const i of cluster.items) {
2139
- flat.push(shapes[i]);
2140
2227
  edges += shapes[i].length / 2;
2228
+ const shown = this._onSurface(pieces[i]);
2229
+ if (!shown) continue;
2230
+ flat.push(shapes[i]);
2231
+ drawn += shapes[i].length / 2;
2232
+ box = box ? unionBox(box, shown) : shown;
2141
2233
  }
2234
+ if (!box) continue;
2142
2235
  painted =
2143
2236
  this._paintThroughMask(
2144
- cluster,
2237
+ box,
2145
2238
  { polys: flat, rule },
2146
2239
  {
2147
- edges,
2240
+ edges: drawn,
2241
+ route: this._routeFor(cluster, edges),
2148
2242
  src,
2149
2243
  op,
2150
2244
  alpha,
@@ -2497,28 +2591,12 @@ class RenderingContext2d {
2497
2591
  // cuts above are meant to keep the extruder off that path; this keeps
2498
2592
  // a future miss from being catastrophic. A sum is finite iff every
2499
2593
  // term is.
2500
- //
2501
- // A triangle wholly off the surface is left out as well: it adds
2502
- // nothing on either route — the rasterizer would walk its rows only for
2503
- // its edges to cancel on the border column, and the server would be
2504
- // sent it only to clip it away — and a zoomed graph has hundreds of
2505
- // edges past the window every frame.
2506
- const right = this.width + 1;
2507
- const bottom = this.height + 1;
2508
2594
  let w = 0;
2509
2595
  for (let i = 0; i < tris.length; i += 6) {
2510
- const ax = tris[i];
2511
- const ay = tris[i + 1];
2512
- const bx = tris[i + 2];
2513
- const by = tris[i + 3];
2514
- const cx = tris[i + 4];
2515
- const cy = tris[i + 5];
2516
- if (!Number.isFinite(ax + ay + bx + by + cx + cy)) continue;
2517
2596
  if (
2518
- (ax < -1 && bx < -1 && cx < -1) ||
2519
- (ay < -1 && by < -1 && cy < -1) ||
2520
- (ax > right && bx > right && cx > right) ||
2521
- (ay > bottom && by > bottom && cy > bottom)
2597
+ !Number.isFinite(
2598
+ tris[i] + tris[i + 1] + tris[i + 2] + tris[i + 3] + tris[i + 4] + tris[i + 5],
2599
+ )
2522
2600
  ) {
2523
2601
  continue;
2524
2602
  }
@@ -2527,19 +2605,6 @@ class RenderingContext2d {
2527
2605
  }
2528
2606
  if (w < tris.length) tris.length = w;
2529
2607
  if (!tris.length) return;
2530
- // Triangles are 16.16 fixed point, and the source origin a direct stroke
2531
- // hands the server is 16 bits: a stroke that reaches that far is cut to
2532
- // the surface first (lib/cliprect.js), a pixel to spare on every side, so
2533
- // what the surface shows is drawn exactly as before. One that does not
2534
- // goes as it came, for the server to clip.
2535
- const edgeX = this.width + 1;
2536
- const edgeY = this.height + 1;
2537
- if (!withinRect(tris, -WIRE_REACH, -WIRE_REACH, WIRE_REACH, WIRE_REACH)) {
2538
- const cut = clipTrianglesToRect(tris, -1, -1, edgeX, edgeY);
2539
- tris.length = 0;
2540
- for (let i = 0; i < cut.length; i++) tris.push(cut[i]);
2541
- if (!tris.length) return;
2542
- }
2543
2608
 
2544
2609
  const op = this._op();
2545
2610
  // round-cap/join disks overlap the stroke body; overlapping coverage
@@ -2555,6 +2620,14 @@ class RenderingContext2d {
2555
2620
  op === this.Render.PictOp.Over;
2556
2621
  const chunk = 4000 * 6;
2557
2622
  if (direct) {
2623
+ this._cullTris(tris);
2624
+ if (!tris.length) return;
2625
+ const cut = this._wireCut(tris);
2626
+ if (cut !== tris) {
2627
+ tris.length = 0;
2628
+ for (let i = 0; i < cut.length; i++) tris.push(cut[i]);
2629
+ if (!tris.length) return;
2630
+ }
2558
2631
  for (let i = 0; i < tris.length; i += chunk) {
2559
2632
  const batch = tris.slice(i, i + chunk);
2560
2633
  // RENDER aligns the source with the *first triangle's first vertex*,
@@ -2588,24 +2661,31 @@ class RenderingContext2d {
2588
2661
  // and took 1.9 s on XQuartz where the bounded version takes 37 ms
2589
2662
  // (react-x11#148). Stale mask content outside the boxes is never
2590
2663
  // composited, so clearing only them is safe.
2664
+ //
2665
+ // The islands are the whole stroke's, on the surface or not, and so are
2666
+ // the clusters and the route each is drawn by (`_rawBox`); only the
2667
+ // triangles the surface shows are rasterized.
2591
2668
  this._ensureFillMask();
2592
2669
  const pieces = this._trisPieces(tris);
2593
2670
  if (!pieces.length) return;
2594
- // the soup itself when it is one piece holding every triangle — the
2595
- // common case (one polyline is one piece), and worth not copying
2596
- const whole =
2597
- pieces.length === 1 &&
2598
- pieces[0].start === 0 &&
2599
- pieces[0].end === tris.length;
2600
2671
  let painted = false;
2601
2672
  for (const cluster of this._maskClusters(pieces, op)) {
2602
- const batch = whole ? tris : this._trisOf(tris, pieces, cluster.items);
2673
+ let edges = 0;
2674
+ for (const i of cluster.items) {
2675
+ edges += (pieces[i].end - pieces[i].start) / 2;
2676
+ }
2677
+ const batch = this._trisBatch(tris, pieces, cluster);
2678
+ if (!batch.length) continue;
2679
+ // the mask over what is drawn, as tight as it always was
2680
+ const box = this._pointsBBox(batch);
2681
+ if (!box) continue;
2603
2682
  painted =
2604
2683
  this._paintThroughMask(
2605
- cluster,
2684
+ box,
2606
2685
  { triangles: batch },
2607
2686
  {
2608
2687
  edges: batch.length / 2,
2688
+ route: this._routeFor(cluster, edges),
2609
2689
  src,
2610
2690
  op,
2611
2691
  alpha: this.globalAlpha,
@@ -2642,17 +2722,17 @@ class RenderingContext2d {
2642
2722
  * linear pre-pass whose only job is to hand the clustering a handful of
2643
2723
  * boxes instead of thousands of triangles.
2644
2724
  *
2645
- * A piece that misses the surface entirely is dropped, leaving a hole in
2646
- * the spans — which is why the caller gathers a batch from the spans it
2647
- * has rather than assuming they tile `tris`.
2725
+ * Each piece is its own box (`_rawBox`), on the surface or not: the
2726
+ * clusters and the routes are the stroke's, and the surface only bounds
2727
+ * the work (`_trisBatch`).
2648
2728
  */
2649
2729
  _trisPieces(tris) {
2650
2730
  const pieces = [];
2651
2731
  let open = null;
2652
2732
  const close = () => {
2653
2733
  if (!open) return;
2654
- const b = this._clampBBox(open.minX, open.minY, open.maxX, open.maxY);
2655
- if (b) pieces.push({ ...b, start: open.start, end: open.end });
2734
+ const b = this._rawBox(open.minX, open.minY, open.maxX, open.maxY);
2735
+ pieces.push({ ...b, start: open.start, end: open.end });
2656
2736
  open = null;
2657
2737
  };
2658
2738
  for (let i = 0; i < tris.length; i += 6) {
@@ -2681,17 +2761,81 @@ class RenderingContext2d {
2681
2761
  return pieces;
2682
2762
  }
2683
2763
 
2684
- /** the triangles of the given pieces, gathered into one soup */
2685
- _trisOf(tris, pieces, items) {
2686
- let n = 0;
2687
- for (const i of items) n += pieces[i].end - pieces[i].start;
2688
- const out = new Array(n);
2689
- let w = 0;
2764
+ /**
2765
+ * One cluster's triangles, gathered from its pieces: less those wholly
2766
+ * off the surface, which add nothing on either route — the rasterizer
2767
+ * would walk their rows only for their edges to cancel on the border
2768
+ * column, and the server would be sent them only to clip them away — and
2769
+ * cut to the wire's reach (`_wireCut`). The soup itself when the cluster
2770
+ * is all of it and all of it is on the surface: the common case, one
2771
+ * polyline in view, and worth not copying.
2772
+ */
2773
+ _trisBatch(tris, pieces, cluster) {
2774
+ const { items } = cluster;
2775
+ const inside =
2776
+ cluster.x >= -1 &&
2777
+ cluster.y >= -1 &&
2778
+ cluster.x + cluster.w <= this.width + 1 &&
2779
+ cluster.y + cluster.h <= this.height + 1;
2780
+ if (
2781
+ inside &&
2782
+ items.length === 1 &&
2783
+ pieces[items[0]].start === 0 &&
2784
+ pieces[items[0]].end === tris.length
2785
+ ) {
2786
+ return this._wireCut(tris);
2787
+ }
2788
+ const out = [];
2690
2789
  for (const i of items) {
2691
- const piece = pieces[i];
2692
- for (let k = piece.start; k < piece.end; ++k) out[w++] = tris[k];
2790
+ for (let k = pieces[i].start; k < pieces[i].end; ++k) out.push(tris[k]);
2693
2791
  }
2694
- return out;
2792
+ if (!inside) this._cullTris(out);
2793
+ return this._wireCut(out);
2794
+ }
2795
+
2796
+ /**
2797
+ * Leave out, in place, every triangle wholly off the surface: nothing of
2798
+ * it can land on it, and a zoomed graph has hundreds of edges past the
2799
+ * window every frame.
2800
+ */
2801
+ _cullTris(tris) {
2802
+ const right = this.width + 1;
2803
+ const bottom = this.height + 1;
2804
+ let w = 0;
2805
+ for (let i = 0; i < tris.length; i += 6) {
2806
+ const ax = tris[i];
2807
+ const ay = tris[i + 1];
2808
+ const bx = tris[i + 2];
2809
+ const by = tris[i + 3];
2810
+ const cx = tris[i + 4];
2811
+ const cy = tris[i + 5];
2812
+ if (
2813
+ (ax < -1 && bx < -1 && cx < -1) ||
2814
+ (ay < -1 && by < -1 && cy < -1) ||
2815
+ (ax > right && bx > right && cx > right) ||
2816
+ (ay > bottom && by > bottom && cy > bottom)
2817
+ ) {
2818
+ continue;
2819
+ }
2820
+ if (w < i) for (let k = 0; k < 6; k++) tris[w + k] = tris[i + k];
2821
+ w += 6;
2822
+ }
2823
+ if (w < tris.length) tris.length = w;
2824
+ return tris;
2825
+ }
2826
+
2827
+ /**
2828
+ * Triangles are 16.16 fixed point, and the source origin a direct stroke
2829
+ * hands the server is 16 bits: a stroke that reaches that far is cut to
2830
+ * the surface first (lib/cliprect.js), a pixel to spare on every side, so
2831
+ * what the surface shows is drawn exactly as before. One that does not
2832
+ * goes as it came, for the server to clip — the same array.
2833
+ */
2834
+ _wireCut(tris) {
2835
+ if (withinRect(tris, -WIRE_REACH, -WIRE_REACH, WIRE_REACH, WIRE_REACH)) {
2836
+ return tris;
2837
+ }
2838
+ return clipTrianglesToRect(tris, -1, -1, this.width + 1, this.height + 1);
2695
2839
  }
2696
2840
 
2697
2841
  /**
@@ -4129,11 +4273,10 @@ class RenderingContext2d {
4129
4273
  if (!rasterizer) return false;
4130
4274
  const { shapes, edges } = fillableShapes(entry.polys);
4131
4275
  if (!shapes.length) return false;
4132
- if (
4133
- routeRaster(bb.w, bb.h, edges, this.window.app.rasterPolicy) !== "local"
4134
- ) {
4276
+ // routed by the clip's own extent, as a fill is (`_rawBox`), and
4277
+ // rasterized over the part of it on the surface
4278
+ if (this._routeFor(this._polysExtent(entry.polys) ?? bb, edges) !== "local")
4135
4279
  return false;
4136
- }
4137
4280
 
4138
4281
  const coverage = rasterizer.rasterize({
4139
4282
  polys: shapes,
@@ -18,6 +18,17 @@ const TRAILING_WS = /[ \t]+$/;
18
18
  const UNSPACED =
19
19
  /[\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}\p{Script=Tai_Le}\p{Script=New_Tai_Lue}\p{Script=Tai_Tham}\p{Script=Tai_Viet}\p{Script=Ahom}\p{Script=Javanese}\p{Script=Balinese}\p{Script=Buginese}]/u;
20
20
 
21
+ /**
22
+ * How far a line's content may reach past its width and still fit it: a
23
+ * 64th of a pixel. The advances of a line are summed in floating point and
24
+ * come out a hair over a width they were set to fill — a line of Verdana at
25
+ * 12.8px measures 410.0125px in the 410px column it was written for — and
26
+ * a browser takes the line as fitting, comparing in 64ths of a pixel with
27
+ * that much to spare (Blink's line breaker adds a LayoutUnit's epsilon to
28
+ * the width it fits to).
29
+ */
30
+ const FIT_SLACK = 1 / 64;
31
+
21
32
  /** How wide a line's tokens are, the white space it ends on left out. */
22
33
  function tokensWidth(toks) {
23
34
  let width = 0;
@@ -46,7 +57,7 @@ function isWsGlyph(g) {
46
57
  *
47
58
  * Content is a plain string or an array of spans
48
59
  * `{ text, family?, size?, weight?, style?, features?, language?,
49
- * letterSpacing?, color?, nowrap?, shapeApart? }`;
60
+ * letterSpacing?, color?, nowrap?, shapeApart?, kernAcross? }`;
50
61
  * span fields override the base style. Spans that share a truthy `nowrap` —
51
62
  * `true`, or any value a caller tells its groups apart by — have no break
52
63
  * opportunity inside them or between them, as the text of an element with
@@ -55,7 +66,12 @@ function isWsGlyph(g) {
55
66
  * apart — are shaped as one text, so a word across them keeps its kerning
56
67
  * and its joining; a span with a truthy `shapeApart` is shaped on its own,
57
68
  * as CSS breaks the shaping at an inline box with a margin, border or
58
- * padding. Options:
69
+ * padding. A span of another letter spacing is shaped on its own too, as a
70
+ * browser shapes an element's `letter-spacing`, but one with a truthy
71
+ * `kernAcross` keeps the kerning its letters make with its neighbours':
72
+ * the spacing a justified line or CSS's `word-spacing` adds to a space is
73
+ * in addition to kerning, and no element's (CSS Text 3, 7.2, 7.3).
74
+ * Options:
59
75
  *
60
76
  * - `maxWidth` — target container width (default: unlimited)
61
77
  * - `align` — 'left' | 'right' | 'center' | 'start' | 'end'
@@ -127,7 +143,10 @@ export class TextLayout {
127
143
  curWidth = 0;
128
144
  };
129
145
  for (let token of tokens) {
130
- while (wraps && curWidth + (cur.length ? kernBefore(token) : 0) + token.width - token.wsWidth > maxWidth) {
146
+ while (
147
+ wraps &&
148
+ curWidth + (cur.length ? kernBefore(token) : 0) + token.width - token.wsWidth > maxWidth + FIT_SLACK
149
+ ) {
131
150
  if (cur.length > 0) {
132
151
  flush();
133
152
  } else if (whole && !UNSPACED.test(text.slice(token.start, token.end))) {
@@ -180,7 +199,7 @@ export class TextLayout {
180
199
  // Elision is the last kept line's business, or a line's that does not
181
200
  // wrap and runs past the width, and it happens before entries exist,
182
201
  // because dropping content means re-shaping the tail.
183
- const cut = (elide && li === lineTokens.length - 1) || (cutWide && tokensWidth(toks) > maxWidth);
202
+ const cut = (elide && li === lineTokens.length - 1) || (cutWide && tokensWidth(toks) > maxWidth + FIT_SLACK);
184
203
  const ellipsis = cut ? this._elide(toks, baseSpan) : null;
185
204
  if (ellipsis) {
186
205
  toks = this._fitBefore(toks, maxWidth - ellipsis.width);
@@ -195,7 +214,13 @@ export class TextLayout {
195
214
  const token = toks[t];
196
215
  // the kerning with the token before it goes before its first run
197
216
  let kern = t ? kernBefore(token) : 0;
198
- for (const frag of token.fragments) {
217
+ for (let f = 0; f < token.fragments.length; f++) {
218
+ const frag = token.fragments[f];
219
+ // and a fragment's with the one before it after that one's last
220
+ // run, where shaping puts a pair's kerning: on its first letter,
221
+ // which keeps it when the second is a space the line ends on
222
+ const before = entries[entries.length - 1];
223
+ if (f && frag.kern && before) before.kernAfter = (before.kernAfter ?? 0) + frag.kern;
199
224
  for (const run of frag.shaped.runs) {
200
225
  const entry = {
201
226
  run,
@@ -219,6 +244,9 @@ export class TextLayout {
219
244
  ? entries[entries.length - 1].end
220
245
  : lineStart;
221
246
  if (ellipsis) {
247
+ // the letter the ellipsis follows is kerned with nothing after it
248
+ const last = entries[entries.length - 1];
249
+ if (last?.kernAfter) entries[entries.length - 1] = { ...last, kernAfter: 0 };
222
250
  // The ellipsis takes the *paragraph* level, which is what a neutral
223
251
  // at the end of a paragraph resolves to under UAX#9 — so reorderRuns
224
252
  // puts it at the right edge of an LTR line and the left edge of an
@@ -253,6 +281,7 @@ export class TextLayout {
253
281
  if (e.ellipsis) positioned.ellipsis = true;
254
282
  runs.push(positioned);
255
283
  x += e.run.width;
284
+ if (e.kernAfter) x += e.kernAfter;
256
285
  const m = e.run.font.metrics(e.run.size);
257
286
  if (m.ascent > ascent) ascent = m.ascent;
258
287
  if (m.descent > descent) descent = m.descent;
@@ -425,15 +454,27 @@ export class TextLayout {
425
454
  * pair either side of a break opportunity was never kerned — Trebuchet MS
426
455
  * sets a space closer to an A, a T or a Y — and a line of it came out
427
456
  * wider than a browser's, which shapes a line whole: shaping breaks only
428
- * at an inline box's margin, border or padding (CSS Text 3, 7.3). Only
429
- * between letters shaped alike at one left-to-right level; a line that
430
- * breaks there never meets it.
457
+ * at an inline box's margin, border or padding (CSS Text 3, 7.3). A line
458
+ * that breaks there never meets it.
431
459
  */
432
460
  _kernBefore(text, prev, next, levels) {
433
461
  if (prev.required) return 0;
434
- const a = prev.fragments[prev.fragments.length - 1];
435
- const b = next.fragments[0];
436
- if (!a || !b || a.shaping !== b.shaping || a.span.shapeApart || b.span.shapeApart) return 0;
462
+ return this._kernBetween(text, prev.fragments[prev.fragments.length - 1], next.fragments[0], levels);
463
+ }
464
+
465
+ /**
466
+ * The kerning between the last letter of fragment `a` and the first of
467
+ * `b`, which follows it: at one left-to-right level, between letters
468
+ * shaped alike, or alike but for the spacing a `kernAcross` span adds
469
+ * (`pairShaping`). A justified line spaces its spaces and nothing else,
470
+ * so each is a span apart, and the pairs a space makes (Arial's space and
471
+ * T, its L and space) were dropped there, where the same line unjustified
472
+ * kept them: it came out wider and broke a word before a browser does.
473
+ */
474
+ _kernBetween(text, a, b, levels) {
475
+ if (!a || !b || a.span.shapeApart || b.span.shapeApart) return 0;
476
+ const shaping = pairShaping(a, b);
477
+ if (!shaping) return 0;
437
478
  const at = a.start + a.text.length;
438
479
  if (at !== b.start) return 0;
439
480
  // two glyphs the face's tables cannot move against each other are set
@@ -450,7 +491,7 @@ export class TextLayout {
450
491
  const after = text.codePointAt(at) > 0xffff ? 2 : 1;
451
492
  const key = normalizedLevels(levels, at - before, at + after);
452
493
  if (key.includes(',') || Number(key) & 1) return 0;
453
- return this.fonts._kernAcross(text.slice(at - before, at + after), before, a.shaping, key);
494
+ return this.fonts._kernAcross(text.slice(at - before, at + after), before, shaping, key);
454
495
  }
455
496
 
456
497
  /**
@@ -523,7 +564,17 @@ export class TextLayout {
523
564
  const shaped = pieces ? pieces[i] : this.fonts._shapeCached(fragText, shaping, fragLevels);
524
565
  // the levels ride along so a later split can re-shape a piece at the
525
566
  // level it actually has, rather than assuming ltr
526
- fragments.push({ text: fragText, span, shaping, shaped, start: fragStart, levels: fragLevels });
567
+ const frag = { text: fragText, span, shaping, shaped, start: fragStart, levels: fragLevels };
568
+ // a group shaped apart from the one before it for the spacing a
569
+ // `kernAcross` span adds still kerns against it (`_kernBetween`)
570
+ if (i === 0 && fragments.length) {
571
+ const kern = this._kernBetween(text, fragments[fragments.length - 1], frag, levels);
572
+ if (kern) {
573
+ frag.kern = kern;
574
+ width += kern;
575
+ }
576
+ }
577
+ fragments.push(frag);
527
578
  width += shaped.width;
528
579
  }
529
580
  pos = groupEnd;
@@ -591,7 +642,7 @@ export class TextLayout {
591
642
  // trailing whitespace does not count against the budget, exactly as it
592
643
  // does not count during the greedy fill
593
644
  const kern = kept.length ? kernBefore(token) : 0;
594
- if (used + kern + token.width - token.wsWidth <= budget) {
645
+ if (used + kern + token.width - token.wsWidth <= budget + FIT_SLACK) {
595
646
  kept.push(token);
596
647
  used += kern + token.width;
597
648
  continue;
@@ -609,9 +660,12 @@ export class TextLayout {
609
660
  let used = 0;
610
661
  for (let i = 0; i < token.fragments.length; i++) {
611
662
  const frag = token.fragments[i];
612
- if (used + frag.shaped.width <= maxWidth) {
663
+ // the kerning with the fragment before it, which a token's first has
664
+ // none of: it starts a line
665
+ const kern = i && frag.kern ? frag.kern : 0;
666
+ if (used + kern + frag.shaped.width <= maxWidth + FIT_SLACK) {
613
667
  headFrags.push(frag);
614
- used += frag.shaped.width;
668
+ used += kern + frag.shaped.width;
615
669
  continue;
616
670
  }
617
671
  // shaped at the bidi level this text actually has: assuming level 0
@@ -626,7 +680,7 @@ export class TextLayout {
626
680
  // and its combining mark, or inside an emoji ZWJ sequence, leaves a
627
681
  // dotted circle or a pair of half-emoji on the two sides of the break.
628
682
  const lead = firstGrapheme(frag.text);
629
- if (used + shape(lead, 0, lead.length).width <= maxWidth) {
683
+ if (used + kern + shape(lead, 0, lead.length).width <= maxWidth + FIT_SLACK) {
630
684
  // binary search the longest grapheme prefix of this fragment that fits
631
685
  const cps = graphemes(frag.text);
632
686
  let lo = 0;
@@ -635,7 +689,7 @@ export class TextLayout {
635
689
  const mid = (lo + hi) >> 1;
636
690
  const prefix = cps.slice(0, mid + 1).join('');
637
691
  const shaped = shape(prefix, 0, prefix.length);
638
- if (used + shaped.width <= maxWidth) {
692
+ if (used + kern + shaped.width <= maxWidth + FIT_SLACK) {
639
693
  best = { len: prefix.length, shaped, text: prefix };
640
694
  lo = mid + 1;
641
695
  } else {
@@ -657,7 +711,8 @@ export class TextLayout {
657
711
  shaping,
658
712
  shaped: best.shaped,
659
713
  start: frag.start,
660
- levels: sliceLevels(frag.levels, 0, best.len)
714
+ levels: sliceLevels(frag.levels, 0, best.len),
715
+ ...(kern ? { kern } : null)
661
716
  });
662
717
  }
663
718
 
@@ -676,7 +731,7 @@ export class TextLayout {
676
731
  });
677
732
  }
678
733
  restFrags.push(...token.fragments.slice(i + 1));
679
- const sum = (frags) => frags.reduce((w, f) => w + f.shaped.width, 0);
734
+ const sum = (frags) => frags.reduce((w, f, j) => w + f.shaped.width + (j && f.kern ? f.kern : 0), 0);
680
735
  const splitAt = restFrags.length ? restFrags[0].start : token.end;
681
736
  const head = headFrags.length
682
737
  ? { fragments: headFrags, width: sum(headFrags), wsWidth: 0, required: false, start: token.start, end: splitAt }
@@ -1192,6 +1247,8 @@ function stripTrailingWhitespace(entries) {
1192
1247
  }
1193
1248
  entries[i] = {
1194
1249
  ...entries[i],
1250
+ // its kerning with what came after it went with its last letter
1251
+ kernAfter: 0,
1195
1252
  end: entries[i].end - cuStripped,
1196
1253
  run: {
1197
1254
  ...run,
@@ -1318,6 +1375,38 @@ function visibleRows(ctx, y) {
1318
1375
  */
1319
1376
  const MAX_SHAPING_STYLES = 16;
1320
1377
 
1378
+ /**
1379
+ * What the letters either side of fragments `a` and `b` are shaped with as
1380
+ * a pair: the style both were shaped with, or where the two differ in
1381
+ * their letter spacing alone and one is a `kernAcross` span's, the spaced
1382
+ * one — spacing is in addition to kerning, and a pair with spacing between
1383
+ * it takes no optional ligature (CSS Text 3, 7.2). None otherwise: an
1384
+ * element's own letter spacing is a change of formatting a browser breaks
1385
+ * the shaping at.
1386
+ */
1387
+ function pairShaping(fa, fb) {
1388
+ const a = fa.shaping;
1389
+ const b = fb.shaping;
1390
+ if (a === b) return a;
1391
+ if (!fa.span.kernAcross && !fb.span.kernAcross) return null;
1392
+ if (
1393
+ a.given !== b.given ||
1394
+ a.font !== b.font ||
1395
+ a.family !== b.family ||
1396
+ a.size !== b.size ||
1397
+ a.weight !== b.weight ||
1398
+ a.style !== b.style ||
1399
+ a.variations !== b.variations ||
1400
+ a.opticalSize !== b.opticalSize ||
1401
+ a.opticalSizing !== b.opticalSizing ||
1402
+ a.features !== b.features ||
1403
+ a.language !== b.language
1404
+ ) {
1405
+ return null;
1406
+ }
1407
+ return a.letterSpacing ? a : b;
1408
+ }
1409
+
1321
1410
  /**
1322
1411
  * The style `span` is shaped with: one already made for a span whose every
1323
1412
  * property shaping reads is the same (`===`), or a new one. Its font is the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.14.6",
3
+ "version": "8.15.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",