ntk 5.3.0 → 5.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/app.js +4 -2
- package/lib/clipboard.js +86 -19
- package/lib/fontconfig.js +99 -12
- package/lib/image.js +3 -2
- package/lib/index.js +16 -2
- package/lib/renderingcontext_2d.js +57 -17
- package/lib/text/fontmanager.js +27 -4
- package/lib/text/fontsource.js +255 -3
- package/lib/window.js +1 -1
- package/package.json +1 -1
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
|
|
17
|
-
* text/fontsource.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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
8
|
-
'fontconfig matching needs node (fc-match CLI)
|
|
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
|
-
|
|
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
|
|
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)
|
|
78
|
-
*
|
|
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
|
-
//
|
|
55
|
-
//
|
|
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
|
|
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,
|
|
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
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
//
|
|
1790
|
-
|
|
1791
|
-
|
|
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
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
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
|
|
package/lib/text/fontmanager.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import Font from './font.js';
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/lib/text/fontsource.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
210
|
+
throw noFontsError('this font source has no fonts added');
|
|
135
211
|
}
|
|
136
212
|
const families = parseFamilies(pattern.family).map((f) => this._aliases.get(f) ?? f);
|
|
137
213
|
const weight = numericWeight(pattern.weight);
|
|
@@ -160,6 +236,179 @@ export class StaticFontSource {
|
|
|
160
236
|
}
|
|
161
237
|
}
|
|
162
238
|
|
|
239
|
+
// A directory of an app's own faces is a handful of files; a system font
|
|
240
|
+
// tree is hundreds, and every one of them is parsed and then retained for
|
|
241
|
+
// the life of the FontManager (one macOS emoji collection alone is 188 MB).
|
|
242
|
+
// Rather than document that and hope, the walk stops and names the option —
|
|
243
|
+
// an app that really means it says so.
|
|
244
|
+
const MAX_FILES = 64;
|
|
245
|
+
const MAX_DEPTH = 8;
|
|
246
|
+
|
|
247
|
+
// Said whenever ntk is handed something it cannot read as fonts. It names
|
|
248
|
+
// every accepted shape rather than the one that was wrong, and states the
|
|
249
|
+
// premise — ntk has no fonts of its own — because the spec that brings people
|
|
250
|
+
// here is `'bundled'`, from an issue proposing a font package that does not
|
|
251
|
+
// exist. Naming that value in the matcher would enshrine an API that never
|
|
252
|
+
// shipped; this answers the question without making it real.
|
|
253
|
+
const ACCEPTED =
|
|
254
|
+
". Pass 'system', a path to a font file or directory, font bytes, an array of " +
|
|
255
|
+
'those, { fonts, alias }, or a FontSource. ntk ships no fonts of its own — see ' +
|
|
256
|
+
'docs/fonts.md';
|
|
257
|
+
|
|
258
|
+
function fs() {
|
|
259
|
+
const mod = globalThis.process?.getBuiltinModule?.('node:fs');
|
|
260
|
+
if (!mod) {
|
|
261
|
+
throw new Error(
|
|
262
|
+
'ntk: a font path can only be read in node. In a browser, fetch the font ' +
|
|
263
|
+
'bytes yourself and pass them: fontSource: [bytes] — see docs/fonts.md'
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
return mod;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** stat a spec'd path, reporting a missing one as the spec mistake it is
|
|
270
|
+
* rather than as a bare ENOENT from somewhere inside ntk */
|
|
271
|
+
function statOf(path) {
|
|
272
|
+
try {
|
|
273
|
+
return fs().statSync(path);
|
|
274
|
+
} catch (err) {
|
|
275
|
+
if (err.code === 'ENOENT') {
|
|
276
|
+
// A bare word is far more likely a spec someone invented — 'bundled' is
|
|
277
|
+
// the one the issue asks for — than a path they mistyped. Answer the
|
|
278
|
+
// question they were actually asking.
|
|
279
|
+
const guess = /[/\\.]/.test(path) ? '' : ACCEPTED;
|
|
280
|
+
throw new Error(`ntk: no such font file or directory: "${path}"${guess}`, { cause: err });
|
|
281
|
+
}
|
|
282
|
+
throw err;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** every font file in a directory, sorted — never readdir order, which would
|
|
287
|
+
* let the filesystem pick which face `sans-serif` lands on */
|
|
288
|
+
function fontFilesIn(dir, recursive, budget, depth = 0) {
|
|
289
|
+
const { readdirSync } = fs();
|
|
290
|
+
const found = [];
|
|
291
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
292
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
293
|
+
for (const entry of entries) {
|
|
294
|
+
const path = `${dir}/${entry.name}`;
|
|
295
|
+
// isDirectory() is false for a symlink, so this skips linked directories
|
|
296
|
+
// and with them any chance of a cycle
|
|
297
|
+
if (entry.isDirectory()) {
|
|
298
|
+
if (recursive && depth < MAX_DEPTH) found.push(...fontFilesIn(path, true, budget, depth + 1));
|
|
299
|
+
} else if (supported.test(entry.name)) {
|
|
300
|
+
found.push(path);
|
|
301
|
+
if (++budget.count > budget.max) {
|
|
302
|
+
throw new Error(
|
|
303
|
+
`ntk: more than ${budget.max} font files under "${budget.root}". That is a font ` +
|
|
304
|
+
"tree rather than an app's own faces, and every one of them would be parsed and " +
|
|
305
|
+
'kept in memory for the life of the process. List the faces you want, or raise ' +
|
|
306
|
+
`the limit deliberately: { fonts: '${budget.root}', maxFiles: N } — see docs/fonts.md`
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return found;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** does this look like a face rather than a typo? */
|
|
315
|
+
function isFace(face) {
|
|
316
|
+
return (
|
|
317
|
+
typeof face === 'string' ||
|
|
318
|
+
ArrayBuffer.isView(face) ||
|
|
319
|
+
face instanceof ArrayBuffer ||
|
|
320
|
+
(!!face && typeof face === 'object' && (typeof face.path === 'string' || face.data != null))
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function addFace(source, face, skipped) {
|
|
325
|
+
try {
|
|
326
|
+
if (typeof face === 'string') return source.add(Font.loadSync(face));
|
|
327
|
+
if (ArrayBuffer.isView(face) || face instanceof ArrayBuffer) return source.add(face);
|
|
328
|
+
const { path, data, ...opts } = face;
|
|
329
|
+
return source.add(path ? Font.loadSync(path, opts.postscriptName) : data, opts);
|
|
330
|
+
} catch (err) {
|
|
331
|
+
// one unparseable file should not take a whole directory down; a spec
|
|
332
|
+
// that yields no usable face at all still throws, below
|
|
333
|
+
skipped.push({ file: typeof face === 'string' ? face : face?.path, error: err });
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function describe(value) {
|
|
339
|
+
if (typeof value === 'string') return `"${value}"`;
|
|
340
|
+
if (Array.isArray(value)) return 'that array';
|
|
341
|
+
return value === null ? 'null' : typeof value;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Resolve a **font spec** to a FontSource.
|
|
346
|
+
*
|
|
347
|
+
* ```
|
|
348
|
+
* spec := FontSource | 'system' | null | undefined
|
|
349
|
+
* | faces | { fonts: faces, alias?, recursive?, maxFiles? }
|
|
350
|
+
* faces := face | face[]
|
|
351
|
+
* face := string (path to a font FILE or DIRECTORY)
|
|
352
|
+
* | Uint8Array | Buffer | ArrayBuffer
|
|
353
|
+
* | { path | data, family?, weight?, style?, postscriptName? }
|
|
354
|
+
* ```
|
|
355
|
+
*
|
|
356
|
+
* Idempotent — a FontSource passes straight through, which is what lets
|
|
357
|
+
* `createClient`, `FontManager` and `setDefaultFontSource` all coerce
|
|
358
|
+
* without caring whether someone already did.
|
|
359
|
+
*
|
|
360
|
+
* Anything built from faces is a `StaticFontSource`, so there is one
|
|
361
|
+
* matching implementation to reason about rather than two. Generic families
|
|
362
|
+
* are inferred (see `inferGenerics`) with an explicit `alias` map winning.
|
|
363
|
+
*
|
|
364
|
+
* @param {*} spec
|
|
365
|
+
* @returns {object|null|undefined} a FontSource
|
|
366
|
+
*/
|
|
367
|
+
export function createFontSource(spec) {
|
|
368
|
+
if (spec == null) return spec; // preserves setDefaultFontSource(null)'s reset
|
|
369
|
+
if (typeof spec.matchSorted === 'function') return spec;
|
|
370
|
+
if (spec === 'system') return new FontconfigFontSource();
|
|
371
|
+
|
|
372
|
+
const configured = !Array.isArray(spec) && typeof spec === 'object' && 'fonts' in spec;
|
|
373
|
+
const { fonts, alias, recursive = false, maxFiles = MAX_FILES } = configured ? spec : { fonts: spec };
|
|
374
|
+
const list = Array.isArray(fonts) ? fonts : [fonts];
|
|
375
|
+
|
|
376
|
+
if (list.length === 0 || !list.every(isFace)) {
|
|
377
|
+
throw new Error(`ntk: ${describe(spec)} is not a font source${ACCEPTED}`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const source = new StaticFontSource();
|
|
381
|
+
const skipped = [];
|
|
382
|
+
const budget = { count: 0, max: maxFiles, root: null };
|
|
383
|
+
let added = 0;
|
|
384
|
+
for (const face of list) {
|
|
385
|
+
if (typeof face === 'string' && statOf(face).isDirectory()) {
|
|
386
|
+
budget.root = face;
|
|
387
|
+
const files = fontFilesIn(face, recursive, budget);
|
|
388
|
+
if (files.length === 0) {
|
|
389
|
+
throw new Error(
|
|
390
|
+
`ntk: no font files in "${face}" — looked for .ttf/.otf/.woff/.woff2/.ttc/.dfont` +
|
|
391
|
+
(recursive ? '' : '. Pass { fonts, recursive: true } to walk subdirectories')
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
for (const file of files) if (addFace(source, file, skipped)) added++;
|
|
395
|
+
} else if (addFace(source, face, skipped)) {
|
|
396
|
+
added++;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
source.skipped = skipped;
|
|
401
|
+
if (added === 0) {
|
|
402
|
+
throw new Error(
|
|
403
|
+
`ntk: none of the ${skipped.length} font file(s) given could be parsed — ` +
|
|
404
|
+
`${skipped[0]?.file ?? 'the first'}: ${skipped[0]?.error?.message ?? 'unknown error'}`
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
for (const [generic, family] of Object.entries(alias ?? {})) source.alias(generic, family);
|
|
409
|
+
return source.inferGenerics();
|
|
410
|
+
}
|
|
411
|
+
|
|
163
412
|
let _default = null;
|
|
164
413
|
|
|
165
414
|
/** the process-wide default FontSource (fontconfig unless overridden) */
|
|
@@ -173,7 +422,10 @@ export function defaultFontSource() {
|
|
|
173
422
|
* created afterwards without an explicit source — including the ones
|
|
174
423
|
* widgets create internally. The primary hook for browser playgrounds:
|
|
175
424
|
* call it once with a StaticFontSource before creating any app/window.
|
|
425
|
+
*
|
|
426
|
+
* Takes a font spec as well as a source, so pointing the whole process at a
|
|
427
|
+
* directory is one line. `null` restores the fontconfig default.
|
|
176
428
|
*/
|
|
177
429
|
export function setDefaultFontSource(source) {
|
|
178
|
-
_default = source;
|
|
430
|
+
_default = createFontSource(source);
|
|
179
431
|
}
|
package/lib/window.js
CHANGED
|
@@ -655,7 +655,7 @@ export default class Window extends Drawable {
|
|
|
655
655
|
});
|
|
656
656
|
if (!this._clearGc) {
|
|
657
657
|
// reusable across reallocs: a GC is valid for any drawable of the
|
|
658
|
-
// same screen and depth
|
|
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,
|