ntk 5.0.0 → 5.1.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/lib/app.js CHANGED
@@ -2,6 +2,7 @@ import Clipboard from './clipboard.js';
2
2
  import { CursorCache } from './cursor.js';
3
3
  import { chooseGLXConfig } from './glx.js';
4
4
  import Pixmap from './pixmap.js';
5
+ import { DEFAULT_RASTER_POLICY, defaultRasterizer } from './rasterize.js';
5
6
  import FontManager from './text/fontmanager.js';
6
7
  import Window from './window.js';
7
8
 
@@ -13,8 +14,9 @@ export default class App {
13
14
  /**
14
15
  * @param {object} display node-x11 display
15
16
  * @param {object} [options] environment hooks: { fontSource (see
16
- * text/fontsource.js), glxVisual (visual id for getContext('opengl')
17
- * instead of querying the server for one) }
17
+ * text/fontsource.js), rasterizer and rasterPolicy (see rasterize.js),
18
+ * glxVisual (visual id for getContext('opengl') instead of querying the
19
+ * server for one) }
18
20
  */
19
21
  constructor(display, options = {}) {
20
22
  // ntk decodes property payloads and atom lists with little-endian
@@ -38,6 +40,7 @@ export default class App {
38
40
  this._fonts = null;
39
41
  this._clipboard = null;
40
42
  this._cursors = null;
43
+ this._rasterizer = undefined;
41
44
  // node-x11 emits X errors it cannot route to a request callback as
42
45
  // 'error' on the client — from inside its packet parser. With no
43
46
  // listener that emit throws and the parser never re-arms, silently
@@ -56,6 +59,31 @@ export default class App {
56
59
  return this._fonts;
57
60
  }
58
61
 
62
+ /**
63
+ * The Rasterizer small fills and strokes go through instead of the server's
64
+ * trapezoid path (see lib/rasterize.js and docs/context-2d.md). Defaults to
65
+ * the process-wide default; `createClient({ rasterizer: null })` sends every
66
+ * drawing to the server.
67
+ */
68
+ get rasterizer() {
69
+ if (this._rasterizer === undefined) {
70
+ this._rasterizer =
71
+ 'rasterizer' in this.options ? this.options.rasterizer : defaultRasterizer();
72
+ }
73
+ return this._rasterizer;
74
+ }
75
+
76
+ set rasterizer(value) {
77
+ this._rasterizer = value;
78
+ }
79
+
80
+ /** thresholds for that decision, merged over DEFAULT_RASTER_POLICY */
81
+ get rasterPolicy() {
82
+ return this.options.rasterPolicy
83
+ ? { ...DEFAULT_RASTER_POLICY, ...this.options.rasterPolicy }
84
+ : DEFAULT_RASTER_POLICY;
85
+ }
86
+
59
87
  /** selection/clipboard transfer: write()/read() text (docs/clipboard.md) */
60
88
  get clipboard() {
61
89
  if (!this._clipboard) this._clipboard = new Clipboard(this);
package/lib/index.js CHANGED
@@ -18,6 +18,13 @@ import {
18
18
  defaultFontSource,
19
19
  setDefaultFontSource
20
20
  } from './text/fontsource.js';
21
+ import {
22
+ CoverageAccumulator,
23
+ DEFAULT_RASTER_POLICY,
24
+ ScanlineRasterizer,
25
+ defaultRasterizer,
26
+ setDefaultRasterizer
27
+ } from './rasterize.js';
21
28
  import { TextLayout } from './text/layout.js';
22
29
  import HtmlView from './widgets/htmlview.js';
23
30
  import SvgView from './widgets/svgview.js';
@@ -54,6 +61,8 @@ const DEFAULT_BUFFER_REQUESTS = { maxSize: 64 * 1024 };
54
61
  * @param {object} [options] passed through to x11.createClient
55
62
  * (e.g. { display: ':1' }); ntk additionally understands
56
63
  * { fontSource } — a pluggable system-font lookup (see docs/fonts.md) —
64
+ * { rasterizer, rasterPolicy } — where small fills and strokes are
65
+ * rasterized, and the thresholds for that choice (see docs/context-2d.md) —
57
66
  * { glxVisual } — a visual id for getContext('opengl') to use instead of
58
67
  * querying the server for one — and
59
68
  * { onXError } — called with X protocol errors no request callback
@@ -151,6 +160,11 @@ export {
151
160
  StaticFontSource,
152
161
  defaultFontSource,
153
162
  setDefaultFontSource,
163
+ CoverageAccumulator,
164
+ ScanlineRasterizer,
165
+ defaultRasterizer,
166
+ setDefaultRasterizer,
167
+ DEFAULT_RASTER_POLICY,
154
168
  TextLayout,
155
169
  HtmlView,
156
170
  SvgView,
package/lib/rasterize.js CHANGED
@@ -1,23 +1,254 @@
1
- // Minimal pure-JS scanline rasterizer for glyph outlines.
1
+ // Pure-JS coverage rasterization: outlines and polygons in, 8-bit alpha out.
2
2
  //
3
- // Takes path commands in y-down pixel coordinates with the origin at the
4
- // glyph baseline (produced by Font.rasterize from fontkit outlines) and
5
- // produces an antialiased 8-bit alpha bitmap suitable for XRender
6
- // AddGlyphs. Non-zero winding rule, 4x4 supersampling.
3
+ // Two consumers, one algorithm:
4
+ //
5
+ // rasterizePath() glyph outlines -> a8 bitmap for XRender AddGlyphs
6
+ // rasterizePolys() device-space path/stroke geometry -> a8 coverage mask,
7
+ // uploaded by the 2d context instead of asking the server
8
+ // to rasterize trapezoids (see routeRaster below)
9
+ //
10
+ // The algorithm is signed-area accumulation, the one font-rs and
11
+ // stb_truetype v2 use. Each edge deposits the exact area it sweeps through
12
+ // every pixel row it crosses into a float accumulator; a prefix sum along the
13
+ // row turns those deltas into winding-weighted coverage. There is no
14
+ // supersampling and no per-scanline crossing sort — coverage is analytic, so
15
+ // the result is closer to a reference rasterizer than 4x4 supersampling was,
16
+ // at a fraction of the cost (~25 microseconds for a 20px icon against ~600).
17
+
18
+ /**
19
+ * Antialiased coverage accumulator over a width x height pixel grid.
20
+ *
21
+ * Geometry is in y-down device space with the grid's top-left at (0, 0);
22
+ * callers translate into that space through the `dx`/`dy` arguments. Add as
23
+ * many polygons as the drawing needs, then read the result with `toAlpha`.
24
+ */
25
+ export class CoverageAccumulator {
26
+ /**
27
+ * @param {number} width
28
+ * @param {number} height
29
+ */
30
+ constructor(width, height) {
31
+ this.width = width;
32
+ this.height = height;
33
+ // one column of slack: an edge that ends exactly on the right border
34
+ // deposits its last fraction one cell past the last pixel
35
+ this.stride = width + 2;
36
+ this.acc = new Float32Array(this.stride * height);
37
+ }
38
+
39
+ /** forget accumulated coverage, keeping the buffer (reuse across draws) */
40
+ reset() {
41
+ this.acc.fill(0);
42
+ return this;
43
+ }
44
+
45
+ /**
46
+ * Accumulate one directed edge. Horizontal edges contribute nothing and
47
+ * are skipped; everything else deposits `dy * direction` of signed area,
48
+ * split across the pixels the edge passes through.
49
+ */
50
+ edge(ax, ay, bx, by) {
51
+ if (ay === by) return;
52
+ let dir = 1;
53
+ let x0 = ax;
54
+ let y0 = ay;
55
+ let x1 = bx;
56
+ let y1 = by;
57
+ if (ay > by) {
58
+ dir = -1;
59
+ x0 = bx;
60
+ y0 = by;
61
+ x1 = ax;
62
+ y1 = ay;
63
+ }
64
+ const { width, stride, acc, height } = this;
65
+ const dxdy = (x1 - x0) / (y1 - y0);
66
+ let x = x0;
67
+ if (y0 < 0) {
68
+ // enter the grid at row 0 rather than accumulating rows nobody reads
69
+ x -= y0 * dxdy;
70
+ y0 = 0;
71
+ }
72
+ const rowEnd = Math.min(height, Math.ceil(y1));
73
+ for (let y = y0 | 0; y < rowEnd; ++y) {
74
+ const line = y * stride;
75
+ const dy = Math.min(y + 1, y1) - Math.max(y, y0);
76
+ const xnext = x + dxdy * dy;
77
+ const d = dy * dir;
78
+ // the row's x span, clamped into the grid — geometry outside the
79
+ // bitmap piles its coverage onto the border column, which is what a
80
+ // clipped fill should look like
81
+ let xa = x < xnext ? x : xnext;
82
+ let xb = x < xnext ? xnext : x;
83
+ if (xa < 0) xa = 0;
84
+ else if (xa > width) xa = width;
85
+ if (xb < 0) xb = 0;
86
+ else if (xb > width) xb = width;
87
+
88
+ const xaFloor = Math.floor(xa);
89
+ const x0i = xaFloor | 0;
90
+ const xbCeil = Math.ceil(xb);
91
+ const x1i = xbCeil | 0;
92
+ if (x1i <= x0i + 1) {
93
+ // the whole span sits inside one pixel: split by the midpoint
94
+ const xmf = 0.5 * (xa + xb) - xaFloor;
95
+ acc[line + x0i] += d - d * xmf;
96
+ acc[line + x0i + 1] += d * xmf;
97
+ } else {
98
+ // trapezoidal split across the pixels the span crosses
99
+ const s = 1 / (xb - xa);
100
+ const x0f = xa - xaFloor;
101
+ const a0 = 0.5 * s * (1 - x0f) * (1 - x0f);
102
+ const x1f = xb - xbCeil + 1;
103
+ const am = 0.5 * s * x1f * x1f;
104
+ acc[line + x0i] += d * a0;
105
+ if (x1i === x0i + 2) {
106
+ acc[line + x0i + 1] += d * (1 - a0 - am);
107
+ } else {
108
+ const a1 = s * (1.5 - x0f);
109
+ acc[line + x0i + 1] += d * (a1 - a0);
110
+ for (let xi = x0i + 2; xi < x1i - 1; ++xi) acc[line + xi] += d * s;
111
+ const a2 = a1 + (x1i - x0i - 3) * s;
112
+ acc[line + x1i - 1] += d * (1 - a2 - am);
113
+ }
114
+ acc[line + x1i] += d * am;
115
+ }
116
+ x = xnext;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Accumulate a closed polygon.
122
+ * @param {ArrayLike<number>} pts flat [x0,y0,x1,y1,…]; the closing edge is implied
123
+ * @param {number} [dx] translation applied to every x
124
+ * @param {number} [dy] translation applied to every y
125
+ */
126
+ polygon(pts, dx = 0, dy = 0) {
127
+ const n = pts.length;
128
+ if (n < 6) return this;
129
+ let px = pts[n - 2] + dx;
130
+ let py = pts[n - 1] + dy;
131
+ for (let i = 0; i < n; i += 2) {
132
+ const x = pts[i] + dx;
133
+ const y = pts[i + 1] + dy;
134
+ this.edge(px, py, x, y);
135
+ px = x;
136
+ py = y;
137
+ }
138
+ return this;
139
+ }
140
+
141
+ /**
142
+ * Accumulate a triangle soup as a *union*: every triangle is wound the same
143
+ * way before it is added, so overlapping triangles reinforce rather than
144
+ * cancel and the clamped non-zero result is their union. This is what a
145
+ * stroke needs — extruded segments, caps and joins overlap constantly, and
146
+ * accumulating them without normalization would punch holes at the seams.
147
+ *
148
+ * @param {ArrayLike<number>} tris flat x,y pairs, three points per triangle
149
+ */
150
+ triangles(tris, dx = 0, dy = 0) {
151
+ for (let i = 0; i + 5 < tris.length; i += 6) {
152
+ const ax = tris[i] + dx;
153
+ const ay = tris[i + 1] + dy;
154
+ let bx = tris[i + 2] + dx;
155
+ let by = tris[i + 3] + dy;
156
+ let cx = tris[i + 4] + dx;
157
+ let cy = tris[i + 5] + dy;
158
+ const area = (bx - ax) * (cy - ay) - (cx - ax) * (by - ay);
159
+ if (area === 0) continue;
160
+ if (area < 0) {
161
+ const tx = bx;
162
+ const ty = by;
163
+ bx = cx;
164
+ by = cy;
165
+ cx = tx;
166
+ cy = ty;
167
+ }
168
+ this.edge(ax, ay, bx, by);
169
+ this.edge(bx, by, cx, cy);
170
+ this.edge(cx, cy, ax, ay);
171
+ }
172
+ return this;
173
+ }
174
+
175
+ /**
176
+ * Integrate the accumulator into 8-bit coverage.
177
+ *
178
+ * @param {Uint8Array|Buffer} [out] destination, `stride * height` bytes
179
+ * @param {'nonzero'|'evenodd'} [rule] fill rule
180
+ * @param {number} [stride] destination row stride (defaults to width; the
181
+ * X wire format wants rows padded to 4 bytes)
182
+ */
183
+ toAlpha(out, rule = 'nonzero', stride = this.width) {
184
+ const { width, height, acc } = this;
185
+ const dst = out ?? new Uint8Array(stride * height);
186
+ const evenodd = rule === 'evenodd';
187
+ for (let y = 0; y < height; ++y) {
188
+ let sum = 0;
189
+ const line = y * this.stride;
190
+ const orow = y * stride;
191
+ for (let x = 0; x < width; ++x) {
192
+ sum += acc[line + x];
193
+ let c = sum < 0 ? -sum : sum;
194
+ if (evenodd) {
195
+ // fold the winding number through a triangle wave: 0,1,2,3 ->
196
+ // 0,1,0,1 with the fractional parts still antialiased
197
+ c %= 2;
198
+ if (c > 1) c = 2 - c;
199
+ } else if (c > 1) {
200
+ c = 1;
201
+ }
202
+ dst[orow + x] = (c * 255 + 0.5) | 0;
203
+ }
204
+ }
205
+ return dst;
206
+ }
207
+ }
7
208
 
8
- const DEFAULT_SS = 4; // supersampling factor per axis
209
+ /**
210
+ * Rasterize closed polygons into an 8-bit coverage bitmap.
211
+ *
212
+ * @param {Array<ArrayLike<number>>} polys flat [x0,y0,…] polygons, device space
213
+ * @param {number} width
214
+ * @param {number} height
215
+ * @param {'nonzero'|'evenodd'} [rule]
216
+ * @param {object} [opts] { dx, dy, out, stride }
217
+ * @returns {Uint8Array|Buffer} `width * height` bytes (or `stride * height`)
218
+ */
219
+ export function rasterizePolys(polys, width, height, rule = 'nonzero', opts = {}) {
220
+ const { dx = 0, dy = 0, out, stride = width } = opts;
221
+ const acc = new CoverageAccumulator(width, height);
222
+ for (const p of polys) acc.polygon(p, dx, dy);
223
+ return acc.toAlpha(out, rule, stride);
224
+ }
225
+
226
+ /**
227
+ * Rasterize a triangle soup into an 8-bit coverage bitmap, unioning overlaps
228
+ * (see CoverageAccumulator#triangles).
229
+ */
230
+ export function rasterizeTriangles(tris, width, height, opts = {}) {
231
+ const { dx = 0, dy = 0, out, stride = width } = opts;
232
+ const acc = new CoverageAccumulator(width, height);
233
+ acc.triangles(tris, dx, dy);
234
+ return acc.toAlpha(out, 'nonzero', stride);
235
+ }
9
236
 
10
237
  /**
238
+ * Glyph outlines -> a8 bitmap for XRender AddGlyphs.
239
+ *
11
240
  * @param {Array} commands path commands ({type: M|L|Q|C|Z, x, y, x1, y1, x2, y2})
12
- * @param {number} [ss] supersampling factor per axis (large glyphs pass 2 —
13
- * AA quality is indistinguishable above ~96px and rasterization is 4x cheaper)
241
+ * in y-down pixel coordinates with the origin at the glyph baseline
242
+ * @param {number} [_ss] ignored; kept so callers that used to ask for a
243
+ * supersampling factor keep working. Coverage is analytic now, so there is
244
+ * no quality/speed dial to turn.
14
245
  * @returns {{width, height, left, top, data: Buffer}|null}
15
246
  * left/top are offsets of the bitmap's top-left corner relative to the
16
247
  * glyph origin (left is typically small, top is typically negative —
17
- * above the baseline). Returns null for empty outlines (e.g. space).
248
+ * above the baseline). `width` is the row stride, padded to 4 bytes as the
249
+ * AddGlyphs wire format requires. Returns null for empty outlines.
18
250
  */
19
- export function rasterizePath(commands, ss = DEFAULT_SS) {
20
- const SS = ss;
251
+ export function rasterizePath(commands, _ss) {
21
252
  const polys = flatten(commands);
22
253
  if (polys.length === 0) return null;
23
254
 
@@ -40,60 +271,9 @@ export function rasterizePath(commands, ss = DEFAULT_SS) {
40
271
  const height = Math.ceil(maxY) - top;
41
272
  if (width <= 0 || height <= 0) return null;
42
273
 
43
- // pad rows to 4 bytes as required by the AddGlyphs wire format
44
274
  const stride = (width + 3) & ~3;
45
- const coverage = new Uint16Array(stride * height);
46
-
47
- // closed edges in supersampled bitmap space
48
- const edges = [];
49
- for (const poly of polys) {
50
- const n = poly.length / 2;
51
- for (let i = 0; i < n; ++i) {
52
- const j = (i + 1) % n;
53
- const x0 = (poly[i * 2] - left) * SS;
54
- const y0 = (poly[i * 2 + 1] - top) * SS;
55
- const x1 = (poly[j * 2] - left) * SS;
56
- const y1 = (poly[j * 2 + 1] - top) * SS;
57
- if (y0 !== y1) edges.push([x0, y0, x1, y1]);
58
- }
59
- }
60
-
61
- const crossings = [];
62
- for (let row = 0; row < height * SS; ++row) {
63
- const y = row + 0.5;
64
- crossings.length = 0;
65
- for (const [x0, y0, x1, y1] of edges) {
66
- if ((y0 <= y && y1 > y) || (y1 <= y && y0 > y)) {
67
- const x = x0 + ((y - y0) * (x1 - x0)) / (y1 - y0);
68
- crossings.push([x, y1 > y0 ? 1 : -1]);
69
- }
70
- }
71
- crossings.sort((a, b) => a[0] - b[0]);
72
-
73
- let winding = 0;
74
- let spanStart = 0;
75
- const py = (row / SS) | 0;
76
- for (const [x, dir] of crossings) {
77
- if (winding === 0) spanStart = x;
78
- winding += dir;
79
- if (winding === 0) {
80
- // fill supersample cells whose centers are inside [spanStart, x)
81
- const from = Math.max(0, Math.ceil(spanStart - 0.5));
82
- const to = Math.min(width * SS - 1, Math.floor(x - 0.5));
83
- for (let cell = from; cell <= to; ++cell) {
84
- coverage[py * stride + ((cell / SS) | 0)]++;
85
- }
86
- }
87
- }
88
- }
89
-
90
275
  const data = Buffer.alloc(stride * height);
91
- const full = SS * SS;
92
- for (let i = 0; i < coverage.length; ++i) {
93
- const c = coverage[i];
94
- data[i] = c >= full ? 255 : ((c * 255) / full) | 0;
95
- }
96
-
276
+ rasterizePolys(polys, width, height, 'nonzero', { dx: -left, dy: -top, out: data, stride });
97
277
  return { width: stride, height, left, top, data };
98
278
  }
99
279
 
@@ -176,3 +356,93 @@ function curveSteps(...coords) {
176
356
  const size = Math.max(maxX - minX, maxY - minY);
177
357
  return Math.min(24, Math.max(3, Math.ceil(size / 3)));
178
358
  }
359
+
360
+ // ---------------------------------------------------------------------------
361
+ // the pluggable seam
362
+ //
363
+ // A Rasterizer answers one question — "give me the coverage of this geometry
364
+ // on a width x height grid" — and the 2d context uploads the answer as an a8
365
+ // mask. Everything downstream (globalAlpha, clip, composite op, gradient and
366
+ // solid sources) is unchanged, because what we hand the server is *coverage*,
367
+ // not colour.
368
+ //
369
+ // Rasterizer interface (duck-typed):
370
+ //
371
+ // rasterize(job) -> Uint8Array | Buffer | null
372
+ // `job` is { polys, triangles, width, height, rule, dx, dy }:
373
+ // - exactly one of `polys` (closed polygons, filled by `rule`) and
374
+ // `triangles` (a stroke's triangle soup, unioned regardless of rule)
375
+ // - `dx`/`dy` must be *added to every coordinate*. Geometry arrives in
376
+ // device space and the grid covers the drawing's bounding box, so the
377
+ // offset is what maps one to the other. It is passed rather than
378
+ // pre-applied because pre-applying means copying every point of every
379
+ // path on every frame, which is the cost this whole route exists to
380
+ // avoid.
381
+ // Return `width * height` bytes of 8-bit coverage, row-major and
382
+ // unpadded, or null to decline — declining routes the drawing back to
383
+ // the server's trapezoid path, so a partial implementation is safe.
384
+ // A rasterizer that only handles, say, non-zero fills can return null
385
+ // for everything else and stay correct.
386
+
387
+ /** the in-repo rasterizer: CoverageAccumulator, no dependencies */
388
+ export class ScanlineRasterizer {
389
+ rasterize({ polys, triangles, width, height, rule, dx = 0, dy = 0 }) {
390
+ const acc = new CoverageAccumulator(width, height);
391
+ if (triangles) acc.triangles(triangles, dx, dy);
392
+ else for (const p of polys) acc.polygon(p, dx, dy);
393
+ return acc.toAlpha(undefined, triangles ? 'nonzero' : rule);
394
+ }
395
+ }
396
+
397
+ let _defaultRasterizer = null;
398
+
399
+ /** the process-wide default Rasterizer */
400
+ export function defaultRasterizer() {
401
+ if (!_defaultRasterizer) _defaultRasterizer = new ScanlineRasterizer();
402
+ return _defaultRasterizer;
403
+ }
404
+
405
+ /**
406
+ * Replace the process-wide default Rasterizer. Affects every app created
407
+ * afterwards without an explicit one. Pass null to send every drawing to the
408
+ * server, which is the behaviour ntk had before local rasterization.
409
+ */
410
+ export function setDefaultRasterizer(rasterizer) {
411
+ _defaultRasterizer = rasterizer;
412
+ }
413
+
414
+ /**
415
+ * When a drawing is cheaper to rasterize here and upload than to hand the
416
+ * server as trapezoids.
417
+ *
418
+ * Coverage upload costs one byte per pixel of the bounding box and grows with
419
+ * *area*; the trapezoid path costs per-request overhead plus 40 bytes per
420
+ * trapezoid and grows with *geometric complexity*. So the threshold is a
421
+ * ratio, not a size — measured against XQuartz with two shapes that bracket
422
+ * the range (a rounded rectangle at 14-79 trapezoids, a stroked icon at
423
+ * 181-1053), local rasterization wins whenever `area / edges` is under ~150
424
+ * and loses above ~220, consistently for both.
425
+ *
426
+ * - `maxArea` — at or below this bounding-box area, always local. No shape
427
+ * measured lost here, so small drawings never pay for the check.
428
+ * - `bytesPerEdge` — above that, local while `area <= bytesPerEdge * edges`.
429
+ * Edge count is free (we just flattened the path); trapezoid count would
430
+ * be a better predictor but costs a trapezoidize to learn.
431
+ * - `maxBytes` — never upload more coverage than this in one go.
432
+ */
433
+ export const DEFAULT_RASTER_POLICY = {
434
+ maxArea: 64 * 64,
435
+ bytesPerEdge: 150,
436
+ maxBytes: 1 << 20
437
+ };
438
+
439
+ /**
440
+ * @returns {'local'|'server'} where a drawing of this size and complexity
441
+ * should be rasterized
442
+ */
443
+ export function routeRaster(width, height, edges, policy = DEFAULT_RASTER_POLICY) {
444
+ const area = width * height;
445
+ if (area <= 0 || area > policy.maxBytes) return 'server';
446
+ if (area <= policy.maxArea) return 'local';
447
+ return area <= policy.bytesPerEdge * edges ? 'local' : 'server';
448
+ }
@@ -17,6 +17,7 @@ import {
17
17
  } from './path.js';
18
18
  import Picture from './picture.js';
19
19
  import Pixmap from './pixmap.js';
20
+ import { routeRaster } from './rasterize.js';
20
21
  import { compositeTraps, drawGlyphRuns } from './text/glyphs.js';
21
22
  import { TextLayout } from './text/layout.js';
22
23
  import { reorderRuns } from './text/shape.js';
@@ -560,6 +561,54 @@ class RenderingContext2d {
560
561
  }
561
562
  }
562
563
 
564
+ // GC for uploading coverage into the scratch a8 mask
565
+ _maskGC() {
566
+ if (!this._fillMaskGC) {
567
+ this._fillMaskGC = this.X.AllocID();
568
+ this.X.CreateGC(this._fillMaskGC, this.fillMaskDrawable.id);
569
+ }
570
+ return this._fillMaskGC;
571
+ }
572
+
573
+ /**
574
+ * Rasterize `job` here and PutImage the coverage into the scratch mask at
575
+ * the drawing's bounding box, instead of asking the server to rasterize
576
+ * trapezoids. Returns false when the app has no rasterizer, when the policy
577
+ * routes this drawing to the server, or when the rasterizer declines — in
578
+ * every one of those cases the caller falls back to the trapezoid path.
579
+ *
580
+ * PutImage writes with Src semantics, so this replaces the mask clear as
581
+ * well as the AddTraps: two requests become one, and the one that is left
582
+ * touches only the bounding box.
583
+ */
584
+ _uploadCoverage(job, b, edges) {
585
+ const rasterizer = this.window.app.rasterizer;
586
+ if (!rasterizer) return false;
587
+ if (routeRaster(b.w, b.h, edges, this.window.app.rasterPolicy) !== 'local') return false;
588
+
589
+ const coverage = rasterizer.rasterize({ ...job, width: b.w, height: b.h });
590
+ if (!coverage) return false;
591
+
592
+ // X wants scanlines padded to 4 bytes; the rasterizer contract is
593
+ // unpadded rows. When the width is already a multiple of 4 — which every
594
+ // power-of-two icon box is — the coverage goes out as a view over the
595
+ // rasterizer's own bytes, with no copy at all.
596
+ const stride = (b.w + 3) & ~3;
597
+ let data;
598
+ if (stride === b.w) {
599
+ data = Buffer.isBuffer(coverage)
600
+ ? coverage
601
+ : Buffer.from(coverage.buffer, coverage.byteOffset, coverage.length);
602
+ } else {
603
+ data = Buffer.alloc(stride * b.h);
604
+ for (let y = 0; y < b.h; ++y) {
605
+ Buffer.from(coverage.buffer, coverage.byteOffset + y * b.w, b.w).copy(data, y * stride);
606
+ }
607
+ }
608
+ this.X.PutImage(2, this.fillMaskDrawable.id, this._maskGC(), b.w, b.h, b.x, b.y, 0, 8, data);
609
+ return true;
610
+ }
611
+
563
612
  /**
564
613
  * Core fill: rasterize device-space polys into the scratch a8 mask,
565
614
  * scale by globalAlpha, intersect with the clip, composite the source.
@@ -584,6 +633,27 @@ class RenderingContext2d {
584
633
  }
585
634
  }
586
635
  if (maxX === -Infinity) return null;
636
+ return this._clampBBox(minX, minY, maxX, maxY);
637
+ }
638
+
639
+ /** the same, for a flat triangle soup (stroke geometry) */
640
+ _trisBBox(tris) {
641
+ let minX = Infinity;
642
+ let minY = Infinity;
643
+ let maxX = -Infinity;
644
+ let maxY = -Infinity;
645
+ for (let i = 0; i < tris.length; i += 2) {
646
+ if (tris[i] < minX) minX = tris[i];
647
+ if (tris[i] > maxX) maxX = tris[i];
648
+ if (tris[i + 1] < minY) minY = tris[i + 1];
649
+ if (tris[i + 1] > maxY) maxY = tris[i + 1];
650
+ }
651
+ if (maxX === -Infinity) return null;
652
+ return this._clampBBox(minX, minY, maxX, maxY);
653
+ }
654
+
655
+ // a pixel of slack for the antialiased edge, clamped to the surface
656
+ _clampBBox(minX, minY, maxX, maxY) {
587
657
  const x = Math.max(0, Math.floor(minX) - 1);
588
658
  const y = Math.max(0, Math.floor(minY) - 1);
589
659
  const w = Math.min(this.width, Math.ceil(maxX) + 1) - x;
@@ -610,8 +680,17 @@ class RenderingContext2d {
610
680
  const R = this.Render;
611
681
 
612
682
  this._ensureFillMask();
613
- R.FillRectangles(R.PictOp.Src, this.fillMask.id, [0, 0, 0, 0], [b.x, b.y, b.w, b.h]);
614
- this._rasterizePolys(this.fillMask, polys, rule);
683
+ const flat = [];
684
+ let edges = 0;
685
+ for (const p of polys) {
686
+ if (p.pts.length < 6) continue;
687
+ flat.push(p.pts);
688
+ edges += p.pts.length / 2;
689
+ }
690
+ if (!this._uploadCoverage({ polys: flat, rule, dx: -b.x, dy: -b.y }, b, edges)) {
691
+ R.FillRectangles(R.PictOp.Src, this.fillMask.id, [0, 0, 0, 0], [b.x, b.y, b.w, b.h]);
692
+ this._rasterizePolys(this.fillMask, polys, rule);
693
+ }
615
694
 
616
695
  if (alpha < 1) {
617
696
  // In with a constant color scales the a8 coverage by that alpha
@@ -763,21 +842,33 @@ class RenderingContext2d {
763
842
  return;
764
843
  }
765
844
 
766
- // render coverage into the scratch mask, then composite through it so
767
- // the stroke honors clip / globalAlpha / composite op
845
+ // Render coverage into the scratch mask, then composite through it so
846
+ // the stroke honors clip / globalAlpha / composite op.
847
+ //
848
+ // Bounded to the stroke's bounding box, for the same reason _fillPolys
849
+ // is: on the wire a Composite is the same size either way, but this
850
+ // branch runs once per stroke, and a wall of 400 round-capped icons is
851
+ // 3200 of them. Clearing and compositing the whole surface each time was
852
+ // ~6 Gpx a frame and took 1.9 s on XQuartz where the bounded version
853
+ // takes 37 ms (react-x11#148). Stale mask content outside the box is
854
+ // never composited, so clearing only the box is safe.
768
855
  this._ensureFillMask();
769
- this.Render.FillRectangles(this.Render.PictOp.Src, this.fillMask.id, [0, 0, 0, 0], [0, 0, this.width, this.height]);
770
- const opaque = this.createSolidPicture(0, 0, 0, 1);
771
- for (let i = 0; i < tris.length; i += chunk) {
772
- this.Render.Triangles(this.Render.PictOp.Add, opaque.id, 0, 0, this.fillMask.id, this.Render.a8, tris.slice(i, i + chunk));
856
+ const b = this._trisBBox(tris);
857
+ if (!b) return;
858
+ if (!this._uploadCoverage({ triangles: tris, dx: -b.x, dy: -b.y }, b, tris.length / 2)) {
859
+ this.Render.FillRectangles(this.Render.PictOp.Src, this.fillMask.id, [0, 0, 0, 0], [b.x, b.y, b.w, b.h]);
860
+ const opaque = this.createSolidPicture(0, 0, 0, 1);
861
+ for (let i = 0; i < tris.length; i += chunk) {
862
+ this.Render.Triangles(this.Render.PictOp.Add, opaque.id, 0, 0, this.fillMask.id, this.Render.a8, tris.slice(i, i + chunk));
863
+ }
773
864
  }
774
865
  if (this.globalAlpha < 1) {
775
- this.Render.FillRectangles(this.Render.PictOp.In, this.fillMask.id, [0, 0, 0, this.globalAlpha], [0, 0, this.width, this.height]);
866
+ this.Render.FillRectangles(this.Render.PictOp.In, this.fillMask.id, [0, 0, 0, this.globalAlpha], [b.x, b.y, b.w, b.h]);
776
867
  }
777
868
  if (this.clipMask) {
778
- this.Render.Composite(this.Render.PictOp.In, this.clipMask.id, 0, this.fillMask.id, 0, 0, 0, 0, 0, 0, this.width, this.height);
869
+ this.Render.Composite(this.Render.PictOp.In, this.clipMask.id, 0, this.fillMask.id, b.x, b.y, 0, 0, b.x, b.y, b.w, b.h);
779
870
  }
780
- this.Render.Composite(op, src.id, this.fillMask.id, this.picture.id, 0, 0, 0, 0, 0, 0, this.width, this.height);
871
+ this.Render.Composite(op, src.id, this.fillMask.id, this.picture.id, b.x, b.y, b.x, b.y, b.x, b.y, b.w, b.h);
781
872
  this._markDirty();
782
873
  }
783
874
 
package/lib/text/font.js CHANGED
@@ -143,11 +143,10 @@ export default class Font {
143
143
  /**
144
144
  * Rasterize a glyph to an 8-bit alpha bitmap (or null for blank glyphs).
145
145
  * Converts fontkit's y-up font-unit path to the y-down pixel commands the
146
- * scanline rasterizer expects. Above 96px supersampling drops from 4x4 to
147
- * 2x2 — visually indistinguishable at that size, 4x cheaper to rasterize.
146
+ * scanline rasterizer expects.
148
147
  */
149
148
  rasterize(glyphId, size) {
150
- return rasterizePath(this._pathCommands(glyphId, size), size > 96 ? 2 : 4);
149
+ return rasterizePath(this._pathCommands(glyphId, size));
151
150
  }
152
151
 
153
152
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "5.0.0",
3
+ "version": "5.1.0",
4
4
  "description": "Desktop UI toolkit for X11 with canvas-like 2d and OpenGL rendering",
5
5
  "author": "Andrey Sidorov <sidorares@yandex.ru>",
6
6
  "license": "MIT",