ntk 8.1.1 → 8.3.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.
@@ -0,0 +1,275 @@
1
+ // The `_NTK_GLYPHD` glyph directory (docs/shared-glyphs.md): the naming half
2
+ // of the display-wide shared glyph cache.
3
+ //
4
+ // The directory does not rasterize and knows nothing about fonts. It owns
5
+ // three things and nothing else: the ICCCM manager selection that names it,
6
+ // the map from page tokens to server-side glyphsets, and the compact local
7
+ // id space inside each set — it is the single writer of lids, which is what
8
+ // keeps them dense enough for CompositeGlyphs8. Presence is a bit it tracks
9
+ // per (token, member key): clients report uploads (`_NTK_GLYPHD_ADDED`), and
10
+ // later askers are told "already there".
11
+ //
12
+ // Lifetime contract, load-bearing for the client's XID-reuse fence: while
13
+ // the directory owns the selection it frees nothing. Eviction opens a new
14
+ // *generation* — fresh sets advertised, old ones no longer handed out; the
15
+ // retired sets stay pinned by this connection (their ids are the cheap part;
16
+ // Xorg dedupes the pixel storage by content anyway) and die with it, or
17
+ // when the last client alias drops, whichever is later.
18
+ //
19
+ // Every ntk app embeds one of these behind the same code path: the first app
20
+ // on a display self-elects (lib/sharedglyphs.js calls `claim()` when nobody
21
+ // owns the selection), a standalone directory process would claim it the
22
+ // same way and simply outlive the apps.
23
+
24
+ import x11 from 'x11';
25
+
26
+ import GlyphSet from './glyphset.js';
27
+ import {
28
+ GLYPHD,
29
+ MAX_LIDS,
30
+ encodeGlyphdReply,
31
+ parseGlyphdRequest
32
+ } from './glyphdwire.js';
33
+
34
+ /**
35
+ * Directory-side policy: the byte budget across the current generation's
36
+ * pages. Uploads reported by clients accumulate against it; exceeding it
37
+ * opens a new generation. It bounds *growth*, not total server memory —
38
+ * retired generations froze at whatever size they reached.
39
+ */
40
+ export const DEFAULT_SHARED_GLYPHS_POLICY = {
41
+ budgetBytes: 32 << 20
42
+ };
43
+
44
+ const TIME_TIMEOUT = 2000;
45
+
46
+ export default class GlyphDirectory {
47
+ /**
48
+ * @param {App} app the connection the directory rides
49
+ * @param {object} [options] `{ budgetBytes }` over DEFAULT_SHARED_GLYPHS_POLICY
50
+ */
51
+ constructor(app, options) {
52
+ this.app = app;
53
+ this.X = app.X;
54
+ this.policy = { ...DEFAULT_SHARED_GLYPHS_POLICY, ...options };
55
+ this.window = null;
56
+ this.generation = 1;
57
+ this.pages = new Map(); // token -> { set, members: Map(key -> {lid, present}), bytes }
58
+ this.active = false;
59
+ this._atoms = null;
60
+ this._bytes = 0; // uploads reported into the current generation
61
+ this._retired = []; // GlyphSets of past generations: pinned, never freed
62
+ this._onXEvent = this._onXEvent.bind(this);
63
+ }
64
+
65
+ /** the selection endpoint's window id, 0 before claim() */
66
+ get wid() {
67
+ return this.window ? this.window.id : 0;
68
+ }
69
+
70
+ /**
71
+ * Claim the manager selection (ICCCM 2.8): an unmapped window, a real
72
+ * timestamp, `SetSelectionOwner`, and a `MANAGER` announcement on the root
73
+ * so waiting clients adopt the new directory.
74
+ *
75
+ * @returns {Promise<number>} the window id of whoever owns the selection
76
+ * afterwards — ours when the claim won, the winner's when it raced and
77
+ * lost, 0 when the server refused the whole dance
78
+ */
79
+ async claim() {
80
+ const X = this.X;
81
+ try {
82
+ const names = [GLYPHD.selection, GLYPHD.ensure, GLYPHD.added, GLYPHD.done, GLYPHD.time];
83
+ const [selection, ensure, added, done, time] = await Promise.all(
84
+ names.map((name) => this._atom(name))
85
+ );
86
+ this._atoms = { selection, ensure, added, done, time };
87
+ this.window = this.app.createWindow({
88
+ width: 1,
89
+ height: 1,
90
+ eventMask: x11.eventMask.PropertyChange
91
+ });
92
+ // ICCCM 2.1: never CurrentTime. The property-append trick gives a real
93
+ // server timestamp; 0 (CurrentTime) is the documented fallback when
94
+ // the server will not answer, which every server ntk targets accepts.
95
+ const stamp = await this._serverTime();
96
+ X.SetSelectionOwner(this.window.id, selection, stamp);
97
+ // SetSelectionOwner is void, and a race is settled by whoever the
98
+ // server says owns the selection now
99
+ const owner = await new Promise((resolve, reject) =>
100
+ X.GetSelectionOwner(selection, (err, wid) => (err ? reject(err) : resolve(wid)))
101
+ );
102
+ if (owner !== this.window.id) {
103
+ this.window.destroy();
104
+ this.window = null;
105
+ return owner;
106
+ }
107
+ this.active = true;
108
+ this.window.on('message', (ev) => this._onMessage(ev));
109
+ // SelectionClear (a successor took over) carries the owner in
110
+ // ev.owner, not ev.wid, so the Window wrapper never sees it — raw
111
+ // listener, the same reason the clipboard has one
112
+ X.on('event', this._onXEvent);
113
+ // announce (ICCCM 2.8): MANAGER on the root with the acquisition
114
+ // timestamp, StructureNotify mask — what surviving clients of a dead
115
+ // directory are listening for
116
+ await this.app
117
+ .rootWindow()
118
+ .sendClientMessage(GLYPHD.manager, [stamp, selection, this.window.id], {
119
+ mask: x11.eventMask.StructureNotify
120
+ });
121
+ return this.window.id;
122
+ } catch {
123
+ if (this.window) {
124
+ this.window.destroy();
125
+ this.window = null;
126
+ }
127
+ this.active = false;
128
+ return 0;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Stop answering. The sets stay: clients still hold aliases into them, and
134
+ * the fence contract ("frees nothing while it owns the selection") has
135
+ * already ended with the ownership — nothing here may free what a reply
136
+ * has advertised, so everything is simply kept until the connection ends.
137
+ */
138
+ deactivate() {
139
+ if (!this.active) return;
140
+ this.active = false;
141
+ this.X.removeListener('event', this._onXEvent);
142
+ }
143
+
144
+ _onXEvent(ev) {
145
+ // SelectionClear: another manager took the selection with a later
146
+ // timestamp — the ICCCM handover. It answers from here on.
147
+ if (ev.type === 29 && this.window && ev.owner === this.window.id) this.deactivate();
148
+ }
149
+
150
+ _onMessage(ev) {
151
+ if (!this.active || !this._atoms) return;
152
+ if (ev.message_type === this._atoms.ensure) {
153
+ this._serve(ev, true).catch(() => {});
154
+ } else if (ev.message_type === this._atoms.added) {
155
+ this._serve(ev, false).catch(() => {});
156
+ }
157
+ }
158
+
159
+ /**
160
+ * One RPC: read the request property off the client's mailbox (the read
161
+ * deletes it), answer, confirm with DONE. Failures on the client's side —
162
+ * a mailbox that vanished mid-request — are its own; every cross-client
163
+ * request here swallows its error rather than warning about a dead peer.
164
+ */
165
+ async _serve(ev, isEnsure) {
166
+ const X = this.X;
167
+ const [mailbox, serial, property] = ev.data;
168
+ const done = (status) => {
169
+ X.SendClientMessage(mailbox, mailbox, this._atoms.done, 32, [serial, status], 0, () => true);
170
+ };
171
+ const prop = await new Promise((resolve) =>
172
+ X.GetProperty(1, mailbox, property, 0, 0, 0x1fffffff, (err, res) => resolve(err ? null : res))
173
+ );
174
+ const request = prop && prop.data ? parseGlyphdRequest(prop.data) : null;
175
+ if (!request || !request.token) return done(0);
176
+ if (isEnsure) {
177
+ const entries = this._ensure(request);
178
+ if (!entries) return done(0);
179
+ const reply = encodeGlyphdReply({
180
+ serial,
181
+ generation: this.generation,
182
+ gsid: this.pages.get(request.token).set.id,
183
+ entries
184
+ });
185
+ X.ChangeProperty(0, mailbox, property, property, 8, reply, () => true);
186
+ return done(1);
187
+ }
188
+ this._added(request);
189
+ return done(1);
190
+ }
191
+
192
+ /**
193
+ * Allocate (or look up) a lid per member key, in request order. The single
194
+ * writer of the lid space is what keeps ids compact — dense from 0 in
195
+ * first-asked order, whichever client asked.
196
+ * @returns {Array<{lid, present}>|null} null refuses the request
197
+ */
198
+ _ensure({ token, keys }) {
199
+ let page = this.pages.get(token);
200
+ if (!page) {
201
+ page = { set: new GlyphSet(this.app), members: new Map(), bytes: 0 };
202
+ this.pages.set(token, page);
203
+ }
204
+ const entries = new Array(keys.length);
205
+ for (let i = 0; i < keys.length; i++) {
206
+ let member = page.members.get(keys[i]);
207
+ if (!member) {
208
+ if (page.members.size >= MAX_LIDS) return null;
209
+ member = { lid: page.members.size, present: false };
210
+ page.members.set(keys[i], member);
211
+ }
212
+ entries[i] = member;
213
+ }
214
+ return entries;
215
+ }
216
+
217
+ /**
218
+ * A client uploaded: flip the presence bits so later askers skip the
219
+ * rasterization, and count the bytes against the generation budget. A
220
+ * token this generation does not advertise (retired since the client
221
+ * bound) is ignored — the uploader still draws from its own alias, and
222
+ * the next generation simply re-earns those glyphs.
223
+ */
224
+ _added({ token, keys, bytes }) {
225
+ const page = this.pages.get(token);
226
+ if (!page) return;
227
+ for (const key of keys) {
228
+ const member = page.members.get(key);
229
+ if (member) member.present = true;
230
+ }
231
+ page.bytes += bytes;
232
+ this._bytes += bytes;
233
+ if (this._bytes > this.policy.budgetBytes) this._openGeneration();
234
+ }
235
+
236
+ /**
237
+ * Eviction, directory style: no set is freed — the current pages retire
238
+ * (pinned here so not even a GC finalizer can free what was advertised)
239
+ * and the next ensure of each token starts a fresh set in a fresh
240
+ * generation. Clients bound to a retired (gsid, generation) notice the
241
+ * mismatch in their next reply and freeze that page.
242
+ */
243
+ _openGeneration() {
244
+ for (const page of this.pages.values()) this._retired.push(page.set);
245
+ this.pages.clear();
246
+ this._bytes = 0;
247
+ this.generation++;
248
+ }
249
+
250
+ // a current server timestamp, via the zero-length property append trick
251
+ // (the same one the clipboard uses): the PropertyNotify it provokes
252
+ // carries the server's clock
253
+ _serverTime() {
254
+ return new Promise((resolve) => {
255
+ const finish = (time) => {
256
+ clearTimeout(timer);
257
+ this.window.removeListener('property', onProperty);
258
+ resolve(time);
259
+ };
260
+ const onProperty = (ev) => {
261
+ if (ev.atom === this._atoms.time && ev.state === 0) finish(ev.time >>> 0);
262
+ };
263
+ const timer = setTimeout(() => finish(0), TIME_TIMEOUT);
264
+ timer.unref?.();
265
+ this.window.on('property', onProperty);
266
+ this.X.ChangeProperty(2, this.window.id, this._atoms.time, this.X.atoms.STRING, 8, Buffer.alloc(0));
267
+ });
268
+ }
269
+
270
+ _atom(name) {
271
+ return new Promise((resolve, reject) =>
272
+ this.X.InternAtom(false, name, (err, atom) => (err ? reject(err) : resolve(atom)))
273
+ );
274
+ }
275
+ }
@@ -0,0 +1,142 @@
1
+ // The `_NTK_GLYPHD` wire vocabulary (docs/shared-glyphs.md): the atoms and
2
+ // the property payload codecs shared by the directory (lib/glyphdirectory.js)
3
+ // and the client (lib/sharedglyphs.js). Pure functions, no X in sight —
4
+ // which is what makes the protocol unit-testable without a server.
5
+
6
+ /** selection + message vocabulary */
7
+ export const GLYPHD = {
8
+ selection: '_NTK_GLYPHD',
9
+ ensure: '_NTK_GLYPHD_ENSURE',
10
+ added: '_NTK_GLYPHD_ADDED',
11
+ done: '_NTK_GLYPHD_DONE',
12
+ property: '_NTK_GLYPHD_RPC',
13
+ time: '_NTK_GLYPHD_TIME',
14
+ manager: 'MANAGER'
15
+ };
16
+
17
+ /** wire format version of the RPC properties */
18
+ export const RPC_VERSION = 1;
19
+
20
+ /** lids are compact so CompositeGlyphs can use narrow encodings; a page
21
+ * cannot outgrow 16-bit ids (fonts cap at 65535 glyphs; corner populations
22
+ * are far smaller). The directory refuses rather than overflows. */
23
+ export const MAX_LIDS = 65536;
24
+
25
+ const FLAG_INDICES = 1;
26
+
27
+ /**
28
+ * The ensure/added request property (format 8, little-endian):
29
+ *
30
+ * u8 version (1)
31
+ * u8 flags (bit 0: members are u32 font glyph indices, not strings)
32
+ * u16 token byte length T
33
+ * u32 member count N
34
+ * u32 uploaded bytes (added only; 0 in an ensure)
35
+ * T token bytes
36
+ * N members — u32 each, or (u16 length + bytes) each
37
+ */
38
+ export function encodeGlyphdRequest({ token, indices, keys, bytes = 0 }) {
39
+ const tokenBuf = Buffer.from(token, 'utf8');
40
+ let size = 12 + tokenBuf.length;
41
+ const keyBufs = indices ? null : keys.map((k) => Buffer.from(String(k), 'utf8'));
42
+ size += indices ? keys.length * 4 : keyBufs.reduce((n, b) => n + 2 + b.length, 0);
43
+ const buf = Buffer.alloc(size);
44
+ buf.writeUInt8(RPC_VERSION, 0);
45
+ buf.writeUInt8(indices ? FLAG_INDICES : 0, 1);
46
+ buf.writeUInt16LE(tokenBuf.length, 2);
47
+ buf.writeUInt32LE(keys.length, 4);
48
+ buf.writeUInt32LE(bytes, 8);
49
+ tokenBuf.copy(buf, 12);
50
+ let o = 12 + tokenBuf.length;
51
+ for (let i = 0; i < keys.length; i++) {
52
+ if (indices) {
53
+ buf.writeUInt32LE(keys[i] >>> 0, o);
54
+ o += 4;
55
+ } else {
56
+ const kb = keyBufs[i];
57
+ buf.writeUInt16LE(kb.length, o);
58
+ kb.copy(buf, o + 2);
59
+ o += 2 + kb.length;
60
+ }
61
+ }
62
+ return buf;
63
+ }
64
+
65
+ /** @returns {{token, indices, keys, bytes}|null} null when malformed */
66
+ export function parseGlyphdRequest(buf) {
67
+ try {
68
+ if (buf.length < 12 || buf.readUInt8(0) !== RPC_VERSION) return null;
69
+ const indices = (buf.readUInt8(1) & FLAG_INDICES) !== 0;
70
+ const tokenLen = buf.readUInt16LE(2);
71
+ const count = buf.readUInt32LE(4);
72
+ const bytes = buf.readUInt32LE(8);
73
+ let o = 12;
74
+ if (o + tokenLen > buf.length) return null;
75
+ const token = buf.toString('utf8', o, o + tokenLen);
76
+ o += tokenLen;
77
+ const keys = new Array(count);
78
+ for (let i = 0; i < count; i++) {
79
+ if (indices) {
80
+ if (o + 4 > buf.length) return null;
81
+ keys[i] = buf.readUInt32LE(o);
82
+ o += 4;
83
+ } else {
84
+ if (o + 2 > buf.length) return null;
85
+ const len = buf.readUInt16LE(o);
86
+ if (o + 2 + len > buf.length) return null;
87
+ keys[i] = buf.toString('utf8', o + 2, o + 2 + len);
88
+ o += 2 + len;
89
+ }
90
+ }
91
+ return { token, indices, keys, bytes };
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * The ensure reply property (format 8), member entries in request order:
99
+ *
100
+ * u8 version (1)
101
+ * u8 status (1 — a refusal sends no reply property at all)
102
+ * u16 0
103
+ * u32 serial (echo of the request's)
104
+ * u32 generation
105
+ * u32 gsid
106
+ * u32 member count N
107
+ * N u32 each: bit 31 = present, bits 0..30 = lid
108
+ */
109
+ export function encodeGlyphdReply({ serial, generation, gsid, entries }) {
110
+ const buf = Buffer.alloc(20 + entries.length * 4);
111
+ buf.writeUInt8(RPC_VERSION, 0);
112
+ buf.writeUInt8(1, 1);
113
+ buf.writeUInt32LE(serial >>> 0, 4);
114
+ buf.writeUInt32LE(generation >>> 0, 8);
115
+ buf.writeUInt32LE(gsid >>> 0, 12);
116
+ buf.writeUInt32LE(entries.length, 16);
117
+ entries.forEach((e, i) => {
118
+ buf.writeUInt32LE(((e.present ? 0x80000000 : 0) | e.lid) >>> 0, 20 + i * 4);
119
+ });
120
+ return buf;
121
+ }
122
+
123
+ /** @returns {{serial, generation, gsid, entries: [{lid, present}]}|null} */
124
+ export function parseGlyphdReply(buf) {
125
+ try {
126
+ if (buf.length < 20 || buf.readUInt8(0) !== RPC_VERSION) return null;
127
+ if (buf.readUInt8(1) !== 1) return null;
128
+ const serial = buf.readUInt32LE(4);
129
+ const generation = buf.readUInt32LE(8);
130
+ const gsid = buf.readUInt32LE(12);
131
+ const count = buf.readUInt32LE(16);
132
+ if (buf.length < 20 + count * 4) return null;
133
+ const entries = new Array(count);
134
+ for (let i = 0; i < count; i++) {
135
+ const word = buf.readUInt32LE(20 + i * 4);
136
+ entries[i] = { lid: word & 0x7fffffff, present: (word & 0x80000000) !== 0 };
137
+ }
138
+ return { serial, generation, gsid, entries };
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
package/lib/glyphset.js CHANGED
@@ -9,14 +9,41 @@ const registry = new FinalizationRegistry(({ Render, X, id }) => {
9
9
 
10
10
  export default class GlyphSet {
11
11
  constructor(app, format) {
12
- const X = app.X;
13
- this.X = X;
12
+ this._bind(app);
13
+ this.Render.CreateGlyphSet(this.id, format || this.Render.a8);
14
+ this._register();
15
+ }
16
+
17
+ /**
18
+ * A new id for an existing glyphset — RENDER `ReferenceGlyphSet`. XIDs are
19
+ * display-global, so `gsid` may name a set another connection created; the
20
+ * server refcounts, and the underlying set stays alive as long as any
21
+ * reference to it does — including across its creator's disconnect. That
22
+ * is what a cross-process glyph cache stands on (docs/shared-glyphs.md).
23
+ * `destroy()` drops only this alias.
24
+ *
25
+ * @param {App} app
26
+ * @param {number} gsid existing glyphset XID, ours or another client's
27
+ * @returns {GlyphSet}
28
+ */
29
+ static referenceTo(app, gsid) {
30
+ const gs = Object.create(GlyphSet.prototype);
31
+ gs._bind(app);
32
+ gs.Render.ReferenceGlyphSet(gs.id, gsid);
33
+ gs._register();
34
+ return gs;
35
+ }
36
+
37
+ _bind(app) {
38
+ this.X = app.X;
14
39
  this.display = app.display;
15
40
  this.Render = app.display.Render;
16
- this.id = X.AllocID();
17
- this.Render.CreateGlyphSet(this.id, format || this.Render.a8);
41
+ this.id = this.X.AllocID();
42
+ }
43
+
44
+ _register() {
18
45
  this._owned = true;
19
- registry.register(this, { Render: this.Render, X, id: this.id }, this);
46
+ registry.register(this, { Render: this.Render, X: this.X, id: this.id }, this);
20
47
  }
21
48
 
22
49
  addGlyphs(glyphs) {
package/lib/index.js CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  import { decodeKey, groupForState } from './keyboard.js';
20
20
  import Pixmap from './pixmap.js';
21
21
  import Picture from './picture.js';
22
+ import Region from './region.js';
22
23
  import { Image, decodeImage, loadImage } from './image.js';
23
24
  import {
24
25
  ImageData,
@@ -48,6 +49,8 @@ import {
48
49
  } from './rasterize.js';
49
50
  import { DEFAULT_MASK_POLICY } from './maskcluster.js';
50
51
  import { DEFAULT_SHAPE_POLICY } from './shapeglyphs.js';
52
+ import { DEFAULT_SHARED_GLYPHS_POLICY } from './glyphdirectory.js';
53
+ import { warmSharedGlyphs } from './text/glyphs.js';
51
54
  import { TextLayout } from './text/layout.js';
52
55
  import SvgView from './widgets/svgview.js';
53
56
  import { cssColor, cssColorStraight, premultiply } from './color.js';
@@ -95,7 +98,11 @@ const DEFAULT_BUFFER_REQUESTS = { maxSize: 64 * 1024 };
95
98
  * `'direct'` or `'off'`; an object sets the direct backend's knobs too (see
96
99
  * docs/context-gles.md) — and
97
100
  * { onXError } — called with X protocol errors no request callback
98
- * claimed (default: console.warn).
101
+ * claimed (default: console.warn) —
102
+ * { sharedGlyphs } — the cross-process shared glyph cache
103
+ * (docs/shared-glyphs.md): on by default, `false` turns it off (as does
104
+ * the `NTK_NO_SHARED_GLYPHS` environment variable), an object sets the
105
+ * directory-side budget (`{ budgetBytes }`).
99
106
  * x11's { bufferRequests } defaults to a 64 KB output buffer here, which
100
107
  * makes a frame one socket write; pass false to write per request.
101
108
  * @param {function} [callback] optional node-style callback
@@ -150,6 +157,15 @@ export function createClient(options, callback) {
150
157
  if (process.env.NTK_STRICT_COLORS) Render.strictColors = true;
151
158
  display.GLX = glxError ? null : GLX;
152
159
 
160
+ const app = new App(display, appOptions);
161
+ // Build the visual -> picture format table (issue #295) now: a
162
+ // picture is bound synchronously, so the table has to be here
163
+ // before anything draws rather than fetched at the first paint.
164
+ // It costs the connection nothing and no wait — node-x11 asked
165
+ // `QueryPictFormats` itself while requiring RENDER and kept the
166
+ // reply, so this only reads it. A server that cannot
167
+ // answer leaves every format picked from the depth, as before.
168
+ app.pictFormats().catch(() => null);
153
169
  // RENDER requires the client to declare its version before using
154
170
  // requests beyond 0.0, and the reply is what gates them afterwards
155
171
  // — CreateSolidFill needs 0.10 (see App#solidPicture). 0.11 is the
@@ -176,7 +192,6 @@ export function createClient(options, callback) {
176
192
  });
177
193
  updateKeyboardMapping(display.min_keycode, display.max_keycode);
178
194
 
179
- const app = new App(display, appOptions);
180
195
  // Under a policy that could pick the direct backend, find out
181
196
  // during the handshake whether it can: two extension queries,
182
197
  // running alongside the round trips already in flight here, so
@@ -225,6 +240,9 @@ export {
225
240
  resolveCursorShape,
226
241
  Pixmap,
227
242
  Picture,
243
+ // server-side XFIXES regions (docs/context-2d.md#region-clips):
244
+ // app.createRegion(rects) makes one, ctx.clipRegion(region) clips to it
245
+ Region,
228
246
  Surface,
229
247
  // fill/stroke styles the 2d context hands back (docs/context-2d.md) —
230
248
  // exported for `instanceof`, not to be constructed directly
@@ -255,6 +273,11 @@ export {
255
273
  DEFAULT_RASTER_POLICY,
256
274
  DEFAULT_MASK_POLICY,
257
275
  DEFAULT_SHAPE_POLICY,
276
+ // cross-process shared glyphs (docs/shared-glyphs.md): the directory-side
277
+ // budget, and the prewarm that makes a first paint of already-shared text
278
+ // rasterize and upload nothing
279
+ DEFAULT_SHARED_GLYPHS_POLICY,
280
+ warmSharedGlyphs,
258
281
  TextLayout,
259
282
  SvgView,
260
283
  cssColor,