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 +17 -0
- package/lib/picture.js +25 -0
- package/lib/renderingcontext_2d.js +30 -0
- package/lib/shadow.js +36 -0
- package/lib/text/fontmanager.js +80 -29
- package/lib/text/layout.js +2 -0
- package/lib/text/shape.js +4 -1
- package/package.json +1 -1
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;
|
package/lib/text/fontmanager.js
CHANGED
|
@@ -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
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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}
|
|
161
|
-
let
|
|
162
|
-
if (
|
|
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,
|
|
166
|
-
return
|
|
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
|
-
|
|
228
|
+
face = this._matchRegistered(
|
|
175
229
|
families.map((f) => f.toLowerCase()),
|
|
176
230
|
weight,
|
|
177
231
|
italic
|
|
178
232
|
);
|
|
179
|
-
if (!
|
|
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
|
-
|
|
240
|
+
face = this._open(candidates[0]);
|
|
187
241
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
//
|
|
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
|
|
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
|
|
296
|
-
// points of one axis would otherwise share a
|
|
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
|
package/lib/text/layout.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|