ntk 5.3.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 CHANGED
@@ -13,8 +13,10 @@ import Window from './window.js';
13
13
  export default class App {
14
14
  /**
15
15
  * @param {object} display node-x11 display
16
- * @param {object} [options] environment hooks: { fontSource (see
17
- * text/fontsource.js), rasterizer and rasterPolicy (see rasterize.js),
16
+ * @param {object} [options] environment hooks: { fontSource — a FontSource
17
+ * or a font spec (see text/fontsource.js); `createClient` resolves a spec
18
+ * before it gets here, so a hand-built App may pass either —
19
+ * rasterizer and rasterPolicy (see rasterize.js),
18
20
  * glxVisual (visual id for getContext('opengl') instead of querying the
19
21
  * server for one) }
20
22
  */
package/lib/clipboard.js CHANGED
@@ -114,6 +114,59 @@ export default class Clipboard {
114
114
  }
115
115
  }
116
116
 
117
+ /**
118
+ * Give a selection back: stop owning it, and stop answering for it.
119
+ *
120
+ * await app.clipboard.clear(); // CLIPBOARD
121
+ * await app.clipboard.clear('XdndSelection'); // at the end of a drag
122
+ *
123
+ * The counterpart to `write()`, which otherwise holds a selection until
124
+ * another client takes it or the app exits. Two things want this: a drag
125
+ * source, which should stop offering its payload once the drag is over
126
+ * (a stale offer answered later is exactly what XDND's "throw out
127
+ * extremely old data" rule is about), and apps that clear the clipboard
128
+ * on purpose — a password manager, say.
129
+ *
130
+ * Clearing a selection this app does not own does nothing, and in
131
+ * particular sends nothing: `SetSelectionOwner(None)` from a non-owner
132
+ * would take the selection away from whoever legitimately holds it.
133
+ *
134
+ * A transfer already in flight still completes — an INCR transfer keeps
135
+ * its own copy of the payload (ICCCM 2.7.2), the same as when another
136
+ * client takes the selection from us.
137
+ *
138
+ * @param {string} [selection] selection atom name, default 'CLIPBOARD'
139
+ * @returns {Promise<void>}
140
+ */
141
+ async clear(selection = 'CLIPBOARD') {
142
+ // nothing has ever been written, so nothing can be owned: answer
143
+ // without interning an atom or creating the helper window
144
+ if (!this._owned.size) return;
145
+ const sel = await this._atom(selection);
146
+ const entry = this._owned.get(sel);
147
+ if (!entry) return;
148
+ this._owned.delete(sel);
149
+ // ICCCM 2.3.1: release with the timestamp the selection was acquired
150
+ // with, not a fresh one. A SetSelectionOwner is ignored when its time is
151
+ // earlier than the selection's current last-change time, so the old
152
+ // stamp is what makes this lose to a client that has taken the selection
153
+ // since — where a fresh one would take it away from them.
154
+ this.X.SetSelectionOwner(0, sel, entry.time);
155
+ // The server answers the release with a SelectionClear addressed to the
156
+ // helper window; _onXEvent deletes an _owned entry that is already gone,
157
+ // which is harmless.
158
+ //
159
+ // GetSelectionOwner here is a barrier, not a check: requests are
160
+ // buffered (one socket write per frame) and it is a reply that forces
161
+ // the flush, so without it the release could still be sitting in our
162
+ // output buffer when the caller's next await resumes. Whoever owns the
163
+ // selection afterwards is not our business — we only promised to stop
164
+ // being the owner.
165
+ await new Promise((resolve, reject) =>
166
+ this.X.GetSelectionOwner(sel, (err) => (err ? reject(err) : resolve()))
167
+ );
168
+ }
169
+
117
170
  /**
118
171
  * Read the current text of a selection from whoever owns it.
119
172
  * Rejects when the selection has no owner, when the owner supports
@@ -121,14 +174,17 @@ export default class Clipboard {
121
174
  * (`timeout` ms, default 2000).
122
175
  *
123
176
  * @param {object} [options] { selection: 'CLIPBOARD' | 'PRIMARY' | ...,
124
- * timeout: ms to wait for the owner at each protocol step }
125
- * @returns {Promise<string>}
177
+ * timeout: ms to wait for the owner at each protocol step;
178
+ * target: one named target, returned as bytes, instead of text;
179
+ * time: the server timestamp of the event that asked for the paste
180
+ * (ICCCM 2.4) — see `targets()` for why it is worth passing }
181
+ * @returns {Promise<string|Buffer>}
126
182
  */
127
- read({ selection = 'CLIPBOARD', timeout = DEFAULT_TIMEOUT, target } = {}) {
183
+ read({ selection = 'CLIPBOARD', timeout = DEFAULT_TIMEOUT, target, time } = {}) {
128
184
  return this._serialize(() =>
129
185
  target === undefined
130
- ? this._read(selection, timeout)
131
- : this._readTarget(selection, target, timeout)
186
+ ? this._read(selection, timeout, time)
187
+ : this._readTarget(selection, target, timeout, time)
132
188
  );
133
189
  }
134
190
 
@@ -142,11 +198,18 @@ export default class Clipboard {
142
198
  * `TARGETS` cheaply, where guessing costs a failed conversion per guess.
143
199
  * Returns `[]` when nothing owns the selection.
144
200
  *
145
- * @param {object} [options] { selection, timeout }
201
+ * `time` is the timestamp of the event that asked for the data. ICCCM 2.4
202
+ * says to pass it rather than CurrentTime, so that an owner which has
203
+ * since replaced its data can tell that the request is for the older
204
+ * value; XDND makes it binding, since a drop must convert with the
205
+ * timestamp from its `XdndDrop` message. Omitting it means CurrentTime,
206
+ * which every mainstream owner accepts and a strict one may not.
207
+ *
208
+ * @param {object} [options] { selection, timeout, time }
146
209
  * @returns {Promise<string[]>}
147
210
  */
148
- targets({ selection = 'CLIPBOARD', timeout = DEFAULT_TIMEOUT } = {}) {
149
- return this._serialize(() => this._targets(selection, timeout));
211
+ targets({ selection = 'CLIPBOARD', timeout = DEFAULT_TIMEOUT, time } = {}) {
212
+ return this._serialize(() => this._targets(selection, timeout, time));
150
213
  }
151
214
 
152
215
  /** Concurrent conversions would share the one transfer property on the
@@ -157,11 +220,11 @@ export default class Clipboard {
157
220
  return result;
158
221
  }
159
222
 
160
- async _targets(selection, timeout) {
223
+ async _targets(selection, timeout, time) {
161
224
  await this._ensure();
162
225
  const sel = await this._atom(selection);
163
226
  const prop = this._transferProp;
164
- const notify = await this._convert(sel, this._atoms.TARGETS, prop, selection, timeout);
227
+ const notify = await this._convert(sel, this._atoms.TARGETS, prop, selection, timeout, time);
165
228
  if (notify.property === 0) return [];
166
229
  const { data, format } = await this._fetchProperty(prop, selection, timeout);
167
230
  // an INCR reassembly reports no format, and a target list is far too
@@ -178,12 +241,12 @@ export default class Clipboard {
178
241
  * `write({ 'image/png': buf })` publishes, this is how another ntk app
179
242
  * gets it back.
180
243
  */
181
- async _readTarget(selection, target, timeout) {
244
+ async _readTarget(selection, target, timeout, time) {
182
245
  await this._ensure();
183
246
  const sel = await this._atom(selection);
184
247
  const atom = await this._atom(target);
185
248
  const prop = this._transferProp;
186
- const notify = await this._convert(sel, atom, prop, selection, timeout);
249
+ const notify = await this._convert(sel, atom, prop, selection, timeout, time);
187
250
  if (notify.property === 0) {
188
251
  const owner = await new Promise((resolve, reject) =>
189
252
  this.X.GetSelectionOwner(sel, (err, wid) => (err ? reject(err) : resolve(wid)))
@@ -204,17 +267,18 @@ export default class Clipboard {
204
267
  );
205
268
  }
206
269
 
207
- async _read(selection, timeout) {
270
+ async _read(selection, timeout, time) {
208
271
  await this._ensure();
209
272
  const X = this.X;
210
273
  const sel = await this._atom(selection);
211
274
  const prop = this._transferProp;
212
275
 
213
- let notify = await this._convert(sel, this._atoms.UTF8_STRING, prop, selection, timeout);
276
+ let notify = await this._convert(sel, this._atoms.UTF8_STRING, prop, selection, timeout, time);
214
277
  if (notify.property === 0) {
215
278
  // property None: no owner, or the owner refused UTF8_STRING (old
216
- // Xt/Motif apps) — ask again for latin-1 STRING before giving up
217
- notify = await this._convert(sel, X.atoms.STRING, prop, selection, timeout);
279
+ // Xt/Motif apps) — ask again for latin-1 STRING before giving up.
280
+ // Same timestamp: this is a retry of one paste, not a second one.
281
+ notify = await this._convert(sel, X.atoms.STRING, prop, selection, timeout, time);
218
282
  }
219
283
  if (notify.property === 0) {
220
284
  const owner = await new Promise((resolve, reject) =>
@@ -711,8 +775,11 @@ export default class Clipboard {
711
775
 
712
776
  // ---- INCR, requestor side ----
713
777
 
714
- // ConvertSelection and wait for the owner's SelectionNotify answer
715
- _convert(sel, target, prop, selectionName, timeout) {
778
+ // ConvertSelection and wait for the owner's SelectionNotify answer.
779
+ // `time` is the timestamp of the event that asked for the data; ICCCM 2.4
780
+ // says to send it rather than CurrentTime. Undefined coerces to 0, which
781
+ // is CurrentTime — the documented default for callers with no event.
782
+ _convert(sel, target, prop, selectionName, timeout, time) {
716
783
  return new Promise((resolve, reject) => {
717
784
  const X = this.X;
718
785
  const wid = this._window.id;
@@ -734,7 +801,7 @@ export default class Clipboard {
734
801
  X.removeListener('event', onEvent);
735
802
  };
736
803
  X.on('event', onEvent);
737
- X.ConvertSelection(wid, sel, target, prop, 0);
804
+ X.ConvertSelection(wid, sel, target, prop, time >>> 0);
738
805
  });
739
806
  }
740
807
 
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
@@ -51,8 +51,9 @@ export class Image {
51
51
  this.height
52
52
  );
53
53
 
54
- // node-x11 has no FreeGC — share one upload GC per app (valid for any
55
- // 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.
56
57
  let gc = app._imageUploadGC;
57
58
  if (!gc) {
58
59
  gc = app._imageUploadGC = X.AllocID();
package/lib/index.js CHANGED
@@ -24,6 +24,7 @@ import FontManager from './text/fontmanager.js';
24
24
  import {
25
25
  FontconfigFontSource,
26
26
  StaticFontSource,
27
+ createFontSource,
27
28
  defaultFontSource,
28
29
  setDefaultFontSource
29
30
  } from './text/fontsource.js';
@@ -69,7 +70,11 @@ const DEFAULT_BUFFER_REQUESTS = { maxSize: 64 * 1024 };
69
70
  *
70
71
  * @param {object} [options] passed through to x11.createClient
71
72
  * (e.g. { display: ':1' }); ntk additionally understands
72
- * { 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) —
73
78
  * { rasterizer, rasterPolicy } — where small fills and strokes are
74
79
  * rasterized, and the thresholds for that choice (see docs/context-2d.md) —
75
80
  * { glxVisual } — a visual id for getContext('opengl') to use instead of
@@ -95,6 +100,14 @@ export function createClient(options, callback) {
95
100
  const layout = loadLayout();
96
101
 
97
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
+
98
111
  x11.createClient(x11Options, (error, display) => {
99
112
  if (error) return reject(error);
100
113
 
@@ -130,7 +143,7 @@ export function createClient(options, callback) {
130
143
  });
131
144
  updateKeyboardMapping(display.min_keycode, display.max_keycode);
132
145
 
133
- resolve(new App(display, options || {}));
146
+ resolve(new App(display, appOptions));
134
147
  });
135
148
  });
136
149
  });
@@ -174,6 +187,7 @@ export {
174
187
  FontManager,
175
188
  FontconfigFontSource,
176
189
  StaticFontSource,
190
+ createFontSource,
177
191
  defaultFontSource,
178
192
  setDefaultFontSource,
179
193
  CoverageAccumulator,
@@ -27,6 +27,22 @@ import { trapezoidize } from './trapezoid.js';
27
27
 
28
28
  const DEFAULT_FONT = '20px sans-serif';
29
29
 
30
+ /**
31
+ * Fallback for a context dropped without `destroy()`, matching Pixmap,
32
+ * Picture and GlyphSet. Only the GCs are freed here: everything else a
33
+ * context owns is a Pixmap or a Picture, which carry their own finalizers,
34
+ * and reaching into them from this one would race those.
35
+ */
36
+ const gcRegistry = new FinalizationRegistry(({ X, gcs }) => {
37
+ safeRelease(X, () => {
38
+ for (const gc of gcs) {
39
+ if (!gc) continue;
40
+ X.FreeGC(gc);
41
+ X.ReleaseID(gc);
42
+ }
43
+ });
44
+ });
45
+
30
46
  /**
31
47
  * What `drawImage` takes as a server-side source: anything that knows its own
32
48
  * size and can hand over a `Picture` for this connection.
@@ -174,6 +190,10 @@ class RenderingContext2d {
174
190
  constructor(window) {
175
191
  const X = window.X;
176
192
  this.X = X;
193
+ // ids land here as they are allocated; the finalizer holds this array
194
+ // rather than the context, so registering cannot keep the context alive
195
+ this._gcs = [];
196
+ gcRegistry.register(this, { X, gcs: this._gcs }, this);
177
197
 
178
198
  this.window = window;
179
199
  this.display = window.app.display;
@@ -236,6 +256,7 @@ class RenderingContext2d {
236
256
  // one GC is enough: it stays valid for any drawable of the same
237
257
  // screen and depth (the backing pixmap matches the window depth)
238
258
  this._gc = this.X.AllocID();
259
+ this._gcs.push(this._gc);
239
260
  this.X.CreateGC(this._gc, target.id);
240
261
  }
241
262
  if (this.picture) this.picture.destroy();
@@ -276,6 +297,7 @@ class RenderingContext2d {
276
297
  destroy() {
277
298
  if (this._destroyed) return;
278
299
  this._destroyed = true;
300
+ gcRegistry.unregister(this);
279
301
 
280
302
  this._clips = [];
281
303
  this._dropMasks();
@@ -287,6 +309,7 @@ class RenderingContext2d {
287
309
  });
288
310
  }
289
311
  this._gc = this._fillMaskGC = null;
312
+ this._gcs.length = 0;
290
313
 
291
314
  for (const picture of this._solidPictures.values()) {
292
315
  picture.destroy();
@@ -656,6 +679,7 @@ class RenderingContext2d {
656
679
  _maskGC() {
657
680
  if (!this._fillMaskGC) {
658
681
  this._fillMaskGC = this.X.AllocID();
682
+ this._gcs.push(this._fillMaskGC);
659
683
  this.X.CreateGC(this._fillMaskGC, this.fillMaskDrawable.id);
660
684
  }
661
685
  return this._fillMaskGC;
@@ -1782,24 +1806,40 @@ class RenderingContext2d {
1782
1806
  sHeight
1783
1807
  );
1784
1808
 
1785
- const pixmap = new Pixmap(this.window.app, { depth: 32, width: sWidth, height: sHeight });
1786
- const picture = new Picture(this.window.app, { drawable: pixmap, format: this.Render.rgba32 });
1787
- pixmap._gc = this.X.AllocID();
1788
- this.X.CreateGC(pixmap._gc, pixmap.id);
1789
- // the crop already happened in getImageData, so it lands at the pixmap's
1790
- // origin — sx/sy here wrote it off the edge
1791
- this.X.PutImage(2, pixmap.id, pixmap._gc, sWidth, sHeight, 0, 0, 0, 32, data);
1809
+ // One upload GC per app rather than one per call, the same sharing
1810
+ // Image.picture does: a GC is valid for any drawable of the same screen
1811
+ // and depth. This used to allocate a GC, a pixmap and a picture every
1812
+ // time and free none of them, so drawing a node-canvas source in an
1813
+ // animation loop leaked three server resources per frame.
1814
+ const app = this.window.app;
1815
+ const pixmap = new Pixmap(app, { depth: 32, width: sWidth, height: sHeight });
1816
+ const picture = new Picture(app, { drawable: pixmap, format: this.Render.rgba32 });
1817
+ try {
1818
+ let gc = app._imageUploadGC;
1819
+ if (!gc) {
1820
+ gc = app._imageUploadGC = this.X.AllocID();
1821
+ this.X.CreateGC(gc, pixmap.id);
1822
+ }
1823
+ // the crop already happened in getImageData, so it lands at the
1824
+ // pixmap's origin — sx/sy here wrote it off the edge
1825
+ this.X.PutImage(2, pixmap.id, gc, sWidth, sHeight, 0, 0, 0, 32, data);
1792
1826
 
1793
- this.Render.Composite(
1794
- this.Render.PictOp.Over,
1795
- picture.id,
1796
- this._compositeMask(),
1797
- this.picture.id,
1798
- 0, 0, 0, 0, 0, 0,
1799
- this.width,
1800
- this.height
1801
- );
1802
- this._markDirty();
1827
+ this.Render.Composite(
1828
+ this.Render.PictOp.Over,
1829
+ picture.id,
1830
+ this._compositeMask(),
1831
+ this.picture.id,
1832
+ 0, 0, 0, 0, 0, 0,
1833
+ this.width,
1834
+ this.height
1835
+ );
1836
+ this._markDirty();
1837
+ } finally {
1838
+ // the composite is queued, and X executes requests in order, so the
1839
+ // server is done with these by the time it reads the frees
1840
+ picture.destroy();
1841
+ pixmap.destroy();
1842
+ }
1803
1843
  }
1804
1844
  }
1805
1845
 
@@ -1,5 +1,10 @@
1
1
  import Font from './font.js';
2
- import { defaultFontSource, detectStyle, numericWeight as numWeight } from './fontsource.js';
2
+ import {
3
+ createFontSource,
4
+ defaultFontSource,
5
+ detectStyle,
6
+ numericWeight as numWeight
7
+ } from './fontsource.js';
3
8
  import { shapeText } from './shape.js';
4
9
  import { TextLayout } from './layout.js';
5
10
 
@@ -21,11 +26,14 @@ import { TextLayout } from './layout.js';
21
26
  *
22
27
  * All system lookup goes through a pluggable FontSource (see
23
28
  * text/fontsource.js) — pass `{ source }` to use something other than
24
- * fontconfig, e.g. a StaticFontSource in a browser bundle.
29
+ * fontconfig, e.g. a StaticFontSource in a browser bundle. `source` also
30
+ * takes a font spec: a path, a directory of faces, or font bytes.
25
31
  */
26
32
  export default class FontManager {
27
33
  constructor({ source } = {}) {
28
- this._source = source ?? null; // resolved lazily so the process-wide default can be set late
34
+ // coerced here rather than in the getter, which is on the match path;
35
+ // null stays null so the process-wide default can still be set late
36
+ this._source = createFontSource(source) ?? null;
29
37
  this._fonts = new Map(); // candidate key -> Font
30
38
  this._matches = new Map(); // family|weight|style -> Font
31
39
  this._fallbacks = new Map(); // family|weight|style -> Map(codepoint -> Font|null)
@@ -146,6 +154,13 @@ export default class FontManager {
146
154
  * font doesn't. Registered fonts first, then the source's fallback chain
147
155
  * (filtered by the source's coverage data — font files are only opened to
148
156
  * confirm). Returns null when nothing on the system covers the codepoint.
157
+ *
158
+ * "Nothing covers it" includes "this environment has no system fonts at
159
+ * all". An app that loaded its own faces still reaches here for the first
160
+ * character they lack — a bullet, a curly quote — and before this returned
161
+ * null there, a fontconfig-less box crashed mid-shape on that character,
162
+ * arbitrarily far from anything about fonts. `shapeText` renders .notdef
163
+ * for a null, which is the right answer to "no font has this glyph".
149
164
  */
150
165
  fallbackFor(codepoint, family = 'sans-serif', opts = {}) {
151
166
  const cacheKey = `${family}|${numWeight(opts.weight)}|${opts.style || ''}`;
@@ -165,7 +180,15 @@ export default class FontManager {
165
180
  }
166
181
  if (!found) {
167
182
  const source = this.source;
168
- const candidates = source.matchSorted({ family, weight: opts.weight, style: opts.style });
183
+ let candidates;
184
+ try {
185
+ candidates = source.matchSorted({ family, weight: opts.weight, style: opts.style });
186
+ } catch (err) {
187
+ // no system fonts to fall back to is an answer, not a crash; a source
188
+ // that failed for any other reason is a real bug and still propagates
189
+ if (err.code !== 'ERR_NTK_NO_FONTS') throw err;
190
+ candidates = [];
191
+ }
169
192
  for (const c of candidates) {
170
193
  if (source.covers && !source.covers(c, codepoint)) continue;
171
194
  try {
@@ -23,7 +23,18 @@
23
23
  // The default source shells out to fc-match (fontconfig) — the behavior ntk
24
24
  // always had. Swap it per-app (`createClient({ fontSource })`), per-manager
25
25
  // (`new FontManager({ source })`) or globally (`setDefaultFontSource()`).
26
- import { charsetHas, matchSortedSync } from '../fontconfig.js';
26
+ //
27
+ // All three of those also accept a **font spec** — a path, a directory, font
28
+ // bytes, or a list of them — which `createFontSource` turns into a
29
+ // StaticFontSource. That is the whole answer for an environment with no
30
+ // fontconfig: ntk ships no fonts, so the app has to hand over the ones it
31
+ // ships, and the spec is the short way to say so.
32
+ //
33
+ // node:fs is fetched through `getBuiltinModule` inside the path branch rather
34
+ // than imported, because this file is the one the browser bundle keeps — its
35
+ // whole purpose is running the text stack without a filesystem. Do not turn
36
+ // that into a static import (test/packaging.test.js fails if you do).
37
+ import { charsetHas, matchSortedSync, noFontsError, supported } from '../fontconfig.js';
27
38
  import Font from './font.js';
28
39
 
29
40
  const WEIGHTS = { normal: 400, bold: 700 };
@@ -53,6 +64,23 @@ export function detectStyle(font) {
53
64
  };
54
65
  }
55
66
 
67
+ // A font is monospaced if it says so, or if it draws a period as wide as a W.
68
+ // The probe is the part that carries its weight — `post.isFixedPitch` is 0 on
69
+ // plenty of genuinely fixed-pitch faces.
70
+ const PROBE = ['i', 'M', 'W', '.'].map((c) => c.codePointAt(0));
71
+
72
+ function isMonospaced(font) {
73
+ if (font.fk.post?.isFixedPitch) return true;
74
+ let width = null;
75
+ for (const cp of PROBE) {
76
+ if (!font.hasGlyph(cp)) return false;
77
+ const advance = font.fk.glyphForCodePoint(cp).advanceWidth;
78
+ if (width === null) width = advance;
79
+ else if (advance !== width) return false;
80
+ }
81
+ return width !== null && width > 0;
82
+ }
83
+
56
84
  /** split a CSS font-family list into normalized lowercase names */
57
85
  export function parseFamilies(family) {
58
86
  return String(family ?? '')
@@ -95,6 +123,8 @@ export class StaticFontSource {
95
123
  this._faces = []; // { font, family, weight, italic, candidate }
96
124
  this._aliases = new Map(); // 'sans-serif' -> 'dejavu sans'
97
125
  this._n = 0;
126
+ /** files a font spec could not parse: [{ file, error }] */
127
+ this.skipped = [];
98
128
  }
99
129
 
100
130
  /**
@@ -129,9 +159,55 @@ export class StaticFontSource {
129
159
  this._aliases.set(name.toLowerCase(), family.toLowerCase());
130
160
  }
131
161
 
162
+ /** what the generic families currently resolve to, for inspection */
163
+ get aliases() {
164
+ return Object.fromEntries(this._aliases);
165
+ }
166
+
167
+ /**
168
+ * Point `sans-serif`, `serif` and `monospace` at added faces.
169
+ *
170
+ * This is not cosmetic. Every widget default in the toolkit is
171
+ * `sans-serif`, and `code`/`pre` ask for `monospace`; with no alias for
172
+ * them `matchSorted` ranks every face equally and breaks the tie on weight
173
+ * distance and then insertion order, so `monospace` silently renders in a
174
+ * proportional face. Inference happens automatically for a source built
175
+ * from a font spec; a hand-built source calls this when it wants it.
176
+ *
177
+ * A generic is only aliased on positive evidence, and a generic with none
178
+ * is deliberately left out — its absence from `aliases` is the signal to
179
+ * pass an explicit one. Monospace is decided structurally, because fonts
180
+ * lie about it: KaTeX_Typewriter reports `isFixedPitch = 0` while every
181
+ * advance is 525. Sans/serif read the font's own family name, the
182
+ * convention DejaVu, Noto, Liberation, Source and IBM Plex all follow —
183
+ * never the filename, which loses on `Inter_18pt-SemiBold.ttf`.
184
+ *
185
+ * Explicit aliases are never overridden.
186
+ */
187
+ inferGenerics() {
188
+ const mono = [];
189
+ const rest = [];
190
+ for (const face of this._faces) (isMonospaced(face.font) ? mono : rest).push(face);
191
+
192
+ // "sans" is checked first and its matches are then excluded, because
193
+ // "SansSerif" and "Sans Serif" contain "serif" — without that, the one
194
+ // family named for both wins the generic it is least suited to
195
+ const sans = rest.filter((f) => /sans/i.test(f.font.familyName || ''));
196
+ const serif = rest.filter((f) => !sans.includes(f) && /serif/i.test(f.font.familyName || ''));
197
+ const evidence = {
198
+ monospace: mono[0]?.family,
199
+ 'sans-serif': sans[0]?.family,
200
+ serif: serif[0]?.family
201
+ };
202
+ for (const [generic, family] of Object.entries(evidence)) {
203
+ if (family && !this._aliases.has(generic)) this._aliases.set(generic, family);
204
+ }
205
+ return this;
206
+ }
207
+
132
208
  matchSorted(pattern = {}) {
133
209
  if (this._faces.length === 0) {
134
- throw new Error('StaticFontSource: no fonts added');
210
+ throw noFontsError('this font source has no fonts added');
135
211
  }
136
212
  const families = parseFamilies(pattern.family).map((f) => this._aliases.get(f) ?? f);
137
213
  const weight = numericWeight(pattern.weight);
@@ -160,6 +236,179 @@ export class StaticFontSource {
160
236
  }
161
237
  }
162
238
 
239
+ // A directory of an app's own faces is a handful of files; a system font
240
+ // tree is hundreds, and every one of them is parsed and then retained for
241
+ // the life of the FontManager (one macOS emoji collection alone is 188 MB).
242
+ // Rather than document that and hope, the walk stops and names the option —
243
+ // an app that really means it says so.
244
+ const MAX_FILES = 64;
245
+ const MAX_DEPTH = 8;
246
+
247
+ // Said whenever ntk is handed something it cannot read as fonts. It names
248
+ // every accepted shape rather than the one that was wrong, and states the
249
+ // premise — ntk has no fonts of its own — because the spec that brings people
250
+ // here is `'bundled'`, from an issue proposing a font package that does not
251
+ // exist. Naming that value in the matcher would enshrine an API that never
252
+ // shipped; this answers the question without making it real.
253
+ const ACCEPTED =
254
+ ". Pass 'system', a path to a font file or directory, font bytes, an array of " +
255
+ 'those, { fonts, alias }, or a FontSource. ntk ships no fonts of its own — see ' +
256
+ 'docs/fonts.md';
257
+
258
+ function fs() {
259
+ const mod = globalThis.process?.getBuiltinModule?.('node:fs');
260
+ if (!mod) {
261
+ throw new Error(
262
+ 'ntk: a font path can only be read in node. In a browser, fetch the font ' +
263
+ 'bytes yourself and pass them: fontSource: [bytes] — see docs/fonts.md'
264
+ );
265
+ }
266
+ return mod;
267
+ }
268
+
269
+ /** stat a spec'd path, reporting a missing one as the spec mistake it is
270
+ * rather than as a bare ENOENT from somewhere inside ntk */
271
+ function statOf(path) {
272
+ try {
273
+ return fs().statSync(path);
274
+ } catch (err) {
275
+ if (err.code === 'ENOENT') {
276
+ // A bare word is far more likely a spec someone invented — 'bundled' is
277
+ // the one the issue asks for — than a path they mistyped. Answer the
278
+ // question they were actually asking.
279
+ const guess = /[/\\.]/.test(path) ? '' : ACCEPTED;
280
+ throw new Error(`ntk: no such font file or directory: "${path}"${guess}`, { cause: err });
281
+ }
282
+ throw err;
283
+ }
284
+ }
285
+
286
+ /** every font file in a directory, sorted — never readdir order, which would
287
+ * let the filesystem pick which face `sans-serif` lands on */
288
+ function fontFilesIn(dir, recursive, budget, depth = 0) {
289
+ const { readdirSync } = fs();
290
+ const found = [];
291
+ const entries = readdirSync(dir, { withFileTypes: true });
292
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
293
+ for (const entry of entries) {
294
+ const path = `${dir}/${entry.name}`;
295
+ // isDirectory() is false for a symlink, so this skips linked directories
296
+ // and with them any chance of a cycle
297
+ if (entry.isDirectory()) {
298
+ if (recursive && depth < MAX_DEPTH) found.push(...fontFilesIn(path, true, budget, depth + 1));
299
+ } else if (supported.test(entry.name)) {
300
+ found.push(path);
301
+ if (++budget.count > budget.max) {
302
+ throw new Error(
303
+ `ntk: more than ${budget.max} font files under "${budget.root}". That is a font ` +
304
+ "tree rather than an app's own faces, and every one of them would be parsed and " +
305
+ 'kept in memory for the life of the process. List the faces you want, or raise ' +
306
+ `the limit deliberately: { fonts: '${budget.root}', maxFiles: N } — see docs/fonts.md`
307
+ );
308
+ }
309
+ }
310
+ }
311
+ return found;
312
+ }
313
+
314
+ /** does this look like a face rather than a typo? */
315
+ function isFace(face) {
316
+ return (
317
+ typeof face === 'string' ||
318
+ ArrayBuffer.isView(face) ||
319
+ face instanceof ArrayBuffer ||
320
+ (!!face && typeof face === 'object' && (typeof face.path === 'string' || face.data != null))
321
+ );
322
+ }
323
+
324
+ function addFace(source, face, skipped) {
325
+ try {
326
+ if (typeof face === 'string') return source.add(Font.loadSync(face));
327
+ if (ArrayBuffer.isView(face) || face instanceof ArrayBuffer) return source.add(face);
328
+ const { path, data, ...opts } = face;
329
+ return source.add(path ? Font.loadSync(path, opts.postscriptName) : data, opts);
330
+ } catch (err) {
331
+ // one unparseable file should not take a whole directory down; a spec
332
+ // that yields no usable face at all still throws, below
333
+ skipped.push({ file: typeof face === 'string' ? face : face?.path, error: err });
334
+ return null;
335
+ }
336
+ }
337
+
338
+ function describe(value) {
339
+ if (typeof value === 'string') return `"${value}"`;
340
+ if (Array.isArray(value)) return 'that array';
341
+ return value === null ? 'null' : typeof value;
342
+ }
343
+
344
+ /**
345
+ * Resolve a **font spec** to a FontSource.
346
+ *
347
+ * ```
348
+ * spec := FontSource | 'system' | null | undefined
349
+ * | faces | { fonts: faces, alias?, recursive?, maxFiles? }
350
+ * faces := face | face[]
351
+ * face := string (path to a font FILE or DIRECTORY)
352
+ * | Uint8Array | Buffer | ArrayBuffer
353
+ * | { path | data, family?, weight?, style?, postscriptName? }
354
+ * ```
355
+ *
356
+ * Idempotent — a FontSource passes straight through, which is what lets
357
+ * `createClient`, `FontManager` and `setDefaultFontSource` all coerce
358
+ * without caring whether someone already did.
359
+ *
360
+ * Anything built from faces is a `StaticFontSource`, so there is one
361
+ * matching implementation to reason about rather than two. Generic families
362
+ * are inferred (see `inferGenerics`) with an explicit `alias` map winning.
363
+ *
364
+ * @param {*} spec
365
+ * @returns {object|null|undefined} a FontSource
366
+ */
367
+ export function createFontSource(spec) {
368
+ if (spec == null) return spec; // preserves setDefaultFontSource(null)'s reset
369
+ if (typeof spec.matchSorted === 'function') return spec;
370
+ if (spec === 'system') return new FontconfigFontSource();
371
+
372
+ const configured = !Array.isArray(spec) && typeof spec === 'object' && 'fonts' in spec;
373
+ const { fonts, alias, recursive = false, maxFiles = MAX_FILES } = configured ? spec : { fonts: spec };
374
+ const list = Array.isArray(fonts) ? fonts : [fonts];
375
+
376
+ if (list.length === 0 || !list.every(isFace)) {
377
+ throw new Error(`ntk: ${describe(spec)} is not a font source${ACCEPTED}`);
378
+ }
379
+
380
+ const source = new StaticFontSource();
381
+ const skipped = [];
382
+ const budget = { count: 0, max: maxFiles, root: null };
383
+ let added = 0;
384
+ for (const face of list) {
385
+ if (typeof face === 'string' && statOf(face).isDirectory()) {
386
+ budget.root = face;
387
+ const files = fontFilesIn(face, recursive, budget);
388
+ if (files.length === 0) {
389
+ throw new Error(
390
+ `ntk: no font files in "${face}" — looked for .ttf/.otf/.woff/.woff2/.ttc/.dfont` +
391
+ (recursive ? '' : '. Pass { fonts, recursive: true } to walk subdirectories')
392
+ );
393
+ }
394
+ for (const file of files) if (addFace(source, file, skipped)) added++;
395
+ } else if (addFace(source, face, skipped)) {
396
+ added++;
397
+ }
398
+ }
399
+
400
+ source.skipped = skipped;
401
+ if (added === 0) {
402
+ throw new Error(
403
+ `ntk: none of the ${skipped.length} font file(s) given could be parsed — ` +
404
+ `${skipped[0]?.file ?? 'the first'}: ${skipped[0]?.error?.message ?? 'unknown error'}`
405
+ );
406
+ }
407
+
408
+ for (const [generic, family] of Object.entries(alias ?? {})) source.alias(generic, family);
409
+ return source.inferGenerics();
410
+ }
411
+
163
412
  let _default = null;
164
413
 
165
414
  /** the process-wide default FontSource (fontconfig unless overridden) */
@@ -173,7 +422,10 @@ export function defaultFontSource() {
173
422
  * created afterwards without an explicit source — including the ones
174
423
  * widgets create internally. The primary hook for browser playgrounds:
175
424
  * call it once with a StaticFontSource before creating any app/window.
425
+ *
426
+ * Takes a font spec as well as a source, so pointing the whole process at a
427
+ * directory is one line. `null` restores the fontconfig default.
176
428
  */
177
429
  export function setDefaultFontSource(source) {
178
- _default = source;
430
+ _default = createFontSource(source);
179
431
  }
package/lib/window.js CHANGED
@@ -655,7 +655,7 @@ export default class Window extends Drawable {
655
655
  });
656
656
  if (!this._clearGc) {
657
657
  // reusable across reallocs: a GC is valid for any drawable of the
658
- // same screen and depth (node-x11 has no FreeGC request)
658
+ // same screen and depth, so one outlives every backing pixmap
659
659
  this._clearGc = this.X.AllocID();
660
660
  this.X.CreateGC(this._clearGc, pixmap.id, {
661
661
  foreground: this.display.screen[0].white_pixel,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "5.3.0",
3
+ "version": "5.4.0",
4
4
  "description": "Desktop UI toolkit for X11 with canvas-like 2d and OpenGL rendering",
5
5
  "author": "Andrey Sidorov <sidorares@yandex.ru>",
6
6
  "license": "MIT",