ntk 7.7.0 → 8.1.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/README.md CHANGED
@@ -53,18 +53,17 @@ rasterizer and cached server-side as XRender glyphs, so drawing a line of
53
53
  text costs about a byte per glyph on the wire. Font names resolve through
54
54
  fontconfig (`fc-match`). Very large and continuously animated sizes render
55
55
  as server-side trapezoids instead of cached bitmaps. A `TextLayout` engine
56
- wraps styled text to a target width, a `MarkdownView` widget renders
57
- markdown (with syntax-highlighted code fences and KaTeX math via
58
- `TexView`) on top of it — see [docs/text.md](docs/text.md) and
59
- [docs/tex.md](docs/tex.md).
56
+ wraps styled text to a target width — see [docs/text.md](docs/text.md).
60
57
 
61
58
  PNG/JPEG images decode client-side (`loadImage`) and composite server-side
62
- via `ctx.drawImage` ([docs/images.md](docs/images.md)). An `HtmlView`
63
- widget renders a static HTML + CSS subset — block flow and flexbox laid
64
- out by yoga-layout, no scripts, no network — with app-controlled link
65
- navigation ([docs/html.md](docs/html.md)). An `SvgView` widget renders
66
- static SVG (shapes, gradients, transforms, `use`) through the same 2d
67
- pipeline ([docs/svg.md](docs/svg.md)).
59
+ via `ctx.drawImage` ([docs/images.md](docs/images.md)). An `SvgView` widget
60
+ renders static SVG (shapes, gradients, transforms, `use`) through the same
61
+ 2d pipeline ([docs/svg.md](docs/svg.md)).
62
+
63
+ Rendering documents — markdown, formulas, rich text — is not ntk's job:
64
+ it draws, and a document is a tree of layout decisions on top of that.
65
+ [`@react-x11/components`](https://github.com/sidorares/react-x11-components)
66
+ is where those live, over the react-x11 renderer.
68
67
 
69
68
  ```js
70
69
  import { createClient } from 'ntk';
package/lib/app.js CHANGED
@@ -5,6 +5,7 @@ import { chooseGLXConfig } from './glx.js';
5
5
  import Picture from './picture.js';
6
6
  import Pixmap from './pixmap.js';
7
7
  import { DEFAULT_RASTER_POLICY, defaultRasterizer } from './rasterize.js';
8
+ import { dropShadowSurfaces } from './shadow.js';
8
9
  import { ShmUploader } from './shm-upload.js';
9
10
  import FontManager from './text/fontmanager.js';
10
11
  import Window from './window.js';
@@ -543,6 +544,9 @@ export default class App {
543
544
  this._shm.dispose();
544
545
  this._shm = undefined;
545
546
  }
547
+ // shadow coverage is cached per connection for the same reason solids
548
+ // are: the contexts that drew it are long gone (see lib/shadow.js)
549
+ dropShadowSurfaces(this);
546
550
  // shared by every context that ever asked (see solidPicture), so their
547
551
  // lifetime is the connection's — context teardown must not free them
548
552
  for (const picture of this._solidPictures.values()) {
package/lib/fontconfig.js CHANGED
@@ -7,13 +7,12 @@ function childProcess() {
7
7
  return builtin('node:child_process');
8
8
  }
9
9
 
10
+ const NOT_NODE =
11
+ 'fontconfig matching needs node (the fc-match CLI) and this is not a node environment';
12
+
10
13
  function execFileSync(...args) {
11
14
  const cp = childProcess();
12
- if (!cp) {
13
- throw noFontsError(
14
- 'fontconfig matching needs node (the fc-match CLI) and this is not a node environment'
15
- );
16
- }
15
+ if (!cp) throw noFontsError(NOT_NODE);
17
16
  return cp.execFileSync(...args);
18
17
  }
19
18
 
@@ -87,16 +86,51 @@ const sortedCache = new Map();
87
86
  // installs fontconfig into a running process.
88
87
  let unavailable = null;
89
88
 
89
+ /**
90
+ * The exit code fc-match left, or null if the child never ran at all.
91
+ *
92
+ * The two exec flavours report it differently: execFileSync puts it in
93
+ * `status` and leaves `code` for the spawn failure, while execFile puts both
94
+ * in `code`. Normalized here so one diagnosis serves the sync and async
95
+ * paths rather than two that can drift apart.
96
+ */
97
+ function exitStatus(err) {
98
+ if (err.status != null) return err.status;
99
+ return typeof err.code === 'number' ? err.code : null;
100
+ }
101
+
90
102
  /**
91
103
  * Did the spawn itself fail, as opposed to fc-match running and being
92
- * unhappy? `status` is null only when the child never ran, and these are the
93
- * codes that mean "no usable binary at this name" rather than a transient
94
- * failure worth reporting verbatim.
104
+ * unhappy? No exit code means the child never ran, and these are the codes
105
+ * that mean "no usable binary at this name" rather than a transient failure
106
+ * worth reporting verbatim.
95
107
  */
96
108
  function isSpawnFailure(err) {
97
- return (
98
- err.status == null && ['ENOENT', 'EACCES', 'EPERM', 'ENOTDIR'].includes(err.code)
99
- );
109
+ return exitStatus(err) == null && ['ENOENT', 'EACCES', 'EPERM', 'ENOTDIR'].includes(err.code);
110
+ }
111
+
112
+ /**
113
+ * An fc-match failure -> the error to report for it.
114
+ *
115
+ * Only a path that actually reports belongs here: a missing binary is
116
+ * memoized in `unavailable` on the way through, which is exactly what
117
+ * `prewarm` must not do (see there). It swallows the raw failure instead.
118
+ */
119
+ function fcMatchError(err) {
120
+ if (err.code === 'ERR_NTK_NO_FONTS') return err; // no child_process at all
121
+ if (isSpawnFailure(err)) {
122
+ unavailable = 'the fc-match CLI (fontconfig) is not installed here';
123
+ return noFontsError(unavailable, err);
124
+ }
125
+ const status = exitStatus(err);
126
+ if (status != null) {
127
+ // fontconfig is installed and said no — an image with fontconfig but no
128
+ // font package answers "No fonts installed on the system" and exits 1.
129
+ // Not memoized: unlike a missing binary this can depend on the pattern.
130
+ const stderr = String(err.stderr || '').trim().split('\n')[0];
131
+ return noFontsError(`fc-match exited ${status}${stderr ? `: ${stderr}` : ''}`, err);
132
+ }
133
+ return err;
100
134
  }
101
135
 
102
136
  function patternFor({ family, weight, style }) {
@@ -109,22 +143,91 @@ function patternFor({ family, weight, style }) {
109
143
 
110
144
  // One command shared by the sync and async paths, so a prewarmed cache entry
111
145
  // is byte-for-byte what the sync call would have computed.
112
- const fcMatchArgs = ['-s', '--format', '%{file}\t%{postscriptname}\t%{charset}\n'];
146
+ //
147
+ // `%{family}` is a *list*: fontconfig keeps every name a face answers to,
148
+ // localized aliases included (`Hiragino Sans`, `ヒラギノ角ゴシック`, and the
149
+ // style-suffixed forms of both are one face), and `--format` joins them with
150
+ // commas. The fields are tab-separated so a comma inside one costs nothing,
151
+ // and `charset` stays last because it is by far the longest.
152
+ const fcMatchArgs = [
153
+ '-s',
154
+ '--format',
155
+ '%{file}\t%{postscriptname}\t%{family}\t%{charset}\n'
156
+ ];
113
157
  const fcMatchOpts = { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 };
114
158
 
115
159
  /** fc-match -s output -> candidates, filtered to formats fontkit can parse */
116
160
  function parseMatches(out) {
117
161
  const list = [];
118
162
  for (const line of out.split('\n')) {
119
- const [path, postscriptName, charset] = line.split('\t');
163
+ const [path, postscriptName, family, charset] = line.split('\t');
120
164
  if (path && supported.test(path)) {
121
- list.push({ path, postscriptName, charset: charset || '', _ranges: null });
165
+ // `families` keeps fontconfig's whole list, in its order; `family` is
166
+ // the first of them, which is the name fontconfig leads with for the
167
+ // current locale and the one to show in a UI.
168
+ const families = family ? family.split(',').filter(Boolean) : [];
169
+ list.push({
170
+ path,
171
+ postscriptName,
172
+ family: families[0] || '',
173
+ families,
174
+ charset: charset || '',
175
+ _ranges: null
176
+ });
122
177
  }
123
178
  }
124
179
  return list;
125
180
  }
126
181
 
127
- const prewarming = new Map();
182
+ /**
183
+ * fc-match output -> the cached candidate list for a pattern. Throws for
184
+ * output that parses to nothing usable, which is a fontconfig answer rather
185
+ * than a fontconfig failure and so is diagnosed separately.
186
+ */
187
+ function cacheMatches(fc, out) {
188
+ const list = parseMatches(out);
189
+ if (list.length === 0) {
190
+ throw noFontsError(
191
+ `fontconfig matched no font ntk can parse for "${fc}" (needs ` +
192
+ '.ttf/.otf/.woff/.woff2/.ttc/.dfont — bitmap .pcf/.bdf fonts are not usable)'
193
+ );
194
+ }
195
+ sortedCache.set(fc, list);
196
+ return list;
197
+ }
198
+
199
+ const inflight = new Map();
200
+
201
+ /**
202
+ * fc-match for a pattern, off the event loop — one child per pattern however
203
+ * many callers ask at once, so a prewarm and an awaiting `matchSorted` share
204
+ * a single spawn instead of racing two.
205
+ *
206
+ * Rejects with the *raw* failure, undiagnosed: `fcMatchError` memoizes a
207
+ * missing binary, and whether that should happen is the caller's decision,
208
+ * not this one's.
209
+ *
210
+ * @returns {Promise<string>} raw fc-match stdout
211
+ */
212
+ function runFcMatch(fc) {
213
+ const pending = inflight.get(fc);
214
+ if (pending) return pending;
215
+ const cp = childProcess();
216
+ if (!cp) return Promise.reject(noFontsError(NOT_NODE));
217
+
218
+ const promise = new Promise((resolve, reject) => {
219
+ cp.execFile('fc-match', [...fcMatchArgs, fc], fcMatchOpts, (err, out, stderr) => {
220
+ inflight.delete(fc);
221
+ if (!err) return resolve(out);
222
+ // execFile hands stderr to the callback; execFileSync hangs it on the
223
+ // error, which is where the shared diagnosis reads it from
224
+ if (err.stderr === undefined) err.stderr = stderr;
225
+ reject(err);
226
+ });
227
+ });
228
+ inflight.set(fc, promise);
229
+ return promise;
230
+ }
128
231
 
129
232
  /**
130
233
  * Seed the match cache for a pattern ahead of time, off the event loop.
@@ -145,87 +248,108 @@ const prewarming = new Map();
145
248
  * A sync call racing this one wins — the child's result is discarded
146
249
  * whenever the pattern is already cached by the time it exits.
147
250
  *
251
+ * `matchSorted` is the reporting variant: same spawn, same cache, but it
252
+ * awaits an answer and so has somewhere to put a failure.
253
+ *
148
254
  * @returns {Promise<void>} resolves once the cache is seeded or the attempt
149
255
  * abandoned
150
256
  */
151
257
  export function prewarm(pattern = {}) {
152
258
  const fc = patternFor(pattern);
153
259
  if (sortedCache.has(fc) || unavailable) return Promise.resolve();
154
- const inflight = prewarming.get(fc);
155
- if (inflight) return inflight;
156
- const cp = childProcess();
157
- if (!cp) return Promise.resolve();
158
-
159
- const promise = new Promise((resolve) => {
160
- cp.execFile('fc-match', [...fcMatchArgs, fc], fcMatchOpts, (err, out) => {
161
- prewarming.delete(fc);
162
- if (!err && !sortedCache.has(fc)) {
260
+ return runFcMatch(fc).then(
261
+ (out) => {
262
+ if (!sortedCache.has(fc)) {
163
263
  const list = parseMatches(out);
164
264
  if (list.length > 0) sortedCache.set(fc, list);
165
265
  }
166
- resolve();
167
- });
168
- });
169
- prewarming.set(fc, promise);
170
- return promise;
266
+ },
267
+ () => {}
268
+ );
269
+ }
270
+
271
+ /**
272
+ * The same match list as `matchSortedSync`, without blocking for it.
273
+ *
274
+ * For a caller that is not inside text layout — a font picker matching as
275
+ * the user types, a preferences page, anything that can await — the sync
276
+ * spawn is ~100ms of stalled event loop per new pattern and buys nothing.
277
+ * This runs the identical command through `execFile` instead, shares the
278
+ * spawn with any prewarm already in flight for the pattern, and fills the
279
+ * same cache, so a later layout answers from memory.
280
+ *
281
+ * Unlike `prewarm` it reports: a missing fc-match rejects here rather than
282
+ * resolving quietly and leaving the diagnosis to a blocking sync call
283
+ * afterwards. Rejections carry `code: 'ERR_NTK_NO_FONTS'` exactly as the
284
+ * sync throws do.
285
+ *
286
+ * @returns {Promise<Array<{path, postscriptName, family: string,
287
+ * families: string[], charset: string}>>}
288
+ */
289
+ export async function matchSorted(pattern = {}) {
290
+ const fc = patternFor(pattern);
291
+ const cached = sortedCache.get(fc);
292
+ if (cached) return cached;
293
+ if (unavailable) throw noFontsError(unavailable);
294
+
295
+ let out;
296
+ try {
297
+ out = await runFcMatch(fc);
298
+ } catch (err) {
299
+ throw fcMatchError(err);
300
+ }
301
+ // A sync call may have answered this pattern while the child ran. Its list
302
+ // is the cached one, and candidates memoize their parsed charset, so hand
303
+ // back what everyone else already holds rather than a fresh copy.
304
+ return sortedCache.get(fc) ?? cacheMatches(fc, out);
171
305
  }
172
306
 
173
307
  /**
174
308
  * Full fontconfig match list for a pattern, best match first — this is the
175
309
  * system's font fallback chain. Each candidate carries the unicode coverage
176
310
  * fontconfig knows about (`charset`, lazily parsed via `charsetHas`), so a
177
- * fallback font for a codepoint can be chosen without opening font files.
311
+ * fallback font for a codepoint can be chosen without opening font files —
312
+ * and the family name fontconfig already knows, so a *list* of matches can be
313
+ * shown without opening them either (issue #273: `sans-serif` returns 139
314
+ * candidates here, and `Font.loadSync` is ~1.2ms a file).
178
315
  *
179
316
  * Cached per pattern; one fc-match invocation (~50ms) per distinct pattern.
317
+ * Synchronous because its main caller is text layout, which cannot await;
318
+ * a caller that can should use `matchSorted` and not block on the spawn.
180
319
  *
181
- * @returns {Array<{path, postscriptName, charset: string}>}
320
+ * @returns {Array<{path, postscriptName, family: string, families: string[],
321
+ * charset: string}>}
182
322
  */
183
323
  export function matchSortedSync(pattern) {
184
324
  const fc = patternFor(pattern);
185
- let list = sortedCache.get(fc);
186
- if (list) return list;
325
+ const cached = sortedCache.get(fc);
326
+ if (cached) return cached;
187
327
  if (unavailable) throw noFontsError(unavailable);
188
328
 
189
329
  let out;
190
330
  try {
191
331
  out = execFileSync('fc-match', [...fcMatchArgs, fc], fcMatchOpts);
192
332
  } catch (err) {
193
- if (err.code === 'ERR_NTK_NO_FONTS') throw err; // no child_process at all
194
- if (isSpawnFailure(err)) {
195
- unavailable = 'the fc-match CLI (fontconfig) is not installed here';
196
- throw noFontsError(unavailable, err);
197
- }
198
- if (err.status != null) {
199
- // fontconfig is installed and said no — an image with fontconfig but no
200
- // font package answers "No fonts installed on the system" and exits 1.
201
- // Not memoized: unlike a missing binary this can depend on the pattern.
202
- const stderr = String(err.stderr || '').trim().split('\n')[0];
203
- throw noFontsError(`fc-match exited ${err.status}${stderr ? `: ${stderr}` : ''}`, err);
204
- }
205
- throw err;
333
+ throw fcMatchError(err);
206
334
  }
207
-
208
- list = parseMatches(out);
209
- if (list.length === 0) {
210
- throw noFontsError(
211
- `fontconfig matched no font ntk can parse for "${fc}" (needs ` +
212
- '.ttf/.otf/.woff/.woff2/.ttc/.dfont — bitmap .pcf/.bdf fonts are not usable)'
213
- );
214
- }
215
- sortedCache.set(fc, list);
216
- return list;
335
+ return cacheMatches(fc, out);
217
336
  }
218
337
 
219
338
  /**
220
339
  * Resolve a font pattern ({family, weight, style}) to the best matching font
221
- * file. Returns { path, postscriptName } or throws if nothing suitable is
222
- * installed. Requires the fc-match CLI (fontconfig) — usual on a Linux
223
- * desktop, absent from slim containers and from stock macOS. Where it is
224
- * missing, hand ntk the fonts instead (see docs/fonts.md).
340
+ * file. Returns { path, postscriptName, family, families } or throws if
341
+ * nothing suitable is installed. Requires the fc-match CLI (fontconfig) —
342
+ * usual on a Linux desktop, absent from slim containers and from stock
343
+ * macOS. Where it is missing, hand ntk the fonts instead (see docs/fonts.md).
225
344
  */
226
345
  export function listFontsSync(pattern) {
227
346
  const [best] = matchSortedSync(pattern);
228
- return { path: best.path, postscriptName: best.postscriptName };
347
+ return {
348
+ path: best.path,
349
+ postscriptName: best.postscriptName,
350
+ family: best.family,
351
+ families: best.families
352
+ };
229
353
  }
230
354
 
231
355
  /**
package/lib/index.js CHANGED
@@ -16,7 +16,6 @@ import {
16
16
  encodeXEmbedInfo,
17
17
  readXEmbedInfo
18
18
  } from './xembed.js';
19
- import { loadLayout } from './yoga.js';
20
19
  import { decodeKey, groupForState } from './keyboard.js';
21
20
  import Pixmap from './pixmap.js';
22
21
  import Picture from './picture.js';
@@ -50,13 +49,8 @@ import {
50
49
  import { DEFAULT_MASK_POLICY } from './maskcluster.js';
51
50
  import { DEFAULT_SHAPE_POLICY } from './shapeglyphs.js';
52
51
  import { TextLayout } from './text/layout.js';
53
- import HtmlView from './widgets/htmlview.js';
54
52
  import SvgView from './widgets/svgview.js';
55
- import MarkdownView from './widgets/markdownview.js';
56
- import TexView, { configureTex, layoutTex, TexBox } from './widgets/tex.js';
57
- import { tokenize as highlightCode } from './widgets/highlight.js';
58
- import { cssColorStraight, premultiply } from './color.js';
59
- import { cssColor, cssLength } from './widgets/css.js';
53
+ import { cssColor, cssColorStraight, premultiply } from './color.js';
60
54
 
61
55
  // rendering context modules register themselves on Drawable. The direct one
62
56
  // comes last on purpose: it wraps the 'opengl' factory the indirect one just
@@ -116,11 +110,7 @@ export function createClient(options, callback) {
116
110
  const x11Options = { ...(options || {}) };
117
111
  if (x11Options.bufferRequests === undefined) x11Options.bufferRequests = DEFAULT_BUFFER_REQUESTS;
118
112
 
119
- // the layout engine's WASM loads alongside the connection, so widgets are
120
- // usable synchronously by the time the App exists (see lib/yoga.js)
121
- const layout = loadLayout();
122
-
123
- const connecting = new Promise((resolve, reject) => {
113
+ const promise = new Promise((resolve, reject) => {
124
114
  // Resolve the font spec here rather than lazily in `app.fonts`, so a
125
115
  // missing directory is a rejected connect instead of a surprise inside
126
116
  // the first paint. Inside the executor so it rejects rather than throws
@@ -206,8 +196,6 @@ export function createClient(options, callback) {
206
196
  });
207
197
  });
208
198
 
209
- const promise = Promise.all([connecting, layout]).then(([app]) => app);
210
-
211
199
  if (callback) {
212
200
  promise.then(
213
201
  (app) => callback(null, app),
@@ -268,18 +256,10 @@ export {
268
256
  DEFAULT_MASK_POLICY,
269
257
  DEFAULT_SHAPE_POLICY,
270
258
  TextLayout,
271
- HtmlView,
272
259
  SvgView,
273
- MarkdownView,
274
- TexView,
275
- TexBox,
276
- layoutTex,
277
- configureTex,
278
- highlightCode,
279
260
  cssColor,
280
261
  cssColorStraight,
281
262
  premultiply,
282
- cssLength,
283
263
  decodeKey,
284
264
  groupForState,
285
265
  // the `code` on a failed GL setup: branch on it rather than on the message
@@ -289,11 +269,4 @@ export {
289
269
  GL_MODES,
290
270
  DEFAULT_GL_POLICY
291
271
  };
292
- // The layout engine ntk lays HtmlView out with — downstream layout consumers
293
- // (e.g. the react-x11 renderer) must import it from here rather than from
294
- // `yoga-layout`, or they get a second WASM instance whose Nodes cannot be
295
- // mixed with ntk's. Its enum constants are readable as soon as ntk is
296
- // imported; `Node`/`Config` need the WASM, which `createClient()` loads —
297
- // `loadLayout()` is there for widgets used without an App.
298
- export { default as Yoga, loadLayout, layoutLoaded } from './yoga.js';
299
272
  export default { createClient };