ntk 7.0.0 → 7.2.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 +51 -0
- package/lib/renderingcontext_2d.js +74 -1
- package/lib/text/font.js +142 -0
- package/lib/text/fontmanager.js +63 -3
- package/lib/text/glyphs.js +70 -8
- package/lib/text/layout.js +11 -1
- package/lib/window.js +6 -4
- package/package.json +1 -1
package/lib/app.js
CHANGED
|
@@ -8,6 +8,15 @@ import { ShmUploader } from './shm-upload.js';
|
|
|
8
8
|
import FontManager from './text/fontmanager.js';
|
|
9
9
|
import Window from './window.js';
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* The highest predefined atom id in the core protocol, `XA_WM_TRANSIENT_FOR`.
|
|
13
|
+
*
|
|
14
|
+
* Atoms 1 to 68 are named by the protocol itself, so every server agrees on
|
|
15
|
+
* them and no server ever frees them. Anything above is interned at runtime
|
|
16
|
+
* and belongs to the connection that asked for it — see `_isolateAtoms`.
|
|
17
|
+
*/
|
|
18
|
+
const PREDEFINED_ATOM_MAX = 68;
|
|
19
|
+
|
|
11
20
|
/**
|
|
12
21
|
* Frame interval used before the display has been asked, in ms — and the one
|
|
13
22
|
* kept on a server that cannot answer.
|
|
@@ -76,6 +85,7 @@ export default class App {
|
|
|
76
85
|
this.display = display;
|
|
77
86
|
this.X = display.client;
|
|
78
87
|
this.options = options;
|
|
88
|
+
this._isolateAtoms();
|
|
79
89
|
this._fonts = null;
|
|
80
90
|
this._clipboard = null;
|
|
81
91
|
this._cursors = null;
|
|
@@ -94,6 +104,47 @@ export default class App {
|
|
|
94
104
|
});
|
|
95
105
|
}
|
|
96
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Give this connection an atom table of its own.
|
|
109
|
+
*
|
|
110
|
+
* node-x11 hands every client the *same* table object — `this.atoms =
|
|
111
|
+
* stdatoms` in xcore, assigned rather than copied — and `InternAtom`
|
|
112
|
+
* answers out of it without a round trip when the name is already there.
|
|
113
|
+
* So an id interned on one connection is handed to every other connection
|
|
114
|
+
* in the process, which have never asked the server for it.
|
|
115
|
+
*
|
|
116
|
+
* That is safe only while something keeps the server alive. An X server
|
|
117
|
+
* frees every atom it holds when its **last** client disconnects, and
|
|
118
|
+
* hands the same ids out again to whatever is interned next. A process
|
|
119
|
+
* that opens connections in sequence — a test suite, a tool that runs a
|
|
120
|
+
* few apps, anything under Xvfb with no window manager holding the server
|
|
121
|
+
* open — therefore writes properties with ids the server has forgotten.
|
|
122
|
+
* The visible half of that is `BadAtom` on a `ChangeProperty` naming an
|
|
123
|
+
* atom the app never chose; the quiet half is worse, because once the id
|
|
124
|
+
* has been reissued to a different name the write lands under *that* atom
|
|
125
|
+
* and no error is reported at all.
|
|
126
|
+
*
|
|
127
|
+
* Keeping only the predefined atoms is what makes the copy correct: ids 1
|
|
128
|
+
* to 68 are fixed by the core protocol, identical on every server, and
|
|
129
|
+
* never freed. Everything above that has to be interned per connection,
|
|
130
|
+
* which now happens because this table starts without it. Within one
|
|
131
|
+
* connection the caching still works, and it is still correct, because a
|
|
132
|
+
* live connection is exactly what stops the server resetting.
|
|
133
|
+
*/
|
|
134
|
+
_isolateAtoms() {
|
|
135
|
+
const shared = this.X.atoms;
|
|
136
|
+
if (!shared) return;
|
|
137
|
+
const own = {};
|
|
138
|
+
const names = {};
|
|
139
|
+
for (const [name, id] of Object.entries(shared)) {
|
|
140
|
+
if (id > PREDEFINED_ATOM_MAX) continue;
|
|
141
|
+
own[name] = id;
|
|
142
|
+
names[id] = name;
|
|
143
|
+
}
|
|
144
|
+
this.X.atoms = own;
|
|
145
|
+
this.X.atom_names = names;
|
|
146
|
+
}
|
|
147
|
+
|
|
97
148
|
/**
|
|
98
149
|
* The fastest refresh rate any active output is running at, in Hz, or
|
|
99
150
|
* `null` until the display has been asked — and on a server with no RandR
|
|
@@ -60,6 +60,23 @@ function intersectBox(a, b) {
|
|
|
60
60
|
|
|
61
61
|
const DEFAULT_FONT = "20px sans-serif";
|
|
62
62
|
|
|
63
|
+
// `"wght" 460, "wdth" 87.5` — the CSS grammar, quotes optional. An object is
|
|
64
|
+
// passed through, so callers can skip the string entirely.
|
|
65
|
+
const VARIATION_RE = /["']?([a-zA-Z0-9]{4})["']?\s+(-?[\d.]+)/g;
|
|
66
|
+
|
|
67
|
+
function parseVariationSettings(val) {
|
|
68
|
+
if (!val || val === "normal") return null;
|
|
69
|
+
if (typeof val === "object") return val;
|
|
70
|
+
if (typeof val !== "string") return null;
|
|
71
|
+
const out = {};
|
|
72
|
+
let any = false;
|
|
73
|
+
for (const [, tag, value] of val.matchAll(VARIATION_RE)) {
|
|
74
|
+
out[tag] = Number(value);
|
|
75
|
+
any = true;
|
|
76
|
+
}
|
|
77
|
+
return any ? out : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
63
80
|
/**
|
|
64
81
|
* Fallback for a context dropped without `destroy()`, matching Pixmap,
|
|
65
82
|
* Picture and GlyphSet. Only the GCs are freed here: everything else a
|
|
@@ -507,6 +524,8 @@ class RenderingContext2d {
|
|
|
507
524
|
gco: this._gco,
|
|
508
525
|
textStyle: this._textStyle,
|
|
509
526
|
fontString: this._lastFontString,
|
|
527
|
+
fontVariations: this._fontVariations,
|
|
528
|
+
textRendering: this._textRendering,
|
|
510
529
|
textAlign: this.textAlign,
|
|
511
530
|
textBaseline: this.textBaseline,
|
|
512
531
|
m: this._m.slice(),
|
|
@@ -529,6 +548,8 @@ class RenderingContext2d {
|
|
|
529
548
|
this._gco = s.gco;
|
|
530
549
|
this._textStyle = s.textStyle;
|
|
531
550
|
this._lastFontString = s.fontString;
|
|
551
|
+
this._fontVariations = s.fontVariations;
|
|
552
|
+
this._textRendering = s.textRendering;
|
|
532
553
|
this.textAlign = s.textAlign;
|
|
533
554
|
this.textBaseline = s.textBaseline;
|
|
534
555
|
this._m = s.m;
|
|
@@ -2320,7 +2341,12 @@ class RenderingContext2d {
|
|
|
2320
2341
|
const positioned = [];
|
|
2321
2342
|
let cursor = ox;
|
|
2322
2343
|
for (const run of reorderRuns(shaped.runs)) {
|
|
2323
|
-
positioned.push({
|
|
2344
|
+
positioned.push({
|
|
2345
|
+
run,
|
|
2346
|
+
x: cursor,
|
|
2347
|
+
y: oy,
|
|
2348
|
+
textRendering: this._textRendering,
|
|
2349
|
+
});
|
|
2324
2350
|
cursor += run.width;
|
|
2325
2351
|
}
|
|
2326
2352
|
this.drawGlyphs(
|
|
@@ -2399,6 +2425,7 @@ class RenderingContext2d {
|
|
|
2399
2425
|
weight: parsed.weight,
|
|
2400
2426
|
style: parsed.style,
|
|
2401
2427
|
size: parsed.size,
|
|
2428
|
+
variations: this._fontVariations,
|
|
2402
2429
|
};
|
|
2403
2430
|
style.font = this.window.app.fonts.match(style.family, style);
|
|
2404
2431
|
this._lastFontString = val;
|
|
@@ -2409,6 +2436,52 @@ class RenderingContext2d {
|
|
|
2409
2436
|
return this._lastFontString || DEFAULT_FONT;
|
|
2410
2437
|
}
|
|
2411
2438
|
|
|
2439
|
+
/**
|
|
2440
|
+
* CSS's `font-variation-settings`, for a variable font: `'"wght" 460'`,
|
|
2441
|
+
* or `{ wght: 460 }`. Axes a font does not have are ignored and values are
|
|
2442
|
+
* clamped to their range, so this is safe to set unconditionally.
|
|
2443
|
+
*
|
|
2444
|
+
* The `wght` axis needs none of this — a numeric weight in the `font`
|
|
2445
|
+
* shorthand already drives it (`ctx.font = '460 40px Inter'`). This is for
|
|
2446
|
+
* the rest: `wdth`, `slnt`, `opsz` and whatever a display face invents.
|
|
2447
|
+
*
|
|
2448
|
+
* Order-independent: setting it after `font` re-resolves the face, so the
|
|
2449
|
+
* two can be assigned either way round.
|
|
2450
|
+
*/
|
|
2451
|
+
set fontVariationSettings(val) {
|
|
2452
|
+
this._fontVariations = parseVariationSettings(val);
|
|
2453
|
+
// re-resolve against the font already in force, if there is one
|
|
2454
|
+
if (this._textStyle) this.font = this._lastFontString;
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
get fontVariationSettings() {
|
|
2458
|
+
return this._fontVariations ?? null;
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
/**
|
|
2462
|
+
* CSS's `text-rendering`: which glyph path this text takes, overriding the
|
|
2463
|
+
* size thresholds in `app.textPolicy`.
|
|
2464
|
+
*
|
|
2465
|
+
* - `'geometricPrecision'` — outlines every draw, glyph origins **not**
|
|
2466
|
+
* rounded to whole pixels. What display text wants, and what any text
|
|
2467
|
+
* whose shape is being animated wants: a variable font's axis moves
|
|
2468
|
+
* advances by fractions of a pixel, and cached glyphs can only land on
|
|
2469
|
+
* whole ones, so those fractions accumulate until a glyph crosses a
|
|
2470
|
+
* rounding boundary and jumps a pixel on its own.
|
|
2471
|
+
* - `'optimizeSpeed'` — cached server-side glyphs at any size.
|
|
2472
|
+
* - `'auto'` (default) — the thresholds decide.
|
|
2473
|
+
*
|
|
2474
|
+
* `'optimizeLegibility'` is accepted and means `'auto'`; ntk has no
|
|
2475
|
+
* hinting to turn on.
|
|
2476
|
+
*/
|
|
2477
|
+
set textRendering(val) {
|
|
2478
|
+
this._textRendering = val || undefined;
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
get textRendering() {
|
|
2482
|
+
return this._textRendering ?? "auto";
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2412
2485
|
// ------------------------------------------------------------------
|
|
2413
2486
|
// gradients / images
|
|
2414
2487
|
|
package/lib/text/font.js
CHANGED
|
@@ -2,6 +2,60 @@ import * as fontkit from 'fontkit';
|
|
|
2
2
|
|
|
3
3
|
import { flatten, rasterizePath } from '../rasterize.js';
|
|
4
4
|
|
|
5
|
+
// Axis coordinates are rounded to this many decimals before anything is
|
|
6
|
+
// instantiated or cached. Every distinct coordinate is a font in its own
|
|
7
|
+
// right — its own rasterized glyphs, its own server-side glyphset — so the
|
|
8
|
+
// precision of the number that reaches here is the precision of the cache,
|
|
9
|
+
// and a slider handing over 459.9999999997 must not mint a face that
|
|
10
|
+
// 460 will never hit again. Two decimals is far finer than any axis is
|
|
11
|
+
// perceptible at, and makes the coordinate that reaches fontkit and the
|
|
12
|
+
// coordinate in the key the same number by construction.
|
|
13
|
+
const AXIS_DECIMALS = 2;
|
|
14
|
+
const round = (v) => Number(v.toFixed(AXIS_DECIMALS));
|
|
15
|
+
|
|
16
|
+
// Instances kept per base font. Reached only by an app driving an axis
|
|
17
|
+
// continuously — an animation, a drag — where the tail is cold by
|
|
18
|
+
// definition; a design system's handful of weights never approaches it.
|
|
19
|
+
// Server-side glyph pages have their own byte budget (text/glyphs.js), and
|
|
20
|
+
// evicting a Font here does not strand them: the key is derived from the
|
|
21
|
+
// coordinates, so re-instantiating the same point produces the same key and
|
|
22
|
+
// finds the same page.
|
|
23
|
+
const MAX_INSTANCES = 64;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Clamp `settings` into a font's design space: drop axes it does not have,
|
|
27
|
+
* clamp the rest to their range, round, and drop anything left sitting on
|
|
28
|
+
* its own default (the base font already *is* the default instance).
|
|
29
|
+
*
|
|
30
|
+
* @returns {object|null} the coordinates to instantiate, or null for "this
|
|
31
|
+
* font already is that instance"
|
|
32
|
+
*/
|
|
33
|
+
export function normalizeVariations(settings, axes) {
|
|
34
|
+
if (!settings || !axes) return null;
|
|
35
|
+
const coords = {};
|
|
36
|
+
let any = false;
|
|
37
|
+
for (const tag of Object.keys(settings).sort()) {
|
|
38
|
+
const axis = axes[tag];
|
|
39
|
+
if (!axis) continue; // a font that has no such axis is not wrong, just flat
|
|
40
|
+
const value = Number(settings[tag]);
|
|
41
|
+
if (!Number.isFinite(value)) continue;
|
|
42
|
+
const clamped = round(Math.min(axis.max, Math.max(axis.min, value)));
|
|
43
|
+
if (clamped === round(axis.default)) continue;
|
|
44
|
+
coords[tag] = clamped;
|
|
45
|
+
any = true;
|
|
46
|
+
}
|
|
47
|
+
return any ? coords : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Canonical cache key for normalized coordinates: `wdth=87.5,wght=460`. */
|
|
51
|
+
export function variationKey(coords) {
|
|
52
|
+
if (!coords) return '';
|
|
53
|
+
return Object.keys(coords)
|
|
54
|
+
.sort()
|
|
55
|
+
.map((tag) => `${tag}=${coords[tag]}`)
|
|
56
|
+
.join(',');
|
|
57
|
+
}
|
|
58
|
+
|
|
5
59
|
/**
|
|
6
60
|
* A single font face (one entry of a .ttc collection, or a whole .ttf/.otf),
|
|
7
61
|
* parsed with fontkit. Wraps everything the text pipeline needs:
|
|
@@ -71,6 +125,94 @@ export default class Font {
|
|
|
71
125
|
return this.fk.unitsPerEm;
|
|
72
126
|
}
|
|
73
127
|
|
|
128
|
+
/**
|
|
129
|
+
* The variation axes this face offers, keyed by OpenType tag:
|
|
130
|
+
* `{ wght: { name, min, default, max } }`. `{}` for a static font, so
|
|
131
|
+
* `Object.keys(font.variationAxes).length` is the "is this variable?"
|
|
132
|
+
* question and needs no other API.
|
|
133
|
+
*/
|
|
134
|
+
get variationAxes() {
|
|
135
|
+
return this.fk.variationAxes ?? {};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A static instance of this face at a point in its design space — the
|
|
140
|
+
* thing a variable font is *for*. `font.variation({ wght: 460 })` is the
|
|
141
|
+
* `font-variation-settings: "wght" 460` of CSS.
|
|
142
|
+
*
|
|
143
|
+
* Instances are ordinary `Font`s from here on: they shape, measure and
|
|
144
|
+
* rasterize like any face, and because the key is derived from the
|
|
145
|
+
* coordinates they get their own glyph page and their own server-side
|
|
146
|
+
* glyphset. That is what keeps the axis honest — leave the key alone and
|
|
147
|
+
* every instance of one file collides in the glyph cache, so the whole
|
|
148
|
+
* axis renders as whichever coordinate got there first.
|
|
149
|
+
*
|
|
150
|
+
* Nothing is rasterized here. Instantiating is a table-level operation;
|
|
151
|
+
* glyphs are still rasterized one at a time, on first use, at the size
|
|
152
|
+
* they are drawn at.
|
|
153
|
+
*
|
|
154
|
+
* Settings for axes this font does not have are ignored, values are
|
|
155
|
+
* clamped to each axis's range, and coordinates that land on their own
|
|
156
|
+
* default return **this font** rather than a redundant copy of it.
|
|
157
|
+
*
|
|
158
|
+
* @param {object} settings axis tag -> value, e.g. `{ wght: 460 }`
|
|
159
|
+
* @returns {Font} the instance, or `this` when the settings are a no-op
|
|
160
|
+
*/
|
|
161
|
+
variation(settings) {
|
|
162
|
+
const coords = normalizeVariations(settings, this.variationAxes);
|
|
163
|
+
if (!coords) return this;
|
|
164
|
+
const suffix = variationKey(coords);
|
|
165
|
+
|
|
166
|
+
if (!this._variations) this._variations = new Map();
|
|
167
|
+
const cached = this._variations.get(suffix);
|
|
168
|
+
if (cached) {
|
|
169
|
+
// Map iterates in insertion order: re-inserting a hit moves it to the
|
|
170
|
+
// tail, so the eviction below drops the least recently asked for
|
|
171
|
+
this._variations.delete(suffix);
|
|
172
|
+
this._variations.set(suffix, cached);
|
|
173
|
+
return cached;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const instance = new Font(this._instantiate(coords), this.path, this.postscriptName);
|
|
177
|
+
instance.key = `${this.key}|${suffix}`;
|
|
178
|
+
/** the face this was cut from, for callers walking back to the axes */
|
|
179
|
+
instance.variationOf = this;
|
|
180
|
+
instance.variationCoords = coords;
|
|
181
|
+
|
|
182
|
+
this._variations.set(suffix, instance);
|
|
183
|
+
if (this._variations.size > MAX_INSTANCES) {
|
|
184
|
+
const oldest = this._variations.keys().next().value;
|
|
185
|
+
this._variations.delete(oldest);
|
|
186
|
+
}
|
|
187
|
+
return instance;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* fontkit's instancing, with the one failure it has turned into something
|
|
192
|
+
* that says what to do about it.
|
|
193
|
+
*
|
|
194
|
+
* `getVariation` rebuilds the face from the original stream at the
|
|
195
|
+
* original directory offset. For a WOFF2 that stream is still
|
|
196
|
+
* Brotli-compressed, so the copy comes back with a directory parsed out of
|
|
197
|
+
* compressed bytes — no `cmap`, and a `Cannot read properties of undefined
|
|
198
|
+
* (reading 'tables')` from somewhere deep in shaping, at the first
|
|
199
|
+
* character drawn rather than here. Checking the instance is well-formed
|
|
200
|
+
* costs one property read and moves the report to the call that caused it.
|
|
201
|
+
*/
|
|
202
|
+
_instantiate(coords) {
|
|
203
|
+
const instance = this.fk.getVariation(coords);
|
|
204
|
+
if (!instance?.directory?.tables?.cmap) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
`ntk: cannot instantiate a variation of ${this.postscriptName || this.key}` +
|
|
207
|
+
(this.fk.type ? ` (${this.fk.type})` : '') +
|
|
208
|
+
'. fontkit can only instantiate axes from an uncompressed font — ' +
|
|
209
|
+
'ship the .ttf/.otf of a variable font rather than its .woff2. ' +
|
|
210
|
+
'See docs/fonts.md#variable-fonts'
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return instance;
|
|
214
|
+
}
|
|
215
|
+
|
|
74
216
|
scale(size) {
|
|
75
217
|
return size / this.fk.unitsPerEm;
|
|
76
218
|
}
|
package/lib/text/fontmanager.js
CHANGED
|
@@ -8,6 +8,41 @@ import {
|
|
|
8
8
|
import { embeddingLevels, normalizedLevels, shapeText } from './shape.js';
|
|
9
9
|
import { TextLayout } from './layout.js';
|
|
10
10
|
|
|
11
|
+
// Bound on the resolved-match map. See the sweep in `match()`.
|
|
12
|
+
const MAX_MATCHES = 512;
|
|
13
|
+
|
|
14
|
+
/** Cache-key fragment for a style's `variations`, before any font is known. */
|
|
15
|
+
function variationsKeyOf(variations) {
|
|
16
|
+
if (!variations) return '';
|
|
17
|
+
return Object.keys(variations)
|
|
18
|
+
.sort()
|
|
19
|
+
.map((tag) => `${tag}=${variations[tag]}`)
|
|
20
|
+
.join(',');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Put a resolved face at the point in its design space the style asked for.
|
|
25
|
+
*
|
|
26
|
+
* The interesting half is `weight`. CSS has said for years that
|
|
27
|
+
* `font-weight: 460` on a variable font means the `wght` axis at 460, not
|
|
28
|
+
* "the nearest face"; a face with a `wght` axis therefore takes the
|
|
29
|
+
* requested weight as a coordinate, and an app that hands ntk a variable
|
|
30
|
+
* file gets the weight it asked for without knowing an axis exists. An
|
|
31
|
+
* explicit `variations.wght` wins, because a caller naming the axis
|
|
32
|
+
* directly is being more specific than one naming a weight.
|
|
33
|
+
*
|
|
34
|
+
* Everything here is a no-op for a static face: `variation()` returns the
|
|
35
|
+
* font unchanged when the settings do not apply, so this costs one property
|
|
36
|
+
* read on the path every non-variable app is already on.
|
|
37
|
+
*/
|
|
38
|
+
function instantiate(font, weight, variations) {
|
|
39
|
+
const axes = font.variationAxes;
|
|
40
|
+
if (!axes || (!axes.wght && !variations)) return font;
|
|
41
|
+
const settings = { ...variations };
|
|
42
|
+
if (axes.wght && settings.wght === undefined) settings.wght = weight;
|
|
43
|
+
return font.variation(settings);
|
|
44
|
+
}
|
|
45
|
+
|
|
11
46
|
/**
|
|
12
47
|
* Font lookup, loading and caching — the entry point of the text API,
|
|
13
48
|
* available as `app.fonts` (and constructible standalone for headless
|
|
@@ -122,9 +157,14 @@ export default class FontManager {
|
|
|
122
157
|
match(family = 'sans-serif', opts = {}) {
|
|
123
158
|
const weight = numWeight(opts.weight);
|
|
124
159
|
const italic = !!(opts.style && opts.style.includes('italic'));
|
|
125
|
-
const cacheKey = `${family}|${weight}|${italic}`;
|
|
160
|
+
const cacheKey = `${family}|${weight}|${italic}|${variationsKeyOf(opts.variations)}`;
|
|
126
161
|
let font = this._matches.get(cacheKey);
|
|
127
|
-
if (font)
|
|
162
|
+
if (font) {
|
|
163
|
+
// insertion order is LRU order; re-inserting a hit moves it to the tail
|
|
164
|
+
this._matches.delete(cacheKey);
|
|
165
|
+
this._matches.set(cacheKey, font);
|
|
166
|
+
return font;
|
|
167
|
+
}
|
|
128
168
|
|
|
129
169
|
const families = String(family)
|
|
130
170
|
.split(',')
|
|
@@ -145,7 +185,19 @@ export default class FontManager {
|
|
|
145
185
|
});
|
|
146
186
|
font = this._open(candidates[0]);
|
|
147
187
|
}
|
|
188
|
+
font = instantiate(font, weight, opts.variations);
|
|
148
189
|
this._matches.set(cacheKey, font);
|
|
190
|
+
// The key now carries a point in a continuous space rather than one of a
|
|
191
|
+
// handful of weights, so an app animating an axis walks this map instead
|
|
192
|
+
// of hitting it. Same sweep as the shaping memo: drop the stale half in
|
|
193
|
+
// one pass rather than one entry per insert.
|
|
194
|
+
if (this._matches.size > MAX_MATCHES) {
|
|
195
|
+
let drop = this._matches.size >> 1;
|
|
196
|
+
for (const key of this._matches.keys()) {
|
|
197
|
+
if (drop-- <= 0) break;
|
|
198
|
+
this._matches.delete(key);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
149
201
|
return font;
|
|
150
202
|
}
|
|
151
203
|
|
|
@@ -239,7 +291,15 @@ export default class FontManager {
|
|
|
239
291
|
*/
|
|
240
292
|
_shapeCached(text, style, levelsKey = '0') {
|
|
241
293
|
const font = style.font;
|
|
242
|
-
|
|
294
|
+
// A resolved `font` already carries its coordinates in its key, so the
|
|
295
|
+
// variations fragment only earns its keep on the family path — where two
|
|
296
|
+
// points of one axis would otherwise share a shaped run, and the second
|
|
297
|
+
// would be drawn with the first's advances.
|
|
298
|
+
const key = font
|
|
299
|
+
? `${font.key}|${style.size}|${style.weight}|${style.style}|${levelsKey}|${text}`
|
|
300
|
+
: `${style.family}|${style.size}|${style.weight}|${style.style}|${variationsKeyOf(
|
|
301
|
+
style.variations
|
|
302
|
+
)}|${levelsKey}|${text}`;
|
|
243
303
|
let shaped = this._shapeCache.get(key);
|
|
244
304
|
if (shaped) {
|
|
245
305
|
// Map iterates in insertion order: re-inserting a hit moves it to the
|
package/lib/text/glyphs.js
CHANGED
|
@@ -20,11 +20,17 @@ import { trapezoidize } from '../trapezoid.js';
|
|
|
20
20
|
* - `cacheBytes` — LRU budget for uploaded glyph bitmaps per connection;
|
|
21
21
|
* least-recently-drawn (face, size) pages are freed server-side
|
|
22
22
|
* (FreeGlyphSet) so transient sizes don't accumulate.
|
|
23
|
+
* - `textRendering` — an app-wide default for the per-run property of the
|
|
24
|
+
* same name (see `routeForRendering`). Left undefined the thresholds
|
|
25
|
+
* decide, which is what almost every app wants; `'geometricPrecision'`
|
|
26
|
+
* here is the blunt instrument for a window that is all display text.
|
|
27
|
+
* A run that names its own always wins.
|
|
23
28
|
*/
|
|
24
29
|
export const DEFAULT_TEXT_POLICY = {
|
|
25
30
|
bitmapMax: 128,
|
|
26
31
|
vectorFrom: 256,
|
|
27
|
-
cacheBytes: 8 << 20
|
|
32
|
+
cacheBytes: 8 << 20,
|
|
33
|
+
textRendering: undefined
|
|
28
34
|
};
|
|
29
35
|
|
|
30
36
|
function policyOf(app) {
|
|
@@ -228,21 +234,75 @@ export function positionGlyphs(positioned) {
|
|
|
228
234
|
*
|
|
229
235
|
* @returns {'bitmap'|'vector'}
|
|
230
236
|
*/
|
|
231
|
-
|
|
237
|
+
/**
|
|
238
|
+
* CSS's `text-rendering`, as far as it means anything here: a run's own
|
|
239
|
+
* answer to which glyph path it takes, overriding the size thresholds.
|
|
240
|
+
*
|
|
241
|
+
* - `geometricPrecision` — the vector path, always. Outlines are flattened
|
|
242
|
+
* at the exact size and glyph origins are not rounded, so advances land
|
|
243
|
+
* where shaping put them. This is what display text wants, and what any
|
|
244
|
+
* text whose shape is being animated wants: a variable font's axis moves
|
|
245
|
+
* advances by fractions of a pixel, and on the bitmap path those fractions
|
|
246
|
+
* accumulate silently until a glyph crosses a rounding boundary and jumps
|
|
247
|
+
* a whole pixel on its own. It is also the path that caches nothing, so
|
|
248
|
+
* an axis under a slider stops minting a glyph page per step.
|
|
249
|
+
* - `optimizeSpeed` — the bitmap path, always. Cached server-side glyphs at
|
|
250
|
+
* any size, for text that is large but static.
|
|
251
|
+
* - `auto` (the default, and anything unrecognized) — the size thresholds
|
|
252
|
+
* and the churn ring decide, as before.
|
|
253
|
+
*
|
|
254
|
+
* `optimizeLegibility` is accepted and means `auto`: ntk has no hinting to
|
|
255
|
+
* turn on, so promising anything by it would be a lie.
|
|
256
|
+
*/
|
|
257
|
+
export function routeForRendering(textRendering) {
|
|
258
|
+
if (textRendering === 'geometricPrecision') return 'vector';
|
|
259
|
+
if (textRendering === 'optimizeSpeed') return 'bitmap';
|
|
260
|
+
return null; // auto: ask the thresholds
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function routeGlyphSize(app, font, size, policy = policyOf(app), textRendering) {
|
|
264
|
+
// Inlined rather than a call to `routeForRendering`: this is the first
|
|
265
|
+
// thing every run of every draw does, and the overwhelmingly common answer
|
|
266
|
+
// is "nobody asked". One `??` and one comparison get us past it.
|
|
267
|
+
const asked = textRendering ?? policy.textRendering;
|
|
268
|
+
if (asked !== undefined && asked !== 'auto') {
|
|
269
|
+
const route = routeForRendering(asked);
|
|
270
|
+
if (route) return route;
|
|
271
|
+
}
|
|
232
272
|
if (size <= policy.bitmapMax) return 'bitmap';
|
|
233
273
|
if (size > policy.vectorFrom) return 'vector';
|
|
234
274
|
|
|
275
|
+
// The ring answers "is this face being drawn at something it has drawn
|
|
276
|
+
// recently, or is it churning?" — so it has to be keyed by the thing that
|
|
277
|
+
// stays put while the churn happens.
|
|
278
|
+
//
|
|
279
|
+
// For a variable font that is the *base* face, not the instance: every
|
|
280
|
+
// point on an axis is a Font of its own, with its own key, so an animated
|
|
281
|
+
// axis handed each step a fresh empty ring and the churn was invisible —
|
|
282
|
+
// it read as eight unrelated faces each drawn once. Keyed by the base and
|
|
283
|
+
// recording the instance alongside the size, an axis sweep and a size
|
|
284
|
+
// sweep look like what they both are, and a page of static text at one
|
|
285
|
+
// weight still reuses its entry on every frame.
|
|
235
286
|
if (!app._sizeRings) app._sizeRings = new Map();
|
|
236
|
-
|
|
287
|
+
// A face with no instances behind it keeps the old ring exactly: its key
|
|
288
|
+
// is already constant across the ring, so the size alone identifies a
|
|
289
|
+
// glyph page and the entries stay numbers. Only a variable instance pays
|
|
290
|
+
// for the composite key, and only in this band — text at or below
|
|
291
|
+
// `bitmapMax` returned above without touching any of it.
|
|
292
|
+
const base = font.variationOf;
|
|
293
|
+
let ring = app._sizeRings.get(base ? base.key : font.key);
|
|
237
294
|
if (!ring) {
|
|
238
295
|
ring = [];
|
|
239
|
-
app._sizeRings.set(font.key, ring);
|
|
296
|
+
app._sizeRings.set(base ? base.key : font.key, ring);
|
|
240
297
|
}
|
|
241
|
-
|
|
298
|
+
// what a glyph page is keyed by, which is exactly what has to repeat for
|
|
299
|
+
// caching to pay for itself
|
|
300
|
+
const entry = base ? `${font.key}@${size}` : size;
|
|
301
|
+
const reused = ring.includes(entry);
|
|
242
302
|
// dedupe consecutive entries so one frame drawing many runs at one size
|
|
243
303
|
// occupies a single slot — the ring then spans ~8 distinct frames
|
|
244
|
-
if (ring[ring.length - 1] !==
|
|
245
|
-
ring.push(
|
|
304
|
+
if (ring[ring.length - 1] !== entry) {
|
|
305
|
+
ring.push(entry);
|
|
246
306
|
if (ring.length > 8) ring.shift();
|
|
247
307
|
}
|
|
248
308
|
|
|
@@ -269,7 +329,9 @@ export function drawGlyphRuns(app, op, srcId, dstId, positioned) {
|
|
|
269
329
|
let vector = null;
|
|
270
330
|
for (let i = 0; i < positioned.length; i++) {
|
|
271
331
|
const { run } = positioned[i];
|
|
272
|
-
if (
|
|
332
|
+
if (
|
|
333
|
+
routeGlyphSize(app, run.font, run.size, policy, positioned[i].textRendering) === 'vector'
|
|
334
|
+
) {
|
|
273
335
|
if (!vector) {
|
|
274
336
|
vector = [];
|
|
275
337
|
bitmap = positioned.slice(0, i);
|
package/lib/text/layout.js
CHANGED
|
@@ -66,6 +66,8 @@ export class TextLayout {
|
|
|
66
66
|
size: s.size ?? style.size ?? 16,
|
|
67
67
|
weight: s.weight ?? style.weight,
|
|
68
68
|
style: s.style ?? style.style,
|
|
69
|
+
variations: s.variations ?? style.variations,
|
|
70
|
+
textRendering: s.textRendering ?? style.textRendering,
|
|
69
71
|
features: s.features ?? style.features,
|
|
70
72
|
language: s.language ?? style.language,
|
|
71
73
|
color: s.color ?? style.color ?? null
|
|
@@ -496,7 +498,15 @@ export class TextLayout {
|
|
|
496
498
|
const color = r.span.color;
|
|
497
499
|
if (batch.length && color !== batchColor) flush();
|
|
498
500
|
batchColor = color;
|
|
499
|
-
batch.push({
|
|
501
|
+
batch.push({
|
|
502
|
+
run: r.run,
|
|
503
|
+
x: x + line.x + r.x,
|
|
504
|
+
y: y + line.baseline,
|
|
505
|
+
// per run, because it is a span property: one paragraph may hold
|
|
506
|
+
// a display word that wants exact positions and body text that
|
|
507
|
+
// wants its glyph cache. `drawGlyphRuns` already partitions.
|
|
508
|
+
textRendering: r.span.textRendering
|
|
509
|
+
});
|
|
500
510
|
}
|
|
501
511
|
}
|
|
502
512
|
flush();
|
package/lib/window.js
CHANGED
|
@@ -1902,8 +1902,9 @@ export default class Window extends Drawable {
|
|
|
1902
1902
|
|
|
1903
1903
|
/**
|
|
1904
1904
|
* Intern several atoms and hand them to `cb` as an object keyed by name.
|
|
1905
|
-
*
|
|
1906
|
-
*
|
|
1905
|
+
* Interned atoms are cached per connection — which is true because App
|
|
1906
|
+
* makes it true, see `_isolateAtoms` — so a repeated call costs no round
|
|
1907
|
+
* trip. Same deferred-chain hazards as setTitle: by the
|
|
1907
1908
|
* time the replies land the window may be gone, so callers re-check
|
|
1908
1909
|
* `this._destroyed`.
|
|
1909
1910
|
*/
|
|
@@ -3371,8 +3372,9 @@ export default class Window extends Drawable {
|
|
|
3371
3372
|
|
|
3372
3373
|
_emitCloseRequest(ev) {
|
|
3373
3374
|
if (ev.format !== 32 || !this.listenerCount('close')) return;
|
|
3374
|
-
//
|
|
3375
|
-
// the
|
|
3375
|
+
// interned atoms are cached per connection (App#_isolateAtoms), so these
|
|
3376
|
+
// are the ids this window's own addProtocol interned — an id another
|
|
3377
|
+
// connection happened to intern would not be one this server still knows
|
|
3376
3378
|
const X = this.X;
|
|
3377
3379
|
if (!X.atoms.WM_PROTOCOLS || ev.message_type !== X.atoms.WM_PROTOCOLS) return;
|
|
3378
3380
|
if (!X.atoms.WM_DELETE_WINDOW || ev.data?.[0] !== X.atoms.WM_DELETE_WINDOW) return;
|