ntk 8.0.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/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
  /**