ntk 8.4.1 → 8.6.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/index.js CHANGED
@@ -48,6 +48,13 @@ import {
48
48
  setDefaultRasterizer
49
49
  } from './rasterize.js';
50
50
  import { DEFAULT_MASK_POLICY } from './maskcluster.js';
51
+ import {
52
+ DEFAULT_SHADOW_POLICY,
53
+ blurCoverage,
54
+ gaussianKernel1d,
55
+ shadowReach,
56
+ shadowSigma
57
+ } from './shadow.js';
51
58
  import { DEFAULT_SHAPE_POLICY } from './shapeglyphs.js';
52
59
  import { DEFAULT_SHARED_GLYPHS_POLICY } from './glyphdirectory.js';
53
60
  import { warmSharedGlyphs } from './text/glyphs.js';
@@ -275,6 +282,16 @@ export {
275
282
  DEFAULT_RASTER_POLICY,
276
283
  DEFAULT_MASK_POLICY,
277
284
  DEFAULT_SHAPE_POLICY,
285
+ // the shadow blur as a primitive (docs/surface.md#baking-a-blur), for a
286
+ // caller drawing shapes ntk's shadow properties never see. `blurCoverage`
287
+ // *bakes* the two separable passes into a surface's pixels — unlike
288
+ // `picture().setBlurFilter()`, whose kernel the server re-runs on every
289
+ // composite, which a cached shadow pays for once per frame
290
+ DEFAULT_SHADOW_POLICY,
291
+ blurCoverage,
292
+ gaussianKernel1d,
293
+ shadowReach,
294
+ shadowSigma,
278
295
  // cross-process shared glyphs (docs/shared-glyphs.md): the directory-side
279
296
  // budget, and the prewarm that makes a first paint of already-shared text
280
297
  // rasterize and upload nothing
package/lib/picture.js CHANGED
@@ -25,10 +25,35 @@ export default class Picture {
25
25
  registry.register(this, { Render: this.Render, X, id: this.id }, this);
26
26
  }
27
27
 
28
+ /**
29
+ * Set the picture's filter — a *property of the picture*, not an operation
30
+ * on its pixels. The server re-applies it every time the picture is
31
+ * sampled, so the cost is per composite and forever, not once.
32
+ *
33
+ * That is what makes it right for resampling (`'bilinear'` under a
34
+ * transform) and a trap for anything expensive: see `setBlurFilter`.
35
+ */
28
36
  setFilter(name, params) {
29
37
  this.Render.SetPictureFilter(this.id, name, params);
30
38
  }
31
39
 
40
+ /**
41
+ * Hang a k×k gaussian `convolution` on the picture.
42
+ *
43
+ * **This re-convolves on every composite** — it is a filter, so the server
44
+ * runs the whole kernel each time the picture is drawn, and the pixels
45
+ * never change on the client's side of the wire. A picture blurred once and
46
+ * then composited each frame pays k² multiply-accumulates per pixel per
47
+ * frame: at radius 61 over 489×134 that is 244M per draw, which is a 1.6s
48
+ * hover on XQuartz and a 9s window repaint (issue #335).
49
+ *
50
+ * Reach for it when the blur really is per-draw and small. To blur
51
+ * something *once* and composite the result cheaply afterwards — a drop
52
+ * shadow, a cached soft edge — bake it instead with `blurCoverage` from
53
+ * ntk's entry point: two separable 1d passes (2k multiplies per pixel, not
54
+ * k²), run once, leaving a surface with the blur in its pixels and no
55
+ * filter of its own. See docs/surface.md#baking-a-blur.
56
+ */
32
57
  setBlurFilter(radius, sigma) {
33
58
  if (radius === 0) {
34
59
  return this.setFilter('convolution', [1, 1, 1]);
@@ -1057,6 +1057,7 @@ class RenderingContext2d {
1057
1057
  textStyle: this._textStyle,
1058
1058
  fontString: this._lastFontString,
1059
1059
  fontVariations: this._fontVariations,
1060
+ fontOpticalSizing: this._fontOpticalSizing,
1060
1061
  textRendering: this._textRendering,
1061
1062
  textAlign: this.textAlign,
1062
1063
  textBaseline: this.textBaseline,
@@ -1086,6 +1087,7 @@ class RenderingContext2d {
1086
1087
  this._textStyle = s.textStyle;
1087
1088
  this._lastFontString = s.fontString;
1088
1089
  this._fontVariations = s.fontVariations;
1090
+ this._fontOpticalSizing = s.fontOpticalSizing;
1089
1091
  this._textRendering = s.textRendering;
1090
1092
  this.textAlign = s.textAlign;
1091
1093
  this.textBaseline = s.textBaseline;
@@ -1249,6 +1251,7 @@ class RenderingContext2d {
1249
1251
  sctx._textStyle = this._textStyle;
1250
1252
  sctx._lastFontString = this._lastFontString;
1251
1253
  sctx._fontVariations = this._fontVariations;
1254
+ sctx._fontOpticalSizing = this._fontOpticalSizing;
1252
1255
  sctx._textRendering = this._textRendering;
1253
1256
  sctx.textAlign = this.textAlign;
1254
1257
  sctx.textBaseline = this.textBaseline;
@@ -4029,6 +4032,7 @@ class RenderingContext2d {
4029
4032
  style: parsed.style,
4030
4033
  size: parsed.size,
4031
4034
  variations: this._fontVariations,
4035
+ opticalSizing: this._fontOpticalSizing,
4032
4036
  };
4033
4037
  style.font = this.window.app.fonts.match(style.family, style);
4034
4038
  this._lastFontString = val;
@@ -4061,6 +4065,32 @@ class RenderingContext2d {
4061
4065
  return this._fontVariations ?? null;
4062
4066
  }
4063
4067
 
4068
+ /**
4069
+ * CSS's `font-optical-sizing`: `'auto'` (the default) sets a face's `opsz`
4070
+ * axis at the size the text is drawn at, `'none'` leaves it wherever the
4071
+ * font file's default is.
4072
+ *
4073
+ * `'auto'` is what the CSS initial value has always been and what a
4074
+ * reader expects — small text set in the family's Text cut, headlines in
4075
+ * its Display cut — so this is the escape hatch, not the switch that
4076
+ * turns the feature on. Reach for it when the size the canvas is drawing
4077
+ * at is not the size the text is *read* at (a canvas scaled up by a
4078
+ * transform, glyphs measured for something else), and pin the axis with
4079
+ * `fontVariationSettings = { opsz: … }` when the answer is a specific
4080
+ * optical size rather than the file's default.
4081
+ *
4082
+ * Order-independent, like `fontVariationSettings`: setting it re-resolves
4083
+ * the face already in force.
4084
+ */
4085
+ set fontOpticalSizing(val) {
4086
+ this._fontOpticalSizing = val === "none" ? "none" : "auto";
4087
+ if (this._textStyle) this.font = this._lastFontString;
4088
+ }
4089
+
4090
+ get fontOpticalSizing() {
4091
+ return this._fontOpticalSizing ?? "auto";
4092
+ }
4093
+
4064
4094
  /**
4065
4095
  * CSS's `text-rendering`: which glyph path this text takes, overriding the
4066
4096
  * size thresholds in `app.textPolicy`.
package/lib/shadow.js CHANGED
@@ -32,6 +32,19 @@
32
32
  // second pass leaves a surface with the blur already in its pixels, so the
33
33
  // cached copy composites as a plain mask rather than re-running a kernel on
34
34
  // every frame.
35
+ //
36
+ // ## What of this is public
37
+ //
38
+ // The bake and the maths around it — `blurCoverage`, `shadowSigma`,
39
+ // `shadowReach`, `gaussianKernel1d` and `DEFAULT_SHADOW_POLICY` — are
40
+ // re-exported from `lib/index.js` (issue #335). A toolkit that draws its own
41
+ // shapes (react-x11 paints a `<box>`'s `boxShadow` itself, because the
42
+ // rounded rect is a path it already has and the result goes through its own
43
+ // paint cache) needs exactly this and nothing else: the alternative on the
44
+ // public surface, `picture().setBlurFilter()`, sets a k×k filter that the
45
+ // server re-runs on *every* composite, which is invisible until someone
46
+ // profiles a real display. The surfaces and the cache below stay private —
47
+ // they are the 2d context's bookkeeping, not a primitive.
35
48
  import { Surface } from './surface.js';
36
49
 
37
50
  /**
@@ -117,8 +130,31 @@ export function gaussianKernel1d(sigma, reach = shadowReach(sigma)) {
117
130
  * and leaving it alive would double what the cache is holding. The output
118
131
  * carries no filter of its own, so compositing it is an ordinary masked
119
132
  * composite no matter how wide the blur was.
133
+ *
134
+ * Public, and the reason is that half of it: a *filter* is re-applied by the
135
+ * server on every composite, so a caller who blurs a picture with
136
+ * `setBlurFilter` and then caches it re-runs the kernel per frame — 244M
137
+ * multiply-accumulates for one 489×134 shadow at σ 10 (issue #335). This
138
+ * bakes instead. `shape` must be a coverage surface with the blur's reach as
139
+ * padding on all four sides, or the result ends in a straight line where the
140
+ * kernel ran out of pixels; see docs/surface.md.
120
141
  */
121
142
  export function blurCoverage(shape, sigma) {
143
+ if (!(sigma > 0) || !Number.isFinite(sigma)) {
144
+ throw new Error(
145
+ `blurCoverage: sigma must be a finite number above 0, got ${sigma}. ` +
146
+ 'A canvas blur radius is a diameter, not a sigma — pass ' +
147
+ 'shadowSigma(blur), which halves it and applies the policy cap. ' +
148
+ 'σ = 0 is no blur at all: composite the coverage as it is.'
149
+ );
150
+ }
151
+ if (shape.format !== 'a8') {
152
+ throw new Error(
153
+ `blurCoverage: needs a coverage surface, got format ${JSON.stringify(shape.format)}. ` +
154
+ "Draw the shape into new Surface(app, { width, height, format: 'a8' }) — " +
155
+ 'the blur runs on alpha, so an argb32 surface would lose its colour.'
156
+ );
157
+ }
122
158
  const app = shape.app;
123
159
  const R = app.display.Render;
124
160
  const { width, height } = shape;
@@ -20,26 +20,69 @@ function variationsKeyOf(variations) {
20
20
  .join(',');
21
21
  }
22
22
 
23
+ /**
24
+ * The optical size a style is set at, or `undefined` for "leave `opsz`
25
+ * alone" — see `instantiate()`.
26
+ *
27
+ * `opsz` is the one axis whose coordinate is not a design choice but a
28
+ * consequence of the size, so it defaults from `size` and needs an escape
29
+ * hatch for each way that default can be wrong:
30
+ *
31
+ * - `opticalSizing: 'none'` is CSS's `font-optical-sizing: none` — the face
32
+ * stays at its own default optical size;
33
+ * - `opticalSize` is the typographic size to use when it is not `size`.
34
+ * `size` on this path is CSS px, and a caller that has already multiplied
35
+ * by a device scale is holding device pixels: a 13px label on a 2×
36
+ * display arrives here as 26 and would pick a display cut. Such a caller
37
+ * passes the unscaled size as `opticalSize` and keeps the scaled one for
38
+ * the glyphs.
39
+ */
40
+ function opticalSizeFor(style) {
41
+ if (!style || style.opticalSizing === 'none') return undefined;
42
+ const size = style.opticalSize ?? style.size;
43
+ return typeof size === 'number' && Number.isFinite(size) ? size : undefined;
44
+ }
45
+
46
+ /** Cache-key fragment for the optical size, `''` when the axis is left alone. */
47
+ function opticalKeyOf(style) {
48
+ return opticalSizeFor(style) ?? '';
49
+ }
50
+
23
51
  /**
24
52
  * Put a resolved face at the point in its design space the style asked for.
25
53
  *
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.
54
+ * Two axes get a coordinate from a style that never names them, because CSS
55
+ * says both are consequences of properties the style does name:
56
+ *
57
+ * - `weight`. `font-weight: 460` on a variable font means the `wght` axis at
58
+ * 460, not "the nearest face", so an app that hands ntk a variable file
59
+ * gets the weight it asked for without knowing an axis exists.
60
+ * - `size`. `font-optical-sizing: auto` has been the initial value since
61
+ * optical sizing was specified: `opsz` tracks `font-size` unless the
62
+ * author says otherwise. Without this a face is drawn at whatever optical
63
+ * size its file happens to default to — on macOS the only San Francisco
64
+ * fontconfig can see is a variable file defaulting to `opsz` 28, a
65
+ * *display* cut, so every 13px menu label was set in display-sized
66
+ * letterforms. Clamping does the rest: 13 against SF's `17..96` lands on
67
+ * 17, which is exactly Apple's Text end.
68
+ *
69
+ * An explicit `variations.wght` / `variations.opsz` wins in both cases,
70
+ * because a caller naming the axis directly is being more specific than one
71
+ * naming a weight or a size — the same order CSS gives
72
+ * `font-variation-settings` over the properties it overlaps.
33
73
  *
34
74
  * Everything here is a no-op for a static face: `variation()` returns the
35
75
  * font unchanged when the settings do not apply, so this costs one property
36
76
  * read on the path every non-variable app is already on.
37
77
  */
38
- function instantiate(font, weight, variations) {
78
+ function instantiate(font, weight, variations, opticalSize) {
39
79
  const axes = font.variationAxes;
40
- if (!axes || (!axes.wght && !variations)) return font;
80
+ if (!axes || (!axes.wght && !axes.opsz && !variations)) return font;
41
81
  const settings = { ...variations };
42
82
  if (axes.wght && settings.wght === undefined) settings.wght = weight;
83
+ if (axes.opsz && settings.opsz === undefined && opticalSize !== undefined) {
84
+ settings.opsz = opticalSize;
85
+ }
43
86
  return font.variation(settings);
44
87
  }
45
88
 
@@ -151,19 +194,30 @@ export default class FontManager {
151
194
  * Resolve a family (or CSS-style comma-separated family list) to a Font.
152
195
  * Registered fonts are consulted first, then fontconfig.
153
196
  *
197
+ * What is cached here is the *face* the pattern resolved to — the
198
+ * expensive half, since it is an fc-match and a file parse — and not the
199
+ * instance `instantiate()` cuts out of it. Two things follow. The map
200
+ * stays one entry per family/weight/style however many points of an axis
201
+ * an app asks for, so a slider animating `wght`, or a tree of labels at a
202
+ * dozen sizes, walks a face's own bounded instance cache rather than
203
+ * evicting other families out of this one. And `size` can drive `opsz`
204
+ * without joining the key: the coordinate is applied per call, so the
205
+ * first size asked for does not become every later one's.
206
+ *
154
207
  * @param {string} family e.g. `'Ubuntu Mono', monospace`
155
- * @param {object} [opts] { weight: 400|'bold'|…, style: 'normal'|'italic' }
208
+ * @param {object} [opts] { weight: 400|'bold'|…, style: 'normal'|'italic',
209
+ * size, variations, opticalSize, opticalSizing: 'auto'|'none' }
156
210
  */
157
211
  match(family = 'sans-serif', opts = {}) {
158
212
  const weight = numWeight(opts.weight);
159
213
  const italic = !!(opts.style && opts.style.includes('italic'));
160
- const cacheKey = `${family}|${weight}|${italic}|${variationsKeyOf(opts.variations)}`;
161
- let font = this._matches.get(cacheKey);
162
- if (font) {
214
+ const cacheKey = `${family}|${weight}|${italic}`;
215
+ let face = this._matches.get(cacheKey);
216
+ if (face) {
163
217
  // insertion order is LRU order; re-inserting a hit moves it to the tail
164
218
  this._matches.delete(cacheKey);
165
- this._matches.set(cacheKey, font);
166
- return font;
219
+ this._matches.set(cacheKey, face);
220
+ return instantiate(face, weight, opts.variations, opticalSizeFor(opts));
167
221
  }
168
222
 
169
223
  const families = String(family)
@@ -171,26 +225,23 @@ export default class FontManager {
171
225
  .map((f) => f.trim().replace(/^["']|["']$/g, ''))
172
226
  .filter(Boolean);
173
227
 
174
- font = this._matchRegistered(
228
+ face = this._matchRegistered(
175
229
  families.map((f) => f.toLowerCase()),
176
230
  weight,
177
231
  italic
178
232
  );
179
- if (!font) {
233
+ if (!face) {
180
234
  // sources understand comma-separated family lists natively
181
235
  const candidates = this.source.matchSorted({
182
236
  family: families.join(','),
183
237
  weight,
184
238
  style: italic ? 'italic' : 'normal'
185
239
  });
186
- font = this._open(candidates[0]);
240
+ face = this._open(candidates[0]);
187
241
  }
188
- font = instantiate(font, weight, opts.variations);
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.
242
+ this._matches.set(cacheKey, face);
243
+ // A long-lived app can name a lot of families. Same sweep as the shaping
244
+ // memo: drop the stale half in one pass rather than one entry per insert.
194
245
  if (this._matches.size > MAX_MATCHES) {
195
246
  let drop = this._matches.size >> 1;
196
247
  for (const key of this._matches.keys()) {
@@ -198,7 +249,7 @@ export default class FontManager {
198
249
  this._matches.delete(key);
199
250
  }
200
251
  }
201
- return font;
252
+ return instantiate(face, weight, opts.variations, opticalSizeFor(opts));
202
253
  }
203
254
 
204
255
  /**
@@ -292,14 +343,14 @@ export default class FontManager {
292
343
  _shapeCached(text, style, levelsKey = '0') {
293
344
  const font = style.font;
294
345
  // 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.
346
+ // variations and optical-size fragments only earn their keep on the
347
+ // family path — where two points of one axis would otherwise share a
348
+ // shaped run, and the second would be drawn with the first's advances.
298
349
  const key = font
299
350
  ? `${font.key}|${style.size}|${style.weight}|${style.style}|${levelsKey}|${text}`
300
351
  : `${style.family}|${style.size}|${style.weight}|${style.style}|${variationsKeyOf(
301
352
  style.variations
302
- )}|${levelsKey}|${text}`;
353
+ )}|${opticalKeyOf(style)}|${levelsKey}|${text}`;
303
354
  let shaped = this._shapeCache.get(key);
304
355
  if (shaped) {
305
356
  // Map iterates in insertion order: re-inserting a hit moves it to the
@@ -67,6 +67,8 @@ 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
+ opticalSize: s.opticalSize ?? style.opticalSize,
71
+ opticalSizing: s.opticalSizing ?? style.opticalSizing,
70
72
  textRendering: s.textRendering ?? style.textRendering,
71
73
  features: s.features ?? style.features,
72
74
  language: s.language ?? style.language,
package/lib/text/shape.js CHANGED
@@ -69,7 +69,10 @@ export function normalizedLevels(levels, start, end) {
69
69
  export function shapeText(fonts, text, style, levels) {
70
70
  const size = style.size ?? 16;
71
71
  const family = style.family ?? 'sans-serif';
72
- const baseFont = style.font ?? fonts.match(family, style);
72
+ // `size` is what drives the `opsz` axis, so a style that leaves it out
73
+ // hands `match()` the size this actually sets at rather than nothing
74
+ const baseFont =
75
+ style.font ?? fonts.match(family, style.size === undefined ? { ...style, size } : style);
73
76
 
74
77
  let baseLevel = 0;
75
78
  if (!levels) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.4.1",
3
+ "version": "8.6.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",