ntk 5.2.0 → 5.4.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/fontconfig.js CHANGED
@@ -4,13 +4,56 @@
4
4
  function execFileSync(...args) {
5
5
  const cp = globalThis.process?.getBuiltinModule?.('node:child_process');
6
6
  if (!cp) {
7
- throw new Error(
8
- 'fontconfig matching needs node (fc-match CLI); in this environment pass a custom fontSource — see docs/fonts.md'
7
+ throw noFontsError(
8
+ 'fontconfig matching needs node (the fc-match CLI) and this is not a node environment'
9
9
  );
10
10
  }
11
11
  return cp.execFileSync(...args);
12
12
  }
13
13
 
14
+ const DOCS = 'https://github.com/sidorares/ntk/blob/master/docs/fonts.md#environments-without-fontconfig';
15
+
16
+ /**
17
+ * The one error for "this environment has nothing to render text with" —
18
+ * fc-match missing, fc-match unhappy, fc-match matching nothing parseable, or
19
+ * a StaticFontSource with no faces.
20
+ *
21
+ * It exists because of where it lands. Font lookup is lazy, so the failure
22
+ * surfaces inside the first text layout with no hint that fonts are involved:
23
+ * a fontconfig-less container used to report exactly `spawnSync fc-match
24
+ * ENOENT` from deep inside shapeText, which reads as "ntk is broken in
25
+ * Docker". The message is long on purpose — it is thrown once, at a reader
26
+ * who does not yet know the subject.
27
+ *
28
+ * `code` is the load-bearing part rather than decoration: FontManager's
29
+ * fallbackFor distinguishes "this environment has no fonts" (degrade to
30
+ * .notdef) from "your custom source threw" (propagate), and it gives a host
31
+ * renderer something to branch on without matching message text.
32
+ *
33
+ * @param {string} reason first line — what specifically was missing
34
+ * @param {Error} [cause] the underlying failure, preserved for debugging
35
+ */
36
+ export function noFontsError(reason, cause) {
37
+ const err = new Error(
38
+ `ntk: no fonts available — ${reason}.\n` +
39
+ '\n' +
40
+ 'ntk ships no font files, so a slim/distroless container, a single-executable\n' +
41
+ 'build, a kiosk image or a CI box without font packages has to supply them:\n' +
42
+ '\n' +
43
+ " createClient({ fontSource: '/app/fonts' }) // a directory of .ttf/.otf files\n" +
44
+ ' createClient({ fontSource: [bytes] }) // font bytes — no filesystem needed\n' +
45
+ '\n' +
46
+ 'Where a package manager is available, installing fontconfig plus a font package\n' +
47
+ 'is simpler: Debian/Ubuntu `apt-get install -y --no-install-recommends fontconfig\n' +
48
+ 'fonts-dejavu-core`; Alpine `apk add fontconfig font-dejavu`.\n' +
49
+ '\n' +
50
+ DOCS,
51
+ cause ? { cause } : undefined
52
+ );
53
+ err.code = 'ERR_NTK_NO_FONTS';
54
+ return err;
55
+ }
56
+
14
57
  // css weight -> fontconfig weight constants
15
58
  const cssToFcWeight = {
16
59
  100: 0, // thin
@@ -24,11 +67,32 @@ const cssToFcWeight = {
24
67
  900: 210 // black
25
68
  };
26
69
 
27
- // formats fontkit can parse
28
- const supported = /\.(ttf|otf|woff|woff2|ttc|dfont)$/i;
70
+ // formats fontkit can parse. Exported so the font-spec resolver filters a
71
+ // directory listing by exactly the same rule fc-match output is filtered by —
72
+ // bitmap .pcf/.bdf fonts are the common near-miss.
73
+ export const supported = /\.(ttf|otf|woff|woff2|ttc|dfont)$/i;
29
74
 
30
75
  const sortedCache = new Map();
31
76
 
77
+ // Why fc-match could not be used, remembered so a render loop that catches
78
+ // the error does not respawn a missing binary every frame. The reason string
79
+ // is cached rather than the Error, so each throw still carries its own stack.
80
+ // A process that somehow gains fontconfig mid-run will not notice; nobody
81
+ // installs fontconfig into a running process.
82
+ let unavailable = null;
83
+
84
+ /**
85
+ * Did the spawn itself fail, as opposed to fc-match running and being
86
+ * unhappy? `status` is null only when the child never ran, and these are the
87
+ * codes that mean "no usable binary at this name" rather than a transient
88
+ * failure worth reporting verbatim.
89
+ */
90
+ function isSpawnFailure(err) {
91
+ return (
92
+ err.status == null && ['ENOENT', 'EACCES', 'EPERM', 'ENOTDIR'].includes(err.code)
93
+ );
94
+ }
95
+
32
96
  function patternFor({ family, weight, style }) {
33
97
  let fc = family || 'sans-serif';
34
98
  const fcWeight = normalizeWeight(weight);
@@ -51,12 +115,31 @@ export function matchSortedSync(pattern) {
51
115
  const fc = patternFor(pattern);
52
116
  let list = sortedCache.get(fc);
53
117
  if (list) return list;
118
+ if (unavailable) throw noFontsError(unavailable);
119
+
120
+ let out;
121
+ try {
122
+ out = execFileSync(
123
+ 'fc-match',
124
+ ['-s', '--format', '%{file}\t%{postscriptname}\t%{charset}\n', fc],
125
+ { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }
126
+ );
127
+ } catch (err) {
128
+ if (err.code === 'ERR_NTK_NO_FONTS') throw err; // no child_process at all
129
+ if (isSpawnFailure(err)) {
130
+ unavailable = 'the fc-match CLI (fontconfig) is not installed here';
131
+ throw noFontsError(unavailable, err);
132
+ }
133
+ if (err.status != null) {
134
+ // fontconfig is installed and said no — an image with fontconfig but no
135
+ // font package answers "No fonts installed on the system" and exits 1.
136
+ // Not memoized: unlike a missing binary this can depend on the pattern.
137
+ const stderr = String(err.stderr || '').trim().split('\n')[0];
138
+ throw noFontsError(`fc-match exited ${err.status}${stderr ? `: ${stderr}` : ''}`, err);
139
+ }
140
+ throw err;
141
+ }
54
142
 
55
- const out = execFileSync(
56
- 'fc-match',
57
- ['-s', '--format', '%{file}\t%{postscriptname}\t%{charset}\n', fc],
58
- { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }
59
- );
60
143
  list = [];
61
144
  for (const line of out.split('\n')) {
62
145
  const [path, postscriptName, charset] = line.split('\t');
@@ -65,7 +148,10 @@ export function matchSortedSync(pattern) {
65
148
  }
66
149
  }
67
150
  if (list.length === 0) {
68
- throw new Error(`No usable font found for pattern "${fc}" (need .ttf/.otf/.ttc)`);
151
+ throw noFontsError(
152
+ `fontconfig matched no font ntk can parse for "${fc}" (needs ` +
153
+ '.ttf/.otf/.woff/.woff2/.ttc/.dfont — bitmap .pcf/.bdf fonts are not usable)'
154
+ );
69
155
  }
70
156
  sortedCache.set(fc, list);
71
157
  return list;
@@ -74,8 +160,9 @@ export function matchSortedSync(pattern) {
74
160
  /**
75
161
  * Resolve a font pattern ({family, weight, style}) to the best matching font
76
162
  * file. Returns { path, postscriptName } or throws if nothing suitable is
77
- * installed. Requires the fc-match CLI (fontconfig), present on any system
78
- * running X11.
163
+ * installed. Requires the fc-match CLI (fontconfig) — usual on a Linux
164
+ * desktop, absent from slim containers and from stock macOS. Where it is
165
+ * missing, hand ntk the fonts instead (see docs/fonts.md).
79
166
  */
80
167
  export function listFontsSync(pattern) {
81
168
  const [best] = matchSortedSync(pattern);
package/lib/image.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import jpeg from 'jpeg-js';
2
2
  import { PNG } from 'pngjs';
3
3
 
4
+ import { fromStraightRgba, pixelLayout } from './imagedata.js';
4
5
  import Picture from './picture.js';
5
6
 
6
7
  /**
@@ -40,26 +41,19 @@ export class Image {
40
41
  const Render = app.display.Render;
41
42
  const pixmap = app.createPixmap({ depth: 32, width: this.width, height: this.height });
42
43
 
43
- // XRender composites premultiplied alpha; PutImage on a 32-bit ZPixmap
44
- // wants BGRA byte order (see AGENTS.md gotchas)
45
- const bgra = Buffer.allocUnsafe(this.width * this.height * 4);
46
- const src = this.data;
47
- for (let i = 0; i < bgra.length; i += 4) {
48
- const a = src[i + 3];
49
- if (a === 255) {
50
- bgra[i] = src[i + 2];
51
- bgra[i + 1] = src[i + 1];
52
- bgra[i + 2] = src[i];
53
- } else {
54
- bgra[i] = (src[i + 2] * a + 127) / 255;
55
- bgra[i + 1] = (src[i + 1] * a + 127) / 255;
56
- bgra[i + 2] = (src[i] * a + 127) / 255;
57
- }
58
- bgra[i + 3] = a;
59
- }
44
+ // straight RGBA in, the server's own premultiplied layout out. This used
45
+ // to assume BGRA, which is only what a little-endian server with the
46
+ // standard visual masks happens to want.
47
+ const bgra = fromStraightRgba(
48
+ this.data,
49
+ pixelLayout(app.display, 32),
50
+ this.width,
51
+ this.height
52
+ );
60
53
 
61
- // node-x11 has no FreeGC — share one upload GC per app (valid for any
62
- // depth-32 drawable on the screen)
54
+ // One upload GC per app: a GC is valid for any depth-32 drawable on the
55
+ // screen, so sharing is cheaper than creating and freeing one per image.
56
+ // It outlives every Image and is released with the connection.
63
57
  let gc = app._imageUploadGC;
64
58
  if (!gc) {
65
59
  gc = app._imageUploadGC = X.AllocID();
@@ -0,0 +1,388 @@
1
+ /**
2
+ * The canvas pixel contract, and the conversions between it and what an X
3
+ * server actually hands over.
4
+ *
5
+ * Canvas `ImageData` is straight (non-premultiplied) RGBA bytes, one byte per
6
+ * channel, rows top to bottom. An X drawable is none of those things by
7
+ * default:
8
+ *
9
+ * - `GetImage` returns the drawable's raw words in the *server's*
10
+ * `image_byte_order`, which is a different field from the byte order this
11
+ * connection speaks;
12
+ * - which bits inside a word hold which channel comes from the visual's
13
+ * masks, not from a convention;
14
+ * - anything XRender composited into is premultiplied, and a drawable
15
+ * without an alpha channel has a fourth byte that is undefined padding
16
+ * rather than opaque.
17
+ *
18
+ * Everything crossing that boundary goes through here, so callers see RGBA
19
+ * and only this file knows about the rest. `readPixels()` is the way out for
20
+ * code that genuinely wants the server's own bytes — it reports the layout
21
+ * alongside them instead of leaving the caller to assume one.
22
+ */
23
+
24
+ /** shift of the lowest set bit, i.e. where a channel starts inside a word */
25
+ function shiftOf(mask) {
26
+ let s = 0;
27
+ while (s < 32 && !((mask >>> s) & 1)) s++;
28
+ return s;
29
+ }
30
+
31
+ /** width of a contiguous mask, so 5- and 6-bit channels can be scaled to 8 */
32
+ function widthOf(mask, shift) {
33
+ let w = 0;
34
+ while (shift + w < 32 && (mask >>> (shift + w)) & 1) w++;
35
+ return w;
36
+ }
37
+
38
+ /**
39
+ * How pixels of `depth` are laid out on this display: which bits hold which
40
+ * channel, whether there is an alpha channel at all, and which end of a word
41
+ * the server writes first.
42
+ *
43
+ * Channel positions come from a TrueColor visual of that depth when the
44
+ * screen has one. Depth 32 has no visual on most servers (XQuartz has none at
45
+ * all) but every depth-32 drawable ntk makes is paired with a `Render.rgba32`
46
+ * picture, whose PictStandardARGB32 format pins A=24 R=16 G=8 B=0 — so that
47
+ * is the fallback, and it is a specification rather than a guess.
48
+ */
49
+ export function pixelLayout(display, depth) {
50
+ const screen = display.screen[0];
51
+ let masks = null;
52
+ for (const visual of Object.values(screen.depths?.[depth] ?? {})) {
53
+ if (visual.class === 4 || visual.class === 5) {
54
+ // TrueColor / DirectColor
55
+ masks = { red: visual.red_mask, green: visual.green_mask, blue: visual.blue_mask };
56
+ break;
57
+ }
58
+ }
59
+ if (!masks) masks = { red: 0xff0000, green: 0x00ff00, blue: 0x0000ff };
60
+
61
+ const bitsPerPixel = display.format?.[depth]?.bits_per_pixel ?? (depth > 16 ? 32 : depth);
62
+ if (bitsPerPixel !== 32) {
63
+ throw new Error(
64
+ `ntk works in 32-bit pixels; depth ${depth} on this server is ` +
65
+ `${bitsPerPixel} bits per pixel. Use readPixels() and unpack it yourself.`
66
+ );
67
+ }
68
+
69
+ const hasAlpha = depth === 32;
70
+ // whatever the colour masks leave over, which for ARGB32 is the top byte
71
+ const alpha = hasAlpha ? (~(masks.red | masks.green | masks.blue) & 0xffffffff) >>> 0 : 0;
72
+
73
+ return {
74
+ depth,
75
+ bitsPerPixel,
76
+ masks: { ...masks, alpha },
77
+ // 0 LSBFirst, 1 MSBFirst — the server's pixel order, NOT display.byte_order
78
+ byteOrder: display.image_byte_order ? 'msb' : 'lsb',
79
+ // XRender composites premultiplied, so anything with an alpha channel
80
+ // that ntk has drawn into holds premultiplied colour
81
+ premultiplied: hasAlpha
82
+ };
83
+ }
84
+
85
+ /** the common case: LSBFirst words with 8-bit channels at ARGB32 positions */
86
+ function isStandardLsb(layout) {
87
+ return (
88
+ layout.byteOrder === 'lsb' &&
89
+ layout.masks.red === 0xff0000 &&
90
+ layout.masks.green === 0x00ff00 &&
91
+ layout.masks.blue === 0x0000ff
92
+ );
93
+ }
94
+
95
+ /**
96
+ * Server pixels -> straight RGBA. `raw` is a `GetImage` reply body.
97
+ *
98
+ * @param {Buffer} raw
99
+ * @param {object} layout from `pixelLayout()`
100
+ * @param {number} width
101
+ * @param {number} height
102
+ * @returns {Uint8ClampedArray}
103
+ */
104
+ export function toStraightRgba(raw, layout, width, height) {
105
+ const n = width * height;
106
+ const out = new Uint8ClampedArray(n * 4);
107
+ const { premultiplied } = layout;
108
+
109
+ if (isStandardLsb(layout)) {
110
+ // bytes arrive B, G, R, A — a straight index shuffle, which measures
111
+ // faster than reading 32-bit words back out of the Buffer
112
+ for (let i = 0; i < n * 4; i += 4) {
113
+ const a = layout.depth === 32 ? raw[i + 3] : 255;
114
+ if (a === 255) {
115
+ out[i] = raw[i + 2];
116
+ out[i + 1] = raw[i + 1];
117
+ out[i + 2] = raw[i];
118
+ } else if (a === 0 || !premultiplied) {
119
+ out[i] = raw[i + 2];
120
+ out[i + 1] = raw[i + 1];
121
+ out[i + 2] = raw[i];
122
+ } else {
123
+ out[i] = (raw[i + 2] * 255) / a;
124
+ out[i + 1] = (raw[i + 1] * 255) / a;
125
+ out[i + 2] = (raw[i] * 255) / a;
126
+ }
127
+ out[i + 3] = a;
128
+ }
129
+ return out;
130
+ }
131
+
132
+ const { masks } = layout;
133
+ const rs = shiftOf(masks.red);
134
+ const gs = shiftOf(masks.green);
135
+ const bs = shiftOf(masks.blue);
136
+ const as = masks.alpha ? shiftOf(masks.alpha) : 0;
137
+ const rw = widthOf(masks.red, rs);
138
+ const gw = widthOf(masks.green, gs);
139
+ const bw = widthOf(masks.blue, bs);
140
+ // scale a channel of `w` bits up to 8, so 0b11111 becomes 255 not 248
141
+ const up = (v, w) => (w === 8 ? v : Math.round((v * 255) / ((1 << w) - 1)));
142
+ const readWord = layout.byteOrder === 'msb'
143
+ ? (o) => raw.readUInt32BE(o)
144
+ : (o) => raw.readUInt32LE(o);
145
+
146
+ for (let i = 0; i < n; i++) {
147
+ const px = readWord(i * 4);
148
+ const a = masks.alpha ? (px & masks.alpha) >>> as : 255;
149
+ let r = up((px & masks.red) >>> rs, rw);
150
+ let g = up((px & masks.green) >>> gs, gw);
151
+ let b = up((px & masks.blue) >>> bs, bw);
152
+ if (premultiplied && a !== 0 && a !== 255) {
153
+ r = (r * 255) / a;
154
+ g = (g * 255) / a;
155
+ b = (b * 255) / a;
156
+ }
157
+ out[i * 4] = r;
158
+ out[i * 4 + 1] = g;
159
+ out[i * 4 + 2] = b;
160
+ out[i * 4 + 3] = a;
161
+ }
162
+ return out;
163
+ }
164
+
165
+ /**
166
+ * Straight RGBA -> server pixels, ready for `PutImage`.
167
+ *
168
+ * @param {Uint8Array} rgba straight RGBA, 4 bytes per pixel
169
+ * @param {object} layout from `pixelLayout()`
170
+ * @param {number} width
171
+ * @param {number} height
172
+ * @returns {Buffer}
173
+ */
174
+ export function fromStraightRgba(rgba, layout, width, height) {
175
+ const n = width * height;
176
+ const out = Buffer.allocUnsafe(n * 4);
177
+ const { premultiplied } = layout;
178
+
179
+ if (isStandardLsb(layout)) {
180
+ for (let i = 0; i < n * 4; i += 4) {
181
+ const a = rgba[i + 3];
182
+ if (!premultiplied || a === 255) {
183
+ out[i] = rgba[i + 2];
184
+ out[i + 1] = rgba[i + 1];
185
+ out[i + 2] = rgba[i];
186
+ } else {
187
+ // +127 rounds to nearest rather than truncating, which otherwise
188
+ // drifts a translucent fill visibly darker over repeated round trips
189
+ out[i] = (rgba[i + 2] * a + 127) / 255;
190
+ out[i + 1] = (rgba[i + 1] * a + 127) / 255;
191
+ out[i + 2] = (rgba[i] * a + 127) / 255;
192
+ }
193
+ out[i + 3] = layout.depth === 32 ? a : 0;
194
+ }
195
+ return out;
196
+ }
197
+
198
+ const { masks } = layout;
199
+ const rs = shiftOf(masks.red);
200
+ const gs = shiftOf(masks.green);
201
+ const bs = shiftOf(masks.blue);
202
+ const as = masks.alpha ? shiftOf(masks.alpha) : 0;
203
+ const rw = widthOf(masks.red, rs);
204
+ const gw = widthOf(masks.green, gs);
205
+ const bw = widthOf(masks.blue, bs);
206
+ const down = (v, w) => (w === 8 ? v : Math.round((v * ((1 << w) - 1)) / 255));
207
+ const writeWord = layout.byteOrder === 'msb'
208
+ ? (v, o) => out.writeUInt32BE(v, o)
209
+ : (v, o) => out.writeUInt32LE(v, o);
210
+
211
+ for (let i = 0; i < n; i++) {
212
+ const a = rgba[i * 4 + 3];
213
+ let r = rgba[i * 4];
214
+ let g = rgba[i * 4 + 1];
215
+ let b = rgba[i * 4 + 2];
216
+ if (premultiplied && a !== 255) {
217
+ r = (r * a + 127) / 255;
218
+ g = (g * a + 127) / 255;
219
+ b = (b * a + 127) / 255;
220
+ }
221
+ const word =
222
+ ((down(r | 0, rw) << rs) |
223
+ (down(g | 0, gw) << gs) |
224
+ (down(b | 0, bw) << bs) |
225
+ (masks.alpha ? (a << as) & masks.alpha : 0)) >>>
226
+ 0;
227
+ writeWord(word, i * 4);
228
+ }
229
+ return out;
230
+ }
231
+
232
+ /**
233
+ * Canvas `ImageData`: straight RGBA in a `Uint8ClampedArray`, rows top to
234
+ * bottom. Both spec constructor forms work:
235
+ *
236
+ * new ImageData(width, height)
237
+ * new ImageData(data, width[, height])
238
+ *
239
+ * `data` may be any `Uint8Array` — a node `Buffer` included — and is adopted
240
+ * as-is when it is already a `Uint8ClampedArray`, copied otherwise.
241
+ */
242
+ export class ImageData {
243
+ constructor(a, b, c) {
244
+ if (typeof a === 'number') {
245
+ const width = a;
246
+ const height = b;
247
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
248
+ throw new Error('ImageData: width and height must be positive integers');
249
+ }
250
+ this.width = width;
251
+ this.height = height;
252
+ this.data = new Uint8ClampedArray(width * height * 4);
253
+ return;
254
+ }
255
+ if (!ArrayBuffer.isView(a)) {
256
+ throw new TypeError('ImageData: first argument must be a width or a typed array');
257
+ }
258
+ const width = b;
259
+ if (!Number.isInteger(width) || width <= 0) {
260
+ throw new Error('ImageData: width must be a positive integer');
261
+ }
262
+ if (a.length % 4 !== 0) {
263
+ throw new Error(`ImageData: data length ${a.length} is not a whole number of RGBA pixels`);
264
+ }
265
+ const height = c ?? a.length / 4 / width;
266
+ if (!Number.isInteger(height) || height <= 0) {
267
+ throw new Error(`ImageData: ${a.length} bytes is not a whole number of ${width}px rows`);
268
+ }
269
+ if (a.length !== width * height * 4) {
270
+ throw new Error(
271
+ `ImageData: data must be ${width * height * 4} bytes for ${width}x${height}, got ${a.length}`
272
+ );
273
+ }
274
+ this.width = width;
275
+ this.height = height;
276
+ this.data =
277
+ a instanceof Uint8ClampedArray
278
+ ? a
279
+ : new Uint8ClampedArray(a.buffer, a.byteOffset, a.byteLength).slice();
280
+ }
281
+
282
+ /** the sRGB colour space, for parity with the browser's property */
283
+ get colorSpace() {
284
+ return 'srgb';
285
+ }
286
+ }
287
+
288
+ // --- EWMH _NET_WM_ICON -----------------------------------------------------
289
+ //
290
+ // The property is a run of CARD32s, repeated once per size:
291
+ //
292
+ // width, height, pixel[0] … pixel[width*height-1]
293
+ //
294
+ // Each pixel is straight ARGB inside the word — A=24 R=16 G=8 B=0. Straight,
295
+ // not premultiplied, which is the same thing ImageData holds, so this is a
296
+ // repack rather than a conversion. The one thing to get right is which byte
297
+ // order: a format-32 property is read back by the server in the *connection's*
298
+ // byte order, which is a different handshake field from the image_byte_order
299
+ // that `pixelLayout()` uses for GetImage.
300
+
301
+ const wordIO = (display) =>
302
+ display.byte_order
303
+ ? { read: (b, o) => b.readUInt32BE(o), write: (b, v, o) => b.writeUInt32BE(v, o) }
304
+ : { read: (b, o) => b.readUInt32LE(o), write: (b, v, o) => b.writeUInt32LE(v, o) };
305
+
306
+ /**
307
+ * Pack one or more images into `_NET_WM_ICON` property bytes.
308
+ *
309
+ * @param {Array<{width: number, height: number, data: Uint8Array}>} images
310
+ * straight RGBA, as `Image` and `ImageData` both are
311
+ * @param {object} display
312
+ * @returns {Buffer}
313
+ */
314
+ export function packIcons(images, display) {
315
+ let words = 0;
316
+ images.forEach((img, i) => {
317
+ const { width, height, data } = img ?? {};
318
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
319
+ throw new Error(`icon ${i}: width and height must be positive integers`);
320
+ }
321
+ if (!data || data.length !== width * height * 4) {
322
+ throw new Error(
323
+ `icon ${i}: data must be ${width * height * 4} RGBA bytes for ${width}x${height}, ` +
324
+ `got ${data ? data.length : 'nothing'}`
325
+ );
326
+ }
327
+ words += 2 + width * height;
328
+ });
329
+
330
+ const out = Buffer.allocUnsafe(words * 4);
331
+ const { write } = wordIO(display);
332
+ let o = 0;
333
+ for (const { width, height, data } of images) {
334
+ write(out, width, o);
335
+ write(out, height, o + 4);
336
+ o += 8;
337
+ for (let i = 0; i < width * height; i++) {
338
+ write(
339
+ out,
340
+ (((data[i * 4 + 3] << 24) |
341
+ (data[i * 4] << 16) |
342
+ (data[i * 4 + 1] << 8) |
343
+ data[i * 4 + 2]) >>>
344
+ 0),
345
+ o
346
+ );
347
+ o += 4;
348
+ }
349
+ }
350
+ return out;
351
+ }
352
+
353
+ /**
354
+ * Read `_NET_WM_ICON` bytes back into images.
355
+ *
356
+ * Deliberately forgiving about what it is handed: a window manager runs this
357
+ * over properties written by other people's clients, so a truncated or
358
+ * nonsensical run stops the scan and returns whatever parsed cleanly rather
359
+ * than throwing or trying to allocate a claimed 4-gigapixel icon.
360
+ *
361
+ * @returns {ImageData[]}
362
+ */
363
+ export function unpackIcons(buf, display) {
364
+ const { read } = wordIO(display);
365
+ const out = [];
366
+ let o = 0;
367
+ while (o + 8 <= buf.length) {
368
+ const width = read(buf, o);
369
+ const height = read(buf, o + 4);
370
+ // guard the multiplication itself, not just its result
371
+ if (width <= 0 || height <= 0 || width > 0xffff || height > 0xffff) break;
372
+ const pixels = width * height;
373
+ if (o + 8 + pixels * 4 > buf.length) break;
374
+ o += 8;
375
+
376
+ const rgba = new Uint8ClampedArray(pixels * 4);
377
+ for (let i = 0; i < pixels; i++) {
378
+ const px = read(buf, o + i * 4);
379
+ rgba[i * 4] = (px >>> 16) & 0xff;
380
+ rgba[i * 4 + 1] = (px >>> 8) & 0xff;
381
+ rgba[i * 4 + 2] = px & 0xff;
382
+ rgba[i * 4 + 3] = (px >>> 24) & 0xff;
383
+ }
384
+ o += pixels * 4;
385
+ out.push(new ImageData(rgba, width, height));
386
+ }
387
+ return out;
388
+ }
package/lib/index.js CHANGED
@@ -9,12 +9,22 @@ import { decodeKey, groupForState } from './keyboard.js';
9
9
  import Pixmap from './pixmap.js';
10
10
  import Picture from './picture.js';
11
11
  import { Image, decodeImage, loadImage } from './image.js';
12
+ import {
13
+ ImageData,
14
+ fromStraightRgba,
15
+ packIcons,
16
+ pixelLayout,
17
+ toStraightRgba,
18
+ unpackIcons
19
+ } from './imagedata.js';
20
+ import { Surface } from './surface.js';
12
21
  import { Path2D, parseSvgPath } from './path.js';
13
22
  import Font from './text/font.js';
14
23
  import FontManager from './text/fontmanager.js';
15
24
  import {
16
25
  FontconfigFontSource,
17
26
  StaticFontSource,
27
+ createFontSource,
18
28
  defaultFontSource,
19
29
  setDefaultFontSource
20
30
  } from './text/fontsource.js';
@@ -60,7 +70,11 @@ const DEFAULT_BUFFER_REQUESTS = { maxSize: 64 * 1024 };
60
70
  *
61
71
  * @param {object} [options] passed through to x11.createClient
62
72
  * (e.g. { display: ':1' }); ntk additionally understands
63
- * { fontSource } — a pluggable system-font lookup (see docs/fonts.md) —
73
+ * { fontSource } — where fonts come from: a FontSource, `'system'` (the
74
+ * default, fontconfig via fc-match), or a font spec naming the faces the
75
+ * app itself ships — `'/app/fonts'`, `'./Inter.ttf'`, `[bytes]`,
76
+ * `{ fonts, alias }` — which is what an environment without fontconfig
77
+ * needs, since ntk ships no fonts (see docs/fonts.md) —
64
78
  * { rasterizer, rasterPolicy } — where small fills and strokes are
65
79
  * rasterized, and the thresholds for that choice (see docs/context-2d.md) —
66
80
  * { glxVisual } — a visual id for getContext('opengl') to use instead of
@@ -86,6 +100,14 @@ export function createClient(options, callback) {
86
100
  const layout = loadLayout();
87
101
 
88
102
  const connecting = new Promise((resolve, reject) => {
103
+ // Resolve the font spec here rather than lazily in `app.fonts`, so a
104
+ // missing directory is a rejected connect instead of a surprise inside
105
+ // the first paint. Inside the executor so it rejects rather than throws
106
+ // synchronously, which keeps the legacy callback form working. The
107
+ // caller's options object is copied, never mutated.
108
+ const appOptions = options ? { ...options } : {};
109
+ if (appOptions.fontSource != null) appOptions.fontSource = createFontSource(appOptions.fontSource);
110
+
89
111
  x11.createClient(x11Options, (error, display) => {
90
112
  if (error) return reject(error);
91
113
 
@@ -121,7 +143,7 @@ export function createClient(options, callback) {
121
143
  });
122
144
  updateKeyboardMapping(display.min_keycode, display.max_keycode);
123
145
 
124
- resolve(new App(display, options || {}));
146
+ resolve(new App(display, appOptions));
125
147
  });
126
148
  });
127
149
  });
@@ -149,7 +171,14 @@ export {
149
171
  resolveCursorShape,
150
172
  Pixmap,
151
173
  Picture,
174
+ Surface,
152
175
  Image,
176
+ ImageData,
177
+ pixelLayout,
178
+ toStraightRgba,
179
+ fromStraightRgba,
180
+ packIcons,
181
+ unpackIcons,
153
182
  decodeImage,
154
183
  loadImage,
155
184
  Path2D,
@@ -158,6 +187,7 @@ export {
158
187
  FontManager,
159
188
  FontconfigFontSource,
160
189
  StaticFontSource,
190
+ createFontSource,
161
191
  defaultFontSource,
162
192
  setDefaultFontSource,
163
193
  CoverageAccumulator,