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.
- package/lib/app.js +108 -0
- package/lib/index.js +2 -0
- package/lib/path.js +196 -6
- package/lib/renderingcontext_2d.js +458 -8
- package/lib/shapeglyphs.js +327 -0
- package/lib/window.js +495 -56
- package/package.json +1 -1
|
@@ -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
|
+
}
|