ntk 8.4.1 → 8.5.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/renderingcontext_2d.js +30 -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
|
@@ -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/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) {
|