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 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({ run, x: cursor, y: oy });
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
  }
@@ -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) return 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
- const key = `${font ? font.key : style.family}|${style.size}|${style.weight}|${style.style}|${levelsKey}|${text}`;
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
@@ -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
- export function routeGlyphSize(app, font, size, policy = policyOf(app)) {
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
- let ring = app._sizeRings.get(font.key);
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
- const reused = ring.includes(size);
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] !== size) {
245
- ring.push(size);
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 (routeGlyphSize(app, run.font, run.size, policy) === 'vector') {
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);
@@ -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({ run: r.run, x: x + line.x + r.x, y: y + line.baseline });
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
- * node-x11 caches interned atoms per connection, so a repeated call
1906
- * costs no round trip. Same deferred-chain hazards as setTitle: by the
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
- // node-x11 caches interned atoms per connection, so these are the ids
3375
- // the 'close' listener's addProtocol already interned
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "7.0.0",
3
+ "version": "7.2.0",
4
4
  "description": "Desktop UI toolkit for X11 with canvas-like 2d and OpenGL rendering",
5
5
  "author": "Andrey Sidorov <sidorares@yandex.ru>",
6
6
  "license": "MIT",