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 +1 -0
- package/lib/index.js +10 -0
- package/lib/path.js +9 -1
- package/lib/renderingcontext_2d.js +210 -0
- package/lib/shadowmath.js +7 -1
- package/lib/shadowtiles.js +326 -0
- package/lib/text/layout.js +124 -13
- package/package.json +2 -1
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
|
+
}
|
package/lib/text/layout.js
CHANGED
|
@@ -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
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
236
|
+
const fit = new LineFit(round);
|
|
140
237
|
const flush = () => {
|
|
141
238
|
lineTokens.push(cur);
|
|
142
239
|
cur = [];
|
|
143
|
-
|
|
240
|
+
fit.reset();
|
|
144
241
|
};
|
|
145
242
|
for (let token of tokens) {
|
|
146
243
|
while (
|
|
147
244
|
wraps &&
|
|
148
|
-
|
|
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
|
-
|
|
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 &&
|
|
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 (
|
|
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:
|
|
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.
|
|
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"
|