ntk 2.2.0 → 3.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.
Files changed (47) hide show
  1. package/README.md +156 -151
  2. package/lib/app.js +64 -21
  3. package/lib/cleanup.js +14 -0
  4. package/lib/drawable.js +12 -18
  5. package/lib/events_map.js +92 -98
  6. package/lib/fontconfig.js +123 -17
  7. package/lib/glyphset.js +40 -21
  8. package/lib/image.js +138 -0
  9. package/lib/index.js +129 -57
  10. package/lib/path.js +734 -0
  11. package/lib/picture.js +84 -41
  12. package/lib/pixmap.js +50 -39
  13. package/lib/rasterize.js +178 -0
  14. package/lib/renderingcontext_2d.js +1078 -695
  15. package/lib/renderingcontext_opengl.js +53 -46
  16. package/lib/renderingcontext_x11.js +59 -67
  17. package/lib/text/font.js +196 -0
  18. package/lib/text/fontmanager.js +233 -0
  19. package/lib/text/fontsource.js +179 -0
  20. package/lib/text/glyphs.js +456 -0
  21. package/lib/text/layout.js +356 -0
  22. package/lib/text/shape.js +141 -0
  23. package/lib/trapezoid.js +131 -0
  24. package/lib/widgets/css.js +676 -0
  25. package/lib/widgets/highlight.js +97 -0
  26. package/lib/widgets/htmlview.js +879 -0
  27. package/lib/widgets/markdown.js +148 -0
  28. package/lib/widgets/markdownview.js +487 -0
  29. package/lib/widgets/mermaid.js +720 -0
  30. package/lib/widgets/svgview.js +528 -0
  31. package/lib/widgets/tex.js +681 -0
  32. package/lib/window.js +691 -308
  33. package/package.json +60 -56
  34. package/examples/example-await.js +0 -23
  35. package/examples/example-co.js +0 -13
  36. package/examples/example.js +0 -14
  37. package/examples/fill-styles.js +0 -39
  38. package/examples/glclock.js +0 -100
  39. package/examples/glxpixmap.js +0 -12
  40. package/examples/gradient.js +0 -41
  41. package/examples/package.json +0 -17
  42. package/examples/simpletex.js +0 -72
  43. package/examples/teapot.js +0 -104
  44. package/examples/teapot.json +0 -6
  45. package/examples/text-rendering.js +0 -44
  46. package/lib/fontface.js +0 -103
  47. package/lib/widgets/webview.js +0 -33
@@ -0,0 +1,179 @@
1
+ // Pluggable system-font lookup — the seam that lets the text stack run in
2
+ // environments without fontconfig or a filesystem (browser bundles, hermetic
3
+ // tests). A font source answers "which fonts match this pattern, in fallback
4
+ // order?" and hands the FontManager something it can open.
5
+ //
6
+ // FontSource interface (duck-typed):
7
+ //
8
+ // matchSorted({ family, weight, style }) -> candidate[]
9
+ // Full match list for a pattern, best first — the fallback chain.
10
+ // `family` may be a CSS-style comma-separated list. Must return at
11
+ // least one candidate or throw. A candidate is openable:
12
+ // { key?, path?, data?, font?, postscriptName? }
13
+ // - `font`: an already-open Font instance (preferred when available)
14
+ // - `data`: font file bytes (Uint8Array/Buffer) for fontkit
15
+ // - `path`: font file path (node only)
16
+ // - `key`: stable cache key (defaults to `${path}#${postscriptName}`)
17
+ //
18
+ // covers(candidate, codepoint) -> boolean [optional]
19
+ // Cheap coverage pre-filter used during per-codepoint fallback, ideally
20
+ // without opening the font. When absent, candidates are opened and
21
+ // checked with `font.hasGlyph()`.
22
+ //
23
+ // The default source shells out to fc-match (fontconfig) — the behavior ntk
24
+ // always had. Swap it per-app (`createClient({ fontSource })`), per-manager
25
+ // (`new FontManager({ source })`) or globally (`setDefaultFontSource()`).
26
+ import { charsetHas, matchSortedSync } from '../fontconfig.js';
27
+ import Font from './font.js';
28
+
29
+ const WEIGHTS = { normal: 400, bold: 700 };
30
+
31
+ /** css-ish weight ('bold' | 400 | '600') -> number, default 400 */
32
+ export function numericWeight(weight) {
33
+ if (weight === undefined) return 400;
34
+ if (WEIGHTS[weight] !== undefined) return WEIGHTS[weight];
35
+ const n = parseInt(weight, 10);
36
+ return Number.isNaN(n) ? 400 : n;
37
+ }
38
+
39
+ /**
40
+ * Detect a Font's weight/italic from its tables. Fonts are sloppy about
41
+ * italics — check the fsSelection bit, then italicAngle, then the
42
+ * subfamily name (KaTeX's italic faces set nothing but the name).
43
+ */
44
+ export function detectStyle(font) {
45
+ const os2 = font.fk['OS/2'];
46
+ return {
47
+ weight: os2 ? os2.usWeightClass : 400,
48
+ italic: !!(
49
+ (os2 && os2.fsSelection.italic) ||
50
+ font.fk.italicAngle ||
51
+ /italic|oblique/i.test(font.fk.subfamilyName || '')
52
+ )
53
+ };
54
+ }
55
+
56
+ /** split a CSS font-family list into normalized lowercase names */
57
+ export function parseFamilies(family) {
58
+ return String(family ?? '')
59
+ .split(',')
60
+ .map((f) => f.trim().replace(/^["']|["']$/g, '').toLowerCase())
61
+ .filter(Boolean);
62
+ }
63
+
64
+ /**
65
+ * The default source: fontconfig via the fc-match CLI, font files opened
66
+ * from disk. Node-only (needs shell access); see StaticFontSource for a
67
+ * self-contained alternative.
68
+ */
69
+ export class FontconfigFontSource {
70
+ matchSorted(pattern) {
71
+ return matchSortedSync(pattern);
72
+ }
73
+
74
+ covers(candidate, codepoint) {
75
+ return charsetHas(candidate, codepoint);
76
+ }
77
+ }
78
+
79
+ /**
80
+ * A font source backed entirely by fonts you hand it — no fontconfig, no
81
+ * filesystem. Works anywhere fontkit runs, including browser bundles:
82
+ *
83
+ * const source = new StaticFontSource();
84
+ * source.add(bytes, { family: 'DejaVu Sans' });
85
+ * source.alias('sans-serif', 'DejaVu Sans');
86
+ * const app = await createClient({ fontSource: source });
87
+ *
88
+ * Matching mirrors fontconfig semantics: every added face is a fallback
89
+ * candidate; faces matching a requested family (in list order) rank first,
90
+ * then closest weight, then style. Coverage checks use the real font
91
+ * tables, so per-codepoint fallback works exactly like the system path.
92
+ */
93
+ export class StaticFontSource {
94
+ constructor() {
95
+ this._faces = []; // { font, family, weight, italic, candidate }
96
+ this._aliases = new Map(); // 'sans-serif' -> 'dejavu sans'
97
+ this._n = 0;
98
+ }
99
+
100
+ /**
101
+ * Register a font.
102
+ * @param {Uint8Array|Buffer|Font} data font file bytes (or an open Font)
103
+ * @param {object} [opts] { family (alias for matching; defaults to the
104
+ * font's family name), weight, style, postscriptName (face of a .ttc) }
105
+ * @returns {Font}
106
+ */
107
+ add(data, opts = {}) {
108
+ const font =
109
+ data instanceof Font
110
+ ? data
111
+ : Font.fromData(data, {
112
+ key: `static:${this._n++}:${opts.family || opts.postscriptName || ''}`,
113
+ postscriptName: opts.postscriptName
114
+ });
115
+ const detected = detectStyle(font);
116
+ this._faces.push({
117
+ font,
118
+ family: (opts.family || font.familyName || '').toLowerCase(),
119
+ weight: opts.weight !== undefined ? numericWeight(opts.weight) : detected.weight,
120
+ italic:
121
+ opts.style !== undefined ? String(opts.style).includes('italic') : detected.italic,
122
+ candidate: null
123
+ });
124
+ return font;
125
+ }
126
+
127
+ /** map a generic family ('sans-serif', 'monospace', …) to an added family */
128
+ alias(name, family) {
129
+ this._aliases.set(name.toLowerCase(), family.toLowerCase());
130
+ }
131
+
132
+ matchSorted(pattern = {}) {
133
+ if (this._faces.length === 0) {
134
+ throw new Error('StaticFontSource: no fonts added');
135
+ }
136
+ const families = parseFamilies(pattern.family).map((f) => this._aliases.get(f) ?? f);
137
+ const weight = numericWeight(pattern.weight);
138
+ const italic = !!(pattern.style && String(pattern.style).includes('italic'));
139
+
140
+ const scored = this._faces.map((face) => {
141
+ let rank = families.indexOf(face.family);
142
+ if (rank === -1) rank = families.length; // non-matching faces trail
143
+ return {
144
+ face,
145
+ score:
146
+ rank * 1e6 + (face.italic !== italic ? 1e4 : 0) + Math.abs(face.weight - weight)
147
+ };
148
+ });
149
+ scored.sort((a, b) => a.score - b.score);
150
+ return scored.map(({ face }) => {
151
+ if (!face.candidate) {
152
+ face.candidate = { key: face.font.key, font: face.font };
153
+ }
154
+ return face.candidate;
155
+ });
156
+ }
157
+
158
+ covers(candidate, codepoint) {
159
+ return candidate.font.hasGlyph(codepoint);
160
+ }
161
+ }
162
+
163
+ let _default = null;
164
+
165
+ /** the process-wide default FontSource (fontconfig unless overridden) */
166
+ export function defaultFontSource() {
167
+ if (!_default) _default = new FontconfigFontSource();
168
+ return _default;
169
+ }
170
+
171
+ /**
172
+ * Replace the process-wide default FontSource. Affects every FontManager
173
+ * created afterwards without an explicit source — including the ones
174
+ * widgets create internally. The primary hook for browser playgrounds:
175
+ * call it once with a StaticFontSource before creating any app/window.
176
+ */
177
+ export function setDefaultFontSource(source) {
178
+ _default = source;
179
+ }
@@ -0,0 +1,456 @@
1
+ import GlyphSet from '../glyphset.js';
2
+ import Picture from '../picture.js';
3
+ import Pixmap from '../pixmap.js';
4
+ import { trapezoidize } from '../trapezoid.js';
5
+
6
+ /**
7
+ * Text rendering policy — the bitmap/vector routing thresholds and the
8
+ * server-side glyph cache budget. Override per app via `app.textPolicy`
9
+ * (partial objects are fine, e.g. `app.textPolicy = { vectorFrom: Infinity }`
10
+ * to opt out of the vector path entirely). See docs/text.md.
11
+ *
12
+ * - `bitmapMax` — at or below this pixel size glyphs always render through
13
+ * cached server-side bitmaps (upload once, ~1 byte per glyph per draw).
14
+ * - `vectorFrom` — above this size glyphs render as trapezoids every draw:
15
+ * bitmap uploads scale with size² (a full Latin set at 256px is ~2.4MB of
16
+ * server cache per face) while outlines scale with ~√size.
17
+ * - Between the two, bitmaps are used unless the size is fractional or the
18
+ * face shows no size reuse across recent draws (continuous zoom/pinch
19
+ * animation) — the cases where per-size caches never amortize.
20
+ * - `cacheBytes` — LRU budget for uploaded glyph bitmaps per connection;
21
+ * least-recently-drawn (face, size) pages are freed server-side
22
+ * (FreeGlyphSet) so transient sizes don't accumulate.
23
+ */
24
+ export const DEFAULT_TEXT_POLICY = {
25
+ bitmapMax: 128,
26
+ vectorFrom: 256,
27
+ cacheBytes: 8 << 20
28
+ };
29
+
30
+ function policyOf(app) {
31
+ return app.textPolicy ? { ...DEFAULT_TEXT_POLICY, ...app.textPolicy } : DEFAULT_TEXT_POLICY;
32
+ }
33
+
34
+ /**
35
+ * Server-side glyph cache for one (font face, pixel size) pair.
36
+ *
37
+ * Wire-efficiency design (see docs/text.md):
38
+ *
39
+ * - Glyph ids inside an XRender glyphset are client-assigned. We assign
40
+ * **compact sequential ids** (0, 1, 2, …) in upload order instead of using
41
+ * font glyph indices, so CompositeGlyphs can use the 8-bit encoding
42
+ * (1 byte per glyph) for the first 256 distinct glyphs of a face/size —
43
+ * which covers virtually all Latin text — and 16-bit after that.
44
+ * - Each glyph's rounded nominal advance is baked into the glyph itself
45
+ * (`offX`), so the server advances the pen automatically and runs of
46
+ * unkerned text need no per-glyph position data at all.
47
+ * - Bitmaps upload lazily, once per glyph, batched into one AddGlyphs
48
+ * request per draw that introduces new glyphs.
49
+ */
50
+ export class GlyphPage {
51
+ constructor(app, font, size) {
52
+ this.app = app;
53
+ this.font = font;
54
+ this.size = size;
55
+ this.glyphset = new GlyphSet(app);
56
+ this.entries = new Map(); // font glyph id -> { lid, adv }
57
+ this.bytes = 0; // uploaded bitmap bytes, for the LRU budget
58
+ }
59
+
60
+ /** number of bits per glyph needed to address this page's ids */
61
+ get bits() {
62
+ return this.entries.size <= 256 ? 8 : 16;
63
+ }
64
+
65
+ /**
66
+ * Ensure all glyphs of a shaped run are uploaded; returns nothing.
67
+ * New glyphs are rasterized and sent in a single AddGlyphs request.
68
+ */
69
+ ensure(glyphs) {
70
+ let batch = null;
71
+ for (const g of glyphs) {
72
+ if (this.entries.has(g.id)) continue;
73
+ const lid = this.entries.size;
74
+ if (lid >= 65536) {
75
+ // 2^16 distinct glyphs at one size — not reachable in practice
76
+ // (fonts cap at 65535 glyphs) but fail loudly rather than corrupt
77
+ throw new Error('glyph page overflow');
78
+ }
79
+ const adv = Math.round(this.font.advanceOf(g.id, this.size));
80
+ const bitmap = this.font.rasterize(g.id, this.size);
81
+ this.entries.set(g.id, { lid, adv });
82
+ if (!batch) batch = [];
83
+ batch.push({
84
+ id: lid,
85
+ width: bitmap ? bitmap.width : 0,
86
+ height: bitmap ? bitmap.height : 0,
87
+ // XRender GLYPHINFO places the image at origin - (x, y); node-x11
88
+ // packs -x and +y, so x here is the bitmap's left bearing and y its
89
+ // (negated, y-down) top — i.e. the ascent above the baseline
90
+ x: bitmap ? bitmap.left : 0,
91
+ y: bitmap ? -bitmap.top : 0,
92
+ // node-x11 AddGlyphs expects 26.6 fixed point and divides by 64
93
+ offX: adv * 64,
94
+ offY: 0,
95
+ image: bitmap ? bitmap.data : Buffer.alloc(0)
96
+ });
97
+ this.bytes += bitmap ? bitmap.data.length : 0;
98
+ }
99
+ if (batch) this.glyphset.addGlyphs(batch);
100
+ }
101
+
102
+ entry(fontGlyphId) {
103
+ return this.entries.get(fontGlyphId);
104
+ }
105
+
106
+ /** free the server-side glyphset (LRU eviction / shutdown) */
107
+ destroy() {
108
+ this.glyphset.destroy();
109
+ this.entries.clear();
110
+ this.bytes = 0;
111
+ }
112
+ }
113
+
114
+ /** per-app page cache: pages (and their server glyphsets) are shared by all
115
+ * windows/pixmaps of a connection. Access refreshes LRU order. */
116
+ export function getGlyphPage(app, font, size) {
117
+ if (!app._glyphPages) app._glyphPages = new Map();
118
+ const key = `${font.key}@${size}`;
119
+ let page = app._glyphPages.get(key);
120
+ if (page) {
121
+ // Map iteration order is insertion order — re-insert to mark recent
122
+ app._glyphPages.delete(key);
123
+ } else {
124
+ page = new GlyphPage(app, font, size);
125
+ }
126
+ app._glyphPages.set(key, page);
127
+ return page;
128
+ }
129
+
130
+ /**
131
+ * Evict least-recently-used glyph pages until uploaded bitmaps fit the
132
+ * policy budget. `inUse` pages (referenced by requests queued this draw)
133
+ * are never evicted. Freeing is safe request-ordering-wise: FreeGlyphSet is
134
+ * queued after any CompositeGlyphs already issued on this connection.
135
+ */
136
+ export function trimGlyphPages(app, policy = policyOf(app), inUse = null) {
137
+ const pages = app._glyphPages;
138
+ if (!pages) return;
139
+ let total = 0;
140
+ for (const page of pages.values()) total += page.bytes;
141
+ for (const [key, page] of pages) {
142
+ if (total <= policy.cacheBytes) break;
143
+ if (inUse && inUse.has(page)) continue;
144
+ pages.delete(key);
145
+ total -= page.bytes;
146
+ page.destroy();
147
+ }
148
+ }
149
+
150
+ const MAX_ELT_GLYPHS = 254; // 255 is the glyphset-switch marker
151
+
152
+ /**
153
+ * Encode positioned glyphs into a minimal CompositeGlyphs elt list.
154
+ *
155
+ * `items` are visual-order glyphs with absolute integer device positions:
156
+ * { gs: glyphsetId, lid: localGlyphId, adv: storedIntAdvance, x, y }
157
+ *
158
+ * The encoder tracks the server-side pen (which auto-advances by each
159
+ * glyph's stored `adv`) and emits position data only when the desired
160
+ * position deviates from it — i.e. at the start, on kerning/mark offsets,
161
+ * on subpixel-rounding drift, and at glyphset switches. Plain text costs
162
+ * 1 byte per glyph (8-bit ids) plus a single 8-byte elt header.
163
+ *
164
+ * @returns {{ gsid, bits, elts }|null} arguments for Render.CompositeGlyphs;
165
+ * elts strings ride on the initial pen, arrays are [dx, dy, string],
166
+ * numbers switch glyphsets (node-x11 wire format). Null when empty.
167
+ */
168
+ export function encodeGlyphItems(items, bits) {
169
+ if (items.length === 0) return null;
170
+ const elts = [];
171
+ const gsid = items[0].gs;
172
+ let curGs = gsid;
173
+ let penX = 0;
174
+ let penY = 0;
175
+ let cur = null; // { dx, dy, str }
176
+
177
+ for (const item of items) {
178
+ if (item.gs !== curGs) {
179
+ if (cur) {
180
+ elts.push([cur.dx, cur.dy, cur.str]);
181
+ cur = null;
182
+ }
183
+ elts.push(item.gs);
184
+ curGs = item.gs;
185
+ }
186
+ if (!cur || item.x !== penX || item.y !== penY || cur.str.length >= MAX_ELT_GLYPHS) {
187
+ if (cur) elts.push([cur.dx, cur.dy, cur.str]);
188
+ cur = { dx: item.x - penX, dy: item.y - penY, str: '' };
189
+ penX = item.x;
190
+ penY = item.y;
191
+ }
192
+ cur.str += String.fromCharCode(item.lid);
193
+ penX += item.adv;
194
+ }
195
+ if (cur) elts.push([cur.dx, cur.dy, cur.str]);
196
+ return { gsid, bits, elts };
197
+ }
198
+
199
+ /**
200
+ * Compute device positions for shaped runs — the single source of truth for
201
+ * where each glyph's origin lands, shared by the renderer and by tests that
202
+ * verify server output pixel-by-pixel.
203
+ *
204
+ * `positioned` is an array of { run, x, y } where `run` comes from
205
+ * Font.shape()/shapeText() (already in visual order) and x/y is the run's
206
+ * baseline origin in device space.
207
+ *
208
+ * @returns {Array<{run, glyph, x, y}>} integer glyph-origin positions
209
+ */
210
+ export function positionGlyphs(positioned) {
211
+ const out = [];
212
+ for (const { run, x, y } of positioned) {
213
+ let cursor = x;
214
+ for (const g of run.glyphs) {
215
+ out.push({ run, glyph: g, x: Math.round(cursor + g.dx), y: Math.round(y - g.dy) });
216
+ cursor += g.ax;
217
+ }
218
+ }
219
+ return out;
220
+ }
221
+
222
+ /**
223
+ * Decide how a (face, size) renders: cached bitmap glyphs or per-draw
224
+ * trapezoids. See DEFAULT_TEXT_POLICY for the reasoning; the middle band
225
+ * additionally watches a small ring of recently drawn sizes per face — a
226
+ * continuously animated size (zoom/pinch) never repeats, so it routes to
227
+ * vector where per-size caches would leak a glyphset every frame.
228
+ *
229
+ * @returns {'bitmap'|'vector'}
230
+ */
231
+ export function routeGlyphSize(app, font, size, policy = policyOf(app)) {
232
+ if (size <= policy.bitmapMax) return 'bitmap';
233
+ if (size > policy.vectorFrom) return 'vector';
234
+
235
+ if (!app._sizeRings) app._sizeRings = new Map();
236
+ let ring = app._sizeRings.get(font.key);
237
+ if (!ring) {
238
+ ring = [];
239
+ app._sizeRings.set(font.key, ring);
240
+ }
241
+ const reused = ring.includes(size);
242
+ // dedupe consecutive entries so one frame drawing many runs at one size
243
+ // occupies a single slot — the ring then spans ~8 distinct frames
244
+ if (ring[ring.length - 1] !== size) {
245
+ ring.push(size);
246
+ if (ring.length > 8) ring.shift();
247
+ }
248
+
249
+ if (!Number.isInteger(size)) return 'vector';
250
+ if (!reused && new Set(ring).size >= 6) return 'vector'; // size churn: animating
251
+ return 'bitmap';
252
+ }
253
+
254
+ /**
255
+ * Draw shaped runs. Routes each run through the bitmap path (cached
256
+ * server-side glyphs, one CompositeGlyphs request) or the vector path
257
+ * (one AddTraps + Composite through a shared scratch a8 mask) according to
258
+ * `routeGlyphSize`.
259
+ *
260
+ * @param {App} app
261
+ * @param {number} op Render.PictOp
262
+ * @param {number} srcId source picture (fill color/gradient)
263
+ * @param {number} dstId destination picture
264
+ * @param {Array<{run, x, y}>} positioned visual-order runs with baseline origins
265
+ */
266
+ export function drawGlyphRuns(app, op, srcId, dstId, positioned) {
267
+ const policy = policyOf(app);
268
+ let bitmap = positioned;
269
+ let vector = null;
270
+ for (let i = 0; i < positioned.length; i++) {
271
+ const { run } = positioned[i];
272
+ if (routeGlyphSize(app, run.font, run.size, policy) === 'vector') {
273
+ if (!vector) {
274
+ vector = [];
275
+ bitmap = positioned.slice(0, i);
276
+ }
277
+ vector.push(positioned[i]);
278
+ } else if (vector) {
279
+ bitmap.push(positioned[i]);
280
+ }
281
+ }
282
+ if (bitmap.length) drawBitmapGlyphRuns(app, op, srcId, dstId, bitmap, policy);
283
+ if (vector) drawVectorGlyphRuns(app, op, srcId, dstId, vector);
284
+ }
285
+
286
+ function drawBitmapGlyphRuns(app, op, srcId, dstId, positioned, policy) {
287
+ const Render = app.display.Render;
288
+ const items = [];
289
+ let bits = 8;
290
+ const pages = new Map();
291
+ for (const { run } of positioned) {
292
+ const page = getGlyphPage(app, run.font, run.size);
293
+ page.ensure(run.glyphs);
294
+ pages.set(run, page);
295
+ }
296
+ for (const page of pages.values()) {
297
+ if (page.bits > bits) bits = page.bits;
298
+ }
299
+ for (const pos of positionGlyphs(positioned)) {
300
+ const page = pages.get(pos.run);
301
+ const e = page.entry(pos.glyph.id);
302
+ items.push({ gs: page.glyphset.id, lid: e.lid, adv: e.adv, x: pos.x, y: pos.y });
303
+ }
304
+ const encoded = encodeGlyphItems(items, bits);
305
+ if (!encoded) return;
306
+ // srcX/srcY align the source with the FIRST glyph's origin (RENDER spec);
307
+ // passing that origin itself makes source coordinates equal destination
308
+ // coordinates, so gradient fill styles line up with canvas space
309
+ Render.CompositeGlyphs(
310
+ encoded.bits,
311
+ op,
312
+ srcId,
313
+ dstId,
314
+ 0,
315
+ encoded.gsid,
316
+ items[0].x,
317
+ items[0].y,
318
+ encoded.elts
319
+ );
320
+ trimGlyphPages(app, policy, new Set(pages.values()));
321
+ }
322
+
323
+ // AddTraps length field is a plain 16-bit request length (no BigReq in
324
+ // node-x11's encoder): stay well under 65535 4-byte units per request
325
+ const MAX_TRAPS_PER_REQUEST = 6000;
326
+
327
+ /**
328
+ * Vector glyph path (issue #45): flatten outlines at the exact (fractional)
329
+ * size, trapezoidate, accumulate every glyph of the draw into one scratch a8
330
+ * mask with a single batched AddTraps, then one Composite to the target.
331
+ * Nothing is cached server-side, so continuously animated sizes cost no
332
+ * cache memory; wire bytes scale with outline complexity (~√size), which
333
+ * beats size²-scaling bitmap uploads above ~128-256px.
334
+ *
335
+ * Positions are intentionally NOT rounded to whole pixels — fractional
336
+ * advances and origins keep zoom animations smooth.
337
+ */
338
+ function drawVectorGlyphRuns(app, op, srcId, dstId, positioned) {
339
+ // unrounded glyph origins + ink bounding box
340
+ const placed = [];
341
+ let minX = Infinity;
342
+ let minY = Infinity;
343
+ for (const { run, x, y } of positioned) {
344
+ let cursor = x;
345
+ for (const g of run.glyphs) {
346
+ const gx = cursor + g.dx;
347
+ const gy = y - g.dy;
348
+ cursor += g.ax;
349
+ const e = run.font.glyphExtents(g.id, run.size);
350
+ if (!Number.isFinite(e.minX) || e.maxX <= e.minX) continue; // blank glyph
351
+ placed.push({ run, glyph: g, x: gx, y: gy });
352
+ if (gx + e.minX < minX) minX = gx + e.minX;
353
+ if (gy + e.minY < minY) minY = gy + e.minY;
354
+ }
355
+ }
356
+ if (placed.length === 0) return;
357
+
358
+ const bx = Math.floor(minX) - 1;
359
+ const by = Math.floor(minY) - 1;
360
+
361
+ const traps = [];
362
+ for (const p of placed) {
363
+ trapezoidize(p.run.font.outline(p.glyph.id, p.run.size), p.x - bx, p.y - by, traps);
364
+ }
365
+ compositeTraps(app, op, srcId, dstId, traps, bx, by);
366
+ }
367
+
368
+ /**
369
+ * Rasterize trapezoids (relative to (bx, by) device coordinates) into the
370
+ * shared scratch a8 mask with batched AddTraps requests, then Composite the
371
+ * source through it onto the destination. Shared by the vector glyph path
372
+ * and by widgets that fill arbitrary outlines (e.g. TeX radicals).
373
+ *
374
+ * When bx/by are omitted they are derived from the trapezoid bounds (the
375
+ * trap coordinates are then treated as absolute device coordinates).
376
+ */
377
+ export function compositeTraps(app, op, srcId, dstId, traps, bx, by) {
378
+ if (traps.length === 0) return;
379
+ const Render = app.display.Render;
380
+
381
+ let maxX = -Infinity;
382
+ let maxY = -Infinity;
383
+ if (bx === undefined) {
384
+ let minX = Infinity;
385
+ let minY = Infinity;
386
+ for (let i = 0; i < traps.length; i += 6) {
387
+ if (traps[i] < minX) minX = traps[i];
388
+ if (traps[i + 3] < minX) minX = traps[i + 3];
389
+ if (traps[i + 1] > maxX) maxX = traps[i + 1];
390
+ if (traps[i + 4] > maxX) maxX = traps[i + 4];
391
+ if (traps[i + 2] < minY) minY = traps[i + 2];
392
+ if (traps[i + 5] > maxY) maxY = traps[i + 5];
393
+ }
394
+ bx = Math.floor(minX);
395
+ by = Math.floor(minY);
396
+ const shifted = new Array(traps.length);
397
+ for (let i = 0; i < traps.length; i += 6) {
398
+ shifted[i] = traps[i] - bx;
399
+ shifted[i + 1] = traps[i + 1] - bx;
400
+ shifted[i + 2] = traps[i + 2] - by;
401
+ shifted[i + 3] = traps[i + 3] - bx;
402
+ shifted[i + 4] = traps[i + 4] - bx;
403
+ shifted[i + 5] = traps[i + 5] - by;
404
+ }
405
+ traps = shifted;
406
+ maxX -= bx;
407
+ maxY -= by;
408
+ } else {
409
+ for (let i = 0; i < traps.length; i += 6) {
410
+ if (traps[i + 1] > maxX) maxX = traps[i + 1];
411
+ if (traps[i + 4] > maxX) maxX = traps[i + 4];
412
+ if (traps[i + 5] > maxY) maxY = traps[i + 5];
413
+ }
414
+ }
415
+ const bw = Math.max(1, Math.ceil(maxX));
416
+ const bh = Math.max(1, Math.ceil(maxY));
417
+
418
+ const mask = scratchMask(app, bw, bh);
419
+ Render.FillRectangles(Render.PictOp.Src, mask.picture.id, [0, 0, 0, 0], [0, 0, bw, bh]);
420
+ for (let i = 0; i < traps.length; i += MAX_TRAPS_PER_REQUEST * 6) {
421
+ Render.AddTraps(mask.picture.id, 0, 0, traps.slice(i, i + MAX_TRAPS_PER_REQUEST * 6));
422
+ }
423
+ // src coords = dst coords (see drawBitmapGlyphRuns) so gradients line up
424
+ Render.Composite(op, srcId, mask.picture.id, dstId, bx, by, 0, 0, bx, by, bw, bh);
425
+ releaseScratchMask(app);
426
+ }
427
+
428
+ // One connection-wide scratch a8 pixmap for trap masks, grown as needed and
429
+ // dropped again when a huge draw would otherwise pin megabytes server-side.
430
+ const SCRATCH_KEEP_AREA = 1 << 21; // ~2MB of a8
431
+
432
+ function scratchMask(app, w, h) {
433
+ let s = app._trapScratch;
434
+ if (!s || s.width < w || s.height < h) {
435
+ if (s) {
436
+ s.picture.destroy();
437
+ s.pixmap.destroy();
438
+ }
439
+ const width = Math.max(w, s ? s.width : 0);
440
+ const height = Math.max(h, s ? s.height : 0);
441
+ const pixmap = new Pixmap(app, { depth: 8, width, height });
442
+ const picture = new Picture(app, { drawable: pixmap, format: app.display.Render.a8 });
443
+ s = { pixmap, picture, width, height };
444
+ app._trapScratch = s;
445
+ }
446
+ return s;
447
+ }
448
+
449
+ function releaseScratchMask(app) {
450
+ const s = app._trapScratch;
451
+ if (s && s.width * s.height > SCRATCH_KEEP_AREA) {
452
+ s.picture.destroy();
453
+ s.pixmap.destroy();
454
+ app._trapScratch = null;
455
+ }
456
+ }