ntk 5.1.0 → 5.3.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/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,23 +41,15 @@ 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
54
  // node-x11 has no FreeGC — share one upload GC per app (valid for any
62
55
  // depth-32 drawable on the screen)
@@ -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,6 +9,15 @@ 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';
@@ -149,7 +158,14 @@ export {
149
158
  resolveCursorShape,
150
159
  Pixmap,
151
160
  Picture,
161
+ Surface,
152
162
  Image,
163
+ ImageData,
164
+ pixelLayout,
165
+ toStraightRgba,
166
+ fromStraightRgba,
167
+ packIcons,
168
+ unpackIcons,
153
169
  decodeImage,
154
170
  loadImage,
155
171
  Path2D,