ntk 8.15.0 → 8.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -135,6 +135,7 @@ parser, say — can import just that piece, and loads only what it needs:
135
135
  | `ntk/path` | `Path2D`, `parseSvgPath` | — |
136
136
  | `ntk/gl` | `GLError`, `GL_MODES`, `DEFAULT_GL_POLICY`, … | — |
137
137
  | `ntk/shadow-math` | `shadowSigma`, `shadowReach`, `blurScale`, … | — |
138
+ | `ntk/shadow-tiles` | `planShadowTiles`, `shadowTileAlpha`, … | — |
138
139
  | `ntk/image` | `Image`, `decodeImage`, `loadImage` | `jpeg-js`, `pngjs` |
139
140
  | `ntk/svg` | `SvgView` (default export) | `htmlparser2`, `domutils` |
140
141
  | `ntk/font` | `Font` (default export) | `fontkit` |
package/lib/index.js CHANGED
@@ -56,6 +56,12 @@ import {
56
56
  shadowReach,
57
57
  shadowSigma
58
58
  } from './shadow.js';
59
+ import {
60
+ planShadowTiles,
61
+ rasterShadowTile,
62
+ shadowTileAlpha,
63
+ tileScale
64
+ } from './shadowtiles.js';
59
65
  import { DEFAULT_SHAPE_POLICY } from './shapeglyphs.js';
60
66
  import { DEFAULT_SHARED_GLYPHS_POLICY } from './glyphdirectory.js';
61
67
  import { warmSharedGlyphs } from './text/glyphs.js';
@@ -292,6 +298,10 @@ export {
292
298
  // would rather draw its shape small in the first place (issue #338)
293
299
  DEFAULT_SHADOW_POLICY,
294
300
  blurCoverage,
301
+ planShadowTiles,
302
+ rasterShadowTile,
303
+ shadowTileAlpha,
304
+ tileScale,
295
305
  blurScale,
296
306
  gaussianKernel1d,
297
307
  shadowReach,
package/lib/path.js CHANGED
@@ -469,7 +469,7 @@ export function parseSvgPath(d) {
469
469
  // --------------------------------------------------------------------------
470
470
  // Path2D
471
471
 
472
- function normalizeRadii(w, h, radii) {
472
+ export function normalizeRadii(w, h, radii) {
473
473
  if (radii === undefined) radii = 0;
474
474
  if (typeof radii === 'number') radii = [radii];
475
475
  const take = (v) => (typeof v === 'number' ? { x: v, y: v } : { x: v.x ?? 0, y: v.y ?? 0 });
@@ -535,6 +535,12 @@ export class Path2D {
535
535
  // box the fast path can never take (e.g. under rotation).
536
536
  this._roundRect = null;
537
537
  this._roundRectMiss = null;
538
+ // While the path is nothing but rect()s and roundRect()s, the 2d context
539
+ // keeps them here in device space — `{ x0, y0, x1, y1, corners }` each —
540
+ // so that a shadowed fill of one, or of one inside another filled
541
+ // evenodd, can be drawn from a shadow tile (lib/shadowtiles.js). Null
542
+ // once the path is anything else; any other path verb clears it.
543
+ this._shapes = null;
538
544
  if (init instanceof Path2D) {
539
545
  this._cmds = init._cmds.map((c) => ({ ...c }));
540
546
  this._x = init._x;
@@ -542,6 +548,7 @@ export class Path2D {
542
548
  this._sx = init._sx;
543
549
  this._sy = init._sy;
544
550
  this._roundRect = init._roundRect;
551
+ this._shapes = init._shapes && [...init._shapes];
545
552
  } else if (typeof init === 'string') {
546
553
  this._append(parseSvgPath(init));
547
554
  }
@@ -567,6 +574,7 @@ export class Path2D {
567
574
  // one roundRect() loses the tag (roundRect itself re-sets it at the end)
568
575
  this._roundRect = null;
569
576
  this._roundRectMiss = null;
577
+ this._shapes = null;
570
578
  for (const c of cmds) {
571
579
  this._cmds.push(c);
572
580
  this._track(c);
@@ -46,6 +46,11 @@ import {
46
46
  shadowSigma,
47
47
  } from "./shadow.js";
48
48
  import { Surface } from "./surface.js";
49
+ import {
50
+ normalizeRadii,
51
+ planShadowTiles,
52
+ shadowTileAlpha,
53
+ } from "./shadowtiles.js";
49
54
  import {
50
55
  BL,
51
56
  BR,
@@ -466,6 +471,23 @@ function parseColor(value) {
466
471
  * rectangle's size unsigned and its corner in 16 bits, so a negative size
467
472
  * that reached the wire threw from inside the paint.
468
473
  */
474
+ const IDENTITY_TRANSFORM = [1, 0, 0, 0, 1, 0, 0, 0, 1];
475
+
476
+ /** whether device box `b` (`{ x0, y0, x1, y1 }`) lies inside box `a` */
477
+ function boxInside(b, a) {
478
+ return b.x0 >= a.x0 && b.y0 >= a.y0 && b.x1 <= a.x1 && b.y1 <= a.y1;
479
+ }
480
+
481
+ /** whether two `{ x, y, width, height }` boxes share any area — either
482
+ * may say `w`/`h` for its size, as a clip rect does */
483
+ function boxesMeet(a, b) {
484
+ const aw = a.width ?? a.w;
485
+ const ah = a.height ?? a.h;
486
+ const bw = b.width ?? b.w;
487
+ const bh = b.height ?? b.h;
488
+ return a.x < b.x + bw && b.x < a.x + aw && a.y < b.y + bh && b.y < a.y + ah;
489
+ }
490
+
469
491
  function finiteRect(x, y, w, h) {
470
492
  return Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(w) && Number.isFinite(h);
471
493
  }
@@ -1586,6 +1608,12 @@ class RenderingContext2d {
1586
1608
  this._shadowOfPolys(polys, { stroke: true });
1587
1609
  return;
1588
1610
  }
1611
+ if (
1612
+ !(args[0] instanceof Path2D) &&
1613
+ this._tiledShadow(this._path._shapes, args[0] === "evenodd")
1614
+ ) {
1615
+ return;
1616
+ }
1589
1617
  const { polys, rule } = this._polysFor(args);
1590
1618
  this._shadowOfPolys(polys, { rule });
1591
1619
  }
@@ -1631,11 +1659,141 @@ class RenderingContext2d {
1631
1659
 
1632
1660
  /** the shadow of an axis-aligned rectangle in user space */
1633
1661
  _shadowOfRect(x, y, w, h, stroke) {
1662
+ if (!stroke && this._tiledShadow(this._withShape([], x, y, w, h, 0), false)) {
1663
+ return;
1664
+ }
1634
1665
  const tmp = new Path2D();
1635
1666
  tmp.rect(x, y, w, h);
1636
1667
  this._shadowOfPolys(flattenPath(tmp._cmds, this._m), { stroke });
1637
1668
  }
1638
1669
 
1670
+ /**
1671
+ * The shadow of a fill whose path is `shapes` — one rect or rounded rect,
1672
+ * or, filled evenodd, one inside another: the frame an inset box shadow
1673
+ * is cast around — drawn from a tile (lib/shadowtiles.js). A tile has a
1674
+ * short name where a path has none, so it is made once and kept with the
1675
+ * other shadow coverage (`cachedShadow`), and a repaint of any part of
1676
+ * the shadow composites that part of it. False for anything else, and
1677
+ * for a shadow a tile cannot draw; the caller then blurs it as before.
1678
+ */
1679
+ _tiledShadow(shapes, evenodd) {
1680
+ if (!shapes?.length || !(this._shadowBlur > 0)) return false;
1681
+ if (!shadowPolicyOf(this.window.app).tiles) return false;
1682
+ if (shapes.length === 1) return this._shadowFromTile(shapes[0], null);
1683
+ if (shapes.length !== 2 || !evenodd) return false;
1684
+ const [p, q] = shapes;
1685
+ if (boxInside(q, p)) return this._shadowFromTile(p, q);
1686
+ if (boxInside(p, q)) return this._shadowFromTile(q, p);
1687
+ return false;
1688
+ }
1689
+
1690
+ _shadowFromTile(outer, inner) {
1691
+ const ox = Math.round(this._shadowOffsetX);
1692
+ const oy = Math.round(this._shadowOffsetY);
1693
+ const snap = (r) => ({
1694
+ x0: Math.round(r.x0) + ox,
1695
+ y0: Math.round(r.y0) + oy,
1696
+ x1: Math.round(r.x1) + ox,
1697
+ y1: Math.round(r.y1) + oy,
1698
+ corners: r.corners,
1699
+ });
1700
+ const o = snap(outer);
1701
+ // a shape with nothing in it casts nothing
1702
+ if (!(o.x1 > o.x0 && o.y1 > o.y0)) return true;
1703
+ let hole = inner && snap(inner);
1704
+ if (hole && !(hole.x1 > hole.x0 && hole.y1 > hole.y0)) hole = null;
1705
+ const plan = planShadowTiles(o, hole, this._shadowBlur);
1706
+ if (!plan) return false;
1707
+ const { x, y, width, height } = plan.bounds;
1708
+ // a composite's corner goes over in 16 bits: a shadow that reaches past
1709
+ // them is drawn the old way, whose box is clamped to what can show
1710
+ if (x < -WIRE_REACH || y < -WIRE_REACH) return false;
1711
+ if (x + width > WIRE_REACH || y + height > WIRE_REACH) return false;
1712
+ if (!(x < this.width && y < this.height && x + width > 0 && y + height > 0)) {
1713
+ return true;
1714
+ }
1715
+ const clip = this._clipRect();
1716
+ if (clip && !boxesMeet(plan.bounds, clip)) return true;
1717
+ const app = this.window.app;
1718
+ const tile = cachedShadow(app, `tile|${plan.key}`, () =>
1719
+ this._shadowTile(plan),
1720
+ );
1721
+ if (!tile) return false;
1722
+ const picture = tile.picture(app);
1723
+ const style = this._fillStyle;
1724
+ const background = this._backgroundPicture;
1725
+ this.fillStyle = this._shadowColor;
1726
+ try {
1727
+ const op = this._op();
1728
+ for (const [sx, sy, sw, sh, dx, dy, dw, dh] of plan.pieces) {
1729
+ if (clip && !boxesMeet({ x: dx, y: dy, width: dw, height: dh }, clip)) {
1730
+ continue;
1731
+ }
1732
+ // a piece drawn at its own size reads the tile as it is: undo the
1733
+ // transform the last stretched one left on the picture
1734
+ if (sw === dw && sh === dh && picture._tileScaled) {
1735
+ this.Render.SetPictureTransform(picture.id, IDENTITY_TRANSFORM);
1736
+ picture.setFilter("nearest");
1737
+ picture._tileScaled = false;
1738
+ } else if (sw !== dw || sh !== dh) {
1739
+ picture._tileScaled = true;
1740
+ }
1741
+ this._drawCoverage(picture, sx, sy, sw, sh, dx, dy, dw, dh, op);
1742
+ }
1743
+ } finally {
1744
+ this._fillStyle = style;
1745
+ this._backgroundPicture = background;
1746
+ }
1747
+ return true;
1748
+ }
1749
+
1750
+ /** A tile's coverage on an `a8` surface: made here, uploaded once. */
1751
+ _shadowTile(plan) {
1752
+ const app = this.window.app;
1753
+ const { width, height } = plan;
1754
+ const surface = new Surface(app, { width, height, format: "a8" });
1755
+ const alpha = shadowTileAlpha(plan);
1756
+ // PutImage wants each row padded to 4 bytes
1757
+ const stride = (width + 3) & ~3;
1758
+ let data;
1759
+ if (stride === width) {
1760
+ data = Buffer.from(alpha.buffer, alpha.byteOffset, alpha.length);
1761
+ } else {
1762
+ data = Buffer.alloc(stride * height);
1763
+ for (let y = 0; y < height; y++) {
1764
+ Buffer.from(alpha.buffer, alpha.byteOffset + y * width, width).copy(
1765
+ data,
1766
+ y * stride,
1767
+ );
1768
+ }
1769
+ }
1770
+ const X = this.X;
1771
+ // one upload GC per app for depth 8, as image.js keeps one for 32
1772
+ let gc = app._coverageUploadGC;
1773
+ if (!gc) {
1774
+ gc = app._coverageUploadGC = X.AllocID();
1775
+ X.CreateGC(gc, surface.pixmap.id);
1776
+ }
1777
+ const maxBytes = ((app.display.max_request_length ?? 65535) - 8) * 4;
1778
+ const rowsPerBand = Math.max(1, Math.floor(maxBytes / stride));
1779
+ for (let y = 0; y < height; y += rowsPerBand) {
1780
+ const rows = Math.min(rowsPerBand, height - y);
1781
+ X.PutImage(
1782
+ 2,
1783
+ surface.pixmap.id,
1784
+ gc,
1785
+ width,
1786
+ rows,
1787
+ 0,
1788
+ y,
1789
+ 0,
1790
+ 8,
1791
+ data.subarray(y * stride, (y + rows) * stride),
1792
+ );
1793
+ }
1794
+ return surface;
1795
+ }
1796
+
1639
1797
  // ------------------------------------------------------------------
1640
1798
  // transform
1641
1799
 
@@ -1764,17 +1922,69 @@ class RenderingContext2d {
1764
1922
  this._path._append(transformCommands(tmp._cmds.slice(1), this._m));
1765
1923
  }
1766
1924
 
1925
+ /** The shapes the default path is so far — none for an empty one, null
1926
+ * once it is anything but rects and rounded rects (Path2D `_shapes`). */
1927
+ _shapesSoFar() {
1928
+ return this._path._cmds.length === 0 ? [] : this._path._shapes;
1929
+ }
1930
+
1931
+ /**
1932
+ * `shapes` and one more, in device space: a rect, or a rounded rect with
1933
+ * `radii` as `roundRect` takes them. Null where the list already was, or
1934
+ * where the transform rotates, skews or mirrors — a tile is drawn upright
1935
+ * — or the box is not a finite one.
1936
+ */
1937
+ _withShape(shapes, x, y, w, h, radii) {
1938
+ if (!shapes || !finiteRect(x, y, w, h)) return null;
1939
+ const [a, b, c, d, e, f] = this._m;
1940
+ if (b !== 0 || c !== 0 || !(a > 0) || !(d > 0)) return null;
1941
+ if (w < 0) {
1942
+ x += w;
1943
+ w = -w;
1944
+ }
1945
+ if (h < 0) {
1946
+ y += h;
1947
+ h = -h;
1948
+ }
1949
+ const corners = normalizeRadii(w, h, radii).map((r) => ({
1950
+ x: r.x * a,
1951
+ y: r.y * d,
1952
+ }));
1953
+ return [
1954
+ ...shapes,
1955
+ {
1956
+ x0: a * x + e,
1957
+ y0: d * y + f,
1958
+ x1: a * (x + w) + e,
1959
+ y1: d * (y + h) + f,
1960
+ corners,
1961
+ },
1962
+ ];
1963
+ }
1964
+
1767
1965
  rect(x, y, w, h) {
1966
+ const shapes = this._shapesSoFar();
1768
1967
  const tmp = new Path2D();
1769
1968
  tmp.rect(x, y, w, h);
1770
1969
  this._path.addPath(tmp, this._m);
1970
+ this._path._shapes = this._withShape(shapes, x, y, w, h, 0);
1771
1971
  }
1772
1972
 
1773
1973
  roundRect(x, y, w, h, radii) {
1974
+ const shapes = this._shapesSoFar();
1774
1975
  const tmp = new Path2D();
1775
1976
  tmp.roundRect(x, y, w, h, radii);
1776
1977
  const wasEmpty = this._path._cmds.length === 0;
1777
1978
  this._path.addPath(tmp, this._m);
1979
+ // Path2D draws a box of negative extent as a plain rect
1980
+ this._path._shapes = this._withShape(
1981
+ shapes,
1982
+ x,
1983
+ y,
1984
+ w,
1985
+ h,
1986
+ w < 0 || h < 0 ? 0 : radii,
1987
+ );
1778
1988
  // Re-derive the recognition tag in device space (addPath cleared it): it
1779
1989
  // survives only when this roundRect is the whole path and the CTM is a
1780
1990
  // pure translation at record time — the default path bakes the transform
package/lib/shadowmath.js CHANGED
@@ -36,13 +36,19 @@
36
36
  * `maxSigma`-wide blur cannot resample its way down to a thumbnail.
37
37
  * `maxScale: 1` blurs everything at full resolution, which is what 8.6
38
38
  * did, and is the setting for a caller that needs the exact kernel.
39
+ * - `tiles` — whether a shadowed fill of a rect, a rounded rect, or a rect
40
+ * less a rounded rect filled evenodd is drawn from a shadow tile made once
41
+ * (lib/shadowtiles.js) rather than blurred on every fill. Tiles are not
42
+ * capped at `maxSigma`: what one costs is bounded by the scale it is made
43
+ * at. `false` puts every shadow back on the path above.
39
44
  */
40
45
  export const DEFAULT_SHADOW_POLICY = {
41
46
  cacheBytes: 4 << 20,
42
47
  maxSigma: 32,
43
48
  maxPixels: 8 << 20,
44
49
  scaleSigma: 4,
45
- maxScale: 4
50
+ maxScale: 4,
51
+ tiles: true
46
52
  };
47
53
 
48
54
  /** the policy for one app, merged over the defaults */
@@ -0,0 +1,326 @@
1
+ // The blurred shadow of a rectangle, a rounded rectangle, or a rectangle
2
+ // with a rounded hole in it, drawn from a tile made once instead of blurred
3
+ // on every fill. No X connection in here: the plan and the pixels, which
4
+ // the 2d context composites (renderingcontext_2d.js) and react-x11's
5
+ // CoreGraphics and Direct2D contexts composite the same way. Exported as
6
+ // `ntk/shadow-tiles`.
7
+ //
8
+ // A gaussian over a shape costs its area times its width, and a shadowed
9
+ // fill paid it on every fill — on XQuartz's software RENDER, a header's
10
+ // `box-shadow: inset 0 0 100px` repainted by a scroll's exposed strip was
11
+ // the difference between 51 and 31 frames a second, and CoreGraphics spent
12
+ // 130 to 380 ms on one at 2x. None of it is a fact about where the shape
13
+ // is or how long its sides are: a rounded rectangle's shadow is the same
14
+ // all along its straight edges, so it is enough to make it once for a
15
+ // rectangle only just long enough to have a straight part, and to stretch
16
+ // that part to the length asked for.
17
+ //
18
+ // So a tile holds the shadow of a shape's *corners* — the shape shortened,
19
+ // on each axis that has room, to three pixels of straight edge — and is
20
+ // drawn as up to nine pieces: the corners as they are, the straight pixel
21
+ // between them stretched. Three rather than one because a filtered stretch
22
+ // reads a pixel either side of the one it stretches, and a piece's
23
+ // neighbours in the tile are straight edge too. The tile is named by the
24
+ // corners, the blur and whether there is a hole — never by where the shape
25
+ // is, how big it is past its corners, or what part of it a paint reaches —
26
+ // so every box in a document with the same shadow shares one, and a scroll
27
+ // that exposes a strip of one draws a strip of it.
28
+ //
29
+ // The pixels are made here, not by blurring on a server or a GPU: the
30
+ // shape's coverage, row by row, then a gaussian in two passes, at the
31
+ // canvas σ — half the blur (HTML, "shadows"; CSS Backgrounds 3, 7.1.1) —
32
+ // with no `maxSigma` cap, since a wide blur's cost here is bounded by the
33
+ // scale it is made at. A gaussian carries nothing finer than about σ/2, so
34
+ // past σ 8 the tile is made at a half, a quarter, … of the size and scaled
35
+ // up as it is drawn, as `blurCoverage` does: a 400-pixel blur's tile is a
36
+ // hundred kilobytes rather than twenty megabytes.
37
+
38
+ import { normalizeRadii } from './path.js';
39
+ import { gaussianKernel1d } from './shadowmath.js';
40
+
41
+ export { normalizeRadii };
42
+
43
+ /** The σ, in tile pixels, below which a tile is not made any smaller —
44
+ * the policy's `scaleSigma`: at 4 the scaled blur stays within a few
45
+ * levels of 8-bit alpha of the exact one. */
46
+ const MIN_TILE_SIGMA = 4;
47
+
48
+ /** How far a tile may be scaled down, whatever the blur. */
49
+ const MAX_TILE_SCALE = 16;
50
+
51
+ /**
52
+ * The most pixels one tile may have. A shape that is all corner on both
53
+ * axes — an ellipse spelled as a `roundRect` — has nothing to stretch, and
54
+ * its tile is the size of the shape; past this it is not made, and the
55
+ * shadow is drawn as it was before tiles.
56
+ */
57
+ export const MAX_TILE_PIXELS = 1 << 20;
58
+
59
+ /** Sub-rows each tile row's coverage is sampled on; the blur smooths the
60
+ * rest. Along a row coverage is exact. */
61
+ const SUBROWS = 8;
62
+
63
+ /** How much smaller than the shape its tile is made, for a blur. */
64
+ export function tileScale(blur) {
65
+ const sigma = blur / 2;
66
+ let k = 1;
67
+ while (k * 2 <= MAX_TILE_SCALE && sigma / (k * 2) >= MIN_TILE_SIGMA) k *= 2;
68
+ return k;
69
+ }
70
+
71
+ /**
72
+ * One axis of a tile: which of its pixels are drawn where. With room for a
73
+ * straight part, three segments — the features before it, at scale; one
74
+ * tile pixel of it, stretched; the features after it, at scale — and the
75
+ * amount the tile's shape is shorter than the real one. Without, the whole
76
+ * axis at scale.
77
+ *
78
+ * `lo`/`hi` are the outer shape's edges and `nearLo`/`nearHi` how far in
79
+ * from each it stops being straight — the outer corners, and where there is
80
+ * a hole its edge and corners — in whole device pixels; `reach` is how far
81
+ * the blur spreads, a whole number of tile pixels of `k` device pixels.
82
+ */
83
+ function planAxis(lo, hi, nearLo, nearHi, reach, k) {
84
+ const origin = lo - reach;
85
+ const flatFrom = lo + nearLo + reach;
86
+ const flatTo = hi - nearHi - reach;
87
+ if (flatTo - flatFrom >= 4 * k) {
88
+ // the stretched pixel `at`, with a straight pixel either side of it
89
+ const at = Math.ceil((flatFrom - origin) / k) + 1;
90
+ const cut = flatTo - origin - (at + 2) * k;
91
+ const size = Math.ceil((hi + reach - cut - origin) / k);
92
+ return {
93
+ origin,
94
+ size,
95
+ cut,
96
+ segments: [
97
+ [0, at, origin, origin + at * k],
98
+ [at, at + 1, origin + at * k, origin + (at + 1) * k + cut],
99
+ [at + 1, size, origin + (at + 1) * k + cut, origin + size * k + cut],
100
+ ],
101
+ };
102
+ }
103
+ const size = Math.ceil((hi - lo + 2 * reach) / k);
104
+ return {
105
+ origin,
106
+ size,
107
+ cut: 0,
108
+ segments: [[0, size, origin, origin + size * k]],
109
+ };
110
+ }
111
+
112
+ /** A number as part of a key, to the 256th of a pixel. */
113
+ const q = (v) => Math.round(v * 256) / 256;
114
+
115
+ /**
116
+ * Where a shadow's tile comes from and how it is drawn, or null where it
117
+ * would be too big to keep. `outer` and `inner` are
118
+ * `{ x0, y0, x1, y1, corners }` in whole device pixels, `corners` the four
119
+ * `{ x, y }` radii from top left, clockwise (`normalizeRadii`); `inner`,
120
+ * when there is one, lies inside `outer`, and the shadow is the one the
121
+ * frame between them casts — an inset box shadow's. The shadow's offset is
122
+ * already in both, and `blur` is the canvas `shadowBlur`, in device pixels.
123
+ *
124
+ * `pieces` are `[sx, sy, sw, sh, dx, dy, dw, dh]` — tile pixels in, device
125
+ * pixels out — and cover the shadow's whole reach once, except a frame's
126
+ * middle, which lies inside the hole by more than the blur reaches and has
127
+ * nothing in it. `key` names the tile's pixels: two plans with one key
128
+ * draw from one tile.
129
+ */
130
+ export function planShadowTiles(outer, inner, blur) {
131
+ const k = tileScale(blur);
132
+ const sigma = blur / 2 / k;
133
+ const reachK = Math.ceil(3 * sigma);
134
+ const reach = reachK * k;
135
+ const oc = outer.corners;
136
+ let nearL = Math.max(oc[0].x, oc[3].x);
137
+ let nearR = Math.max(oc[1].x, oc[2].x);
138
+ let nearT = Math.max(oc[0].y, oc[1].y);
139
+ let nearB = Math.max(oc[3].y, oc[2].y);
140
+ if (inner) {
141
+ const ic = inner.corners;
142
+ nearL = Math.max(nearL, inner.x0 - outer.x0 + Math.max(ic[0].x, ic[3].x));
143
+ nearR = Math.max(nearR, outer.x1 - inner.x1 + Math.max(ic[1].x, ic[2].x));
144
+ nearT = Math.max(nearT, inner.y0 - outer.y0 + Math.max(ic[0].y, ic[1].y));
145
+ nearB = Math.max(nearB, outer.y1 - inner.y1 + Math.max(ic[3].y, ic[2].y));
146
+ }
147
+ const ax = planAxis(outer.x0, outer.x1, nearL, nearR, reach, k);
148
+ const ay = planAxis(outer.y0, outer.y1, nearT, nearB, reach, k);
149
+ if (ax.size * ay.size > MAX_TILE_PIXELS) return null;
150
+
151
+ // the tile's own shape, in tile pixels: the real one shortened by the
152
+ // cut on each stretched axis — every edge past the cut moves back by it
153
+ const toTile = (r) => ({
154
+ x0: (r.x0 - ax.origin) / k,
155
+ y0: (r.y0 - ay.origin) / k,
156
+ x1: (r.x1 - ax.cut - ax.origin) / k,
157
+ y1: (r.y1 - ay.cut - ay.origin) / k,
158
+ corners: r.corners.map((c) => ({ x: c.x / k, y: c.y / k })),
159
+ });
160
+ const shape = { outer: toTile(outer), inner: inner ? toTile(inner) : null };
161
+
162
+ const middle = (axis, i) => axis.segments.length === 3 && i === 1;
163
+ const pieces = [];
164
+ ay.segments.forEach(([ty0, ty1, dy0, dy1], j) => {
165
+ ax.segments.forEach(([tx0, tx1, dx0, dx1], i) => {
166
+ if (inner && middle(ax, i) && middle(ay, j)) return;
167
+ if (!(dx1 > dx0 && dy1 > dy0)) return;
168
+ pieces.push([tx0, ty0, tx1 - tx0, ty1 - ty0, dx0, dy0, dx1 - dx0, dy1 - dy0]);
169
+ });
170
+ });
171
+
172
+ const geometry = (r) =>
173
+ r
174
+ ? [r.x0, r.y0, r.x1, r.y1, ...r.corners.flatMap((c) => [c.x, c.y])].map(q).join(',')
175
+ : '';
176
+ return {
177
+ key: `${blur}|${ax.size}x${ay.size}|${geometry(shape.outer)}|${geometry(shape.inner)}`,
178
+ k,
179
+ sigma,
180
+ reach: reachK,
181
+ width: ax.size,
182
+ height: ay.size,
183
+ shape,
184
+ pieces,
185
+ bounds: {
186
+ x: ax.origin,
187
+ y: ay.origin,
188
+ width: ax.segments.at(-1)[3] - ax.origin,
189
+ height: ay.segments.at(-1)[3] - ay.origin,
190
+ },
191
+ };
192
+ }
193
+
194
+ /** The x extent of a rounded rectangle along the line `y`, or null where
195
+ * the line misses it. A corner is rounded only with both radii. */
196
+ function spanAt(r, y) {
197
+ if (!(y >= r.y0 && y < r.y1)) return null;
198
+ const [tl, tr, br, bl] = r.corners;
199
+ // how far a corner's ellipse reaches across, `d` above or below its
200
+ // centre, as a fraction of its horizontal radius
201
+ const across = (d, ry) => {
202
+ const t = d / ry;
203
+ return Math.sqrt(Math.max(0, 1 - t * t));
204
+ };
205
+ let left = r.x0;
206
+ let right = r.x1;
207
+ if (tl.x > 0 && tl.y > 0 && y < r.y0 + tl.y) {
208
+ left = r.x0 + tl.x * (1 - across(r.y0 + tl.y - y, tl.y));
209
+ } else if (bl.x > 0 && bl.y > 0 && y > r.y1 - bl.y) {
210
+ left = r.x0 + bl.x * (1 - across(y - (r.y1 - bl.y), bl.y));
211
+ }
212
+ if (tr.x > 0 && tr.y > 0 && y < r.y0 + tr.y) {
213
+ right = r.x1 - tr.x * (1 - across(r.y0 + tr.y - y, tr.y));
214
+ } else if (br.x > 0 && br.y > 0 && y > r.y1 - br.y) {
215
+ right = r.x1 - br.x * (1 - across(y - (r.y1 - br.y), br.y));
216
+ }
217
+ return right > left ? [left, right] : null;
218
+ }
219
+
220
+ /** Add `weight` times the coverage of the span `[a, b)` to a row. */
221
+ function addSpan(row, width, a, b, weight) {
222
+ const from = Math.max(0, a);
223
+ const to = Math.min(width, b);
224
+ if (!(to > from)) return;
225
+ const i0 = Math.floor(from);
226
+ const i1 = Math.ceil(to) - 1;
227
+ if (i0 === i1) {
228
+ row[i0] += (to - from) * weight;
229
+ return;
230
+ }
231
+ row[i0] += (i0 + 1 - from) * weight;
232
+ for (let i = i0 + 1; i < i1; i++) row[i] += weight;
233
+ row[i1] += (to - i1) * weight;
234
+ }
235
+
236
+ /**
237
+ * The tile's coverage before the blur: how much of each tile pixel the
238
+ * shape covers — the outer shape less the hole — 0..1, row-major.
239
+ */
240
+ export function shadowCoverage(plan) {
241
+ const { width, height, shape } = plan;
242
+ const out = new Float32Array(width * height);
243
+ const weight = 1 / SUBROWS;
244
+ for (let y = 0; y < height; y++) {
245
+ const row = out.subarray(y * width, (y + 1) * width);
246
+ for (let s = 0; s < SUBROWS; s++) {
247
+ const at = y + (s + 0.5) / SUBROWS;
248
+ const span = spanAt(shape.outer, at);
249
+ if (!span) continue;
250
+ addSpan(row, width, span[0], span[1], weight);
251
+ const hole = shape.inner && spanAt(shape.inner, at);
252
+ if (hole) addSpan(row, width, hole[0], hole[1], -weight);
253
+ }
254
+ }
255
+ return out;
256
+ }
257
+
258
+ /** A gaussian of the tile's σ over `values`, rows then columns, in place.
259
+ * Outside the tile is nothing, which its padding of `reach` makes true. */
260
+ function blurTile(values, width, height, sigma, reach) {
261
+ const kernel = gaussianKernel1d(sigma, reach);
262
+ const line = new Float32Array(Math.max(width, height));
263
+ for (let y = 0; y < height; y++) {
264
+ const base = y * width;
265
+ for (let x = 0; x < width; x++) line[x] = values[base + x];
266
+ for (let x = 0; x < width; x++) {
267
+ let sum = 0;
268
+ const from = Math.max(0, x - reach);
269
+ const to = Math.min(width - 1, x + reach);
270
+ for (let t = from; t <= to; t++) sum += line[t] * kernel[t - x + reach];
271
+ values[base + x] = sum;
272
+ }
273
+ }
274
+ for (let x = 0; x < width; x++) {
275
+ for (let y = 0; y < height; y++) line[y] = values[y * width + x];
276
+ for (let y = 0; y < height; y++) {
277
+ let sum = 0;
278
+ const from = Math.max(0, y - reach);
279
+ const to = Math.min(height - 1, y + reach);
280
+ for (let t = from; t <= to; t++) sum += line[t] * kernel[t - y + reach];
281
+ values[y * width + x] = sum;
282
+ }
283
+ }
284
+ }
285
+
286
+ /** The tile's blurred coverage, 0..1, row-major. */
287
+ export function shadowTileCoverage(plan) {
288
+ const values = shadowCoverage(plan);
289
+ blurTile(values, plan.width, plan.height, plan.sigma, plan.reach);
290
+ return values;
291
+ }
292
+
293
+ /** The tile as an `a8` coverage surface stores it: one byte a pixel,
294
+ * rows unpadded. */
295
+ export function shadowTileAlpha(plan) {
296
+ const values = shadowTileCoverage(plan);
297
+ const out = new Uint8Array(values.length);
298
+ for (let i = 0; i < values.length; i++) {
299
+ const v = values[i];
300
+ out[i] = v > 0 ? Math.round(Math.min(1, v) * 255) : 0;
301
+ }
302
+ return out;
303
+ }
304
+
305
+ /**
306
+ * The tile in a colour, as straight RGBA — for a surface that has no
307
+ * coverage format to composite through a colour. `rgba` is 0..1 each.
308
+ */
309
+ export function rasterShadowTile(plan, rgba) {
310
+ const values = shadowTileCoverage(plan);
311
+ const [r, g, b, a] = rgba;
312
+ const R = Math.round(r * 255);
313
+ const G = Math.round(g * 255);
314
+ const B = Math.round(b * 255);
315
+ const out = new Uint8Array(values.length * 4);
316
+ for (let i = 0; i < values.length; i++) {
317
+ const v = values[i];
318
+ if (!(v > 0)) continue;
319
+ const o = i * 4;
320
+ out[o] = R;
321
+ out[o + 1] = G;
322
+ out[o + 2] = B;
323
+ out[o + 3] = Math.round(Math.min(1, v) * a * 255);
324
+ }
325
+ return out;
326
+ }
@@ -25,15 +25,107 @@ const UNSPACED =
25
25
  * 12.8px measures 410.0125px in the 410px column it was written for — and
26
26
  * a browser takes the line as fitting, comparing in 64ths of a pixel with
27
27
  * that much to spare (Blink's line breaker adds a LayoutUnit's epsilon to
28
- * the width it fits to).
28
+ * the width it fits to). What a browser compares is the line's items,
29
+ * each rounded up to a 64th first, which a layout does with `fit: 'items'`
30
+ * (`LineFit`).
29
31
  */
30
32
  const FIT_SLACK = 1 / 64;
31
33
 
32
- /** How wide a line's tokens are, the white space it ends on left out. */
33
- function tokensWidth(toks) {
34
- let width = 0;
35
- for (let i = 0; i < toks.length; i++) width += toks[i].width + (i ? kernBefore(toks[i]) : 0);
36
- return toks.length ? width - toks[toks.length - 1].wsWidth : 0;
34
+ /** A width rounded up to a 64th of a pixel, as Blink rounds an item's
35
+ * shaped width up to a LayoutUnit (`ShapeResult::SnappedWidth`). The
36
+ * nudge keeps a sum that lands on a 64th, give or take the last bit of a
37
+ * double, where it lands. */
38
+ function snapUp(width) {
39
+ return Math.ceil(width * 64 - 1e-7) / 64;
40
+ }
41
+
42
+ /**
43
+ * A line's width as it is fitted: its spans' parts of it summed, its
44
+ * trailing white space left out — each part rounded up to a 64th of a pixel
45
+ * first where the layout fits as a browser does (`fit: 'items'`), as it is
46
+ * without, a float sum.
47
+ *
48
+ * A browser measures a line in items — the text of one element between its
49
+ * edges, shaped — and rounds each one's width up to a LayoutUnit before it
50
+ * adds it (Blink's `ShapeResult::SnappedWidth`, which every text item's
51
+ * `inline_size` on a line is), so a line of three items is up to three 64ths
52
+ * wider than its advances. Compared with the width and its 64th to spare
53
+ * (`FIT_SLACK`), a line of Verdana 0.002px past its 529px, the `<abbr>` in
54
+ * it making three items, is 2 64ths past and breaks, as it does in Chrome;
55
+ * a line that is one item and a hair over still fits. A span is an item,
56
+ * and so is its part of a line; a `kernAcross` span is not one of its own
57
+ * — it is a space a justified line or `word-spacing` spaces out, which a
58
+ * browser spaces inside the item — and is measured with the span before it.
59
+ *
60
+ * Kept a token at a time: `add` puts one on the line, `with` is the line's
61
+ * width were the next one put on it.
62
+ */
63
+ class LineFit {
64
+ constructor(round) {
65
+ this.round = round;
66
+ this.reset();
67
+ }
68
+
69
+ reset() {
70
+ /** the items closed on the line, each rounded */
71
+ this.closed = 0;
72
+ /** the width of the item the line ends in, as far as it goes */
73
+ this.open = 0;
74
+ this.span = null;
75
+ }
76
+
77
+ add(token, kern) {
78
+ const frags = token.fragments;
79
+ for (let i = 0; i < frags.length; i++) {
80
+ const frag = frags[i];
81
+ const width = frag.shaped.width + (frag.kern ?? 0) + (i === 0 ? kern : 0);
82
+ if (this.span === null || sameItem(this.span, frag.span)) {
83
+ this.open += width;
84
+ } else {
85
+ this.closed += this.round(this.open);
86
+ this.open = width;
87
+ }
88
+ this.span = frag.span;
89
+ }
90
+ // a token of no text, a forced break's, adds nothing but its kerning
91
+ if (!frags.length) this.open += kern;
92
+ }
93
+
94
+ /** The line's width with `token` on it, the white space it ends on left
95
+ * out. */
96
+ with(token, kern) {
97
+ const { closed, open, span } = this;
98
+ this.add(token, kern);
99
+ const width = this.closed + this.round(this.open - token.wsWidth);
100
+ this.closed = closed;
101
+ this.open = open;
102
+ this.span = span;
103
+ return width;
104
+ }
105
+ }
106
+
107
+ /** Whether a span goes on measuring the item the one before it is in: the
108
+ * same span, or a space spaced out apart (`kernAcross`) either side. */
109
+ function sameItem(before, span) {
110
+ return before === span || span.kernAcross || before.kernAcross;
111
+ }
112
+
113
+ /** How a layout rounds its items before it adds them (`LineFit`). */
114
+ function itemRounding(options) {
115
+ return options.fit === 'items' ? snapUp : unrounded;
116
+ }
117
+
118
+ function unrounded(width) {
119
+ return width;
120
+ }
121
+
122
+ /** A line's tokens' width as it is fitted (`LineFit`). */
123
+ function fittedWidth(toks, round) {
124
+ if (!toks.length) return 0;
125
+ const fit = new LineFit(round);
126
+ for (let i = 0; i < toks.length - 1; i++) fit.add(toks[i], i ? kernBefore(toks[i]) : 0);
127
+ const last = toks.length - 1;
128
+ return fit.with(toks[last], last ? kernBefore(toks[last]) : 0);
37
129
  }
38
130
 
39
131
  /** The kerning between a token and the one before it on its line
@@ -89,6 +181,10 @@ function isWsGlyph(g) {
89
181
  * past the line's end, as CSS's `overflow-wrap: normal` does — but for
90
182
  * text in a script written without spaces (Thai, Khmer, Javanese…), whose
91
183
  * words the breaker cannot find, which is cut as a fallback
184
+ * - `fit` — 'items' fits and measures a line as a browser does: each span's
185
+ * part of it rounded up to a 64th of a pixel before it is added, as Blink
186
+ * rounds an element's text on a line up to a LayoutUnit; a line's `width`
187
+ * is that sum. Default: the advances as they add up (`LineFit`)
92
188
  *
93
189
  * The result is inspectable before/without drawing: `width`, `height`,
94
190
  * `truncated`, and `lines[] = { x, y, height, baseline, width, ascent,
@@ -134,18 +230,19 @@ export class TextLayout {
134
230
  const whole = options.overflowWrap === 'normal';
135
231
  // text that does not wrap is a line to each forced break, however wide
136
232
  const wraps = options.wrap !== false;
233
+ const round = itemRounding(options);
137
234
  {
138
235
  let cur = [];
139
- let curWidth = 0;
236
+ const fit = new LineFit(round);
140
237
  const flush = () => {
141
238
  lineTokens.push(cur);
142
239
  cur = [];
143
- curWidth = 0;
240
+ fit.reset();
144
241
  };
145
242
  for (let token of tokens) {
146
243
  while (
147
244
  wraps &&
148
- curWidth + (cur.length ? kernBefore(token) : 0) + token.width - token.wsWidth > maxWidth + FIT_SLACK
245
+ fit.with(token, cur.length ? kernBefore(token) : 0) > maxWidth + FIT_SLACK
149
246
  ) {
150
247
  if (cur.length > 0) {
151
248
  flush();
@@ -160,7 +257,7 @@ export class TextLayout {
160
257
  token = rest;
161
258
  }
162
259
  }
163
- curWidth += token.width + (cur.length ? kernBefore(token) : 0);
260
+ fit.add(token, cur.length ? kernBefore(token) : 0);
164
261
  cur.push(token);
165
262
  if (token.required) flush();
166
263
  }
@@ -199,7 +296,7 @@ export class TextLayout {
199
296
  // Elision is the last kept line's business, or a line's that does not
200
297
  // wrap and runs past the width, and it happens before entries exist,
201
298
  // because dropping content means re-shaping the tail.
202
- const cut = (elide && li === lineTokens.length - 1) || (cutWide && tokensWidth(toks) > maxWidth + FIT_SLACK);
299
+ const cut = (elide && li === lineTokens.length - 1) || (cutWide && fittedWidth(toks, round) > maxWidth + FIT_SLACK);
203
300
  const ellipsis = cut ? this._elide(toks, baseSpan) : null;
204
301
  if (ellipsis) {
205
302
  toks = this._fitBefore(toks, maxWidth - ellipsis.width);
@@ -275,13 +372,26 @@ export class TextLayout {
275
372
  let natural = 0;
276
373
  const runs = [];
277
374
  let x = 0;
375
+ // and how wide it is as it was fitted (`LineFit`): where its spans'
376
+ // parts are each rounded up to a 64th, so is its width, and a layout
377
+ // laid out again at its own width breaks nowhere new
378
+ let fitted = 0;
379
+ let item = 0;
380
+ let itemSpan = null;
278
381
  for (const e of entries) {
382
+ const from = x;
279
383
  if (e.kern) x += e.kern;
280
384
  const positioned = { x, width: e.run.width, run: e.run, span: e.span, start: e.start, end: e.end };
281
385
  if (e.ellipsis) positioned.ellipsis = true;
282
386
  runs.push(positioned);
283
387
  x += e.run.width;
284
388
  if (e.kernAfter) x += e.kernAfter;
389
+ if (itemSpan !== null && !sameItem(itemSpan, e.span)) {
390
+ fitted += round(item);
391
+ item = 0;
392
+ }
393
+ item += x - from;
394
+ itemSpan = e.span;
285
395
  const m = e.run.font.metrics(e.run.size);
286
396
  if (m.ascent > ascent) ascent = m.ascent;
287
397
  if (m.descent > descent) descent = m.descent;
@@ -294,7 +404,8 @@ export class TextLayout {
294
404
  descent = m.descent;
295
405
  natural = m.lineHeight;
296
406
  }
297
- if (x > layoutWidth) layoutWidth = x;
407
+ if (runs.length) fitted += round(item);
408
+ if (fitted > layoutWidth) layoutWidth = fitted;
298
409
  // Half-leading (CSS Inline Layout 3): the slack between the glyphs and
299
410
  // the line box is split evenly above and below, rather than all of it
300
411
  // landing under the text. This is what makes a single line sit
@@ -312,7 +423,7 @@ export class TextLayout {
312
423
  y,
313
424
  height: box,
314
425
  baseline: y + leading + ascent,
315
- width: x,
426
+ width: fitted,
316
427
  ascent,
317
428
  descent,
318
429
  runs,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.15.0",
3
+ "version": "8.16.1",
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",
@@ -31,6 +31,7 @@
31
31
  "./path": "./lib/path.js",
32
32
  "./gl": "./lib/gl.js",
33
33
  "./shadow-math": "./lib/shadowmath.js",
34
+ "./shadow-tiles": "./lib/shadowtiles.js",
34
35
  "./image": "./lib/image.js",
35
36
  "./svg": "./lib/widgets/svgview.js",
36
37
  "./font": "./lib/text/font.js"