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.
@@ -1,6 +1,7 @@
1
1
  import GlyphSet from '../glyphset.js';
2
2
  import Picture from '../picture.js';
3
3
  import Pixmap from '../pixmap.js';
4
+ import { fontPageToken, sharedGlyphsFor } from '../sharedglyphs.js';
4
5
  import { trapezoidize } from '../trapezoid.js';
5
6
 
6
7
  /**
@@ -37,6 +38,25 @@ function policyOf(app) {
37
38
  return app.textPolicy ? { ...DEFAULT_TEXT_POLICY, ...app.textPolicy } : DEFAULT_TEXT_POLICY;
38
39
  }
39
40
 
41
+ /** a fresh AddGlyphs object — fresh per call because node-x11 mutates what
42
+ * it is given (pads rows, divides offX by 64); the source bitmap survives */
43
+ function uploadGlyph(lid, bitmap, adv) {
44
+ return {
45
+ id: lid,
46
+ width: bitmap ? bitmap.width : 0,
47
+ height: bitmap ? bitmap.height : 0,
48
+ // XRender GLYPHINFO places the image at origin - (x, y); node-x11
49
+ // packs -x and +y, so x here is the bitmap's left bearing and y its
50
+ // (negated, y-down) top — i.e. the ascent above the baseline
51
+ x: bitmap ? bitmap.left : 0,
52
+ y: bitmap ? -bitmap.top : 0,
53
+ // node-x11 AddGlyphs expects 26.6 fixed point and divides by 64
54
+ offX: adv * 64,
55
+ offY: 0,
56
+ image: bitmap ? bitmap.data : Buffer.alloc(0)
57
+ };
58
+ }
59
+
40
60
  /**
41
61
  * Server-side glyph cache for one (font face, pixel size) pair.
42
62
  *
@@ -51,32 +71,52 @@ function policyOf(app) {
51
71
  * (`offX`), so the server advances the pen automatically and runs of
52
72
  * unkerned text need no per-glyph position data at all.
53
73
  * - Bitmaps upload lazily, once per glyph, batched into one AddGlyphs
54
- * request per draw that introduces new glyphs.
74
+ * request per draw that introduces new glyphs — and, when the display's
75
+ * shared glyph directory is live (docs/shared-glyphs.md), re-bind to the
76
+ * display-wide set so other ntk processes skip the work entirely.
55
77
  */
56
78
  export class GlyphPage {
57
79
  constructor(app, font, size) {
58
80
  this.app = app;
59
81
  this.font = font;
60
82
  this.size = size;
61
- this.glyphset = new GlyphSet(app);
62
- this.entries = new Map(); // font glyph id -> { lid, adv }
63
- this.bytes = 0; // uploaded bitmap bytes, for the LRU budget
83
+ // With shared glyphs off this page is exactly what it always was: a
84
+ // private set from birth. With them on, the set is minted lazily — a
85
+ // page that resolves entirely from the directory never creates one.
86
+ this.glyphset = sharedGlyphsFor(app) ? null : new GlyphSet(app);
87
+ this.entries = new Map(); // font glyph id -> { lid, adv, gs }
88
+ this.bytes = 0; // privately uploaded bitmap bytes, for the LRU budget
89
+ this._lids = 0; // next private lid (dense, upload order)
90
+ this._maxLid = -1; // widest id this page composites, private or shared
91
+ this._privateCount = 0; // entries still bound to the private set
92
+ this._shared = undefined; // SharedPage binding, resolved on first ensure
64
93
  }
65
94
 
66
95
  /** number of bits per glyph needed to address this page's ids */
67
96
  get bits() {
68
- return this.entries.size <= 256 ? 8 : 16;
97
+ return this._maxLid > 255 ? 16 : 8;
69
98
  }
70
99
 
71
100
  /**
72
- * Ensure all glyphs of a shaped run are uploaded; returns nothing.
73
- * New glyphs are rasterized and sent in a single AddGlyphs request.
101
+ * Ensure all glyphs of a shaped run are drawable; returns nothing.
102
+ *
103
+ * New glyphs are rasterized and sent in a single AddGlyphs request to the
104
+ * page's private set — this draw's pixels never wait on anything. When the
105
+ * shared directory is live they are also asked about (docs/shared-glyphs.md):
106
+ * the reply re-binds their entries to the display-wide set — uploading the
107
+ * retained bitmap only when this process was first to need the glyph — and
108
+ * once nothing private remains the private set is freed. Every later draw
109
+ * of those glyphs, in this process and every other, is then pure
110
+ * CompositeGlyphs. A page that wants the shared entries *before* first
111
+ * paint warms up front: see `warmSharedGlyphs`.
74
112
  */
75
113
  ensure(glyphs) {
76
114
  let batch = null;
115
+ let ask = null;
116
+ const shared = this._sharedBinding();
77
117
  for (const g of glyphs) {
78
118
  if (this.entries.has(g.id)) continue;
79
- const lid = this.entries.size;
119
+ const lid = this._lids++;
80
120
  if (lid >= 65536) {
81
121
  // 2^16 distinct glyphs at one size — not reachable in practice
82
122
  // (fonts cap at 65535 glyphs) but fail loudly rather than corrupt
@@ -84,36 +124,77 @@ export class GlyphPage {
84
124
  }
85
125
  const adv = Math.round(this.font.advanceOf(g.id, this.size));
86
126
  const bitmap = this.font.rasterize(g.id, this.size);
87
- this.entries.set(g.id, { lid, adv });
127
+ if (!this.glyphset) this.glyphset = new GlyphSet(this.app);
128
+ if (lid > this._maxLid) this._maxLid = lid;
129
+ this.entries.set(g.id, { lid, adv, gs: this.glyphset.id, private: true });
130
+ this._privateCount++;
88
131
  if (!batch) batch = [];
89
- batch.push({
90
- id: lid,
91
- width: bitmap ? bitmap.width : 0,
92
- height: bitmap ? bitmap.height : 0,
93
- // XRender GLYPHINFO places the image at origin - (x, y); node-x11
94
- // packs -x and +y, so x here is the bitmap's left bearing and y its
95
- // (negated, y-down) top — i.e. the ascent above the baseline
96
- x: bitmap ? bitmap.left : 0,
97
- y: bitmap ? -bitmap.top : 0,
98
- // node-x11 AddGlyphs expects 26.6 fixed point and divides by 64
99
- offX: adv * 64,
100
- offY: 0,
101
- image: bitmap ? bitmap.data : Buffer.alloc(0)
102
- });
132
+ batch.push(uploadGlyph(lid, bitmap, adv));
103
133
  this.bytes += bitmap ? bitmap.data.length : 0;
134
+ if (shared && shared.open) {
135
+ if (!ask) ask = [];
136
+ ask.push({ key: g.id, payload: { bitmap, adv } });
137
+ }
104
138
  }
105
139
  if (batch) this.glyphset.addGlyphs(batch);
140
+ if (ask) shared.ask(ask);
106
141
  }
107
142
 
108
143
  entry(fontGlyphId) {
109
144
  return this.entries.get(fontGlyphId);
110
145
  }
111
146
 
112
- /** free the server-side glyphset (LRU eviction / shutdown) */
147
+ /** the shared side of this page, bound on first use; null when the
148
+ * feature is off or the font's bytes cannot be content-addressed */
149
+ _sharedBinding() {
150
+ if (this._shared !== undefined) return this._shared;
151
+ const client = sharedGlyphsFor(this.app);
152
+ const token = client ? fontPageToken(this.font, this.size) : null;
153
+ this._shared = token
154
+ ? client.bindPage({
155
+ token,
156
+ indices: true, // member keys are the font glyph indices themselves
157
+ makeGlyph: (key, payload, lid) => {
158
+ // absent from the shared set: upload the bitmap retained at
159
+ // mint time, or rasterize now on the warm path (which retained
160
+ // nothing precisely because the glyph was expected to be there)
161
+ const bitmap =
162
+ payload.bitmap !== undefined ? payload.bitmap : this.font.rasterize(key, this.size);
163
+ return uploadGlyph(lid, bitmap, payload.adv);
164
+ },
165
+ adopt: (key, lid, gsid, payload) => this._adoptShared(key, lid, gsid, payload)
166
+ })
167
+ : null;
168
+ return this._shared;
169
+ }
170
+
171
+ /** re-bind one glyph to the shared set (confirmed present, or uploaded by
172
+ * us); drop the private set once nothing composites from it any more */
173
+ _adoptShared(key, lid, gsid, payload) {
174
+ const prev = this.entries.get(key);
175
+ this.entries.set(key, { lid, adv: payload.adv, gs: gsid });
176
+ if (lid > this._maxLid) this._maxLid = lid;
177
+ if (prev && prev.private && --this._privateCount === 0 && this.glyphset) {
178
+ // ordered after any CompositeGlyphs already issued, like the LRU's
179
+ // frees; new glyphs during a directory outage mint a fresh set
180
+ this.glyphset.destroy();
181
+ this.glyphset = null;
182
+ this.bytes = 0;
183
+ this._lids = 0;
184
+ }
185
+ }
186
+
187
+ /** free the server-side glyphset and shared alias (LRU eviction / shutdown) */
113
188
  destroy() {
114
- this.glyphset.destroy();
189
+ if (this.glyphset) this.glyphset.destroy();
190
+ this.glyphset = null;
191
+ if (this._shared) this._shared.destroy();
192
+ this._shared = undefined;
115
193
  this.entries.clear();
116
194
  this.bytes = 0;
195
+ this._lids = 0;
196
+ this._maxLid = -1;
197
+ this._privateCount = 0;
117
198
  }
118
199
  }
119
200
 
@@ -133,6 +214,42 @@ export function getGlyphPage(app, font, size) {
133
214
  return page;
134
215
  }
135
216
 
217
+ /**
218
+ * Warm one (font, size) page from the shared glyph directory before first
219
+ * paint (docs/shared-glyphs.md): resolve `text`'s glyphs into shared-set
220
+ * entries so that the first draw of already-shared text rasterizes nothing
221
+ * and uploads nothing — the cold-start win a synchronous first `fillText`
222
+ * cannot have, since it cannot wait for the directory's answer and falls
223
+ * back to a private upload for exactly one frame instead.
224
+ *
225
+ * Glyphs the directory has never seen are rasterized here and uploaded once,
226
+ * which is the same work the first draw would have done. Resolves `true`
227
+ * when the page ended up bound to the shared cache; `false` means the
228
+ * feature is off or degraded and drawing will use the private path — either
229
+ * way the following draws are correct.
230
+ *
231
+ * @param {App} app
232
+ * @param {Font} font
233
+ * @param {number} size pixel size
234
+ * @param {string} text whose glyphs to warm (shaped with defaults)
235
+ * @returns {Promise<boolean>}
236
+ */
237
+ export async function warmSharedGlyphs(app, font, size, text) {
238
+ if (!sharedGlyphsFor(app)) return false;
239
+ const page = getGlyphPage(app, font, size);
240
+ const shared = page._sharedBinding();
241
+ if (!shared || !shared.open) return false;
242
+ const ask = [];
243
+ const seen = new Set();
244
+ for (const g of font.shape(String(text), size).glyphs) {
245
+ if (page.entries.has(g.id) || seen.has(g.id)) continue;
246
+ seen.add(g.id);
247
+ ask.push({ key: g.id, payload: { adv: Math.round(font.advanceOf(g.id, size)) } });
248
+ }
249
+ if (ask.length) await shared.ask(ask);
250
+ return shared.open && shared.bound;
251
+ }
252
+
136
253
  /**
137
254
  * Evict least-recently-used glyph pages until uploaded bitmaps fit the
138
255
  * policy budget. `inUse` pages (referenced by requests queued this draw)
@@ -225,6 +342,67 @@ export function positionGlyphs(positioned) {
225
342
  return out;
226
343
  }
227
344
 
345
+ /**
346
+ * Ink extents of positioned runs, in whatever coordinates their origins are
347
+ * given in — the union of every glyph's bounding box, laid out exactly as
348
+ * `positionGlyphs` lays it out (pen at `x`, glyph at `pen + dx`, `y - dy`,
349
+ * pen advanced by `ax`).
350
+ *
351
+ * Returns `null` when nothing inks: a run of spaces has extents but no
352
+ * bounding box, and neither does an empty array. Blank glyphs report an
353
+ * empty `cbox` (`minX` infinite), which the comparisons below drop on their
354
+ * own.
355
+ *
356
+ * This is what sizes a glyph shadow's coverage surface — the run-shaped
357
+ * counterpart of the context's `_shapedInk`, which measures one shaped
358
+ * string from its own origin.
359
+ *
360
+ * @param {Array<{run, x, y}>} positioned
361
+ * @returns {{minX: number, minY: number, maxX: number, maxY: number}|null}
362
+ */
363
+ export function positionedRunsInk(positioned) {
364
+ let minX = Infinity;
365
+ let minY = Infinity;
366
+ let maxX = -Infinity;
367
+ let maxY = -Infinity;
368
+ for (const { run, x, y } of positioned) {
369
+ let cursor = x;
370
+ for (const g of run.glyphs) {
371
+ const e = run.font.glyphExtents(g.id, run.size);
372
+ const gx = cursor + g.dx;
373
+ const gy = y - g.dy;
374
+ cursor += g.ax;
375
+ if (gx + e.minX < minX) minX = gx + e.minX;
376
+ if (gx + e.maxX > maxX) maxX = gx + e.maxX;
377
+ if (gy + e.minY < minY) minY = gy + e.minY;
378
+ if (gy + e.maxY > maxY) maxY = gy + e.maxY;
379
+ }
380
+ }
381
+ return minX <= maxX && minY <= maxY ? { minX, minY, maxX, maxY } : null;
382
+ }
383
+
384
+ // Identity, not content, for anything that wants to name a run cheaply.
385
+ // A shaped run is immutable and shared — the shaping memo hands the same
386
+ // object back, and a TextLayout holds on to the ones its lines are made of
387
+ // — so a small integer per object is a complete name for the glyphs in it,
388
+ // bought at O(1) instead of O(glyphs). Weak, so naming a run keeps nothing
389
+ // alive.
390
+ const runIds = new WeakMap();
391
+ let nextRunId = 0;
392
+
393
+ /**
394
+ * A stable small integer for one run object, for cache keys that would
395
+ * otherwise have to serialize its glyphs. Runs built fresh on every draw
396
+ * (rather than kept, as a `TextLayout` keeps them) get a fresh id each time
397
+ * and so never hit such a cache — which is the honest answer, since nothing
398
+ * cheap can tell them apart.
399
+ */
400
+ export function runId(run) {
401
+ let id = runIds.get(run);
402
+ if (id === undefined) runIds.set(run, (id = ++nextRunId));
403
+ return id;
404
+ }
405
+
228
406
  /**
229
407
  * Decide how a (face, size) renders: cached bitmap glyphs or per-draw
230
408
  * trapezoids. See DEFAULT_TEXT_POLICY for the reasoning; the middle band
@@ -361,7 +539,7 @@ function drawBitmapGlyphRuns(app, op, srcId, dstId, positioned, policy) {
361
539
  for (const pos of positionGlyphs(positioned)) {
362
540
  const page = pages.get(pos.run);
363
541
  const e = page.entry(pos.glyph.id);
364
- items.push({ gs: page.glyphset.id, lid: e.lid, adv: e.adv, x: pos.x, y: pos.y });
542
+ items.push({ gs: e.gs, lid: e.lid, adv: e.adv, x: pos.x, y: pos.y });
365
543
  }
366
544
  const encoded = encodeGlyphItems(items, bits);
367
545
  if (!encoded) return;
@@ -220,9 +220,11 @@ function pathBBox(path) {
220
220
  * inline `style` beats the presentation attribute, the initial fill is black
221
221
  * and the initial stroke is none, `<line>` never fills, `<use>` paints its
222
222
  * target with the *use* element's style, and non-rendered subtrees
223
- * contribute nothing. It starts at the root's children rather than the root,
224
- * because `draw` does: presentation attributes on the root `<svg>` are not
225
- * applied, and a scan that applied them would disagree with what is painted.
223
+ * contribute nothing. It starts at the root element itself, because `draw`
224
+ * does: the root `<svg>` is an ordinary element for inheritance, and every
225
+ * mainstream icon set puts `fill`/`stroke` there (issue #306). The two walks
226
+ * have to agree — a scan that skipped the root would disagree with what is
227
+ * painted.
226
228
  *
227
229
  * @returns {{ kind: 'mono'|'multi', solo: string|null }} `solo` is the one
228
230
  * paint a `mono` document uses — a colour, or the literal `'currentColor'`
@@ -284,7 +286,7 @@ function scanPaints(root, ids) {
284
286
  }
285
287
  };
286
288
 
287
- kids(root, INHERITED.fill, INHERITED.stroke, 0);
289
+ visit(root, INHERITED.fill, INHERITED.stroke, 0);
288
290
  return {
289
291
  kind: multi || paints.size > 1 ? 'multi' : 'mono',
290
292
  solo: paints.size === 1 ? [...paints][0] : null
@@ -433,7 +435,7 @@ export default class SvgView {
433
435
  this._renderChildren(
434
436
  this._root,
435
437
  ctx,
436
- { ...INHERITED, color: opts.color ?? this.color },
438
+ this._style(this._root, { ...INHERITED, color: opts.color ?? this.color }),
437
439
  1,
438
440
  0
439
441
  );