ntk 6.6.1 → 7.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.
@@ -0,0 +1,327 @@
1
+ // Rounded-rectangle fast path (issue #211): the server-side corner-glyph
2
+ // cache and the geometry helpers behind it.
3
+ //
4
+ // A recognized `fill()`/`stroke()` of an axis-aligned rounded rect is not
5
+ // rasterized as a polygon at all: its only curved ink — the corners — is
6
+ // emitted as XRender *glyphs* (the one path every server accelerates, cached
7
+ // server-side after first use), and the straight runs between them as
8
+ // FillRectangles. The 2d context owns the recognition and the emission
9
+ // (lib/renderingcontext_2d.js); this module owns the cache and the corner
10
+ // bitmaps.
11
+ //
12
+ // The pieces partition the pixels: corner glyphs own their integer-cut
13
+ // boxes, rects own the strips between, and no pixel is painted twice — which
14
+ // is what makes translucent colours safe with no mask format and no
15
+ // accumulation tricks. Each corner rasterizes the true geometry inside its
16
+ // region through the same ellipse lowering and coverage accumulator the
17
+ // polygon fallback uses, so the two routes agree to the last antialiased
18
+ // step.
19
+ //
20
+ // Corners are the whole cache: keyed by (kind, rx, ry, borderWidth, corner),
21
+ // so width and height are *not* in the key — an animating progress bar or
22
+ // pill keeps all its glyphs while the rects stretch. The server additionally
23
+ // deduplicates glyph bitmaps across clients by content hash, so every app
24
+ // using the same radii shares storage.
25
+ //
26
+ // Mirrored corners: only the top-left corner of a (kind, rx, ry, bw) family
27
+ // is ever rasterized; the other three are byte-level mirror flips of that
28
+ // bitmap (see `orientCorner`). RENDER has no per-glyph transform, so the
29
+ // *server* still needs all four bitmaps — the flip saves the three extra
30
+ // rasterizations and makes the four corners of a box pixel-exact mirror
31
+ // images of each other, which direct per-corner rasterization does not
32
+ // guarantee (floating-point coverage accumulation is not symmetric under
33
+ // reflection).
34
+
35
+ import GlyphSet from './glyphset.js';
36
+ import { ellipseSegments, flattenPath } from './path.js';
37
+ import { rasterizePolys } from './rasterize.js';
38
+
39
+ /**
40
+ * Shape rendering policy, overridable per app via `app.shapePolicy`
41
+ * (partial objects are fine — `app.shapePolicy = { maxRadius: 0 }` disables
42
+ * the fast path entirely, which is also what `NTK_NO_SHAPE_GLYPHS=1` does).
43
+ *
44
+ * - `maxRadius` — corners with a radius (or stroke-band extent) above this
45
+ * fall back to the polygon route. Glyph-atlas behaviour past ~64px is
46
+ * driver-specific; the conservative default keeps every plausible widget
47
+ * inside and every atlas happy.
48
+ * - `cacheBytes` — budget for uploaded corner bitmaps per connection. A
49
+ * design system's whole population is 8 glyphs x r^2 bytes per (r, bw) —
50
+ * typically 10-20KB — so even this small budget is generous. Exceeding it
51
+ * (an adversarial animated-radius load) resets the page; steady states
52
+ * never do.
53
+ */
54
+ export const DEFAULT_SHAPE_POLICY = {
55
+ maxRadius: 64,
56
+ cacheBytes: 256 << 10
57
+ };
58
+
59
+ const envDisabled =
60
+ typeof process !== 'undefined' && process.env && process.env.NTK_NO_SHAPE_GLYPHS;
61
+
62
+ export function shapePolicyOf(app) {
63
+ const policy = app.shapePolicy
64
+ ? { ...DEFAULT_SHAPE_POLICY, ...app.shapePolicy }
65
+ : DEFAULT_SHAPE_POLICY;
66
+ return envDisabled ? { ...policy, maxRadius: 0 } : policy;
67
+ }
68
+
69
+ // corner codes, also the two flip bits: bit 0 = mirrored in x, bit 1 = in y
70
+ export const TL = 0;
71
+ export const TR = 1;
72
+ export const BL = 2;
73
+ export const BR = 3;
74
+
75
+ /** cache key of one corner glyph. `bw` is 0 for fills. */
76
+ export function cornerKey(kind, rx, ry, bw, corner) {
77
+ return `${kind}|${rx}|${ry}|${bw}|${corner}`;
78
+ }
79
+
80
+ /**
81
+ * Rasterize the top-left master bitmap of a corner family.
82
+ *
83
+ * kind 'fill': the quarter disc — the corner box is rx x ry with the ellipse
84
+ * centred on its bottom-right point; everything inside the arc has full
85
+ * coverage, so the box's straight cut edges land on interior pixels.
86
+ *
87
+ * kind 'stroke': the quarter ring — the stroke band around a corner of path
88
+ * radius `r = rx` with line width `bw`. The box is K x K, K = ceil(r + bw/2),
89
+ * anchored on the *outer* corner of the band; the ink is the annulus sector
90
+ * plus the straight band continuations out to the box edges, so the box
91
+ * edges cut the band where its pixels are fully covered whatever half-pixel
92
+ * the arc ends on (a 1px border's path sits on half-integers).
93
+ *
94
+ * The arc is lowered exactly the way the polygon fallback lowers it —
95
+ * `ellipseSegments` cubics flattened by `flattenPath` at the default
96
+ * tolerance, rasterized by the same coverage accumulator — so the fast path
97
+ * agrees with the fallback to the last antialiased step.
98
+ *
99
+ * @returns {{ w, h, stride, data: Uint8Array }} unpadded w x h coverage
100
+ */
101
+ export function rasterizeCornerMaster(kind, rx, ry, bw) {
102
+ let w;
103
+ let h;
104
+ let cmds;
105
+ if (kind === 'fill') {
106
+ w = rx;
107
+ h = ry;
108
+ const arc = ellipseSegments(rx, ry, rx, ry, 0, Math.PI, Math.PI * 1.5);
109
+ cmds = [
110
+ { type: 'M', x: arc.start.x, y: arc.start.y }, // (0, ry)
111
+ ...arc.cmds, // -> (rx, 0)
112
+ { type: 'L', x: rx, y: ry },
113
+ { type: 'Z' }
114
+ ];
115
+ } else {
116
+ const r = rx;
117
+ const ro = r + bw / 2;
118
+ const ri = r - bw / 2;
119
+ const K = Math.ceil(ro);
120
+ w = K;
121
+ h = K;
122
+ const outer = ellipseSegments(ro, ro, ro, ro, 0, Math.PI, Math.PI * 1.5);
123
+ // the inner arc runs backwards, closing the ring; at ri = 0 it collapses
124
+ // to the centre point and the ink is the whole quarter disc
125
+ const inner = ellipseSegments(ro, ro, ri, ri, 0, Math.PI * 1.5, Math.PI, true);
126
+ cmds = [
127
+ { type: 'M', x: 0, y: K },
128
+ { type: 'L', x: 0, y: ro },
129
+ ...outer.cmds, // -> (ro, 0)
130
+ { type: 'L', x: K, y: 0 },
131
+ { type: 'L', x: K, y: bw },
132
+ { type: 'L', x: inner.start.x, y: inner.start.y }, // (ro, bw)
133
+ ...inner.cmds, // -> (bw, ro)
134
+ { type: 'L', x: bw, y: K },
135
+ { type: 'Z' }
136
+ ];
137
+ }
138
+ const polys = [];
139
+ for (const poly of flattenPath(cmds)) if (poly.pts.length >= 6) polys.push(poly.pts);
140
+ const data = rasterizePolys(polys, w, h, 'nonzero');
141
+ return { w, h, stride: (w + 3) & ~3, data };
142
+ }
143
+
144
+ /**
145
+ * One oriented corner bitmap from the top-left master: a byte-level mirror
146
+ * flip, padded to the 4-byte row stride AddGlyphs wants. Flipping bytes
147
+ * rather than re-rasterizing mirrored geometry keeps the four corners exact
148
+ * mirror images and costs a copy instead of a coverage accumulation.
149
+ */
150
+ export function orientCorner(master, corner) {
151
+ const { w, h, stride, data } = master;
152
+ const out = Buffer.alloc(stride * h);
153
+ const flipX = (corner & 1) !== 0;
154
+ const flipY = (corner & 2) !== 0;
155
+ for (let y = 0; y < h; y++) {
156
+ const srow = (flipY ? h - 1 - y : y) * w;
157
+ const orow = y * stride;
158
+ if (flipX) {
159
+ for (let x = 0; x < w; x++) out[orow + x] = data[srow + (w - 1 - x)];
160
+ } else {
161
+ out.set(data.subarray(srow, srow + w), orow);
162
+ }
163
+ }
164
+ return out;
165
+ }
166
+
167
+ /**
168
+ * Server-side cache of corner glyphs — one page per app, all radii and
169
+ * border widths together, mirroring `GlyphPage` for text (text/glyphs.js).
170
+ *
171
+ * Local ids are compact and sequential so CompositeGlyphs uses the 8-bit
172
+ * encoding for the first 256 distinct corners (a shape population never
173
+ * leaves it in practice); new corners upload lazily in one AddGlyphs batch
174
+ * per draw that introduces any.
175
+ */
176
+ export class ShapeGlyphPage {
177
+ constructor(app) {
178
+ this.app = app;
179
+ this.glyphset = new GlyphSet(app);
180
+ this.entries = new Map(); // cornerKey -> { lid }
181
+ this.bytes = 0; // uploaded bitmap bytes, for the cache budget
182
+ }
183
+
184
+ /** bits per glyph id needed to address this page */
185
+ get bits() {
186
+ return this.entries.size <= 256 ? 8 : 16;
187
+ }
188
+
189
+ /**
190
+ * Ensure the corners of one drawing are uploaded. `specs` is
191
+ * [{ key, kind, rx, ry, bw, corner }] — a spec whose key is present costs
192
+ * a Map hit and nothing else. Within one batch the top-left master of each
193
+ * (kind, rx, ry, bw) family rasterizes once and the other corners mirror
194
+ * it (see `orientCorner`).
195
+ */
196
+ ensure(specs) {
197
+ let batch = null;
198
+ let masters = null;
199
+ for (const spec of specs) {
200
+ if (this.entries.has(spec.key)) continue;
201
+ const lid = this.entries.size;
202
+ if (lid >= 65536) {
203
+ // 2^16 corners on one connection — unreachable (the cache budget
204
+ // resets the page long before), but fail loudly rather than corrupt
205
+ throw new Error('shape glyph page overflow');
206
+ }
207
+ const mkey = `${spec.kind}|${spec.rx}|${spec.ry}|${spec.bw}`;
208
+ if (!masters) masters = new Map();
209
+ let master = masters.get(mkey);
210
+ if (!master) {
211
+ master = rasterizeCornerMaster(spec.kind, spec.rx, spec.ry, spec.bw);
212
+ masters.set(mkey, master);
213
+ }
214
+ const image = orientCorner(master, spec.corner);
215
+ this.entries.set(spec.key, { lid });
216
+ if (!batch) batch = [];
217
+ batch.push({
218
+ id: lid,
219
+ // width is the padded stride, like the text page: node-x11 then
220
+ // ships the buffer as-is instead of re-padding a copy, and the spare
221
+ // zero columns composite as no-ops under Over
222
+ width: master.stride,
223
+ height: master.h,
224
+ // x/y 0: the glyph's top-left lands exactly on its elt position
225
+ x: 0,
226
+ y: 0,
227
+ // corners do not advance a pen. node-x11 expects offX/offY in 26.6
228
+ // fixed point and mutates the objects it is given, which is why
229
+ // these are fresh one-shot objects (see AGENTS.md)
230
+ offX: 0,
231
+ offY: 0,
232
+ image
233
+ });
234
+ this.bytes += image.length;
235
+ }
236
+ if (batch) this.glyphset.addGlyphs(batch);
237
+ }
238
+
239
+ entry(key) {
240
+ return this.entries.get(key);
241
+ }
242
+
243
+ /** free the server-side glyphset (budget reset / shutdown) */
244
+ destroy() {
245
+ this.glyphset.destroy();
246
+ this.entries.clear();
247
+ this.bytes = 0;
248
+ }
249
+ }
250
+
251
+ /** the per-app page, created on first use */
252
+ export function getShapeGlyphPage(app) {
253
+ if (!app._shapeGlyphPage) app._shapeGlyphPage = new ShapeGlyphPage(app);
254
+ return app._shapeGlyphPage;
255
+ }
256
+
257
+ /**
258
+ * Enforce the cache budget after a draw. Corner populations are so small
259
+ * that per-glyph LRU would be bookkeeping for its own sake — over budget
260
+ * (an adversarial animated-radius load) the whole page resets and the
261
+ * corners still in use re-upload on the next draw, bounding server memory
262
+ * at `cacheBytes` plus one animation step. FreeGlyphSet is queued after any
263
+ * CompositeGlyphs already issued, so mid-frame resets are ordering-safe.
264
+ */
265
+ export function trimShapeGlyphs(app, policy = shapePolicyOf(app)) {
266
+ const page = app._shapeGlyphPage;
267
+ if (page && page.bytes > policy.cacheBytes) {
268
+ page.destroy();
269
+ app._shapeGlyphPage = null;
270
+ }
271
+ }
272
+
273
+ /**
274
+ * The strips of a rounded-rect fill that are not corner glyphs, as a flat
275
+ * FillRectangles list. Rows are cut at every corner-box edge; each band
276
+ * spans between the corner boxes that reach it. Uniform radii produce the
277
+ * classic 3 bands; fully general per-corner radii at most 5.
278
+ *
279
+ * `c` holds the effective corner box sizes { tlw, tlh, trw, trh, blw, blh,
280
+ * brw, brh } (zero for square corners). Caller guarantees same-edge sums fit
281
+ * (normalizeRadii does) and that diagonally opposite boxes do not overlap.
282
+ */
283
+ export function roundRectBandRects(x, y, w, h, c) {
284
+ const cuts = [...new Set([0, c.tlh, c.trh, h - c.blh, h - c.brh, h])]
285
+ .filter((v) => v >= 0 && v <= h)
286
+ .sort((a, b) => a - b);
287
+ const rects = [];
288
+ for (let i = 0; i + 1 < cuts.length; i++) {
289
+ const y0 = cuts[i];
290
+ const y1 = cuts[i + 1];
291
+ if (y1 <= y0) continue;
292
+ const left = Math.max(y0 < c.tlh ? c.tlw : 0, y1 > h - c.blh ? c.blw : 0);
293
+ const right = Math.max(y0 < c.trh ? c.trw : 0, y1 > h - c.brh ? c.brw : 0);
294
+ const bandW = w - left - right;
295
+ if (bandW > 0) rects.push(x + left, y + y0, bandW, y1 - y0);
296
+ }
297
+ return rects;
298
+ }
299
+
300
+ // process-wide observability: every bail-out is a silent perf cliff, so the
301
+ // counters are always collected (cheap increments) and printed at exit under
302
+ // NTK_DEBUG_SHAPES=1. Contexts carry their own copies for per-surface
303
+ // inspection (ctx.shapeStats).
304
+ export const shapeGlyphCounters = { hits: 0, misses: {} };
305
+
306
+ export function countShapeMiss(reason) {
307
+ shapeGlyphCounters.misses[reason] = (shapeGlyphCounters.misses[reason] || 0) + 1;
308
+ }
309
+
310
+ export function countShapeHit() {
311
+ shapeGlyphCounters.hits++;
312
+ }
313
+
314
+ if (
315
+ typeof process !== 'undefined' &&
316
+ process.env &&
317
+ process.env.NTK_DEBUG_SHAPES &&
318
+ typeof process.on === 'function'
319
+ ) {
320
+ process.on('exit', () => {
321
+ const { hits, misses } = shapeGlyphCounters;
322
+ const parts = Object.entries(misses).map(([k, v]) => `${k}: ${v}`);
323
+ console.error(
324
+ `ntk shapes: ${hits} fast-path draws, misses { ${parts.join(', ')} }`
325
+ );
326
+ });
327
+ }