ntk 7.1.0 → 7.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.
@@ -525,6 +525,7 @@ class RenderingContext2d {
525
525
  textStyle: this._textStyle,
526
526
  fontString: this._lastFontString,
527
527
  fontVariations: this._fontVariations,
528
+ textRendering: this._textRendering,
528
529
  textAlign: this.textAlign,
529
530
  textBaseline: this.textBaseline,
530
531
  m: this._m.slice(),
@@ -548,6 +549,7 @@ class RenderingContext2d {
548
549
  this._textStyle = s.textStyle;
549
550
  this._lastFontString = s.fontString;
550
551
  this._fontVariations = s.fontVariations;
552
+ this._textRendering = s.textRendering;
551
553
  this.textAlign = s.textAlign;
552
554
  this.textBaseline = s.textBaseline;
553
555
  this._m = s.m;
@@ -1008,8 +1010,9 @@ class RenderingContext2d {
1008
1010
  // extrude-polyline pushes the first and last points outward along the
1009
1011
  // line, which on a closed loop extends the seam over band it already
1010
1012
  // covers — invisible against an opaque colour, a double-blended edge
1011
- // against a translucent one.
1012
- const closedStroke =
1013
+ // against a translucent one. Runs cut at an escaping join (see
1014
+ // escapingJoins) want butt ends for the same reason.
1015
+ const buttStroke =
1013
1016
  cap === "butt"
1014
1017
  ? stroke
1015
1018
  : extrudePolyline({
@@ -1063,6 +1066,108 @@ class RenderingContext2d {
1063
1066
  // bevel-to-arc gap depth for turn angle θ: r * (1 - cos(θ/2))
1064
1067
  if (r * (1 - Math.sqrt((1 + dot) / 2)) > 0.05) addDisk(b[0], b[1]);
1065
1068
  };
1069
+ /**
1070
+ * Interior vertices whose join extrude-polyline cannot be trusted with,
1071
+ * ascending; null — the common case — for a polyline with none.
1072
+ *
1073
+ * Whatever the join style, it closes the *inner* side of a join at the
1074
+ * intersection of the two inner offsets, r/cos(φ/2) from the vertex for
1075
+ * a turn of φ: 'bevel' bevels the outer side and still emits that
1076
+ * point, and `miterLimit` only chooses which side gets bevelled. As φ
1077
+ * approaches a reversal the intersection runs away to infinity, so a
1078
+ * hairpin — a cusp in a curve, a polyline that doubles back — threw a
1079
+ * spike hundreds of pixels off the path that no join style and no miter
1080
+ * limit could reach (issue #233).
1081
+ *
1082
+ * The intersection is legitimate only while it stays inside the two
1083
+ * segments, which it does when each is at least r·tan(φ/2) long: that
1084
+ * is how far back along both the point sits. Where they are shorter it
1085
+ * is ink outside the path, and the run is cut at that vertex instead —
1086
+ * both sides then end butt on it, so the inner corner is the union of
1087
+ * the two rectangles, and the outer side gets addJoinWedge.
1088
+ */
1089
+ const escapingJoins = (run) => {
1090
+ let cuts = null;
1091
+ for (let i = 1; i < run.length - 1; i++) {
1092
+ const ax = run[i][0] - run[i - 1][0];
1093
+ const ay = run[i][1] - run[i - 1][1];
1094
+ const bx = run[i + 1][0] - run[i][0];
1095
+ const by = run[i + 1][1] - run[i][1];
1096
+ // This runs over every vertex of every stroke and almost never
1097
+ // fires, so it is written to answer "no" in multiplications alone:
1098
+ // squared lengths, and the raw (unnormalized) dot and cross, which
1099
+ // give tan(φ/2) = cross / (|a||b| + dot) directly.
1100
+ const la = ax * ax + ay * ay;
1101
+ const lb = bx * bx + by * by;
1102
+ if (!la || !lb) continue;
1103
+ const shortest = la < lb ? la : lb;
1104
+ const dot = ax * bx + ay * by;
1105
+ // a turn of 90° or less has tan(φ/2) <= 1, and so cannot reach past
1106
+ // a segment that is already at least r long
1107
+ if (dot >= 0 && r * r <= shortest) continue;
1108
+ const cross = ax * by - ay * bx;
1109
+ // r·tan(φ/2) > min(|a|, |b|), squared. A non-positive denominator is
1110
+ // the reversal the tangent is infinite at.
1111
+ const denom = Math.sqrt(la * lb) + dot;
1112
+ if (denom <= 0 || (r * cross) ** 2 > shortest * denom * denom) {
1113
+ (cuts ??= []).push(i);
1114
+ }
1115
+ }
1116
+ return cuts;
1117
+ };
1118
+ /**
1119
+ * The outer side of the join at `b`, which cutting the run leaves to us:
1120
+ * the wedge between the two segments' outer offsets. A miter within the
1121
+ * limit fills it to the tip, everything else bevels — the same choice
1122
+ * the extruder would have made, on geometry that stays put.
1123
+ */
1124
+ const addJoinWedge = (a, b, c) => {
1125
+ const l1 = Math.hypot(b[0] - a[0], b[1] - a[1]);
1126
+ const l2 = Math.hypot(c[0] - b[0], c[1] - b[1]);
1127
+ if (!l1 || !l2) return;
1128
+ const ux = (b[0] - a[0]) / l1;
1129
+ const uy = (b[1] - a[1]) / l1;
1130
+ const vx = (c[0] - b[0]) / l2;
1131
+ const vy = (c[1] - b[1]) / l2;
1132
+ // outer side: away from the turn. A zero cross product is a straight
1133
+ // run or an exact reversal, and neither leaves a wedge to fill.
1134
+ const cross = ux * vy - uy * vx;
1135
+ if (!cross) return;
1136
+ // each segment's own offset at b: the normal r out on the outer side
1137
+ const s = cross > 0 ? -r : r;
1138
+ const p1x = b[0] - uy * s;
1139
+ const p1y = b[1] + ux * s;
1140
+ const p2x = b[0] - vy * s;
1141
+ const p2y = b[1] + vx * s;
1142
+ const dot = Math.max(-1, Math.min(1, ux * vx + uy * vy));
1143
+ const ratio = 1 / Math.sqrt((1 + dot) / 2); // miter length / r
1144
+ if (join === "miter" && ratio <= this.miterLimit) {
1145
+ // the tip is r·ratio along the bisector, where the two offsets meet
1146
+ const mx = p1x + p2x - 2 * b[0];
1147
+ const my = p1y + p2y - 2 * b[1];
1148
+ const ml = Math.hypot(mx, my);
1149
+ const tx = b[0] + (mx / ml) * r * ratio;
1150
+ const ty = b[1] + (my / ml) * r * ratio;
1151
+ tris.push(b[0], b[1], p1x, p1y, tx, ty);
1152
+ tris.push(b[0], b[1], tx, ty, p2x, p2y);
1153
+ return;
1154
+ }
1155
+ tris.push(b[0], b[1], p1x, p1y, p2x, p2y);
1156
+ };
1157
+ const emit = (run, extruder) => {
1158
+ const mesh = extruder.build(run);
1159
+ for (const tri of mesh.cells) {
1160
+ for (let i = 0; i < 3; ++i) {
1161
+ tris.push(mesh.positions[tri[i]][0], mesh.positions[tri[i]][1]);
1162
+ }
1163
+ }
1164
+ };
1165
+ // a square cap's extension of end point `p` away from its neighbour `q`
1166
+ const capOut = (p, q) => {
1167
+ const d = Math.hypot(p[0] - q[0], p[1] - q[1]);
1168
+ if (!d) return p;
1169
+ return [p[0] + ((p[0] - q[0]) / d) * r, p[1] + ((p[1] - q[1]) / d) * r];
1170
+ };
1066
1171
  // one polyline through extrusion + round-geometry post-processing;
1067
1172
  // closed loops carry the seam point at both ends and get no caps
1068
1173
  const extrudeRun = (pts, closed) => {
@@ -1089,11 +1194,30 @@ class RenderingContext2d {
1089
1194
  ];
1090
1195
  pts = [mid, ...pts.slice(1), mid];
1091
1196
  }
1092
- const mesh = (closed ? closedStroke : stroke).build(pts);
1093
- for (const tri of mesh.cells) {
1094
- for (let i = 0; i < 3; ++i) {
1095
- tris.push(mesh.positions[tri[i]][0], mesh.positions[tri[i]][1]);
1197
+ const cuts = escapingJoins(pts);
1198
+ if (!cuts) {
1199
+ emit(pts, closed ? buttStroke : stroke);
1200
+ } else {
1201
+ // Extrude the pieces between the cuts, each ending butt on the cut
1202
+ // vertex it shares with the next. That leaves the run's own two ends
1203
+ // to us as well: extrude-polyline squares both ends of whatever it
1204
+ // is handed, so a square cap is applied here instead.
1205
+ const last = pts.length - 1;
1206
+ let from = 0;
1207
+ for (const to of [...cuts, last]) {
1208
+ const run = pts.slice(from, to + 1);
1209
+ if (!closed && cap === "square") {
1210
+ if (from === 0) run[0] = capOut(run[0], run[1]);
1211
+ if (to === last)
1212
+ run[run.length - 1] = capOut(
1213
+ run[run.length - 1],
1214
+ run[run.length - 2],
1215
+ );
1216
+ }
1217
+ emit(run, buttStroke);
1218
+ from = to;
1096
1219
  }
1220
+ for (const i of cuts) addJoinWedge(pts[i - 1], pts[i], pts[i + 1]);
1097
1221
  }
1098
1222
  if (roundJoin) {
1099
1223
  // every real vertex is interior now, the seam included: a closed
@@ -2339,7 +2463,12 @@ class RenderingContext2d {
2339
2463
  const positioned = [];
2340
2464
  let cursor = ox;
2341
2465
  for (const run of reorderRuns(shaped.runs)) {
2342
- positioned.push({ run, x: cursor, y: oy });
2466
+ positioned.push({
2467
+ run,
2468
+ x: cursor,
2469
+ y: oy,
2470
+ textRendering: this._textRendering,
2471
+ });
2343
2472
  cursor += run.width;
2344
2473
  }
2345
2474
  this.drawGlyphs(
@@ -2451,6 +2580,30 @@ class RenderingContext2d {
2451
2580
  return this._fontVariations ?? null;
2452
2581
  }
2453
2582
 
2583
+ /**
2584
+ * CSS's `text-rendering`: which glyph path this text takes, overriding the
2585
+ * size thresholds in `app.textPolicy`.
2586
+ *
2587
+ * - `'geometricPrecision'` — outlines every draw, glyph origins **not**
2588
+ * rounded to whole pixels. What display text wants, and what any text
2589
+ * whose shape is being animated wants: a variable font's axis moves
2590
+ * advances by fractions of a pixel, and cached glyphs can only land on
2591
+ * whole ones, so those fractions accumulate until a glyph crosses a
2592
+ * rounding boundary and jumps a pixel on its own.
2593
+ * - `'optimizeSpeed'` — cached server-side glyphs at any size.
2594
+ * - `'auto'` (default) — the thresholds decide.
2595
+ *
2596
+ * `'optimizeLegibility'` is accepted and means `'auto'`; ntk has no
2597
+ * hinting to turn on.
2598
+ */
2599
+ set textRendering(val) {
2600
+ this._textRendering = val || undefined;
2601
+ }
2602
+
2603
+ get textRendering() {
2604
+ return this._textRendering ?? "auto";
2605
+ }
2606
+
2454
2607
  // ------------------------------------------------------------------
2455
2608
  // gradients / images
2456
2609
 
@@ -0,0 +1,312 @@
1
+ // Direct rendering context: OpenGL ES 2 on the GPU, frames delivered to the
2
+ // server as dma-buf descriptors over DRI3 + Present (lib/glswapchain.js).
3
+ //
4
+ // The API is WebGL-shaped and camelCase — `gl.createShader`, `gl.drawElements`
5
+ // — because that is what the addon's ES 2 binding exposes and what anyone
6
+ // writing shaders already knows. It is *not* the same API as the indirect GLX
7
+ // context, whose OpenGL 1.x commands are PascalCase and whose pipeline has no
8
+ // shaders at all; the two backends are honestly different rather than one
9
+ // pretending to be the other. Cross-backend code branches on `gl.backend`
10
+ // ('direct' or 'indirect') — see docs/context-gles.md.
11
+ //
12
+ // Setup is synchronous, which is the point: creating the GPU context and its
13
+ // buffers needs the DRM device and nothing from the X server, so `gl.*` works
14
+ // on the line after `getContext`. Only *presenting* needs the server, and
15
+ // `ready` is what reports whether that works — it resolves when the first
16
+ // buffer has been imported by the server, which is the moment the whole path
17
+ // is proven.
18
+
19
+ import Drawable from './drawable.js';
20
+ import { GLError, backendFor, glError, loadDriAddon } from './gl.js';
21
+ import { GLSwapchain } from './glswapchain.js';
22
+
23
+ /**
24
+ * One GPU context per app and pixel format, not one per surface.
25
+ *
26
+ * An EGL context is expensive (a device, a GBM device, a display, a config
27
+ * scan) and every surface in an app wants the same one; sharing it also means
28
+ * textures and programs are shared between surfaces, as they are between
29
+ * canvases in a browser tab. The cost is that exactly one surface is current
30
+ * at a time, which `_bind` takes care of.
31
+ */
32
+ function sharedGpu(app, dri, { format, depthSize, devicePath }) {
33
+ const key = `${format}|${depthSize}|${devicePath ?? ''}`;
34
+ const cache = (app._glGpus ??= new Map());
35
+ const existing = cache.get(key);
36
+ if (existing) return existing;
37
+ let gpu;
38
+ try {
39
+ gpu = new dri.Gpu({ format, depthSize, ...(devicePath ? { devicePath } : {}) });
40
+ } catch (err) {
41
+ throw glError(
42
+ GLError.CONTEXT_FAILED,
43
+ `could not create a GPU context on ${devicePath ?? 'the default render node'}: ${err.message}`,
44
+ null,
45
+ err
46
+ );
47
+ }
48
+ cache.set(key, gpu);
49
+ return gpu;
50
+ }
51
+
52
+ class RenderingContextGLES {
53
+ constructor(window, config = {}) {
54
+ const app = window.app;
55
+ const caps = app._glCapsResolved;
56
+ if (!caps) {
57
+ throw glError(
58
+ GLError.CONTEXT_FAILED,
59
+ "getContext('gles') needs the direct-rendering probe to have answered, and it has not",
60
+ `createClient() runs the probe during the handshake when glPolicy could pick the
61
+ direct backend, so a context can be created synchronously afterwards. Under the
62
+ default policy ('indirect') it does not run, and asking for this context by name
63
+ does not make it retroactive. Either:
64
+
65
+ const app = await createClient({ glPolicy: 'auto' }); // probe at connect
66
+ await app.glCapabilities(); // or ask, once, later`
67
+ );
68
+ }
69
+ if (!caps.direct) throw caps.reason;
70
+
71
+ const dri = loadDriAddon();
72
+ this.window = window;
73
+ this.app = app;
74
+ this.X = window.X;
75
+ this.dri = dri;
76
+ /** which backend this is, for code that runs on either */
77
+ this.backend = 'direct';
78
+ this.error = null;
79
+
80
+ // The buffer format has to match how the server will read the pixmap, and
81
+ // that is the window's depth: 24 is XRGB (opaque), 32 is ARGB (the
82
+ // compositor blends the alpha). A window created without an explicit depth
83
+ // has the root's.
84
+ const depth = window.depth || app.display.screen[0].root_depth || 24;
85
+ if (depth !== 24 && depth !== 32) {
86
+ throw glError(
87
+ GLError.CONTEXT_FAILED,
88
+ `direct rendering needs a 24- or 32-bit window, and this one is ${depth}-bit`,
89
+ 'Create the window with depth 24 (opaque) or 32 (per-pixel alpha, via\napp.findArgbVisual()).'
90
+ );
91
+ }
92
+ this.depth = depth;
93
+
94
+ const policy = app.glPolicy;
95
+ this.gpu = sharedGpu(app, dri, {
96
+ format: depth === 32 ? dri.FORMAT.ARGB8888 : dri.FORMAT.XRGB8888,
97
+ depthSize: config.depthSize ?? config.DEPTH_SIZE ?? 16,
98
+ devicePath: policy.devicePath ?? caps.device
99
+ });
100
+
101
+ this.swapchain = new GLSwapchain({
102
+ window,
103
+ gpu: this.gpu,
104
+ dri,
105
+ DRI3: caps.DRI3,
106
+ Present: caps.Present,
107
+ depth,
108
+ policy
109
+ });
110
+ window._setGenericEventSink(caps.Present.majorOpcode, this.swapchain);
111
+
112
+ /**
113
+ * Resolves once a frame's buffer has been accepted by the server — the
114
+ * whole path proven, not just the parts on this side of the socket — and
115
+ * rejects with a coded error if it cannot be. Nothing needs to await it
116
+ * before drawing; it is how a caller decides to show a fallback instead.
117
+ */
118
+ this.ready = new Promise((resolve, reject) => {
119
+ this.swapchain.onValidated = (err) => {
120
+ if (err) {
121
+ this.error = err;
122
+ reject(err);
123
+ } else resolve(this);
124
+ };
125
+ });
126
+ // a rejection nobody is listening for must not take the process down;
127
+ // `error` and the onError hook are the other ways to find out
128
+ this.ready.catch(() => {});
129
+ this.swapchain.onReady = () => this._onFrameAvailable();
130
+ this._frameWanted = null;
131
+
132
+ // GL entry points and constants, bound so that whichever surface this
133
+ // context owns is the current one when they run
134
+ this._installGL();
135
+ this.makeCurrent();
136
+ // settle `ready` now rather than on the first frame — see validate()
137
+ this.swapchain.validate();
138
+ }
139
+
140
+ /**
141
+ * Copy the addon's ES 2 namespace onto this context.
142
+ *
143
+ * Every function is wrapped with the currency check rather than documented
144
+ * as the caller's job: the shared context means another surface may have
145
+ * been current since the last call here, and a GL call against the wrong
146
+ * surface draws into the wrong window. The check is one comparison against
147
+ * a field — next to a native call, it does not register.
148
+ */
149
+ _installGL() {
150
+ const table = this.dri.gl;
151
+ for (const key in table) {
152
+ const value = table[key];
153
+ if (typeof value !== 'function') {
154
+ this[key] = value; // GL constants
155
+ continue;
156
+ }
157
+ this[key] = (...args) => {
158
+ if (this.app._glCurrent !== this) this._bind();
159
+ return value(...args);
160
+ };
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Make this context's surface current, sizing it to the window first.
166
+ *
167
+ * Call it at the top of a frame: that is where a resize can be honoured
168
+ * without throwing away a half-drawn one.
169
+ */
170
+ makeCurrent() {
171
+ if (this.error || this._destroyed || this.window._destroyed) return this;
172
+ const width = this.window.width;
173
+ const height = this.window.height;
174
+ if (width !== this._width || height !== this._height) {
175
+ this._width = width;
176
+ this._height = height;
177
+ this._surface = null; // a new size is a new generation
178
+ }
179
+ this._bind();
180
+ return this;
181
+ }
182
+
183
+ _bind() {
184
+ if (this.error || this._destroyed) return;
185
+ if (!this._surface) {
186
+ this._surface = this.swapchain.surfaceFor(this._width ?? this.window.width, this._height ?? this.window.height);
187
+ }
188
+ this.gpu.makeCurrent(this._surface);
189
+ this.app._glCurrent = this;
190
+ }
191
+
192
+ /**
193
+ * Is a frame worth drawing right now?
194
+ *
195
+ * False when every buffer is still with the server. Drawing anyway is not
196
+ * wrong, only wasted: the swap that followed would have nowhere to go.
197
+ * `onFrameAvailable` is the other half — it fires when this turns true.
198
+ */
199
+ canRender() {
200
+ return this.swapchain.canRender();
201
+ }
202
+
203
+ /** Called when `canRender()` became true again after a swap was refused. */
204
+ set onFrameAvailable(fn) {
205
+ this._frameWanted = fn;
206
+ }
207
+
208
+ get onFrameAvailable() {
209
+ return this._frameWanted;
210
+ }
211
+
212
+ _onFrameAvailable() {
213
+ this._frameWanted?.();
214
+ }
215
+
216
+ /**
217
+ * Show the frame just drawn.
218
+ *
219
+ * Named as the indirect context names it, so a draw loop can end the same
220
+ * way on either backend; `swapBuffers` is the same call under the spelling
221
+ * the rest of this API uses. Returns false when the frame could not be
222
+ * shown yet — see `canRender`.
223
+ */
224
+ SwapBuffers() {
225
+ if (this.error || this._destroyed || this.window._destroyed) return false;
226
+ const sent = this.swapchain.swap();
227
+ // A resize seen only now still gets picked up: the next frame binds a
228
+ // generation at the new size. Checked after the swap so the frame that was
229
+ // drawn at the old size is the one that goes out.
230
+ if (this.window.width !== this._width || this.window.height !== this._height) {
231
+ this._surface = null;
232
+ }
233
+ return sent;
234
+ }
235
+
236
+ swapBuffers() {
237
+ return this.SwapBuffers();
238
+ }
239
+
240
+ /** The GL renderer string, once there is a context — handy in bug reports. */
241
+ get renderer() {
242
+ try {
243
+ return this.dri.gl.getString(this.dri.GL.RENDERER);
244
+ } catch {
245
+ return null;
246
+ }
247
+ }
248
+
249
+ destroy() {
250
+ if (this._destroyed) return;
251
+ this._destroyed = true;
252
+ this.swapchain.destroy();
253
+ this.window._setGenericEventSink(0, null);
254
+ if (this.app._glCurrent === this) {
255
+ this.app._glCurrent = null;
256
+ try {
257
+ this.gpu.makeCurrent(null);
258
+ } catch {
259
+ // the context is going away regardless
260
+ }
261
+ }
262
+ this._surface = null;
263
+ // the Gpu itself is shared and outlives this context (App#close frees it)
264
+ }
265
+
266
+ [Symbol.dispose]() {
267
+ this.destroy();
268
+ }
269
+ }
270
+
271
+ Drawable.renderingContextFactory['gles'] = (window, config) => new RenderingContextGLES(window, config);
272
+
273
+ // `getContext('opengl')` is the backend-neutral name: it is what the indirect
274
+ // context registered before there was a choice, and what code that does not
275
+ // care should keep asking for. The policy decides which one it gets, and the
276
+ // default policy is still the indirect one, so nothing changes under an app
277
+ // that has not opted in.
278
+ const indirectFactory = Drawable.renderingContextFactory['opengl'];
279
+ Drawable.renderingContextFactory['opengl'] = (window, config) => {
280
+ const app = window.app;
281
+ const backend = backendFor(app);
282
+ if (backend === 'direct') return new RenderingContextGLES(window, config);
283
+ if (backend === 'off') {
284
+ const caps = app._glCapsResolved;
285
+ throw (
286
+ caps?.reason ??
287
+ glError(GLError.DISABLED, "glPolicy is 'off', so getContext('opengl') has no backend to use")
288
+ );
289
+ }
290
+ // null: the policy could pick direct, but the probe has not answered — a
291
+ // policy raised after connecting. 'direct' must not quietly become the other
292
+ // backend, because the whole point of asking for it by name is that the draw
293
+ // code only speaks ES 2.
294
+ if (backend === null) {
295
+ if (app.glPolicy.mode === 'direct') {
296
+ throw glError(
297
+ GLError.CONTEXT_FAILED,
298
+ "glPolicy is 'direct' but the direct-rendering probe has not answered, so there is no context to give you",
299
+ 'The probe runs inside createClient() when the policy is set there. A policy\n' +
300
+ 'raised afterwards needs one `await app.glCapabilities()` first.'
301
+ );
302
+ }
303
+ console.warn(
304
+ "ntk: glPolicy is 'auto' but the direct-rendering probe has not answered yet, so " +
305
+ "getContext('opengl') is using indirect GLX. Pass glPolicy to createClient(), or " +
306
+ 'await app.glCapabilities() before creating the context.'
307
+ );
308
+ }
309
+ return indirectFactory(window, config);
310
+ };
311
+
312
+ export default RenderingContextGLES;
@@ -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);
@@ -67,6 +67,7 @@ export class TextLayout {
67
67
  weight: s.weight ?? style.weight,
68
68
  style: s.style ?? style.style,
69
69
  variations: s.variations ?? style.variations,
70
+ textRendering: s.textRendering ?? style.textRendering,
70
71
  features: s.features ?? style.features,
71
72
  language: s.language ?? style.language,
72
73
  color: s.color ?? style.color ?? null
@@ -497,7 +498,15 @@ export class TextLayout {
497
498
  const color = r.span.color;
498
499
  if (batch.length && color !== batchColor) flush();
499
500
  batchColor = color;
500
- 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
+ });
501
510
  }
502
511
  }
503
512
  flush();