ntk 7.7.0 → 8.1.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/shadow.js ADDED
@@ -0,0 +1,199 @@
1
+ // Drop shadows for the 2d context (issue #272): the blur, the surfaces it
2
+ // runs on, and the cache that keeps a redrawn shadow from being rebuilt.
3
+ //
4
+ // The drawing itself lives in renderingcontext_2d.js — everything here is
5
+ // the part with no X connection in it (the kernel maths) or the part every
6
+ // shadowed operation shares (the coverage surfaces and their budget).
7
+ //
8
+ // ## How a shadow is drawn
9
+ //
10
+ // A shadow is the drawing's *coverage*, blurred, offset, and painted in one
11
+ // colour. Coverage is what an `a8` Surface holds and what XRender composites
12
+ // a solid through, so the whole thing is three server-side steps:
13
+ //
14
+ // 1. draw the shape into a padded a8 surface — white on transparent, so
15
+ // every pixel is its own alpha
16
+ // 2. blur it: two convolution passes, horizontal then vertical
17
+ // 3. composite the result as a mask, with `shadowColor` as the source
18
+ //
19
+ // The padding in (1) is not optional. A convolution samples outside the
20
+ // picture, where RepeatNone reads transparent, so a shape drawn flush to the
21
+ // surface edge ends in a straight line where the kernel ran out of pixels.
22
+ // `reach` is how far coverage can spread, and every surface carries it on
23
+ // all four sides.
24
+ //
25
+ // ## Why two passes
26
+ //
27
+ // A gaussian is separable, and that is the difference between a shadow you
28
+ // can animate and one you cannot: a k-wide 2d kernel costs k² multiplies per
29
+ // pixel where two 1d passes cost 2k. At `shadowBlur: 30` that is 8281 against
30
+ // 182. RENDER has no separable-convolution filter, but it does not need one —
31
+ // two `convolution` filters and two composites are the same thing, and the
32
+ // second pass leaves a surface with the blur already in its pixels, so the
33
+ // cached copy composites as a plain mask rather than re-running a kernel on
34
+ // every frame.
35
+ import { Surface } from './surface.js';
36
+
37
+ /**
38
+ * Shadow policy — the cost ceilings, per app via `app.shadowPolicy`
39
+ * (partial objects are merged over the defaults). See docs/context-2d.md.
40
+ *
41
+ * - `cacheBytes` — LRU budget for retained shadow coverage per connection.
42
+ * Keyed by (text, font, blur) for text; least-recently-drawn surfaces are
43
+ * destroyed server-side once the total goes over.
44
+ * - `maxSigma` — the widest gaussian actually run. The kernel is 6σ+1 wide,
45
+ * the request carries every tap, and the server multiplies each of them
46
+ * per pixel per pass, so an unbounded `shadowBlur` is an unbounded
47
+ * request and an unbounded stall. Past this the blur stops widening.
48
+ * - `maxPixels` — the largest coverage surface built for one shadow. Beyond
49
+ * it the shadow is dropped rather than turning one drawing into a
50
+ * multi-megabyte allocation; the drawing itself is unaffected.
51
+ */
52
+ export const DEFAULT_SHADOW_POLICY = {
53
+ cacheBytes: 4 << 20,
54
+ maxSigma: 32,
55
+ maxPixels: 8 << 20
56
+ };
57
+
58
+ /** the policy for one app, merged over the defaults */
59
+ export function shadowPolicyOf(app) {
60
+ return app?.shadowPolicy
61
+ ? { ...DEFAULT_SHADOW_POLICY, ...app.shadowPolicy }
62
+ : DEFAULT_SHADOW_POLICY;
63
+ }
64
+
65
+ /**
66
+ * The gaussian a `shadowBlur` asks for.
67
+ *
68
+ * `shadowBlur` is a **diameter**, not a radius: the canvas spec says a
69
+ * shadow is blurred by a gaussian whose standard deviation is half of it.
70
+ * Getting this wrong is invisible until someone compares against a browser,
71
+ * so it is one line with a test on it — `shadowBlur: 8` must be σ = 4 here
72
+ * exactly as it is in Chrome and Firefox.
73
+ *
74
+ * Clamped to the policy's `maxSigma`, which is the only place a shadow
75
+ * silently stops matching a browser; the cap is chosen so that no shadow a
76
+ * UI actually draws reaches it.
77
+ */
78
+ export function shadowSigma(blur, policy = DEFAULT_SHADOW_POLICY) {
79
+ const sigma = blur / 2;
80
+ return sigma > policy.maxSigma ? policy.maxSigma : sigma;
81
+ }
82
+
83
+ /**
84
+ * How far the blur spreads coverage, in pixels — the kernel's half-width,
85
+ * and therefore the padding every coverage surface needs on each side.
86
+ *
87
+ * Truncating a gaussian at 3σ leaves 0.3% of its weight outside, which is
88
+ * below one step of the 8-bit coverage it is convolving.
89
+ */
90
+ export function shadowReach(sigma) {
91
+ return sigma > 0 ? Math.ceil(sigma * 3) : 0;
92
+ }
93
+
94
+ /**
95
+ * A normalized 1d gaussian, `2 * reach + 1` taps wide.
96
+ *
97
+ * Normalizing the *truncated* kernel rather than the ideal one is what keeps
98
+ * a flat interior at full coverage: a shadow under an opaque box must stay
99
+ * opaque in the middle, and a kernel summing to 0.997 would leave it at 254.
100
+ */
101
+ export function gaussianKernel1d(sigma, reach = shadowReach(sigma)) {
102
+ const values = new Array(reach * 2 + 1);
103
+ let sum = 0;
104
+ for (let i = -reach; i <= reach; i++) {
105
+ const v = Math.exp(-(i * i) / (2 * sigma * sigma));
106
+ values[i + reach] = v;
107
+ sum += v;
108
+ }
109
+ for (let i = 0; i < values.length; i++) values[i] /= sum;
110
+ return values;
111
+ }
112
+
113
+ /**
114
+ * Blur an a8 coverage surface, returning a new one holding the result.
115
+ *
116
+ * The input is destroyed: a caller has no use for the sharp copy afterwards,
117
+ * and leaving it alive would double what the cache is holding. The output
118
+ * carries no filter of its own, so compositing it is an ordinary masked
119
+ * composite no matter how wide the blur was.
120
+ */
121
+ export function blurCoverage(shape, sigma) {
122
+ const app = shape.app;
123
+ const R = app.display.Render;
124
+ const { width, height } = shape;
125
+ const kernel = gaussianKernel1d(sigma);
126
+ const scratch = new Surface(app, { width, height, format: 'a8' });
127
+ const out = new Surface(app, { width, height, format: 'a8' });
128
+
129
+ const pass = (src, dst, params) => {
130
+ src.picture().setFilter('convolution', params);
131
+ // Src, not Over: each pass replaces the destination, which was cleared
132
+ // to transparent on creation. Over would accumulate the sharp copy's
133
+ // coverage under the blurred one and give the shadow a hard core.
134
+ R.Composite(R.PictOp.Src, src.picture().id, 0, dst.picture().id, 0, 0, 0, 0, 0, 0, width, height);
135
+ };
136
+ pass(shape, scratch, [kernel.length, 1, ...kernel]);
137
+ pass(scratch, out, [1, kernel.length, ...kernel]);
138
+
139
+ shape.destroy();
140
+ scratch.destroy();
141
+ return out;
142
+ }
143
+
144
+ /**
145
+ * A shadow's coverage, from the cache when it has been seen before.
146
+ *
147
+ * `build()` returns the finished (blurred) surface, or null when there is
148
+ * nothing to draw. A null `key` means "not cacheable" — the geometry has no
149
+ * short name, as a path's does not — and the caller owns the surface it gets
150
+ * back. With a key, the cache owns it and the caller must not destroy it.
151
+ *
152
+ * Cached on the app rather than the context because contexts are short-lived
153
+ * (`Surface.render` builds one per call) while a shadow is a property of the
154
+ * drawing, which outlives them — the same reason `solidPicture` and the
155
+ * glyph pages live there.
156
+ */
157
+ export function cachedShadow(app, key, build) {
158
+ if (key === null) return build();
159
+ const cache = (app._shadowSurfaces ??= new Map());
160
+ const hit = cache.get(key);
161
+ if (hit) {
162
+ // Map iteration order is insertion order — re-insert to mark recent
163
+ cache.delete(key);
164
+ cache.set(key, hit);
165
+ return hit;
166
+ }
167
+ const surface = build();
168
+ if (!surface) return null;
169
+ cache.set(key, surface);
170
+ trimShadowSurfaces(app, shadowPolicyOf(app), surface);
171
+ return surface;
172
+ }
173
+
174
+ /**
175
+ * Evict least-recently-drawn shadow coverage until the cache fits its
176
+ * budget. `keep` is never evicted: it is the surface the caller is about to
177
+ * composite, and a budget smaller than one shadow must not free it mid-draw.
178
+ */
179
+ export function trimShadowSurfaces(app, policy = shadowPolicyOf(app), keep = null) {
180
+ const cache = app._shadowSurfaces;
181
+ if (!cache) return;
182
+ let total = 0;
183
+ for (const surface of cache.values()) total += surface.bytes;
184
+ for (const [key, surface] of cache) {
185
+ if (total <= policy.cacheBytes) break;
186
+ if (surface === keep) continue;
187
+ cache.delete(key);
188
+ total -= surface.bytes;
189
+ surface.destroy();
190
+ }
191
+ }
192
+
193
+ /** free every cached shadow surface — connection teardown */
194
+ export function dropShadowSurfaces(app) {
195
+ const cache = app._shadowSurfaces;
196
+ if (!cache) return;
197
+ for (const surface of cache.values()) surface.destroy();
198
+ cache.clear();
199
+ }
@@ -9,11 +9,22 @@
9
9
  // Full match list for a pattern, best first — the fallback chain.
10
10
  // `family` may be a CSS-style comma-separated list. Must return at
11
11
  // least one candidate or throw. A candidate is openable:
12
- // { key?, path?, data?, font?, postscriptName? }
12
+ // { key?, path?, data?, font?, postscriptName?, family?, families? }
13
13
  // - `font`: an already-open Font instance (preferred when available)
14
14
  // - `data`: font file bytes (Uint8Array/Buffer) for fontkit
15
15
  // - `path`: font file path (node only)
16
16
  // - `key`: stable cache key (defaults to `${path}#${postscriptName}`)
17
+ // - `family`: the face's family name, for showing a match list without
18
+ // opening every file in it; `families` its aliases, first one first
19
+ // (both optional, but both sources here fill them in)
20
+ //
21
+ // matchSortedAsync({ family, weight, style }) -> Promise<candidate[]>
22
+ // The same answer, awaited. Text layout is synchronous and always calls
23
+ // matchSorted; this is for an app asking a source directly — a font
24
+ // picker matching as the user types — where the fontconfig source's
25
+ // ~100ms spawn has no business blocking the event loop. A source with
26
+ // nothing to await implements it by resolving immediately, so a caller
27
+ // can be written once against either.
17
28
  //
18
29
  // covers(candidate, codepoint) -> boolean [optional]
19
30
  // Cheap coverage pre-filter used during per-codepoint fallback, ideally
@@ -35,7 +46,14 @@
35
46
  // — its whole purpose is running the text stack without a filesystem. See
36
47
  // builtin.js for how the lazy lookup stays version-tolerant.
37
48
  import { builtin } from '../builtin.js';
38
- import { charsetHas, matchSortedSync, noFontsError, prewarm, supported } from '../fontconfig.js';
49
+ import {
50
+ charsetHas,
51
+ matchSorted,
52
+ matchSortedSync,
53
+ noFontsError,
54
+ prewarm,
55
+ supported
56
+ } from '../fontconfig.js';
39
57
  import Font from './font.js';
40
58
 
41
59
  const WEIGHTS = { normal: 400, bold: 700 };
@@ -111,6 +129,16 @@ export class FontconfigFontSource {
111
129
  return matchSortedSync(pattern);
112
130
  }
113
131
 
132
+ /**
133
+ * Non-blocking sibling: the fc-match spawn runs off the event loop and
134
+ * seeds the same cache, so a later synchronous layout for the pattern is
135
+ * a cache hit. Rejects with the same ERR_NTK_NO_FONTS the sync path
136
+ * throws — the failure case must not be the one that blocks.
137
+ */
138
+ matchSortedAsync(pattern) {
139
+ return matchSorted(pattern);
140
+ }
141
+
114
142
  covers(candidate, codepoint) {
115
143
  return charsetHas(candidate, codepoint);
116
144
  }
@@ -237,12 +265,28 @@ export class StaticFontSource {
237
265
  scored.sort((a, b) => a.score - b.score);
238
266
  return scored.map(({ face }) => {
239
267
  if (!face.candidate) {
240
- face.candidate = { key: face.font.key, font: face.font };
268
+ // the font's own family name, not the (lowercased, possibly aliased)
269
+ // one it is matched by — this field is the one to show a reader
270
+ const name = face.font.familyName || '';
271
+ face.candidate = {
272
+ key: face.font.key,
273
+ font: face.font,
274
+ family: name,
275
+ families: name ? [name] : []
276
+ };
241
277
  }
242
278
  return face.candidate;
243
279
  });
244
280
  }
245
281
 
282
+ /**
283
+ * Nothing here is off-process, so matching is already instant — this
284
+ * exists so an app can await either source without asking which it has.
285
+ */
286
+ async matchSortedAsync(pattern = {}) {
287
+ return this.matchSorted(pattern);
288
+ }
289
+
246
290
  covers(candidate, codepoint) {
247
291
  return candidate.font.hasGlyph(codepoint);
248
292
  }
@@ -51,10 +51,10 @@ export class TextLayout {
51
51
  const maxWidth = options.maxWidth ?? Infinity;
52
52
 
53
53
  // ---- normalize spans, resolve fonts eagerly ----
54
- // unknown span fields ride along untouched (widgets attach markers like
55
- // MarkdownView's _deco/_href and read them back from line runs)
56
- // An empty span list is a legitimate thing to lay out — MarkdownView
57
- // reaches it for a blank paragraph while a document is being typed —
54
+ // unknown span fields ride along untouched, so a caller can attach its
55
+ // own markers to a span and read them back off the line runs
56
+ // An empty span list is a legitimate thing to lay out — a document view
57
+ // reaches it for a blank paragraph while one is being typed —
58
58
  // and every line still needs a style to take its metrics from, so
59
59
  // stand one in rather than crashing on the first empty line.
60
60
  const given = typeof content === 'string' ? [{ text: content }] : content;
@@ -9,7 +9,7 @@
9
9
  // view.setSvg('<svg viewBox="0 0 24 24">…</svg>');
10
10
  // wnd.map();
11
11
  //
12
- // Standalone (windowless) use mirrors HtmlView/MarkdownView:
12
+ // Standalone (windowless) use, which is how a document renderer drives it:
13
13
  // const view = new SvgView(null);
14
14
  // view.setSvg(svgText);
15
15
  // view.draw(ctx, x, y, width, height);
@@ -22,7 +22,7 @@
22
22
  import { textContent } from 'domutils';
23
23
  import { parseDocument } from 'htmlparser2';
24
24
 
25
- import { Path2D, flattenPath, matApply } from '../path.js';
25
+ import { Path2D, flattenPath } from '../path.js';
26
26
 
27
27
  const INHERITED = {
28
28
  fill: '#000',
@@ -59,7 +59,7 @@ const STYLE_ATTRS = {
59
59
  const NUMERIC = new Set(['strokeWidth', 'miterLimit', 'fillOpacity', 'strokeOpacity', 'fontSize']);
60
60
 
61
61
  // documents may come from an XML parse (setSvg: exact case) or from an HTML
62
- // parse (HtmlView inline <svg>: tag/attribute names lowercased) — compare
62
+ // parse (inline <svg>: tag/attribute names lowercased) — compare
63
63
  // names lowercased and look attributes up by their lowercase form too
64
64
  const tag = (node) => (node.name || '').toLowerCase();
65
65
 
@@ -342,8 +342,9 @@ export default class SvgView {
342
342
  }
343
343
 
344
344
  /**
345
- * Adopt an already-parsed `<svg>` element (htmlparser2 DOM node) — used
346
- * by HtmlView for inline SVG. HTML-mode parses (lowercased tag/attribute
345
+ * Adopt an already-parsed `<svg>` element (htmlparser2 DOM node) — how a
346
+ * host document hands over its inline SVG, and how react-x11's `<svg>`
347
+ * element passes JSX children. HTML-mode parses (lowercased tag/attribute
347
348
  * names) are handled.
348
349
  */
349
350
  setSvgDom(element) {
@@ -485,16 +486,15 @@ export default class SvgView {
485
486
  if (!bbox) return v;
486
487
  return axis === 'x' ? bbox.x + v * bbox.w : bbox.y + v * bbox.h;
487
488
  };
488
- // ntk gradients live in device space: map user-space endpoints through
489
- // the current transform
490
- const m = ctx.getTransform();
491
- const mat = [m.a, m.b, m.c, m.d, m.e, m.f];
492
- const dev = (px, py) => matApply(mat, px, py);
493
-
489
+ // gradient coordinates are user space, like the path's own — the
490
+ // context resolves them against the transform in force when it paints,
491
+ // which is this one (issue #271)
494
492
  let gradient;
495
493
  if (tag(node) === 'lineargradient') {
496
- const [x1, y1] = dev(coord(a.x1, 0, 'x'), coord(a.y1, 0, 'y'));
497
- const [x2, y2] = dev(coord(a.x2, 1, 'x'), coord(a.y2, 0, 'y'));
494
+ const x1 = coord(a.x1, 0, 'x');
495
+ const y1 = coord(a.y1, 0, 'y');
496
+ const x2 = coord(a.x2, 1, 'x');
497
+ const y2 = coord(a.y2, 0, 'y');
498
498
  gradient = ctx.createLinearGradient(x1, y1, x2, y2);
499
499
  } else {
500
500
  const cx = coord(a.cx, 0.5, 'x');
@@ -502,9 +502,7 @@ export default class SvgView {
502
502
  let r = a.r === undefined ? 0.5 : parseFloat(a.r);
503
503
  if (String(a.r ?? '').endsWith('%')) r /= 100;
504
504
  if (bbox) r *= (bbox.w + bbox.h) / 2;
505
- const [dcx, dcy] = dev(cx, cy);
506
- const scale = Math.sqrt(Math.abs(mat[0] * mat[3] - mat[1] * mat[2])) || 1;
507
- gradient = ctx.createRadialGradient(dcx, dcy, 0, dcx, dcy, r * scale);
505
+ gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
508
506
  }
509
507
 
510
508
  for (const stop of node.children || []) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "7.7.0",
3
+ "version": "8.1.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",
@@ -36,21 +36,15 @@
36
36
  "dependencies": {
37
37
  "bidi-js": "^1.0.3",
38
38
  "canvas-fontstyle": "^1.0.1",
39
- "css-select": "^7.0.0",
40
39
  "domutils": "^4.0.2",
41
40
  "extrude-polyline": "^1.0.6",
42
41
  "fontkit": "^2.0.4",
43
- "highlight.js": "^11.11.1",
44
42
  "htmlparser2": "^12.0.0",
45
43
  "jpeg-js": "^0.4.4",
46
- "katex": "^0.18.1",
47
44
  "linebreak": "^1.1.0",
48
- "marked": "^18.0.7",
49
45
  "parse-color": "^1.0.0",
50
46
  "pngjs": "^7.0.0",
51
- "postcss": "^8.5.23",
52
- "x11": "^3.9.0",
53
- "yoga-layout": "^3.2.1"
47
+ "x11": "^3.9.0"
54
48
  },
55
49
  "optionalDependencies": {
56
50
  "x11-dri": ">=0.2.0 <1"
@@ -60,6 +54,7 @@
60
54
  "check-release-message": "node scripts/check-release-message.mjs"
61
55
  },
62
56
  "devDependencies": {
63
- "@conventional-commits/parser": "^0.4.1"
57
+ "@conventional-commits/parser": "^0.4.1",
58
+ "katex": "^0.18.1"
64
59
  }
65
60
  }