ntk 8.12.2 → 8.12.4
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/fontconfig.js +204 -15
- package/lib/text/font.js +32 -6
- package/lib/text/fontsource.js +7 -7
- package/lib/text/layout.js +1 -3
- package/lib/text/shape.js +20 -0
- package/package.json +1 -1
package/lib/fontconfig.js
CHANGED
|
@@ -255,6 +255,9 @@ function cacheMatches(fc, out) {
|
|
|
255
255
|
return list;
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
// fc -> { promise, base }: one child per pattern however many callers ask.
|
|
259
|
+
// `base` is where a prewarm writes its answer (`spawnToFiles`), null for a
|
|
260
|
+
// child whose answer only reaches the event loop.
|
|
258
261
|
const inflight = new Map();
|
|
259
262
|
|
|
260
263
|
/**
|
|
@@ -270,13 +273,14 @@ const inflight = new Map();
|
|
|
270
273
|
*/
|
|
271
274
|
function runFcMatch(fc) {
|
|
272
275
|
const pending = inflight.get(fc);
|
|
273
|
-
if (pending) return pending;
|
|
276
|
+
if (pending) return pending.promise;
|
|
274
277
|
const cp = childProcess();
|
|
275
278
|
if (!cp) return Promise.reject(noFontsError(NOT_NODE));
|
|
276
279
|
|
|
277
|
-
const
|
|
280
|
+
const entry = { promise: null, base: null };
|
|
281
|
+
entry.promise = new Promise((resolve, reject) => {
|
|
278
282
|
cp.execFile('fc-match', [...fcMatchArgs, fc], fcMatchOpts, (err, out, stderr) => {
|
|
279
|
-
inflight.delete(fc);
|
|
283
|
+
if (inflight.get(fc) === entry) inflight.delete(fc);
|
|
280
284
|
if (!err) return resolve(out);
|
|
281
285
|
// execFile hands stderr to the callback; execFileSync hangs it on the
|
|
282
286
|
// error, which is where the shared diagnosis reads it from
|
|
@@ -284,8 +288,152 @@ function runFcMatch(fc) {
|
|
|
284
288
|
reject(err);
|
|
285
289
|
});
|
|
286
290
|
});
|
|
287
|
-
inflight.set(fc,
|
|
288
|
-
return promise;
|
|
291
|
+
inflight.set(fc, entry);
|
|
292
|
+
return entry.promise;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// What a prewarm's child runs: fc-match with its answer in a file, then —
|
|
296
|
+
// once that file is complete — the exit status in another, which is what a
|
|
297
|
+
// synchronous caller waits for (`answerSync`). Nothing but shell builtins
|
|
298
|
+
// runs after fc-match, so a PATH that holds fc-match alone is enough.
|
|
299
|
+
const PREWARM_SCRIPT = 'fc-match "$@" > "$0.out" 2> "$0.err"; echo $? > "$0.done"';
|
|
300
|
+
|
|
301
|
+
// The directory prewarms write into, made on first use: undefined until
|
|
302
|
+
// then, null where it cannot be (no shell, no writable tmpdir), which leaves
|
|
303
|
+
// prewarms on the event loop the way they always were.
|
|
304
|
+
let prewarmDir;
|
|
305
|
+
let prewarmCount = 0;
|
|
306
|
+
|
|
307
|
+
function prewarmBase() {
|
|
308
|
+
const fs = builtin('node:fs');
|
|
309
|
+
const path = builtin('node:path');
|
|
310
|
+
if (prewarmDir === undefined) {
|
|
311
|
+
prewarmDir = null;
|
|
312
|
+
const p = globalThis.process;
|
|
313
|
+
const os = builtin('node:os');
|
|
314
|
+
if (p && p.platform !== 'win32' && fs && os && path && fs.existsSync('/bin/sh')) {
|
|
315
|
+
try {
|
|
316
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ntk-fc-'));
|
|
317
|
+
p.once('exit', () => {
|
|
318
|
+
try {
|
|
319
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
320
|
+
} catch {
|
|
321
|
+
// a tmpdir cleaned up under us is already clean
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
prewarmDir = dir;
|
|
325
|
+
} catch {
|
|
326
|
+
// a read-only or missing tmpdir: prewarm the old way
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return prewarmDir === null ? null : path.join(prewarmDir, String(prewarmCount++));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** The status and answer a prewarm left, once it has left them; null while
|
|
334
|
+
* it is still running. */
|
|
335
|
+
function readPrewarm(base) {
|
|
336
|
+
const fs = builtin('node:fs');
|
|
337
|
+
let done;
|
|
338
|
+
try {
|
|
339
|
+
done = fs.readFileSync(`${base}.done`, 'utf8');
|
|
340
|
+
} catch {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
// `echo` writes the status and the newline in one go, but a read can land
|
|
344
|
+
// between the file appearing and its bytes arriving
|
|
345
|
+
if (!done.endsWith('\n')) return null;
|
|
346
|
+
const status = Number(done);
|
|
347
|
+
let out = '';
|
|
348
|
+
if (status === 0) {
|
|
349
|
+
try {
|
|
350
|
+
out = fs.readFileSync(`${base}.out`, 'utf8');
|
|
351
|
+
} catch {
|
|
352
|
+
return { status: -1, out: '' };
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return { status, out };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function removePrewarm(base) {
|
|
359
|
+
const fs = builtin('node:fs');
|
|
360
|
+
for (const ext of ['.out', '.err', '.done']) {
|
|
361
|
+
try {
|
|
362
|
+
fs.unlinkSync(base + ext);
|
|
363
|
+
} catch {
|
|
364
|
+
// never written, or already gone
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* A prewarm's child, answering through files so that the synchronous path
|
|
371
|
+
* can take its answer without the event loop (`answerSync`) — which the
|
|
372
|
+
* first text layout is holding, from inside a render, for as long as the
|
|
373
|
+
* first frame takes. A prewarm answered through `execFile` alone lands after
|
|
374
|
+
* that, so the layout it was started for spawned fc-match a second time and
|
|
375
|
+
* waited for that one instead.
|
|
376
|
+
*
|
|
377
|
+
* Null where files cannot be used; the caller prewarms the old way.
|
|
378
|
+
*/
|
|
379
|
+
function spawnToFiles(fc) {
|
|
380
|
+
const cp = childProcess();
|
|
381
|
+
const base = cp ? prewarmBase() : null;
|
|
382
|
+
if (base === null) return null;
|
|
383
|
+
let child;
|
|
384
|
+
try {
|
|
385
|
+
child = cp.spawn('/bin/sh', ['-c', PREWARM_SCRIPT, base, ...fcMatchArgs, fc], {
|
|
386
|
+
stdio: 'ignore'
|
|
387
|
+
});
|
|
388
|
+
} catch {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
const entry = { promise: null, base, answer: undefined };
|
|
392
|
+
entry.promise = new Promise((resolve, reject) => {
|
|
393
|
+
const settle = () => {
|
|
394
|
+
if (inflight.get(fc) === entry) inflight.delete(fc);
|
|
395
|
+
// answered already, by a synchronous caller that could not wait
|
|
396
|
+
if (entry.answer === undefined) {
|
|
397
|
+
const left = readPrewarm(base);
|
|
398
|
+
removePrewarm(base);
|
|
399
|
+
entry.answer = left && left.status === 0 ? left.out : null;
|
|
400
|
+
}
|
|
401
|
+
if (entry.answer !== null) resolve(entry.answer);
|
|
402
|
+
else reject(new Error(`fc-match for "${fc}" did not answer`));
|
|
403
|
+
};
|
|
404
|
+
child.once('error', settle);
|
|
405
|
+
child.once('exit', settle);
|
|
406
|
+
});
|
|
407
|
+
inflight.set(fc, entry);
|
|
408
|
+
return entry;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// the synchronous path's nap while a prewarm finishes
|
|
412
|
+
const nap = new Int32Array(new SharedArrayBuffer(4));
|
|
413
|
+
|
|
414
|
+
// How long the synchronous path waits on a prewarm before asking fc-match
|
|
415
|
+
// itself. fc-match answers in tens of milliseconds, and a prewarm that is
|
|
416
|
+
// running has a head start on any spawn that could replace it.
|
|
417
|
+
const PREWARM_WAIT_MS = 3000;
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* An in-flight prewarm's answer, waited for synchronously: its stdout, or
|
|
421
|
+
* null if it failed or took too long — the caller then spawns fc-match
|
|
422
|
+
* itself, which is also what reports why.
|
|
423
|
+
*/
|
|
424
|
+
function answerSync(fc, entry) {
|
|
425
|
+
const deadline = performance.now() + PREWARM_WAIT_MS;
|
|
426
|
+
let left = readPrewarm(entry.base);
|
|
427
|
+
while (left === null && performance.now() < deadline) {
|
|
428
|
+
Atomics.wait(nap, 0, 0, 1);
|
|
429
|
+
left = readPrewarm(entry.base);
|
|
430
|
+
}
|
|
431
|
+
if (inflight.get(fc) === entry) inflight.delete(fc);
|
|
432
|
+
if (entry.answer === undefined) {
|
|
433
|
+
entry.answer = left && left.status === 0 ? left.out : null;
|
|
434
|
+
if (left !== null) removePrewarm(entry.base);
|
|
435
|
+
}
|
|
436
|
+
return entry.answer;
|
|
289
437
|
}
|
|
290
438
|
|
|
291
439
|
/**
|
|
@@ -294,8 +442,10 @@ function runFcMatch(fc) {
|
|
|
294
442
|
* matchSortedSync is deliberately synchronous — it answers from inside text
|
|
295
443
|
* layout — so the first layout for a pattern pays the fc-match spawn (~50ms)
|
|
296
444
|
* as a first-paint stall. Starting the same command here with a non-blocking
|
|
297
|
-
*
|
|
298
|
-
* the critical path (issue #182).
|
|
445
|
+
* spawn, while the X connection is still being set up, moves that cost off
|
|
446
|
+
* the critical path (issue #182). The child answers through files as well as
|
|
447
|
+
* through the event loop, so a layout that asks for the pattern before the
|
|
448
|
+
* loop has run takes the prewarm's answer rather than spawning its own.
|
|
299
449
|
*
|
|
300
450
|
* Never rejects and never reports: a prewarm is an optimization, and an app
|
|
301
451
|
* that never renders text must not crash — or even warn — over a missing
|
|
@@ -304,9 +454,6 @@ function runFcMatch(fc) {
|
|
|
304
454
|
* `unavailable`: the first sync throw keeps its original spawn error as
|
|
305
455
|
* `cause`.
|
|
306
456
|
*
|
|
307
|
-
* A sync call racing this one wins — the child's result is discarded
|
|
308
|
-
* whenever the pattern is already cached by the time it exits.
|
|
309
|
-
*
|
|
310
457
|
* `matchSorted` is the reporting variant: same spawn, same cache, but it
|
|
311
458
|
* awaits an answer and so has somewhere to put a failure.
|
|
312
459
|
*
|
|
@@ -316,7 +463,8 @@ function runFcMatch(fc) {
|
|
|
316
463
|
export function prewarm(pattern = {}) {
|
|
317
464
|
const fc = patternFor(pattern);
|
|
318
465
|
if (sortedCache.has(fc) || unavailable) return Promise.resolve();
|
|
319
|
-
|
|
466
|
+
const pending = inflight.get(fc)?.promise ?? spawnToFiles(fc)?.promise ?? runFcMatch(fc);
|
|
467
|
+
return pending.then(
|
|
320
468
|
(out) => {
|
|
321
469
|
if (!sortedCache.has(fc)) {
|
|
322
470
|
const list = parseMatches(out);
|
|
@@ -327,6 +475,31 @@ export function prewarm(pattern = {}) {
|
|
|
327
475
|
);
|
|
328
476
|
}
|
|
329
477
|
|
|
478
|
+
// the faces a family is asked for in: regular, bold, and both in italic
|
|
479
|
+
const FACES = [
|
|
480
|
+
[400, 'normal'],
|
|
481
|
+
[700, 'normal'],
|
|
482
|
+
[400, 'italic'],
|
|
483
|
+
[700, 'italic']
|
|
484
|
+
];
|
|
485
|
+
const facesWarmed = new Set();
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Prewarm a family in the four faces text is set in — once per family.
|
|
489
|
+
*
|
|
490
|
+
* A document asks for its family's faces one at a time, as layout reaches
|
|
491
|
+
* the first bold word, the first emphasis, and each ask that misses is a
|
|
492
|
+
* synchronous fc-match on the way to the first frame: a Markdown document
|
|
493
|
+
* waited on five of them, 600 ms on XQuartz. Started together they run side
|
|
494
|
+
* by side, and each later ask finds its answer there.
|
|
495
|
+
*/
|
|
496
|
+
export function prewarmFaces(pattern = {}) {
|
|
497
|
+
const family = pattern.family || 'sans-serif';
|
|
498
|
+
if (facesWarmed.has(family) || unavailable) return;
|
|
499
|
+
facesWarmed.add(family);
|
|
500
|
+
for (const [weight, style] of FACES) prewarm({ family, weight, style });
|
|
501
|
+
}
|
|
502
|
+
|
|
330
503
|
/**
|
|
331
504
|
* The same match list as `matchSortedSync`, without blocking for it.
|
|
332
505
|
*
|
|
@@ -352,10 +525,18 @@ export async function matchSorted(pattern = {}) {
|
|
|
352
525
|
if (unavailable) throw noFontsError(unavailable);
|
|
353
526
|
|
|
354
527
|
let out;
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
528
|
+
const pending = inflight.get(fc);
|
|
529
|
+
if (pending?.base) {
|
|
530
|
+
// a prewarm running for the pattern: its answer, or — if it has none —
|
|
531
|
+
// a spawn of our own, which is what reports why
|
|
532
|
+
out = await pending.promise.catch(() => undefined);
|
|
533
|
+
}
|
|
534
|
+
if (out === undefined) {
|
|
535
|
+
try {
|
|
536
|
+
out = await runFcMatch(fc);
|
|
537
|
+
} catch (err) {
|
|
538
|
+
throw fcMatchError(err);
|
|
539
|
+
}
|
|
359
540
|
}
|
|
360
541
|
// A sync call may have answered this pattern while the child ran. Its list
|
|
361
542
|
// is the cached one, and candidates memoize their parsed charset, so hand
|
|
@@ -385,6 +566,14 @@ export function matchSortedSync(pattern) {
|
|
|
385
566
|
if (cached) return cached;
|
|
386
567
|
if (unavailable) throw noFontsError(unavailable);
|
|
387
568
|
|
|
569
|
+
// The family's other faces are the next asks (`prewarmFaces`): started
|
|
570
|
+
// now, they run beside this one instead of after it.
|
|
571
|
+
prewarmFaces(pattern);
|
|
572
|
+
const pending = inflight.get(fc);
|
|
573
|
+
if (pending?.base) {
|
|
574
|
+
const out = answerSync(fc, pending);
|
|
575
|
+
if (out !== null) return sortedCache.get(fc) ?? cacheMatches(fc, out);
|
|
576
|
+
}
|
|
388
577
|
let out;
|
|
389
578
|
try {
|
|
390
579
|
out = execFileSync('fc-match', [...fcMatchArgs, fc], fcMatchOpts);
|
package/lib/text/font.js
CHANGED
|
@@ -75,6 +75,12 @@ export default class Font {
|
|
|
75
75
|
this.path = path;
|
|
76
76
|
/** stable cache key for glyph pages and font caches */
|
|
77
77
|
this.key = `${path}#${postscriptName || fkFont.postscriptName || ''}`;
|
|
78
|
+
// What the face's tables say, read once. fontkit's getters walk to the
|
|
79
|
+
// table on every read, and a text layout asks per run and per word: in
|
|
80
|
+
// a long document they were most of a relayout (see `metrics`).
|
|
81
|
+
this._upem = 0;
|
|
82
|
+
this._vertical = null;
|
|
83
|
+
this._space = -1;
|
|
78
84
|
}
|
|
79
85
|
|
|
80
86
|
static loadSync(path, postscriptName) {
|
|
@@ -214,28 +220,48 @@ export default class Font {
|
|
|
214
220
|
}
|
|
215
221
|
|
|
216
222
|
scale(size) {
|
|
217
|
-
return size / this.fk.unitsPerEm;
|
|
223
|
+
return size / (this._upem ||= this.fk.unitsPerEm);
|
|
218
224
|
}
|
|
219
225
|
|
|
220
226
|
/**
|
|
221
227
|
* Pixel-space metrics for a font size. `ascent`/`capHeight`/`xHeight` are
|
|
222
228
|
* positive (above baseline), `descent` is positive (below baseline).
|
|
229
|
+
*
|
|
230
|
+
* A new object every call, which a caller may keep or change; the numbers
|
|
231
|
+
* behind it are the face's, read from its tables once.
|
|
223
232
|
*/
|
|
224
233
|
metrics(size) {
|
|
234
|
+
const v = (this._vertical ??= {
|
|
235
|
+
ascent: this.fk.ascent,
|
|
236
|
+
descent: -this.fk.descent,
|
|
237
|
+
lineGap: this.fk.lineGap,
|
|
238
|
+
capHeight: this.fk.capHeight,
|
|
239
|
+
xHeight: this.fk.xHeight
|
|
240
|
+
});
|
|
225
241
|
const s = this.scale(size);
|
|
226
|
-
const ascent =
|
|
227
|
-
const descent =
|
|
228
|
-
const lineGap =
|
|
242
|
+
const ascent = v.ascent * s;
|
|
243
|
+
const descent = v.descent * s;
|
|
244
|
+
const lineGap = v.lineGap * s;
|
|
229
245
|
return {
|
|
230
246
|
ascent,
|
|
231
247
|
descent,
|
|
232
248
|
lineGap,
|
|
233
249
|
lineHeight: ascent + descent + lineGap,
|
|
234
|
-
capHeight:
|
|
235
|
-
xHeight:
|
|
250
|
+
capHeight: v.capHeight * s,
|
|
251
|
+
xHeight: v.xHeight * s
|
|
236
252
|
};
|
|
237
253
|
}
|
|
238
254
|
|
|
255
|
+
/**
|
|
256
|
+
* The advance of U+0020 at `size` — what a run of trailing spaces is
|
|
257
|
+
* worth to a line. Looked up once: it was a cmap lookup for every word a
|
|
258
|
+
* layout ended with a space, which in a paragraph is every word.
|
|
259
|
+
*/
|
|
260
|
+
spaceAdvance(size) {
|
|
261
|
+
if (this._space < 0) this._space = this.fk.glyphForCodePoint(0x20).advanceWidth;
|
|
262
|
+
return this._space * this.scale(size);
|
|
263
|
+
}
|
|
264
|
+
|
|
239
265
|
hasGlyph(codepoint) {
|
|
240
266
|
return this.fk.hasGlyphForCodePoint(codepoint);
|
|
241
267
|
}
|
package/lib/text/fontsource.js
CHANGED
|
@@ -51,7 +51,7 @@ import {
|
|
|
51
51
|
matchSorted,
|
|
52
52
|
matchSortedSync,
|
|
53
53
|
noFontsError,
|
|
54
|
-
|
|
54
|
+
prewarmFaces,
|
|
55
55
|
supported
|
|
56
56
|
} from '../fontconfig.js';
|
|
57
57
|
import Font from './font.js';
|
|
@@ -116,13 +116,13 @@ export function parseFamilies(family) {
|
|
|
116
116
|
export class FontconfigFontSource {
|
|
117
117
|
constructor() {
|
|
118
118
|
// Construction is the moment fontconfig is chosen as the lookup path, so
|
|
119
|
-
// the fc-match
|
|
119
|
+
// the fc-match spawns for the pattern every widget default resolves to —
|
|
120
120
|
// sans-serif at regular weight, exactly as FontManager.match normalizes
|
|
121
|
-
// it —
|
|
122
|
-
// layout for ~50ms. Fire-and-forget:
|
|
123
|
-
// constructed where fc-match is
|
|
124
|
-
// is actually rendered.
|
|
125
|
-
|
|
121
|
+
// it — and for its bold and italic start here, asynchronously, instead of
|
|
122
|
+
// stalling the first text layout for ~50ms each. Fire-and-forget:
|
|
123
|
+
// prewarm never rejects, and a source constructed where fc-match is
|
|
124
|
+
// missing stays silent until (unless) text is actually rendered.
|
|
125
|
+
prewarmFaces({ family: 'sans-serif' });
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
matchSorted(pattern) {
|
package/lib/text/layout.js
CHANGED
|
@@ -324,10 +324,8 @@ export class TextLayout {
|
|
|
324
324
|
if (last) {
|
|
325
325
|
const m = TRAILING_WS.exec(last.text);
|
|
326
326
|
if (m) {
|
|
327
|
-
const spaceGlyph = last.span.font.fk.glyphForCodePoint(0x20);
|
|
328
327
|
const spacing = Number.isFinite(last.span.letterSpacing) ? last.span.letterSpacing : 0;
|
|
329
|
-
wsWidth =
|
|
330
|
-
m[0].length * (spaceGlyph.advanceWidth * last.span.font.scale(last.span.size) + spacing);
|
|
328
|
+
wsWidth = m[0].length * (last.span.font.spaceAdvance(last.span.size) + spacing);
|
|
331
329
|
}
|
|
332
330
|
}
|
|
333
331
|
return { fragments, width, wsWidth, required, start, end };
|
package/lib/text/shape.js
CHANGED
|
@@ -25,9 +25,29 @@ function neverSwitches(cp) {
|
|
|
25
25
|
*/
|
|
26
26
|
export function embeddingLevels(text, direction = 'auto') {
|
|
27
27
|
const explicit = direction === 'ltr' || direction === 'rtl' ? direction : null;
|
|
28
|
+
// Text that nothing can make right-to-left is level 0 throughout, which
|
|
29
|
+
// is what the algorithm would work out a character at a time: every
|
|
30
|
+
// layout asks, and most text is Latin, CJK or digits with no bidi at all.
|
|
31
|
+
if (explicit !== 'rtl' && !MAY_REVERSE.test(text)) {
|
|
32
|
+
return {
|
|
33
|
+
levels: new Uint8Array(text.length),
|
|
34
|
+
paragraphs: text.length ? [{ start: 0, end: text.length - 1, level: 0 }] : []
|
|
35
|
+
};
|
|
36
|
+
}
|
|
28
37
|
return bidi.getEmbeddingLevels(text, explicit);
|
|
29
38
|
}
|
|
30
39
|
|
|
40
|
+
/**
|
|
41
|
+
* What can raise a level in a left-to-right paragraph: a strong
|
|
42
|
+
* right-to-left character (Hebrew, Arabic, Syriac, Thaana, N'Ko and their
|
|
43
|
+
* neighbours, their presentation forms, and the right-to-left blocks of the
|
|
44
|
+
* supplementary planes, found by their high surrogates), an Arabic number,
|
|
45
|
+
* RLM and ALM, and every explicit embedding, override and isolate. Anything
|
|
46
|
+
* else resolves to 0.
|
|
47
|
+
*/
|
|
48
|
+
const MAY_REVERSE =
|
|
49
|
+
/[\u0590-\u08FF\u200F\u202A-\u202E\u2066-\u2069\uFB1D-\uFDFF\uFE70-\uFEFF]|[\uD802\uD803\uD83A\uD83B]/;
|
|
50
|
+
|
|
31
51
|
/**
|
|
32
52
|
* Compact embedding-levels encoding for the shaping memo key: a single
|
|
33
53
|
* number when uniform over [start, end) — the common case — else
|