ntk 8.8.1 → 8.9.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 +21 -1
- package/lib/fontconfig.js +60 -1
- package/lib/gl.js +72 -0
- package/lib/glx.js +9 -5
- package/lib/renderingcontext_cgl.js +14 -1
- package/lib/renderingcontext_gles.js +15 -1
- package/lib/text/fontmanager.js +29 -7
- package/lib/text/layout.js +6 -2
- package/lib/text/shape.js +64 -2
- package/package.json +1 -1
package/lib/app.js
CHANGED
|
@@ -5,12 +5,15 @@ import {
|
|
|
5
5
|
DIRECT_SAMPLES,
|
|
6
6
|
GLError,
|
|
7
7
|
backendFor,
|
|
8
|
+
directStencilSize,
|
|
8
9
|
glCapabilities,
|
|
9
10
|
glError,
|
|
10
11
|
nativeRefreshRate,
|
|
11
12
|
requestedSamples,
|
|
13
|
+
requestedStencil,
|
|
12
14
|
resolveGLPolicy,
|
|
13
|
-
warnDirectSamples
|
|
15
|
+
warnDirectSamples,
|
|
16
|
+
warnDirectStencil
|
|
14
17
|
} from './gl.js';
|
|
15
18
|
import { chooseGLXConfig } from './glx.js';
|
|
16
19
|
import Picture from './picture.js';
|
|
@@ -637,6 +640,14 @@ export default class App {
|
|
|
637
640
|
* request that cannot be met also warns once per connection, because an
|
|
638
641
|
* aliased edge is otherwise found months later in a screenshot.
|
|
639
642
|
*
|
|
643
|
+
* `stencilSize` is answered the same way, and matters more: a stencil test
|
|
644
|
+
* against a framebuffer with no stencil buffer passes everywhere, so
|
|
645
|
+
* stencil-based drawing renders wrong instead of failing. `STENCIL_SIZE`
|
|
646
|
+
* becomes the CGL context's stencil size on macOS (8 when the spec names
|
|
647
|
+
* none, as the Cocoa backend's surfaces have); the dri3 flavor has nothing
|
|
648
|
+
* to ask EGL with, so it answers 0 and a request for more warns once
|
|
649
|
+
* (`directStencilSize` in lib/gl.js).
|
|
650
|
+
*
|
|
640
651
|
* @param {object} [spec] GLX attributes, e.g. `{ DEPTH_SIZE: 24 }`
|
|
641
652
|
*/
|
|
642
653
|
async chooseGLConfig(spec = {}) {
|
|
@@ -659,6 +670,12 @@ export default class App {
|
|
|
659
670
|
// answer honestly below rather than dropping the attribute (issue #341)
|
|
660
671
|
const wantSamples = requestedSamples(spec);
|
|
661
672
|
if (wantSamples > DIRECT_SAMPLES) warnDirectSamples(this, caps.flavor, wantSamples);
|
|
673
|
+
// the same for stencil, where it matters more: a flavor that cannot ask
|
|
674
|
+
// for a stencil buffer answers 0, because a stencil test against no
|
|
675
|
+
// buffer passes everywhere and the draw code would never find out
|
|
676
|
+
const wantStencil = requestedStencil(spec);
|
|
677
|
+
const stencilSize = directStencilSize(caps.flavor, wantStencil);
|
|
678
|
+
if (wantStencil > stencilSize) warnDirectStencil(this, caps.flavor, wantStencil);
|
|
662
679
|
|
|
663
680
|
return {
|
|
664
681
|
backend: 'direct',
|
|
@@ -671,6 +688,9 @@ export default class App {
|
|
|
671
688
|
class: 4, // TrueColor; the only class these buffers can be read as
|
|
672
689
|
doubleBuffer: true, // a swap chain, always
|
|
673
690
|
depthSize: spec.DEPTH_SIZE ?? 16,
|
|
691
|
+
// what the context will ask for (8 on appledri unless the spec says),
|
|
692
|
+
// carried here so the spec reaches getContext with the rest of it
|
|
693
|
+
stencilSize,
|
|
674
694
|
// no flavor can allocate a multisampled window buffer yet; said out
|
|
675
695
|
// loud so a caller can supersample instead of assuming MSAA
|
|
676
696
|
samples: DIRECT_SAMPLES,
|
package/lib/fontconfig.js
CHANGED
|
@@ -133,8 +133,67 @@ function fcMatchError(err) {
|
|
|
133
133
|
return err;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/**
|
|
137
|
+
* macOS's own face for a CSS generic family that fontconfig answers there
|
|
138
|
+
* with the wrong script's face, keyed by the generic.
|
|
139
|
+
*
|
|
140
|
+
* Only `sans-serif` is here, because it is the only generic fontconfig
|
|
141
|
+
* resolves to a CJK face on a Mac (see `platformFamilies`). `serif` and
|
|
142
|
+
* `monospace` land on PT Serif and Andale Mono, both of which set Latin,
|
|
143
|
+
* Cyrillic and Greek at their own widths.
|
|
144
|
+
*/
|
|
145
|
+
const DARWIN_GENERICS = new Map([['sans-serif', 'Helvetica']]);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A family list with macOS's own face for a generic named ahead of it — on
|
|
149
|
+
* macOS only, and only for the generics in `DARWIN_GENERICS`. Everywhere
|
|
150
|
+
* else the list goes to fc-match exactly as it came.
|
|
151
|
+
*
|
|
152
|
+
* Homebrew's fontconfig (2.18) ships 48-guessfamily.conf, which tags a query
|
|
153
|
+
* naming a generic with that generic, and under it a `sans-serif` query
|
|
154
|
+
* ranks every face with "Sans" in its name ahead of all of 60-latin.conf's
|
|
155
|
+
* preferences — Verdana, Arial and Helvetica come in past the hundredth
|
|
156
|
+
* candidate. So `fc-match sans-serif` answers with whichever "…Sans…" face
|
|
157
|
+
* ranks first, and on a Mac that is Hiragino Sans: a Japanese face whose
|
|
158
|
+
* Cyrillic, Greek, `…` and `—` are full-width. "Мост" set in it is four
|
|
159
|
+
* em-wide cells, and CoreText sets it the same way — the advances are the
|
|
160
|
+
* font's own, so what is wrong is the face, not the shaping. Without that
|
|
161
|
+
* one file (XQuartz's fontconfig ships without it) the same query answers
|
|
162
|
+
* Verdana.
|
|
163
|
+
*
|
|
164
|
+
* Browsers never ask fontconfig on macOS, and all of them give `sans-serif`
|
|
165
|
+
* Helvetica. Naming it first does the same here, and the generic stays
|
|
166
|
+
* after it, so a codepoint Helvetica lacks still falls back through
|
|
167
|
+
* fontconfig's sans-serif list — CJK to Hiragino, as before.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} family CSS-style family list, as FontManager hands it over
|
|
170
|
+
* @param {string} [platform] `process.platform`; a parameter so that a test
|
|
171
|
+
* can ask for either answer on any machine
|
|
172
|
+
* @returns {string} the family list to hand fc-match
|
|
173
|
+
*/
|
|
174
|
+
export function platformFamilies(family, platform = globalThis.process?.platform) {
|
|
175
|
+
if (platform !== 'darwin') return family;
|
|
176
|
+
const names = String(family)
|
|
177
|
+
.split(',')
|
|
178
|
+
.map((name) => name.trim())
|
|
179
|
+
.filter(Boolean);
|
|
180
|
+
const lower = names.map((name) => name.replace(/^["']|["']$/g, '').toLowerCase());
|
|
181
|
+
const out = [];
|
|
182
|
+
let changed = false;
|
|
183
|
+
for (let i = 0; i < names.length; i++) {
|
|
184
|
+
const face = DARWIN_GENERICS.get(lower[i]);
|
|
185
|
+
// a list that already names the face ahead of the generic needs nothing
|
|
186
|
+
if (face && !lower.slice(0, i).includes(face.toLowerCase())) {
|
|
187
|
+
out.push(face);
|
|
188
|
+
changed = true;
|
|
189
|
+
}
|
|
190
|
+
out.push(names[i]);
|
|
191
|
+
}
|
|
192
|
+
return changed ? out.join(',') : family;
|
|
193
|
+
}
|
|
194
|
+
|
|
136
195
|
function patternFor({ family, weight, style }) {
|
|
137
|
-
let fc = family || 'sans-serif';
|
|
196
|
+
let fc = platformFamilies(family || 'sans-serif');
|
|
138
197
|
const fcWeight = normalizeWeight(weight);
|
|
139
198
|
if (fcWeight !== undefined) fc += `:weight=${fcWeight}`;
|
|
140
199
|
if (style && style.includes('italic')) fc += ':slant=italic';
|
package/lib/gl.js
CHANGED
|
@@ -535,6 +535,78 @@ export function warnDirectSamples(app, flavor, wanted) {
|
|
|
535
535
|
);
|
|
536
536
|
}
|
|
537
537
|
|
|
538
|
+
/**
|
|
539
|
+
* The stencil bits a CGL context asks for when the config names no size:
|
|
540
|
+
* eight, what the Cocoa backend's GL surfaces carry (DEPTH24_STENCIL8), so
|
|
541
|
+
* a window on the `appledri` flavor has the same buffers whichever of the
|
|
542
|
+
* two macOS paths draws it. A stencil buffer costs next to nothing, and the
|
|
543
|
+
* failure without one is silent: a stencil test with no buffer behind it
|
|
544
|
+
* passes everywhere, so a stencil-then-cover fill does not fail — it fills
|
|
545
|
+
* its whole cover quad.
|
|
546
|
+
*/
|
|
547
|
+
export const DEFAULT_STENCIL_SIZE = 8;
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* The stencil size a spec or config asks for, read the way the depth size
|
|
551
|
+
* is: `stencilSize` (a config `chooseGLConfig` answered) before
|
|
552
|
+
* `STENCIL_SIZE` (GLX's vocabulary). `undefined` when it names neither,
|
|
553
|
+
* which means "the flavor's default" rather than 0 — an explicit 0 asks for
|
|
554
|
+
* no stencil buffer and is honoured as such. `null` is GLX's "don't care"
|
|
555
|
+
* and reads as unnamed too.
|
|
556
|
+
*/
|
|
557
|
+
export function requestedStencil(spec = {}) {
|
|
558
|
+
const bits = spec.stencilSize ?? spec.STENCIL_SIZE;
|
|
559
|
+
if (bits === undefined || bits === null) return undefined;
|
|
560
|
+
return Number(bits) || 0;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* The stencil bits a direct-backend window gets for a request, per flavor —
|
|
565
|
+
* the one place that decides it, as `DIRECT_SAMPLES` is for samples.
|
|
566
|
+
*
|
|
567
|
+
* - `appledri`: what was asked, `DEFAULT_STENCIL_SIZE` when nothing was. It
|
|
568
|
+
* travels to `kCGLPFAStencilSize` on the CGL pixel format, which CGL
|
|
569
|
+
* treats as a minimum.
|
|
570
|
+
* - `dri3`: 0, whatever was asked. x11-dri's `Gpu` builds its EGLConfig from
|
|
571
|
+
* the colour format and a depth size and takes no stencil size, so none is
|
|
572
|
+
* asked for — and EGL sorts configs smallest stencil first, so assume
|
|
573
|
+
* none. There is nothing here to ask with; what can be done is say so
|
|
574
|
+
* (`warnDirectStencil`). When the addon grows the option, this becomes the
|
|
575
|
+
* request.
|
|
576
|
+
*/
|
|
577
|
+
export function directStencilSize(flavor, wanted) {
|
|
578
|
+
if (flavor === 'appledri') return wanted ?? DEFAULT_STENCIL_SIZE;
|
|
579
|
+
return 0;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Say — once per connection, on the console — that a stencil request cannot
|
|
584
|
+
* be met on this flavor.
|
|
585
|
+
*
|
|
586
|
+
* Unlike a missing sample buffer this is not a downgrade that merely looks
|
|
587
|
+
* worse: a stencil test against a framebuffer with no stencil buffer passes
|
|
588
|
+
* everywhere, so draw code built on it renders wrong instead of failing, and
|
|
589
|
+
* this warning and the `stencilSize` field are the only signals it gets.
|
|
590
|
+
*/
|
|
591
|
+
export function warnDirectStencil(app, flavor, wanted) {
|
|
592
|
+
if (app._warnedDirectStencil) return;
|
|
593
|
+
app._warnedDirectStencil = true;
|
|
594
|
+
console.warn(
|
|
595
|
+
`ntk: this GL request asks for STENCIL_SIZE=${wanted}, and the direct backend` +
|
|
596
|
+
`${flavor ? ` (${flavor} flavor)` : ''} cannot give its window a stencil buffer, so it has ` +
|
|
597
|
+
'stencilSize: 0 — and a stencil test with no buffer behind it passes everywhere.\n' +
|
|
598
|
+
'\n' +
|
|
599
|
+
' The stencil size belongs to the EGLConfig x11-dri builds one layer below\n' +
|
|
600
|
+
' ntk, and its Gpu takes no such option yet — there is nothing here to ask with.\n' +
|
|
601
|
+
'\n' +
|
|
602
|
+
' Branch on config.stencilSize (or gl.stencilSize) rather than on having asked.\n' +
|
|
603
|
+
' For a stencil buffer today: draw into a framebuffer of your own with a\n' +
|
|
604
|
+
' DEPTH24_STENCIL8 renderbuffer attached, or use indirect GLX, where\n' +
|
|
605
|
+
" STENCIL_SIZE picks an fbconfig that has one (glPolicy: 'indirect').\n" +
|
|
606
|
+
' See docs/context-gles.md#stencil.'
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
|
|
538
610
|
/**
|
|
539
611
|
* The backend `getContext('opengl')` should use right now, synchronously.
|
|
540
612
|
*
|
package/lib/glx.js
CHANGED
|
@@ -227,11 +227,12 @@ function matchLegacy(configs, spec) {
|
|
|
227
227
|
* `null` means "don't care". `screen` picks the X screen (default 0),
|
|
228
228
|
* `visual` pins a specific visual id and skips the search.
|
|
229
229
|
* @returns {Promise<{visual: number, depth: number, class: number,
|
|
230
|
-
* doubleBuffer: boolean, depthSize: number,
|
|
231
|
-
* fbconfig: number|null, config: object}>}
|
|
232
|
-
* samples per pixel the chosen config has — 0 for
|
|
233
|
-
*
|
|
234
|
-
* at to
|
|
230
|
+
* doubleBuffer: boolean, depthSize: number, stencilSize: number|null,
|
|
231
|
+
* samples: number|null, fbconfig: number|null, config: object}>}
|
|
232
|
+
* `samples` is the colour samples per pixel the chosen config has — 0 for
|
|
233
|
+
* no multisampling — and `stencilSize` its stencil bits; both are `null`
|
|
234
|
+
* only in the pinned-`visual` case, where no fbconfig was looked at to
|
|
235
|
+
* know.
|
|
235
236
|
*/
|
|
236
237
|
export async function chooseGLXConfig(app, spec = {}) {
|
|
237
238
|
const GLX = app.display.GLX;
|
|
@@ -268,6 +269,7 @@ export async function chooseGLXConfig(app, spec = {}) {
|
|
|
268
269
|
depthSize: toNumber(want.DEPTH_SIZE) || 0,
|
|
269
270
|
// the caller pinned the visual, so nothing was chosen and no fbconfig
|
|
270
271
|
// was read: unknown, which is not the same claim as "none"
|
|
272
|
+
stencilSize: null,
|
|
271
273
|
samples: null,
|
|
272
274
|
screen: screenNum,
|
|
273
275
|
fbconfig: null,
|
|
@@ -286,6 +288,7 @@ export async function chooseGLXConfig(app, spec = {}) {
|
|
|
286
288
|
class: info.visual.class,
|
|
287
289
|
doubleBuffer: !!cfg.DOUBLEBUFFER,
|
|
288
290
|
depthSize: cfg.DEPTH_SIZE || 0,
|
|
291
|
+
stencilSize: cfg.STENCIL_SIZE || 0,
|
|
289
292
|
samples: cfg.SAMPLES || 0,
|
|
290
293
|
screen: screenNum,
|
|
291
294
|
fbconfig: cfg.FBCONFIG_ID,
|
|
@@ -307,6 +310,7 @@ export async function chooseGLXConfig(app, spec = {}) {
|
|
|
307
310
|
class: info.visual.class,
|
|
308
311
|
doubleBuffer: !!cfg.doubleBufferMode,
|
|
309
312
|
depthSize: cfg.depthBits || 0,
|
|
313
|
+
stencilSize: cfg.stencilBits || 0,
|
|
310
314
|
samples: cfg.SAMPLES || 0,
|
|
311
315
|
screen: screenNum,
|
|
312
316
|
fbconfig: null,
|
|
@@ -47,9 +47,11 @@ import {
|
|
|
47
47
|
DIRECT_SAMPLES,
|
|
48
48
|
GLError,
|
|
49
49
|
backendFor,
|
|
50
|
+
directStencilSize,
|
|
50
51
|
glError,
|
|
51
52
|
loadDriAddon,
|
|
52
53
|
requestedSamples,
|
|
54
|
+
requestedStencil,
|
|
53
55
|
warnDirectSamples
|
|
54
56
|
} from './gl.js';
|
|
55
57
|
|
|
@@ -134,6 +136,13 @@ does not make it retroactive. Either:
|
|
|
134
136
|
const wantSamples = Math.max(requestedSamples(config), Number(config.samples) || 0);
|
|
135
137
|
if (wantSamples > DIRECT_SAMPLES) warnDirectSamples(app, 'appledri', wantSamples);
|
|
136
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Stencil bits the context asked CGL for: the config's `stencilSize` /
|
|
141
|
+
* `STENCIL_SIZE`, and 8 when it names none (`directStencilSize`). CGL
|
|
142
|
+
* takes the size as a minimum, so the buffer has at least this many.
|
|
143
|
+
*/
|
|
144
|
+
this.stencilSize = directStencilSize('appledri', requestedStencil(config));
|
|
145
|
+
|
|
137
146
|
this._screen = config.screen ?? 0;
|
|
138
147
|
this._clientId = caps.appleClientId ?? dri.apple.clientId();
|
|
139
148
|
|
|
@@ -145,7 +154,11 @@ does not make it retroactive. Either:
|
|
|
145
154
|
// covered (docs/context-gles.md#macos).
|
|
146
155
|
try {
|
|
147
156
|
this.ctx = new dri.apple.Context({
|
|
148
|
-
depthSize: config.depthSize ?? config.DEPTH_SIZE ?? 16
|
|
157
|
+
depthSize: config.depthSize ?? config.DEPTH_SIZE ?? 16,
|
|
158
|
+
// Without this the pixel format has no stencil buffer at all, and GL
|
|
159
|
+
// raises no error over it: a stencil test against a missing buffer
|
|
160
|
+
// passes everywhere, so a stencil-then-cover fill covers its whole quad.
|
|
161
|
+
stencilSize: this.stencilSize
|
|
149
162
|
});
|
|
150
163
|
} catch (err) {
|
|
151
164
|
throw glError(
|
|
@@ -21,10 +21,13 @@ import {
|
|
|
21
21
|
DIRECT_SAMPLES,
|
|
22
22
|
GLError,
|
|
23
23
|
backendFor,
|
|
24
|
+
directStencilSize,
|
|
24
25
|
glError,
|
|
25
26
|
loadDriAddon,
|
|
26
27
|
requestedSamples,
|
|
27
|
-
|
|
28
|
+
requestedStencil,
|
|
29
|
+
warnDirectSamples,
|
|
30
|
+
warnDirectStencil
|
|
28
31
|
} from './gl.js';
|
|
29
32
|
import { GLSwapchain } from './glswapchain.js';
|
|
30
33
|
|
|
@@ -116,6 +119,17 @@ does not make it retroactive. Either:
|
|
|
116
119
|
const wantSamples = Math.max(requestedSamples(config), Number(config.samples) || 0);
|
|
117
120
|
if (wantSamples > DIRECT_SAMPLES) warnDirectSamples(app, caps.flavor, wantSamples);
|
|
118
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Stencil bits the window's buffers have: 0 on this flavor, whatever the
|
|
124
|
+
* config asks for, because x11-dri's `Gpu` takes no stencil size to pass
|
|
125
|
+
* on (`directStencilSize`). A request for one is answered the way a
|
|
126
|
+
* sample count is — once, out loud — because a stencil test with no
|
|
127
|
+
* buffer behind it passes everywhere rather than failing.
|
|
128
|
+
*/
|
|
129
|
+
const wantStencil = requestedStencil(config);
|
|
130
|
+
this.stencilSize = directStencilSize('dri3', wantStencil);
|
|
131
|
+
if (wantStencil > this.stencilSize) warnDirectStencil(app, 'dri3', wantStencil);
|
|
132
|
+
|
|
119
133
|
const policy = app.glPolicy;
|
|
120
134
|
this.gpu = sharedGpu(app, dri, {
|
|
121
135
|
format: depth === 32 ? dri.FORMAT.ARGB8888 : dri.FORMAT.XRGB8888,
|
package/lib/text/fontmanager.js
CHANGED
|
@@ -43,6 +43,26 @@ function opticalSizeFor(style) {
|
|
|
43
43
|
return typeof size === 'number' && Number.isFinite(size) ? size : undefined;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Cache-key fragment for what shapes a run beyond its face and size: the
|
|
48
|
+
* OpenType features (either form fontkit takes — an array of tags, or an
|
|
49
|
+
* object of tag → on/off), the language and the letter spacing. `''` for a
|
|
50
|
+
* style with none of them, which is nearly every style.
|
|
51
|
+
*/
|
|
52
|
+
function shapingKeyOf(style) {
|
|
53
|
+
const { features, language, letterSpacing } = style;
|
|
54
|
+
if (!features && !language && !letterSpacing) return '';
|
|
55
|
+
let feats = '';
|
|
56
|
+
if (Array.isArray(features)) feats = [...features].sort().join(',');
|
|
57
|
+
else if (features) {
|
|
58
|
+
feats = Object.keys(features)
|
|
59
|
+
.sort()
|
|
60
|
+
.map((tag) => `${tag}=${features[tag] ? 1 : 0}`)
|
|
61
|
+
.join(',');
|
|
62
|
+
}
|
|
63
|
+
return `${feats}|${language ?? ''}|${letterSpacing || 0}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
46
66
|
/** Cache-key fragment for the optical size, `''` when the axis is left alone. */
|
|
47
67
|
function opticalKeyOf(style) {
|
|
48
68
|
return opticalSizeFor(style) ?? '';
|
|
@@ -346,11 +366,16 @@ export default class FontManager {
|
|
|
346
366
|
// variations and optical-size fragments only earn their keep on the
|
|
347
367
|
// family path — where two points of one axis would otherwise share a
|
|
348
368
|
// shaped run, and the second would be drawn with the first's advances.
|
|
369
|
+
// Features, language and spacing change the glyphs a word shapes to, so
|
|
370
|
+
// they are part of the key on both paths: without them a word shaped once
|
|
371
|
+
// plain answered every later request for it with the plain glyphs, and a
|
|
372
|
+
// `tnum` asked for after that point was silently ignored.
|
|
373
|
+
const shaping = shapingKeyOf(style);
|
|
349
374
|
const key = font
|
|
350
|
-
? `${font.key}|${style.size}|${style.weight}|${style.style}|${levelsKey}|${text}`
|
|
375
|
+
? `${font.key}|${style.size}|${style.weight}|${style.style}|${shaping}|${levelsKey}|${text}`
|
|
351
376
|
: `${style.family}|${style.size}|${style.weight}|${style.style}|${variationsKeyOf(
|
|
352
377
|
style.variations
|
|
353
|
-
)}|${opticalKeyOf(style)}|${levelsKey}|${text}`;
|
|
378
|
+
)}|${opticalKeyOf(style)}|${shaping}|${levelsKey}|${text}`;
|
|
354
379
|
let shaped = this._shapeCache.get(key);
|
|
355
380
|
if (shaped) {
|
|
356
381
|
// Map iterates in insertion order: re-inserting a hit moves it to the
|
|
@@ -390,13 +415,10 @@ export default class FontManager {
|
|
|
390
415
|
* levels cannot recover it (RTL text in an RTL paragraph carries level 1,
|
|
391
416
|
* which reads as an even base) and start/end alignment depends on it.
|
|
392
417
|
*
|
|
393
|
-
* `features` and `
|
|
394
|
-
*
|
|
395
|
-
* key either), so a style with either shapes uncached rather than
|
|
396
|
-
* poisoning entries other callers share.
|
|
418
|
+
* `features`, `language` and `letterSpacing` are part of the memo key, so
|
|
419
|
+
* a style with any of them has entries of its own.
|
|
397
420
|
*/
|
|
398
421
|
_shapeCachedWhole(text, style) {
|
|
399
|
-
if (style.features || style.language) return shapeText(this, text, style);
|
|
400
422
|
const emb = embeddingLevels(text, style.direction);
|
|
401
423
|
const baseLevel = emb.paragraphs.length ? emb.paragraphs[0].level : 0;
|
|
402
424
|
const shaped = this._shapeCached(text, style, normalizedLevels(emb.levels, 0, text.length));
|
package/lib/text/layout.js
CHANGED
|
@@ -21,7 +21,8 @@ function isWsGlyph(g) {
|
|
|
21
21
|
* across relayouts), and bidi reordering is applied per line (UAX#9 L2).
|
|
22
22
|
*
|
|
23
23
|
* Content is a plain string or an array of spans
|
|
24
|
-
* `{ text, family?, size?, weight?, style?, features?, language?,
|
|
24
|
+
* `{ text, family?, size?, weight?, style?, features?, language?,
|
|
25
|
+
* letterSpacing?, color? }`;
|
|
25
26
|
* span fields override the base style. Options:
|
|
26
27
|
*
|
|
27
28
|
* - `maxWidth` — target container width (default: unlimited)
|
|
@@ -72,6 +73,7 @@ export class TextLayout {
|
|
|
72
73
|
textRendering: s.textRendering ?? style.textRendering,
|
|
73
74
|
features: s.features ?? style.features,
|
|
74
75
|
language: s.language ?? style.language,
|
|
76
|
+
letterSpacing: s.letterSpacing ?? style.letterSpacing,
|
|
75
77
|
color: s.color ?? style.color ?? null
|
|
76
78
|
};
|
|
77
79
|
merged.font = s.font ?? style.font ?? fonts.match(merged.family, merged);
|
|
@@ -322,7 +324,9 @@ export class TextLayout {
|
|
|
322
324
|
const m = TRAILING_WS.exec(last.text);
|
|
323
325
|
if (m) {
|
|
324
326
|
const spaceGlyph = last.span.font.fk.glyphForCodePoint(0x20);
|
|
325
|
-
|
|
327
|
+
const spacing = Number.isFinite(last.span.letterSpacing) ? last.span.letterSpacing : 0;
|
|
328
|
+
wsWidth =
|
|
329
|
+
m[0].length * (spaceGlyph.advanceWidth * last.span.font.scale(last.span.size) + spacing);
|
|
326
330
|
}
|
|
327
331
|
}
|
|
328
332
|
return { fragments, width, wsWidth, required, start, end };
|
package/lib/text/shape.js
CHANGED
|
@@ -45,6 +45,62 @@ export function normalizedLevels(levels, start, end) {
|
|
|
45
45
|
return Array.prototype.slice.call(levels, start, end).join(',');
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* The ligatures a font applies by default and a reader can do without. CSS
|
|
50
|
+
* turns them off wherever letters are spaced apart, since an `fi` drawn as
|
|
51
|
+
* one glyph cannot be spaced in its middle: the gap would open on one side of
|
|
52
|
+
* the pair and not the other. Required ones — Arabic joining forms, `rlig` —
|
|
53
|
+
* stay on.
|
|
54
|
+
*/
|
|
55
|
+
const OPTIONAL_LIGATURES = ['liga', 'clig', 'dlig', 'hlig'];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The features a letter-spaced run shapes with: the optional ligatures off,
|
|
59
|
+
* underneath whatever the caller asked for — a style that names `liga`
|
|
60
|
+
* itself still gets it. `features` is either form fontkit takes, an array of
|
|
61
|
+
* tags to turn on or an object of tag → on/off.
|
|
62
|
+
*/
|
|
63
|
+
/**
|
|
64
|
+
* A copy of `features` for fontkit, which **adds to the object it is handed**
|
|
65
|
+
* — `rvrn` and the like, as it plans the shaping. Handed a caller's own
|
|
66
|
+
* object that is a style quietly growing keys, which changes the memo key it
|
|
67
|
+
* is filed under next time, and a frozen one throws.
|
|
68
|
+
*/
|
|
69
|
+
function ownFeatures(features) {
|
|
70
|
+
if (!features) return features;
|
|
71
|
+
return Array.isArray(features) ? [...features] : { ...features };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function spacedFeatures(features) {
|
|
75
|
+
const off = Object.fromEntries(OPTIONAL_LIGATURES.map((tag) => [tag, false]));
|
|
76
|
+
if (!features) return off;
|
|
77
|
+
if (Array.isArray(features)) {
|
|
78
|
+
for (const tag of features) off[tag] = true;
|
|
79
|
+
return off;
|
|
80
|
+
}
|
|
81
|
+
return { ...off, ...features };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Space a shaped run's glyphs apart by `spacing` px: added to the advance of
|
|
86
|
+
* every glyph that has one, so a combining mark keeps sitting on its base.
|
|
87
|
+
* An rtl run stores its glyphs in visual order, and its spacing belongs on
|
|
88
|
+
* the *reading* side of each glyph — the left — so there the glyph is drawn
|
|
89
|
+
* that much further along its advance as well.
|
|
90
|
+
*/
|
|
91
|
+
function spaceGlyphs(shaped, spacing) {
|
|
92
|
+
const rtl = shaped.direction === 'rtl';
|
|
93
|
+
let width = 0;
|
|
94
|
+
for (const g of shaped.glyphs) {
|
|
95
|
+
if (g.ax > 0) {
|
|
96
|
+
g.ax += spacing;
|
|
97
|
+
if (rtl) g.dx += spacing;
|
|
98
|
+
}
|
|
99
|
+
width += g.ax;
|
|
100
|
+
}
|
|
101
|
+
shaped.width = width;
|
|
102
|
+
}
|
|
103
|
+
|
|
48
104
|
/**
|
|
49
105
|
* Shape a string through the full pipeline:
|
|
50
106
|
*
|
|
@@ -61,7 +117,7 @@ export function normalizedLevels(levels, start, end) {
|
|
|
61
117
|
* @param {FontManager} fonts
|
|
62
118
|
* @param {string} text
|
|
63
119
|
* @param {object} style { font | family/weight/style, size, features,
|
|
64
|
-
* language, direction: 'ltr'|'rtl'|'auto' }
|
|
120
|
+
* language, letterSpacing, direction: 'ltr'|'rtl'|'auto' }
|
|
65
121
|
* @param {ArrayLike<number>} [levels] precomputed embedding levels for `text`
|
|
66
122
|
* @returns {{text, width, baseLevel, runs: Array<{font, size, direction,
|
|
67
123
|
* level, width, glyphs, text, start, end}>}}
|
|
@@ -108,6 +164,11 @@ export function shapeText(fonts, text, style, levels) {
|
|
|
108
164
|
i += ch.length;
|
|
109
165
|
}
|
|
110
166
|
|
|
167
|
+
// px added after every glyph, CSS's `letter-spacing`; 0 leaves the run
|
|
168
|
+
// exactly as the font shapes it, ligatures and all
|
|
169
|
+
const spacing = Number.isFinite(style.letterSpacing) ? style.letterSpacing : 0;
|
|
170
|
+
const features = spacing ? spacedFeatures(style.features) : ownFeatures(style.features);
|
|
171
|
+
|
|
111
172
|
const runs = [];
|
|
112
173
|
let width = 0;
|
|
113
174
|
for (const item of items) {
|
|
@@ -115,9 +176,10 @@ export function shapeText(fonts, text, style, levels) {
|
|
|
115
176
|
const sub = text.slice(item.start, item.end);
|
|
116
177
|
const shaped = item.font.shape(sub, size, {
|
|
117
178
|
direction,
|
|
118
|
-
features
|
|
179
|
+
features,
|
|
119
180
|
language: style.language
|
|
120
181
|
});
|
|
182
|
+
if (spacing) spaceGlyphs(shaped, spacing);
|
|
121
183
|
shaped.level = item.level;
|
|
122
184
|
shaped.text = sub;
|
|
123
185
|
shaped.start = item.start;
|