ntk 8.0.0 → 8.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/app.js +4 -0
- package/lib/fontconfig.js +185 -61
- package/lib/renderingcontext_2d.js +616 -22
- package/lib/shadow.js +199 -0
- package/lib/text/fontsource.js +47 -3
- package/lib/text/layout.js +16 -3
- package/lib/widgets/svgview.js +9 -12
- package/package.json +1 -1
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?
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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,
|
|
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
|
-
|
|
186
|
-
if (
|
|
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
|
-
|
|
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
|
|
222
|
-
* installed. Requires the fc-match CLI (fontconfig) —
|
|
223
|
-
* desktop, absent from slim containers and from stock
|
|
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 {
|
|
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
|
/**
|