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/app.js +4 -2
- package/lib/clipboard.js +652 -83
- package/lib/fontconfig.js +99 -12
- package/lib/image.js +13 -19
- package/lib/imagedata.js +388 -0
- package/lib/index.js +32 -2
- package/lib/renderingcontext_2d.js +416 -42
- package/lib/surface.js +118 -0
- package/lib/text/fontmanager.js +27 -4
- package/lib/text/fontsource.js +255 -3
- package/lib/widgets/svgview.js +118 -2
- package/lib/window.js +137 -1
- package/package.json +1 -1
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
|
|
8
|
-
'fontconfig matching needs node (fc-match CLI)
|
|
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
|
-
|
|
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
|
|
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)
|
|
78
|
-
*
|
|
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
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
//
|
|
62
|
-
//
|
|
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();
|
package/lib/imagedata.js
ADDED
|
@@ -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
|
|
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,
|
|
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,
|