ntk 7.6.1 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -53,18 +53,17 @@ rasterizer and cached server-side as XRender glyphs, so drawing a line of
53
53
  text costs about a byte per glyph on the wire. Font names resolve through
54
54
  fontconfig (`fc-match`). Very large and continuously animated sizes render
55
55
  as server-side trapezoids instead of cached bitmaps. A `TextLayout` engine
56
- wraps styled text to a target width, a `MarkdownView` widget renders
57
- markdown (with syntax-highlighted code fences and KaTeX math via
58
- `TexView`) on top of it — see [docs/text.md](docs/text.md) and
59
- [docs/tex.md](docs/tex.md).
56
+ wraps styled text to a target width — see [docs/text.md](docs/text.md).
60
57
 
61
58
  PNG/JPEG images decode client-side (`loadImage`) and composite server-side
62
- via `ctx.drawImage` ([docs/images.md](docs/images.md)). An `HtmlView`
63
- widget renders a static HTML + CSS subset — block flow and flexbox laid
64
- out by yoga-layout, no scripts, no network — with app-controlled link
65
- navigation ([docs/html.md](docs/html.md)). An `SvgView` widget renders
66
- static SVG (shapes, gradients, transforms, `use`) through the same 2d
67
- pipeline ([docs/svg.md](docs/svg.md)).
59
+ via `ctx.drawImage` ([docs/images.md](docs/images.md)). An `SvgView` widget
60
+ renders static SVG (shapes, gradients, transforms, `use`) through the same
61
+ 2d pipeline ([docs/svg.md](docs/svg.md)).
62
+
63
+ Rendering documents — markdown, formulas, rich text — is not ntk's job:
64
+ it draws, and a document is a tree of layout decisions on top of that.
65
+ [`@react-x11/components`](https://github.com/sidorares/react-x11-components)
66
+ is where those live, over the react-x11 renderer.
68
67
 
69
68
  ```js
70
69
  import { createClient } from 'ntk';
package/lib/image.js CHANGED
@@ -79,6 +79,18 @@ export class Image {
79
79
  return picture;
80
80
  }
81
81
 
82
+ /**
83
+ * The Pixmap those pixels live in on `app` (uploading on first use, like
84
+ * `picture`). What it is for is building a *second* Picture over the same
85
+ * upload — `ctx.createPattern` needs a repeating one, and changing the
86
+ * cached picture's attributes instead would change how `drawImage` samples
87
+ * this image everywhere else.
88
+ */
89
+ pixmap(app) {
90
+ this.picture(app);
91
+ return this._uploads.get(app).pixmap;
92
+ }
93
+
82
94
  /** free server-side copies of this image (safe to draw again afterwards) */
83
95
  destroy() {
84
96
  for (const { pixmap, picture } of this._uploads.values()) {
package/lib/index.js CHANGED
@@ -16,7 +16,6 @@ import {
16
16
  encodeXEmbedInfo,
17
17
  readXEmbedInfo
18
18
  } from './xembed.js';
19
- import { loadLayout } from './yoga.js';
20
19
  import { decodeKey, groupForState } from './keyboard.js';
21
20
  import Pixmap from './pixmap.js';
22
21
  import Picture from './picture.js';
@@ -47,21 +46,17 @@ import {
47
46
  defaultRasterizer,
48
47
  setDefaultRasterizer
49
48
  } from './rasterize.js';
49
+ import { DEFAULT_MASK_POLICY } from './maskcluster.js';
50
50
  import { DEFAULT_SHAPE_POLICY } from './shapeglyphs.js';
51
51
  import { TextLayout } from './text/layout.js';
52
- import HtmlView from './widgets/htmlview.js';
53
52
  import SvgView from './widgets/svgview.js';
54
- import MarkdownView from './widgets/markdownview.js';
55
- import TexView, { configureTex, layoutTex, TexBox } from './widgets/tex.js';
56
- import { tokenize as highlightCode } from './widgets/highlight.js';
57
- import { cssColorStraight, premultiply } from './color.js';
58
- import { cssColor, cssLength } from './widgets/css.js';
53
+ import { cssColor, cssColorStraight, premultiply } from './color.js';
59
54
 
60
55
  // rendering context modules register themselves on Drawable. The direct one
61
56
  // comes last on purpose: it wraps the 'opengl' factory the indirect one just
62
57
  // registered, so that the backend-neutral name can dispatch on glPolicy.
63
58
  import './renderingcontext_x11.js';
64
- import './renderingcontext_2d.js';
59
+ import { CanvasGradient, CanvasPattern } from './renderingcontext_2d.js';
65
60
  import './renderingcontext_opengl.js';
66
61
  import './renderingcontext_gles.js';
67
62
 
@@ -115,11 +110,7 @@ export function createClient(options, callback) {
115
110
  const x11Options = { ...(options || {}) };
116
111
  if (x11Options.bufferRequests === undefined) x11Options.bufferRequests = DEFAULT_BUFFER_REQUESTS;
117
112
 
118
- // the layout engine's WASM loads alongside the connection, so widgets are
119
- // usable synchronously by the time the App exists (see lib/yoga.js)
120
- const layout = loadLayout();
121
-
122
- const connecting = new Promise((resolve, reject) => {
113
+ const promise = new Promise((resolve, reject) => {
123
114
  // Resolve the font spec here rather than lazily in `app.fonts`, so a
124
115
  // missing directory is a rejected connect instead of a surprise inside
125
116
  // the first paint. Inside the executor so it rejects rather than throws
@@ -205,8 +196,6 @@ export function createClient(options, callback) {
205
196
  });
206
197
  });
207
198
 
208
- const promise = Promise.all([connecting, layout]).then(([app]) => app);
209
-
210
199
  if (callback) {
211
200
  promise.then(
212
201
  (app) => callback(null, app),
@@ -237,6 +226,10 @@ export {
237
226
  Pixmap,
238
227
  Picture,
239
228
  Surface,
229
+ // fill/stroke styles the 2d context hands back (docs/context-2d.md) —
230
+ // exported for `instanceof`, not to be constructed directly
231
+ CanvasGradient,
232
+ CanvasPattern,
240
233
  Image,
241
234
  ImageData,
242
235
  pixelLayout,
@@ -260,20 +253,13 @@ export {
260
253
  defaultRasterizer,
261
254
  setDefaultRasterizer,
262
255
  DEFAULT_RASTER_POLICY,
256
+ DEFAULT_MASK_POLICY,
263
257
  DEFAULT_SHAPE_POLICY,
264
258
  TextLayout,
265
- HtmlView,
266
259
  SvgView,
267
- MarkdownView,
268
- TexView,
269
- TexBox,
270
- layoutTex,
271
- configureTex,
272
- highlightCode,
273
260
  cssColor,
274
261
  cssColorStraight,
275
262
  premultiply,
276
- cssLength,
277
263
  decodeKey,
278
264
  groupForState,
279
265
  // the `code` on a failed GL setup: branch on it rather than on the message
@@ -283,11 +269,4 @@ export {
283
269
  GL_MODES,
284
270
  DEFAULT_GL_POLICY
285
271
  };
286
- // The layout engine ntk lays HtmlView out with — downstream layout consumers
287
- // (e.g. the react-x11 renderer) must import it from here rather than from
288
- // `yoga-layout`, or they get a second WASM instance whose Nodes cannot be
289
- // mixed with ntk's. Its enum constants are readable as soon as ntk is
290
- // imported; `Node`/`Config` need the WASM, which `createClient()` loads —
291
- // `loadLayout()` is there for widgets used without an App.
292
- export { default as Yoga, loadLayout, layoutLoaded } from './yoga.js';
293
272
  export default { createClient };
@@ -0,0 +1,191 @@
1
+ // Splitting one drawing's coverage mask into a few, when its pieces are
2
+ // scattered (issue #264).
3
+ //
4
+ // A fill or stroke rasterizes coverage into one a8 mask sized to the
5
+ // drawing's ink bounding box, and that mask costs width x height whatever
6
+ // the coverage inside it is. For one shape the bound is right. For a path
7
+ // holding N disjoint subpaths the bound is their *union*, so batching N
8
+ // draws into one path trades N small masks for one big one — which wins
9
+ // when the pieces span the box anyway (long edges of a graph) and loses
10
+ // badly when they do not (its handle dots): at 1100x700, 735 batched edge
11
+ // strokes cost 3.9 MB -> 1.3 MB of mask and 53% less frame time, while 19
12
+ // edges plus 40 small discs batched the same way cost ~0.75 MB *more* and
13
+ // 40% more frame time than drawing them singly.
14
+ //
15
+ // Rather than leave that cliff to every caller, the boxes go through here
16
+ // first and the mask is emitted once per cluster. The partition is by gaps
17
+ // only: a cut is legal where nothing straddles it, which is what keeps
18
+ // every cluster box disjoint from every other. Disjoint boxes are what make
19
+ // the split invisible — no pixel is composited twice (a translucent colour
20
+ // would blend twice at any overlap), and the winding number a fill asks for
21
+ // is unchanged, because a closed subpath contributes nothing to the winding
22
+ // of a point outside its own box.
23
+ //
24
+ // Cutting is not free: each extra mask is a few more requests. So a cut has
25
+ // to pay for itself in mask area, and the cheapest useful unit of area is
26
+ // the policy's `minSaving`. That single rule bounds the outcome from both
27
+ // sides — every cut removes at least `minSaving` pixels of mask, so the
28
+ // number of clusters can never exceed the union area divided by it.
29
+
30
+ /**
31
+ * When one drawing's mask is worth splitting into several.
32
+ *
33
+ * - `minSaving` — mask pixels a cut has to remove to be worth the extra
34
+ * mask pass it costs. 64x64 is the same "small enough not to think about"
35
+ * box `DEFAULT_RASTER_POLICY.maxArea` uses: below it, an extra pass is
36
+ * dearer than the area it would save.
37
+ * - `maxMasks` — hard cap on clusters per drawing, so a pathological path
38
+ * (thousands of scattered dots) cannot turn one drawing into thousands of
39
+ * request groups. Cuts are taken most-valuable-first, so the cap keeps the
40
+ * ones that matter.
41
+ *
42
+ * `{ maxMasks: 1 }` — or `minSaving: Infinity` — disables the split, which
43
+ * is what a composite op that writes outside its coverage gets.
44
+ */
45
+ export const DEFAULT_MASK_POLICY = {
46
+ minSaving: 64 * 64,
47
+ maxMasks: 32
48
+ };
49
+
50
+ /** the policy for one app, merged over the defaults */
51
+ export function maskPolicyOf(app) {
52
+ return app.maskPolicy
53
+ ? { ...DEFAULT_MASK_POLICY, ...app.maskPolicy }
54
+ : DEFAULT_MASK_POLICY;
55
+ }
56
+
57
+ const area = (b) => b.w * b.h;
58
+
59
+ /** The smallest {x, y, w, h} box holding both of two boxes. */
60
+ export function unionBox(a, b) {
61
+ const x = Math.min(a.x, b.x);
62
+ const y = Math.min(a.y, b.y);
63
+ return {
64
+ x,
65
+ y,
66
+ w: Math.max(a.x + a.w, b.x + b.w) - x,
67
+ h: Math.max(a.y + a.h, b.y + b.h) - y
68
+ };
69
+ }
70
+
71
+ function unionOf(boxes, order) {
72
+ let out = { ...boxes[order[0]] };
73
+ for (let i = 1; i < order.length; ++i) out = unionBox(out, boxes[order[i]]);
74
+ return out;
75
+ }
76
+
77
+ /**
78
+ * The most valuable gap cut of one group along one axis, or null when the
79
+ * group has no gap on it.
80
+ *
81
+ * `order` is the group's members sorted by that axis' start. A cut between
82
+ * members i and i+1 is legal only where nothing straddles it — the next box
83
+ * has to start at or past the far edge of every box before it, which for a
84
+ * union box is just its own far edge. What the cut is worth is the mask area
85
+ * it removes: the parent box less the two halves.
86
+ */
87
+ function bestGap(boxes, order, box, axis) {
88
+ const n = order.length;
89
+ const start = axis === 'x' ? 'x' : 'y';
90
+ const size = axis === 'x' ? 'w' : 'h';
91
+
92
+ // suffix[i] is the union box of order[i..n-1]; the prefix is carried
93
+ // along the scan below
94
+ const suffix = new Array(n);
95
+ suffix[n - 1] = { ...boxes[order[n - 1]] };
96
+ for (let i = n - 2; i >= 0; --i) {
97
+ suffix[i] = unionBox(boxes[order[i]], suffix[i + 1]);
98
+ }
99
+
100
+ let best = null;
101
+ let prefix = { ...boxes[order[0]] };
102
+ for (let i = 0; i + 1 < n; ++i) {
103
+ if (i > 0) prefix = unionBox(prefix, boxes[order[i]]);
104
+ const next = boxes[order[i + 1]];
105
+ if (next[start] < prefix[start] + prefix[size]) continue; // straddled
106
+ const saving = area(box) - area(prefix) - area(suffix[i + 1]);
107
+ if (!best || saving > best.saving) {
108
+ best = { axis, at: i, saving, left: prefix, right: suffix[i + 1] };
109
+ }
110
+ }
111
+ return best;
112
+ }
113
+
114
+ /** a group of pieces, its box, and the best cut available to it */
115
+ function makeNode(boxes, byX, byY, box) {
116
+ const node = { box: box ?? unionOf(boxes, byX), byX, byY, split: null };
117
+ if (byX.length > 1) {
118
+ const x = bestGap(boxes, byX, node.box, 'x');
119
+ const y = bestGap(boxes, byY, node.box, 'y');
120
+ node.split = !x ? y : !y || x.saving >= y.saving ? x : y;
121
+ }
122
+ return node;
123
+ }
124
+
125
+ /** the two halves of `node`, each with its own next-best cut */
126
+ function splitNode(boxes, node) {
127
+ const { axis, at, left, right } = node.split;
128
+ const cut = axis === 'x' ? node.byX : node.byY;
129
+ const other = axis === 'x' ? node.byY : node.byX;
130
+ const inLeft = new Set(cut.slice(0, at + 1));
131
+ // the other axis' order survives the filter, so neither half is re-sorted
132
+ const leftOther = other.filter((i) => inLeft.has(i));
133
+ const rightOther = other.filter((i) => !inLeft.has(i));
134
+ const leftCut = cut.slice(0, at + 1);
135
+ const rightCut = cut.slice(at + 1);
136
+ return axis === 'x'
137
+ ? [
138
+ makeNode(boxes, leftCut, leftOther, left),
139
+ makeNode(boxes, rightCut, rightOther, right)
140
+ ]
141
+ : [
142
+ makeNode(boxes, leftOther, leftCut, left),
143
+ makeNode(boxes, rightOther, rightCut, right)
144
+ ];
145
+ }
146
+
147
+ /**
148
+ * Partition one drawing's pieces into mask clusters.
149
+ *
150
+ * @param {Array<{x, y, w, h}>} boxes one integer box per piece — a fill's
151
+ * subpaths, a stroke's islands of triangles — already clamped to the
152
+ * surface. Boxes may overlap; overlapping ones always end up in the same
153
+ * cluster.
154
+ * @param {{minSaving: number, maxMasks: number}} [policy]
155
+ * @returns {Array<{x, y, w, h, items: number[]}>} clusters, each with the
156
+ * indices of the pieces it holds. Cluster boxes are pairwise disjoint,
157
+ * and their union is the union of `boxes`.
158
+ */
159
+ export function clusterBoxes(boxes, policy = DEFAULT_MASK_POLICY) {
160
+ const n = boxes.length;
161
+ if (!n) return [];
162
+ // merged here as well as in maskPolicyOf, so that a partial policy from a
163
+ // direct caller ({ maxMasks: 1 }) cannot silently zero the other field
164
+ const { minSaving, maxMasks: cap } = { ...DEFAULT_MASK_POLICY, ...policy };
165
+ const maxMasks = Math.max(1, cap | 0);
166
+ const all = boxes.map((_, i) => i);
167
+ if (n === 1 || maxMasks === 1 || !(minSaving < Infinity)) {
168
+ return [{ ...unionOf(boxes, all), items: all }];
169
+ }
170
+
171
+ const byX = all.slice().sort((a, b) => boxes[a].x - boxes[b].x || a - b);
172
+ const byY = all.slice().sort((a, b) => boxes[a].y - boxes[b].y || a - b);
173
+ const nodes = [makeNode(boxes, byX, byY)];
174
+ // most-valuable cut first, so a maxMasks cap keeps the cuts that matter
175
+ while (nodes.length < maxMasks) {
176
+ let pick = -1;
177
+ let best = minSaving;
178
+ for (let i = 0; i < nodes.length; ++i) {
179
+ const split = nodes[i].split;
180
+ if (split && split.saving >= best) {
181
+ best = split.saving;
182
+ pick = i;
183
+ }
184
+ }
185
+ if (pick < 0) break;
186
+ const [a, b] = splitNode(boxes, nodes[pick]);
187
+ nodes[pick] = a;
188
+ nodes.push(b);
189
+ }
190
+ return nodes.map((node) => ({ ...node.box, items: node.byX }));
191
+ }