ntk 8.0.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/app.js +4 -0
- package/lib/fontconfig.js +185 -61
- package/lib/renderingcontext_2d.js +583 -19
- package/lib/shadow.js +199 -0
- package/lib/text/fontsource.js +47 -3
- package/lib/widgets/svgview.js +9 -12
- package/package.json +1 -1
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
|
+
}
|
package/lib/text/fontsource.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
}
|
package/lib/widgets/svgview.js
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
import { textContent } from 'domutils';
|
|
23
23
|
import { parseDocument } from 'htmlparser2';
|
|
24
24
|
|
|
25
|
-
import { Path2D, flattenPath
|
|
25
|
+
import { Path2D, flattenPath } from '../path.js';
|
|
26
26
|
|
|
27
27
|
const INHERITED = {
|
|
28
28
|
fill: '#000',
|
|
@@ -486,16 +486,15 @@ export default class SvgView {
|
|
|
486
486
|
if (!bbox) return v;
|
|
487
487
|
return axis === 'x' ? bbox.x + v * bbox.w : bbox.y + v * bbox.h;
|
|
488
488
|
};
|
|
489
|
-
//
|
|
490
|
-
// the
|
|
491
|
-
|
|
492
|
-
const mat = [m.a, m.b, m.c, m.d, m.e, m.f];
|
|
493
|
-
const dev = (px, py) => matApply(mat, px, py);
|
|
494
|
-
|
|
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)
|
|
495
492
|
let gradient;
|
|
496
493
|
if (tag(node) === 'lineargradient') {
|
|
497
|
-
const
|
|
498
|
-
const
|
|
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');
|
|
499
498
|
gradient = ctx.createLinearGradient(x1, y1, x2, y2);
|
|
500
499
|
} else {
|
|
501
500
|
const cx = coord(a.cx, 0.5, 'x');
|
|
@@ -503,9 +502,7 @@ export default class SvgView {
|
|
|
503
502
|
let r = a.r === undefined ? 0.5 : parseFloat(a.r);
|
|
504
503
|
if (String(a.r ?? '').endsWith('%')) r /= 100;
|
|
505
504
|
if (bbox) r *= (bbox.w + bbox.h) / 2;
|
|
506
|
-
|
|
507
|
-
const scale = Math.sqrt(Math.abs(mat[0] * mat[3] - mat[1] * mat[2])) || 1;
|
|
508
|
-
gradient = ctx.createRadialGradient(dcx, dcy, 0, dcx, dcy, r * scale);
|
|
505
|
+
gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
|
|
509
506
|
}
|
|
510
507
|
|
|
511
508
|
for (const stop of node.children || []) {
|