ntk 5.3.0 → 6.0.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
  }
@@ -28,11 +28,17 @@ function isWsGlyph(g) {
28
28
  * - `align` — 'left' | 'right' | 'center' | 'start' | 'end'
29
29
  * - `lineHeight` — multiplier over natural font line height (default 1)
30
30
  * - `direction` — 'ltr' | 'rtl' | 'auto' base paragraph direction
31
+ * - `maxLines` — cap the number of lines (default: unlimited)
32
+ * - `overflow` — 'clip' (default) or 'ellipsis', what a `maxLines` cut looks
33
+ * like. See `_elide`.
31
34
  *
32
- * The result is inspectable before/without drawing: `width`, `height`, and
33
- * `lines[] = { x, y, baseline, width, ascent, descent, runs, start, end }`
34
- * with `runs[] = { x, width, run, span, start, end }` in visual order
35
- * (`start`/`end` are logical UTF-16 ranges into the full text).
35
+ * The result is inspectable before/without drawing: `width`, `height`,
36
+ * `truncated`, and `lines[] = { x, y, height, baseline, width, ascent,
37
+ * descent, runs, start, end }` with
38
+ * `runs[] = { x, width, run, span, start, end }` in visual order
39
+ * (`start`/`end` are logical UTF-16 ranges into the full text). A line's
40
+ * box is `y` to `y + height`; its glyphs sit centred in it, from
41
+ * `baseline - ascent` to `baseline + descent`.
36
42
  *
37
43
  * `caretPosition(index)` / `indexAt(x, y)` map logical code-point indices
38
44
  * to visual caret geometry and back (bidi/ligature/trailing-whitespace
@@ -143,6 +149,17 @@ export class TextLayout {
143
149
  if (cur.length) lineTokens.push(cur);
144
150
  }
145
151
 
152
+ // ---- cap the line count ----
153
+ // The cut happens here, between filling and assembly, so the dropped
154
+ // lines cost nothing to position and `height` counts only what is kept.
155
+ const maxLines = Number.isFinite(options.maxLines)
156
+ ? Math.max(0, Math.floor(options.maxLines))
157
+ : Infinity;
158
+ /** did `maxLines` drop content? */
159
+ this.truncated = lineTokens.length > maxLines;
160
+ if (this.truncated) lineTokens.length = maxLines;
161
+ const elide = this.truncated && options.overflow === 'ellipsis';
162
+
146
163
  // ---- assemble lines: strip trailing ws, bidi-reorder, position ----
147
164
  const baseSpan = spans[0];
148
165
  const lineHeightMul = options.lineHeight ?? 1;
@@ -150,7 +167,16 @@ export class TextLayout {
150
167
  let y = 0;
151
168
  let layoutWidth = 0;
152
169
 
153
- for (const toks of lineTokens) {
170
+ for (let li = 0; li < lineTokens.length; li++) {
171
+ let toks = lineTokens[li];
172
+ const lineStart = toks.length ? toks[0].start : 0;
173
+ let lineEnd = toks.length ? toks[toks.length - 1].end : lineStart;
174
+
175
+ // Elision is the last kept line's business only, and it happens before
176
+ // entries exist, because dropping content means re-shaping the tail.
177
+ const ellipsis = elide && li === lineTokens.length - 1 ? this._elide(toks, baseSpan) : null;
178
+ if (ellipsis) toks = this._fitBefore(toks, maxWidth - ellipsis.width);
179
+
154
180
  // entries carry .level so reorderRuns can order them (UAX#9 L2);
155
181
  // .start/.end are absolute code-unit ranges into the full text, kept
156
182
  // for caret positioning (caretPosition / indexAt)
@@ -168,14 +194,34 @@ export class TextLayout {
168
194
  }
169
195
  }
170
196
  }
171
- const lineStart = toks[0].start;
172
- const lineEnd = toks[toks.length - 1].end;
173
- const trailing = stripTrailingWhitespace(entries);
197
+ let trailing = stripTrailingWhitespace(entries);
174
198
  const contentEnd = trailing
175
199
  ? trailing.start
176
200
  : entries.length
177
201
  ? entries[entries.length - 1].end
178
202
  : lineStart;
203
+ if (ellipsis) {
204
+ // The ellipsis takes the *paragraph* level, which is what a neutral
205
+ // at the end of a paragraph resolves to under UAX#9 — so reorderRuns
206
+ // puts it at the right edge of an LTR line and the left edge of an
207
+ // RTL one, without a special case here. Its logical range is empty
208
+ // and pinned at the cut, so caret mapping treats it as the end of
209
+ // the visible text rather than as characters of its own.
210
+ for (const run of ellipsis.shaped.runs) {
211
+ entries.push({
212
+ run,
213
+ span: ellipsis.span,
214
+ level: this.baseLevel,
215
+ start: contentEnd,
216
+ end: contentEnd,
217
+ ellipsis: true
218
+ });
219
+ }
220
+ // whatever whitespace was stripped is gone for good, not merely
221
+ // pushed past the line edge: there is no wrap for it to precede
222
+ trailing = null;
223
+ lineEnd = contentEnd;
224
+ }
179
225
  entries = reorderRuns(entries);
180
226
 
181
227
  let ascent = 0;
@@ -184,7 +230,9 @@ export class TextLayout {
184
230
  const runs = [];
185
231
  let x = 0;
186
232
  for (const e of entries) {
187
- runs.push({ x, width: e.run.width, run: e.run, span: e.span, start: e.start, end: e.end });
233
+ const positioned = { x, width: e.run.width, run: e.run, span: e.span, start: e.start, end: e.end };
234
+ if (e.ellipsis) positioned.ellipsis = true;
235
+ runs.push(positioned);
188
236
  x += e.run.width;
189
237
  const m = e.run.font.metrics(e.run.size);
190
238
  if (m.ascent > ascent) ascent = m.ascent;
@@ -199,10 +247,23 @@ export class TextLayout {
199
247
  natural = m.lineHeight;
200
248
  }
201
249
  if (x > layoutWidth) layoutWidth = x;
250
+ // Half-leading (CSS Inline Layout 3): the slack between the glyphs and
251
+ // the line box is split evenly above and below, rather than all of it
252
+ // landing under the text. This is what makes a single line sit
253
+ // centred in a box measured from `layout.height`, and it applies at
254
+ // `lineHeight: 1` too — a font's natural line height includes its line
255
+ // gap, which is 8px at 16px for some UI faces.
256
+ //
257
+ // A multiplier small enough to make the box shorter than the glyphs
258
+ // gives negative leading and the text overflows evenly on both sides,
259
+ // which is also what CSS does.
260
+ const box = natural * lineHeightMul;
261
+ const leading = (box - (ascent + descent)) / 2;
202
262
  this.lines.push({
203
263
  x: 0,
204
264
  y,
205
- baseline: y + ascent,
265
+ height: box,
266
+ baseline: y + leading + ascent,
206
267
  width: x,
207
268
  ascent,
208
269
  descent,
@@ -212,7 +273,7 @@ export class TextLayout {
212
273
  _contentEnd: contentEnd,
213
274
  _trailing: trailing
214
275
  });
215
- y += natural * lineHeightMul;
276
+ y += box;
216
277
  }
217
278
 
218
279
  this.width = layoutWidth;
@@ -243,7 +304,9 @@ export class TextLayout {
243
304
  if (fragText.length > 0) {
244
305
  const fragLevels = normalizedLevels(levels, pos, pos + fragText.length);
245
306
  const shaped = this.fonts._shapeCached(fragText, span, fragLevels);
246
- fragments.push({ text: fragText, span, shaped, start: pos });
307
+ // the levels ride along so a later split can re-shape a piece at the
308
+ // level it actually has, rather than assuming ltr
309
+ fragments.push({ text: fragText, span, shaped, start: pos, levels: fragLevels });
247
310
  width += shaped.width;
248
311
  }
249
312
  pos = fragEnd;
@@ -261,6 +324,67 @@ export class TextLayout {
261
324
  return { fragments, width, wsWidth, required, start, end };
262
325
  }
263
326
 
327
+ /**
328
+ * The ellipsis to append to a line that was cut short: `{ text, span,
329
+ * shaped, width }`.
330
+ *
331
+ * Shaped in the style of the line's **logically trailing** span, so an
332
+ * elided line ending in a large or bold word gets a matching ellipsis
333
+ * rather than one in the paragraph's base style. That span is chosen
334
+ * before the cut, not after: choosing it after would make the ellipsis
335
+ * width depend on a cut that depends on the ellipsis width.
336
+ *
337
+ * U+2026 is not universal — a subsetted icon or maths face may well lack
338
+ * it — so when neither the span's font nor any fallback covers it, three
339
+ * periods stand in. Shaping the real character through a font that has no
340
+ * glyph for it would draw a .notdef box, which is a worse way to say
341
+ * "there is more text".
342
+ */
343
+ _elide(toks, baseSpan) {
344
+ let span = baseSpan;
345
+ for (let i = toks.length - 1; i >= 0; i--) {
346
+ const frags = toks[i].fragments;
347
+ if (frags.length) {
348
+ span = frags[frags.length - 1].span;
349
+ break;
350
+ }
351
+ }
352
+ const covered =
353
+ span.font.hasGlyph(0x2026) || this.fonts.fallbackFor(0x2026, span.family, span) !== null;
354
+ const text = covered ? '…' : '...';
355
+ const shaped = this.fonts._shapeCached(text, span, String(this.baseLevel));
356
+ return { text, span, shaped, width: shaped.width };
357
+ }
358
+
359
+ /**
360
+ * The longest prefix of a line's tokens whose width fits `budget` — whole
361
+ * tokens while they fit, then a grapheme-boundary cut into the first one
362
+ * that does not.
363
+ *
364
+ * Cutting has to happen here rather than by slicing the text, because the
365
+ * tail is re-shaped: kerning and ligatures across the cut change widths,
366
+ * and in a mixed-direction line the visually-last run is not the logically
367
+ * last one. Working in tokens keeps both facts inside the machinery that
368
+ * already knows them.
369
+ */
370
+ _fitBefore(toks, budget) {
371
+ const kept = [];
372
+ let used = 0;
373
+ for (const token of toks) {
374
+ // trailing whitespace does not count against the budget, exactly as it
375
+ // does not count during the greedy fill
376
+ if (used + token.width - token.wsWidth <= budget) {
377
+ kept.push(token);
378
+ used += token.width;
379
+ continue;
380
+ }
381
+ const [head] = this._forceBreak(token, budget - used);
382
+ if (head && head.fragments.length) kept.push(head);
383
+ break;
384
+ }
385
+ return kept;
386
+ }
387
+
264
388
  // split an over-wide token at the widest cluster boundary that fits
265
389
  _forceBreak(token, maxWidth) {
266
390
  const headFrags = [];
@@ -272,15 +396,22 @@ export class TextLayout {
272
396
  used += frag.shaped.width;
273
397
  continue;
274
398
  }
275
- // binary search the longest codepoint prefix of this fragment that fits
276
- const cps = Array.from(frag.text);
399
+ // binary search the longest grapheme prefix of this fragment that fits.
400
+ // Graphemes rather than code points: cutting between a base character
401
+ // and its combining mark, or inside an emoji ZWJ sequence, leaves a
402
+ // dotted circle or a pair of half-emoji on the two sides of the break.
403
+ const cps = graphemes(frag.text);
277
404
  let lo = 0;
278
405
  let hi = cps.length - 1;
279
406
  let best = null;
280
407
  while (lo <= hi) {
281
408
  const mid = (lo + hi) >> 1;
282
409
  const prefix = cps.slice(0, mid + 1).join('');
283
- const shaped = this.fonts._shapeCached(prefix, frag.span, '0');
410
+ // shaped at the bidi level this text actually has: assuming level 0
411
+ // here re-shapes an rtl word as ltr, which lays its glyphs out
412
+ // backwards and hands reorderRuns an even level that stops it from
413
+ // being reordered at all
414
+ const shaped = this.fonts._shapeCached(prefix, frag.span, sliceLevels(frag.levels, 0, prefix.length));
284
415
  if (used + shaped.width <= maxWidth) {
285
416
  best = { len: prefix.length, shaped, text: prefix };
286
417
  lo = mid + 1;
@@ -289,17 +420,26 @@ export class TextLayout {
289
420
  }
290
421
  }
291
422
  if (best) {
292
- headFrags.push({ text: best.text, span: frag.span, shaped: best.shaped, start: frag.start });
423
+ headFrags.push({
424
+ text: best.text,
425
+ span: frag.span,
426
+ shaped: best.shaped,
427
+ start: frag.start,
428
+ levels: sliceLevels(frag.levels, 0, best.len)
429
+ });
293
430
  }
294
431
 
295
432
  const restFrags = [];
296
- const restText = frag.text.slice(best ? best.len : 0);
433
+ const cut = best ? best.len : 0;
434
+ const restText = frag.text.slice(cut);
297
435
  if (restText) {
436
+ const restLevels = sliceLevels(frag.levels, cut, frag.text.length);
298
437
  restFrags.push({
299
438
  text: restText,
300
439
  span: frag.span,
301
- shaped: this.fonts._shapeCached(restText, frag.span, '0'),
302
- start: frag.start + (best ? best.len : 0)
440
+ shaped: this.fonts._shapeCached(restText, frag.span, restLevels),
441
+ start: frag.start + cut,
442
+ levels: restLevels
303
443
  });
304
444
  }
305
445
  restFrags.push(...token.fragments.slice(i + 1));
@@ -417,8 +557,13 @@ export class TextLayout {
417
557
  * `[0, codePointCount]` (out-of-range indices clamp).
418
558
  *
419
559
  * @returns {{ x, y, height, line }} `x` is the caret's visual x within
420
- * the layout box (alignment included), `y` the top of the line box,
560
+ * the layout box (alignment included), `y` the top of the **glyphs**,
421
561
  * `height` = ascent + descent, `line` the line index.
562
+ *
563
+ * `y` tracks the text rather than the line box, so a caret drawn from it
564
+ * stays locked to the glyphs whatever the leading. For a full-height
565
+ * selection band instead, the line box is `line.y` to `line.y +
566
+ * line.height`.
422
567
  */
423
568
  caretPosition(index) {
424
569
  const offs = this._offsets();
@@ -435,7 +580,7 @@ export class TextLayout {
435
580
  const line = this.lines[li];
436
581
  return {
437
582
  x: this._caretXInLine(line, cu),
438
- y: line.y,
583
+ y: line.baseline - line.ascent,
439
584
  height: line.ascent + line.descent,
440
585
  line: li
441
586
  };
@@ -484,6 +629,9 @@ export class TextLayout {
484
629
  break;
485
630
  }
486
631
  }
632
+ // the ellipsis stands for text that is not displayed, so anywhere on it
633
+ // is the end of what is: it has no indices of its own to land in
634
+ if (target.ellipsis) return this._cpOf(line._contentEnd);
487
635
  return this._cpOf(this._runIndexAt(target, cx));
488
636
  }
489
637
 
@@ -623,6 +771,24 @@ export class TextLayout {
623
771
  }
624
772
  }
625
773
 
774
+ // Grapheme clusters (UAX#29), for cut points that never land inside one.
775
+ // Intl.Segmenter is in node >= 16 and every current browser; where it is
776
+ // somehow absent, code points are the old behaviour and still safe for the
777
+ // scripts that reach a force-break most often.
778
+ let segmenter;
779
+ function graphemes(text) {
780
+ if (segmenter === undefined) {
781
+ segmenter =
782
+ typeof Intl !== 'undefined' && Intl.Segmenter
783
+ ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
784
+ : null;
785
+ }
786
+ if (!segmenter) return Array.from(text);
787
+ const out = [];
788
+ for (const { segment } of segmenter.segment(text)) out.push(segment);
789
+ return out;
790
+ }
791
+
626
792
  // UTF-16 length of a glyph cluster's codePoints array
627
793
  function cuLength(codePoints) {
628
794
  let len = 0;
@@ -683,6 +849,14 @@ function stripTrailingWhitespace(entries) {
683
849
  return result();
684
850
  }
685
851
 
852
+ // The levels key for a code-unit slice of a fragment. A uniform key covers
853
+ // any slice of itself; a per-character one has to be cut to match.
854
+ function sliceLevels(key, start, end) {
855
+ if (key === undefined) return '0';
856
+ if (!key.includes(',')) return key;
857
+ return key.split(',').slice(start, end).join(',');
858
+ }
859
+
686
860
  // compact levels key for the shaping cache: single char when uniform
687
861
  function normalizedLevels(levels, start, end) {
688
862
  let uniform = true;
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": "6.0.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",