ntk 8.14.0 → 8.14.2
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/text/anchors.js +81 -0
- package/lib/text/font.js +53 -5
- package/lib/text/fontmanager.js +102 -13
- package/lib/text/layout.js +148 -11
- package/lib/text/shape.js +17 -8
- package/package.json +1 -1
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// fontkit's mark attachment, for a face whose tables leave an anchor out.
|
|
2
|
+
//
|
|
3
|
+
// A mark-to-base, mark-to-ligature or mark-to-mark subtable holds an anchor
|
|
4
|
+
// for each base (ligature component, earlier mark) and mark class, and the
|
|
5
|
+
// OpenType spec lets one be NULL: that subtable attaches no mark of that
|
|
6
|
+
// class to that glyph. HarfBuzz answers "not applied", and the lookup's next
|
|
7
|
+
// subtable gets its turn. fontkit reads the NULL's coordinates and throws
|
|
8
|
+
// `Cannot read properties of null (reading 'xCoordinate')` out of a layout,
|
|
9
|
+
// and faces ship them: Noto Sans Bold holds 2,822, DejaVu Sans Mono 198 (a
|
|
10
|
+
// Lithuanian Į̃ reaches one), Amiri, Noto Naskh Arabic and FreeSerif
|
|
11
|
+
// thousands. Upstream that is foliojs/fontkit#367; #374 returns early
|
|
12
|
+
// instead, which ends the crash but still counts the subtable as applied.
|
|
13
|
+
//
|
|
14
|
+
// Answering "not applied" means wrapping the processor's `applyLookup`,
|
|
15
|
+
// which runs for every glyph at every lookup: 2% of shaping a word the
|
|
16
|
+
// first time. So a face pays it only from the first NULL its text reaches.
|
|
17
|
+
// Until then `applyAnchor` alone is watched — it runs when a mark attaches —
|
|
18
|
+
// and a NULL there abandons the layout (`NO_ANCHOR`), which `Font#_layout`
|
|
19
|
+
// shapes again with the wrapper in. Abandoning loses nothing: fontkit keeps
|
|
20
|
+
// no state from a layout but the tables it decoded, which a second one
|
|
21
|
+
// reads the same.
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Thrown through fontkit's layout from a NULL anchor, and caught here or in
|
|
25
|
+
* `Font#_layout`. One error, made once: a face can reach NULLs many times a
|
|
26
|
+
* word, and a fresh error would take a stack trace each time. It says what
|
|
27
|
+
* happened to anything that calls a watched face's `layout` directly.
|
|
28
|
+
*/
|
|
29
|
+
export const NO_ANCHOR = new Error(
|
|
30
|
+
'a mark attachment subtable has a NULL anchor for this glyph and mark class: ' +
|
|
31
|
+
"shaped through ntk's Font, that is a subtable that did not apply"
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
/** The face's GPOS processor, where fontkit shapes it through GPOS; else null. */
|
|
35
|
+
function processorOf(fk) {
|
|
36
|
+
try {
|
|
37
|
+
return fk._layoutEngine?.engine?.GPOSProcessor ?? null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Watch a face's mark attachment for a NULL anchor, which then abandons the
|
|
45
|
+
* layout it is reached in with `NO_ANCHOR`.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} fk a fontkit font
|
|
48
|
+
* @returns {boolean} whether there is anything to watch: false for a face
|
|
49
|
+
* fontkit does not position through GPOS
|
|
50
|
+
*/
|
|
51
|
+
export function watchAnchors(fk) {
|
|
52
|
+
const gpos = processorOf(fk);
|
|
53
|
+
if (!gpos || typeof gpos.applyAnchor !== 'function') return false;
|
|
54
|
+
if (Object.hasOwn(gpos, 'applyAnchor')) return true;
|
|
55
|
+
const applyAnchor = gpos.applyAnchor;
|
|
56
|
+
gpos.applyAnchor = function (markRecord, baseAnchor, baseGlyphIndex) {
|
|
57
|
+
if (baseAnchor == null || markRecord.markAnchor == null) throw NO_ANCHOR;
|
|
58
|
+
return applyAnchor.call(this, markRecord, baseAnchor, baseGlyphIndex);
|
|
59
|
+
};
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* From here on a subtable that reaches a NULL anchor is one that did not
|
|
65
|
+
* apply, so the lookup's next subtable is tried, as HarfBuzz tries it.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} fk a fontkit font `watchAnchors` has been handed
|
|
68
|
+
*/
|
|
69
|
+
export function tolerateAnchors(fk) {
|
|
70
|
+
const gpos = processorOf(fk);
|
|
71
|
+
if (!gpos || Object.hasOwn(gpos, 'applyLookup')) return;
|
|
72
|
+
const applyLookup = gpos.applyLookup;
|
|
73
|
+
gpos.applyLookup = function (lookupType, table) {
|
|
74
|
+
try {
|
|
75
|
+
return applyLookup.call(this, lookupType, table);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if (err === NO_ANCHOR) return false;
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
package/lib/text/font.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fontkit from 'fontkit';
|
|
2
2
|
|
|
3
3
|
import { flatten, rasterizePath } from '../rasterize.js';
|
|
4
|
+
import { NO_ANCHOR, tolerateAnchors, watchAnchors } from './anchors.js';
|
|
4
5
|
import { marksCover, standInMarks } from './marks.js';
|
|
5
6
|
|
|
6
7
|
// Axis coordinates are rounded to this many decimals before anything is
|
|
@@ -127,6 +128,10 @@ export default class Font {
|
|
|
127
128
|
// at its first shaping; null once the real ones are back, or never were
|
|
128
129
|
// stood in for
|
|
129
130
|
this._marks = undefined;
|
|
131
|
+
this._drawable = undefined;
|
|
132
|
+
// whether the face's mark attachment is watched for a NULL anchor
|
|
133
|
+
// (./anchors.js): set at its first shaping, false once one has been met
|
|
134
|
+
this._anchors = false;
|
|
130
135
|
}
|
|
131
136
|
|
|
132
137
|
static loadSync(path, postscriptName) {
|
|
@@ -308,8 +313,29 @@ export default class Font {
|
|
|
308
313
|
return this._space * this.scale(size);
|
|
309
314
|
}
|
|
310
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Whether fontkit can make a glyph of this face at all. It makes one from
|
|
318
|
+
* `glyf`, `CFF ` or `CFF2` outlines, or an `sbix` or `COLR`/`CPAL` colour
|
|
319
|
+
* glyph, and answers null for anything else — and its shaper throws on the
|
|
320
|
+
* null. A bitmap-only colour font (`CBDT`/`CBLC`) is that face: Noto Color
|
|
321
|
+
* Emoji and EmojiOne as most Linux desktops ship them, which fontconfig
|
|
322
|
+
* answers first for an emoji. Its cmap says it has the character and
|
|
323
|
+
* nothing can be shaped or drawn from it, so it covers nothing here
|
|
324
|
+
* (`hasGlyph`), and `FontManager` hands it out neither as a match nor as a
|
|
325
|
+
* fallback.
|
|
326
|
+
*/
|
|
327
|
+
get drawable() {
|
|
328
|
+
if (this._drawable === undefined) {
|
|
329
|
+
const t = this.fk.directory?.tables ?? {};
|
|
330
|
+
this._drawable = Boolean(
|
|
331
|
+
t.glyf || t['CFF '] || t.CFF2 || t.sbix || (t.COLR && t.CPAL)
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
return this._drawable;
|
|
335
|
+
}
|
|
336
|
+
|
|
311
337
|
hasGlyph(codepoint) {
|
|
312
|
-
return this.fk.hasGlyphForCodePoint(codepoint);
|
|
338
|
+
return this.drawable && this.fk.hasGlyphForCodePoint(codepoint);
|
|
313
339
|
}
|
|
314
340
|
|
|
315
341
|
/**
|
|
@@ -329,7 +355,7 @@ export default class Font {
|
|
|
329
355
|
* @returns {number|null} font glyph id, as `shape()` would report in `glyphs[].id`
|
|
330
356
|
*/
|
|
331
357
|
glyphIdFor(codepoint) {
|
|
332
|
-
if (!this.
|
|
358
|
+
if (!this.hasGlyph(codepoint)) return null;
|
|
333
359
|
return this.fk.glyphForCodePoint(codepoint).id;
|
|
334
360
|
}
|
|
335
361
|
|
|
@@ -376,16 +402,38 @@ export default class Font {
|
|
|
376
402
|
*/
|
|
377
403
|
_layout(text, opts) {
|
|
378
404
|
const { features, script, language, direction } = opts;
|
|
379
|
-
if (this._marks === undefined)
|
|
405
|
+
if (this._marks === undefined) {
|
|
406
|
+
this._marks = standInMarks(this.fk);
|
|
407
|
+
this._anchors = watchAnchors(this.fk);
|
|
408
|
+
}
|
|
380
409
|
const marks = this._marks;
|
|
381
410
|
// fontkit adds to the features object it is handed: a second shaping
|
|
382
411
|
// gets what the caller asked for, not what the first left there
|
|
383
412
|
const again = marks === null || features == null ? features : Array.isArray(features) ? [...features] : { ...features };
|
|
384
|
-
const run = this.
|
|
413
|
+
const run = this._fkLayout(text, features, script, language, direction);
|
|
385
414
|
if (marks === null || !run.glyphs.some((glyph) => marksCover(marks.bits, glyph.id))) return run;
|
|
386
415
|
marks.restore();
|
|
387
416
|
this._marks = null;
|
|
388
|
-
return this.
|
|
417
|
+
return this._fkLayout(text, again, script, language, direction);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* fontkit's layout, shaped once more where a NULL anchor abandoned it: a
|
|
422
|
+
* face's mark attachment is watched for one until its text reaches the
|
|
423
|
+
* first, and takes one as a subtable that did not apply from then on
|
|
424
|
+
* (./anchors.js).
|
|
425
|
+
*/
|
|
426
|
+
_fkLayout(text, features, script, language, direction) {
|
|
427
|
+
if (!this._anchors) return this.fk.layout(text, features, script, language, direction);
|
|
428
|
+
const again = features == null ? features : Array.isArray(features) ? [...features] : { ...features };
|
|
429
|
+
try {
|
|
430
|
+
return this.fk.layout(text, features, script, language, direction);
|
|
431
|
+
} catch (err) {
|
|
432
|
+
if (err !== NO_ANCHOR) throw err;
|
|
433
|
+
tolerateAnchors(this.fk);
|
|
434
|
+
this._anchors = false;
|
|
435
|
+
return this.fk.layout(text, again, script, language, direction);
|
|
436
|
+
}
|
|
389
437
|
}
|
|
390
438
|
|
|
391
439
|
/** nominal (unshaped) advance of a glyph id, in pixels */
|
package/lib/text/fontmanager.js
CHANGED
|
@@ -82,6 +82,13 @@ const shapePrefixes = new WeakMap();
|
|
|
82
82
|
/** The key a style's words are memoized under, with the embedding levels
|
|
83
83
|
* they were shaped at: one string per style and level, kept, so a lookup
|
|
84
84
|
* hashes nothing it has not hashed before. */
|
|
85
|
+
/** A levels key (`normalizedLevels`) for part of the text it was made for. */
|
|
86
|
+
function sliceKey(key, start, end) {
|
|
87
|
+
if (!key.includes(',')) return key;
|
|
88
|
+
const levels = key.split(',').slice(start, end);
|
|
89
|
+
return levels.every((l) => l === levels[0]) ? levels[0] : levels.join(',');
|
|
90
|
+
}
|
|
91
|
+
|
|
85
92
|
function shapeGroupOf(style, levelsKey) {
|
|
86
93
|
const font = style.font;
|
|
87
94
|
let known = shapePrefixes.get(style);
|
|
@@ -111,8 +118,12 @@ function shapePrefixOf(style) {
|
|
|
111
118
|
// plain answered every later request for it with the plain glyphs, and a
|
|
112
119
|
// `tnum` asked for after that point was silently ignored.
|
|
113
120
|
const shaping = shapingKeyOf(style);
|
|
121
|
+
// The family list too where a face is given, since the letters that face
|
|
122
|
+
// lacks are set in the families after it (`fallbackFor`): a word shaped
|
|
123
|
+
// under `Ahem, Times New Roman` answered the same word under `Ahem,
|
|
124
|
+
// Arial`, in Times.
|
|
114
125
|
return font
|
|
115
|
-
? `${font.key}|${style.size}|${style.weight}|${style.style}|${shaping}`
|
|
126
|
+
? `${font.key}|${style.family ?? ''}|${style.size}|${style.weight}|${style.style}|${shaping}`
|
|
116
127
|
: `${style.family}|${style.size}|${style.weight}|${style.style}|${variationsKeyOf(
|
|
117
128
|
style.variations
|
|
118
129
|
)}|${opticalKeyOf(style)}|${shaping}`;
|
|
@@ -217,6 +228,8 @@ export default class FontManager {
|
|
|
217
228
|
this._shapeCache = new Map();
|
|
218
229
|
this._shapeCacheBefore = new Map();
|
|
219
230
|
this._shapeCount = 0;
|
|
231
|
+
// whether two letters shape otherwise together than apart (`_meets`)
|
|
232
|
+
this._meetings = new Map();
|
|
220
233
|
// a paragraph's layout up to the line fill, for its next width
|
|
221
234
|
// (text/paragraphs.js, TextLayout)
|
|
222
235
|
this._paragraphs = new ParagraphCache();
|
|
@@ -253,6 +266,28 @@ export default class FontManager {
|
|
|
253
266
|
);
|
|
254
267
|
}
|
|
255
268
|
|
|
269
|
+
/**
|
|
270
|
+
* The best of `candidates` a glyph can be made from (`Font.drawable`):
|
|
271
|
+
* `emoji`, or a stylesheet's `"Noto Color Emoji"`, is a bitmap-only face
|
|
272
|
+
* on most Linux desktops, and a base face nothing can be shaped in threw
|
|
273
|
+
* on the first character of its text. The first candidate still, where
|
|
274
|
+
* none is — there is nothing better to hand out.
|
|
275
|
+
*/
|
|
276
|
+
_firstDrawable(candidates) {
|
|
277
|
+
let first = null;
|
|
278
|
+
for (const c of candidates) {
|
|
279
|
+
let font;
|
|
280
|
+
try {
|
|
281
|
+
font = this._open(c);
|
|
282
|
+
} catch {
|
|
283
|
+
continue; // unparseable candidate — try the next one
|
|
284
|
+
}
|
|
285
|
+
if (font.drawable) return font;
|
|
286
|
+
first ??= font;
|
|
287
|
+
}
|
|
288
|
+
return first ?? this._open(candidates[0]);
|
|
289
|
+
}
|
|
290
|
+
|
|
256
291
|
/** open (and cache) a match candidate — see fontsource.js for the shape */
|
|
257
292
|
_open(candidate) {
|
|
258
293
|
const key = candidate.key ?? `${candidate.path}#${candidate.postscriptName || ''}`;
|
|
@@ -309,7 +344,7 @@ export default class FontManager {
|
|
|
309
344
|
let bestScore = Infinity;
|
|
310
345
|
for (const family of families) {
|
|
311
346
|
for (const r of this._registered) {
|
|
312
|
-
if (r.family !== family) continue;
|
|
347
|
+
if (r.family !== family || !r.font.drawable) continue;
|
|
313
348
|
const score = Math.abs(r.weight - weight) + (r.italic !== italic ? 1000 : 0);
|
|
314
349
|
if (score < bestScore) {
|
|
315
350
|
bestScore = score;
|
|
@@ -361,12 +396,19 @@ export default class FontManager {
|
|
|
361
396
|
if (!face) {
|
|
362
397
|
// sources understand comma-separated family lists natively; one that
|
|
363
398
|
// can answer the best face alone saves reading the fallback chain the
|
|
364
|
-
// face may never need
|
|
399
|
+
// face may never need — unless no glyph can be made from that face
|
|
365
400
|
const pattern = patternOf(family, weight, italic);
|
|
366
401
|
const source = this.source;
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
402
|
+
let head = null;
|
|
403
|
+
if (typeof source.matchFirst === 'function') {
|
|
404
|
+
const candidate = source.matchFirst(pattern);
|
|
405
|
+
try {
|
|
406
|
+
head = this._open(candidate);
|
|
407
|
+
} catch {
|
|
408
|
+
// unparseable: the chain has the next candidate
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
face = head?.drawable ? head : this._firstDrawable(source.matchSorted(pattern));
|
|
370
412
|
}
|
|
371
413
|
this._matches.set(cacheKey, face);
|
|
372
414
|
// A long-lived app can name a lot of families. Same sweep as the shaping
|
|
@@ -383,9 +425,17 @@ export default class FontManager {
|
|
|
383
425
|
|
|
384
426
|
/**
|
|
385
427
|
* Find a font that has a glyph for `codepoint`, for use when the primary
|
|
386
|
-
* font doesn't.
|
|
387
|
-
*
|
|
388
|
-
*
|
|
428
|
+
* font doesn't. The registered faces of the families the style names
|
|
429
|
+
* first, in the order it names them, then any registered face, then the
|
|
430
|
+
* source's fallback chain (filtered by the source's coverage data — font
|
|
431
|
+
* files are only opened to confirm). Returns null when nothing on the
|
|
432
|
+
* system covers the codepoint.
|
|
433
|
+
*
|
|
434
|
+
* The families first because that is what a list of them is for: CSS's
|
|
435
|
+
* `font-family: Icons, Arial` sets a letter the icon face lacks in Arial,
|
|
436
|
+
* as a browser does. Walking every registered face instead gave it to
|
|
437
|
+
* whichever was registered first — an app's serif — however far down the
|
|
438
|
+
* list, or off it, that face was.
|
|
389
439
|
*
|
|
390
440
|
* "Nothing covers it" includes "this environment has no system fonts at
|
|
391
441
|
* all". An app that loaded its own faces still reaches here for the first
|
|
@@ -404,10 +454,23 @@ export default class FontManager {
|
|
|
404
454
|
if (perCp.has(codepoint)) return perCp.get(codepoint);
|
|
405
455
|
|
|
406
456
|
let found = null;
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
457
|
+
if (this._registered.length) {
|
|
458
|
+
const weight = numWeight(opts.weight);
|
|
459
|
+
const italic = !!opts.style?.includes('italic');
|
|
460
|
+
for (const name of familiesOf(family)) {
|
|
461
|
+
const font = this._matchRegistered([name.toLowerCase()], weight, italic);
|
|
462
|
+
if (font && font.hasGlyph(codepoint)) {
|
|
463
|
+
found = font;
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (!found) {
|
|
469
|
+
for (const r of this._registered) {
|
|
470
|
+
if (r.font.hasGlyph(codepoint)) {
|
|
471
|
+
found = r.font;
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
411
474
|
}
|
|
412
475
|
}
|
|
413
476
|
if (!found) {
|
|
@@ -474,6 +537,32 @@ export default class FontManager {
|
|
|
474
537
|
* embedding-levels encoding: a single number when uniform for the whole
|
|
475
538
|
* fragment (the common case), else comma-separated per-char levels.
|
|
476
539
|
*/
|
|
540
|
+
/**
|
|
541
|
+
* Whether the two letters of `pair`, cut at `cut`, shape otherwise
|
|
542
|
+
* together than apart in this style: their glyphs or their advances
|
|
543
|
+
* change — a kerning pair, a ligature, a joining script, a mark on the
|
|
544
|
+
* letter before. Compared as a set and a sum, so a right-to-left pair,
|
|
545
|
+
* whose glyphs come in visual order, is read as a left-to-right one is.
|
|
546
|
+
* Kept, since the pairs a paragraph's spans meet at are few and repeat.
|
|
547
|
+
*/
|
|
548
|
+
_meets(pair, cut, style, levelsKey = '0') {
|
|
549
|
+
const key = `${shapeGroupOf(style, levelsKey)}|${cut}|${pair}`;
|
|
550
|
+
let meets = this._meetings.get(key);
|
|
551
|
+
if (meets !== undefined) return meets;
|
|
552
|
+
const whole = this._shapeCached(pair, style, levelsKey);
|
|
553
|
+
const left = this._shapeCached(pair.slice(0, cut), style, sliceKey(levelsKey, 0, cut));
|
|
554
|
+
const right = this._shapeCached(pair.slice(cut), style, sliceKey(levelsKey, cut, pair.length));
|
|
555
|
+
const ids = (...shaped) =>
|
|
556
|
+
shaped
|
|
557
|
+
.flatMap((s) => s.runs.flatMap((r) => r.glyphs.map((g) => g.id)))
|
|
558
|
+
.sort((a, b) => a - b)
|
|
559
|
+
.join(',');
|
|
560
|
+
meets = Math.abs(whole.width - left.width - right.width) > 1e-6 || ids(whole) !== ids(left, right);
|
|
561
|
+
if (this._meetings.size >= SHAPE_GENERATION) this._meetings.clear();
|
|
562
|
+
this._meetings.set(key, meets);
|
|
563
|
+
return meets;
|
|
564
|
+
}
|
|
565
|
+
|
|
477
566
|
_shapeCached(text, style, levelsKey = '0') {
|
|
478
567
|
// A hit in this generation is the whole cost of a word a paragraph has
|
|
479
568
|
// shaped before — a relayout at a new width asks for every word again,
|
package/lib/text/layout.js
CHANGED
|
@@ -40,12 +40,16 @@ function isWsGlyph(g) {
|
|
|
40
40
|
*
|
|
41
41
|
* Content is a plain string or an array of spans
|
|
42
42
|
* `{ text, family?, size?, weight?, style?, features?, language?,
|
|
43
|
-
* letterSpacing?, color?, nowrap? }`;
|
|
43
|
+
* letterSpacing?, color?, nowrap?, shapeApart? }`;
|
|
44
44
|
* span fields override the base style. Spans that share a truthy `nowrap` —
|
|
45
45
|
* `true`, or any value a caller tells its groups apart by — have no break
|
|
46
46
|
* opportunity inside them or between them, as the text of an element with
|
|
47
47
|
* CSS's `white-space: nowrap` has none: the break after the last of them is
|
|
48
|
-
* the next span's to allow.
|
|
48
|
+
* the next span's to allow. Neighbouring spans shaped alike — a colour
|
|
49
|
+
* apart — are shaped as one text, so a word across them keeps its kerning
|
|
50
|
+
* and its joining; a span with a truthy `shapeApart` is shaped on its own,
|
|
51
|
+
* as CSS breaks the shaping at an inline box with a margin, border or
|
|
52
|
+
* padding. Options:
|
|
49
53
|
*
|
|
50
54
|
* - `maxWidth` — target container width (default: unlimited)
|
|
51
55
|
* - `align` — 'left' | 'right' | 'center' | 'start' | 'end'
|
|
@@ -393,24 +397,80 @@ export class TextLayout {
|
|
|
393
397
|
return { spans, text, baseLevel, tokens };
|
|
394
398
|
}
|
|
395
399
|
|
|
400
|
+
/**
|
|
401
|
+
* Whether the letters that meet at an edge between two spans of a group
|
|
402
|
+
* shape otherwise together than apart: their glyphs or their advances
|
|
403
|
+
* change. Compared as a set and a sum, which holds in a right-to-left
|
|
404
|
+
* pair as in a left-to-right one.
|
|
405
|
+
*/
|
|
406
|
+
_meets(text, group, levels, shaping) {
|
|
407
|
+
for (let i = 1; i < group.length; i++) {
|
|
408
|
+
const at = group[i].start;
|
|
409
|
+
const low = text.charCodeAt(at - 1);
|
|
410
|
+
const before = low >= 0xdc00 && low < 0xe000 && at >= 2 ? 2 : 1;
|
|
411
|
+
const after = text.codePointAt(at) > 0xffff ? 2 : 1;
|
|
412
|
+
const from = at - before;
|
|
413
|
+
const to = at + after;
|
|
414
|
+
if (this.fonts._meets(text.slice(from, to), before, shaping, normalizedLevels(levels, from, to))) return true;
|
|
415
|
+
}
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
|
|
396
419
|
_makeToken(text, start, end, levels, spanAt, required) {
|
|
397
420
|
const fragments = [];
|
|
398
421
|
let width = 0;
|
|
399
422
|
let pos = start;
|
|
400
423
|
while (pos < end) {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
//
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
424
|
+
// The spans of the token shaped alike — a colour apart, say — are
|
|
425
|
+
// shaped as one text and the glyphs shared out among them after, so
|
|
426
|
+
// a word that crosses from one to the next keeps the kerning between
|
|
427
|
+
// its letters there, and a script its joining, as a browser shapes
|
|
428
|
+
// it: an opening quote an `::before` makes kerned with nothing, and
|
|
429
|
+
// an Arabic word in two colours came apart into isolated letters.
|
|
430
|
+
// A span marked `shapeApart` is shaped on its own, as CSS breaks the
|
|
431
|
+
// shaping at an inline box with a margin, border or padding.
|
|
432
|
+
const group = [];
|
|
433
|
+
let shaping = null;
|
|
434
|
+
for (let at = pos; at < end; ) {
|
|
435
|
+
const next = spanAt(at);
|
|
436
|
+
if (shaping && (next.shaping !== shaping || next.span.shapeApart || group[group.length - 1].span.shapeApart))
|
|
437
|
+
break;
|
|
438
|
+
shaping = next.shaping;
|
|
439
|
+
const fragEnd = Math.min(end, next.end);
|
|
440
|
+
group.push({ span: next.span, start: at, end: fragEnd });
|
|
441
|
+
at = fragEnd;
|
|
442
|
+
}
|
|
443
|
+
const groupEnd = group[group.length - 1].end;
|
|
444
|
+
// Only where the letters either side of a span's edge shape otherwise
|
|
445
|
+
// together than apart — a kerning pair, a joining script, a mark on
|
|
446
|
+
// the letter before — is the word shaped whole: two letters are a
|
|
447
|
+
// lookup the memo almost always holds, and a syntax-coloured line of
|
|
448
|
+
// code, a span to a token, shaped each of its words afresh otherwise,
|
|
449
|
+
// at twice the cost of the line.
|
|
450
|
+
let pieces = null;
|
|
451
|
+
if (group.length > 1) {
|
|
452
|
+
const joined = text.slice(pos, groupEnd);
|
|
453
|
+
if (!HARD_BREAKS.test(joined) && this._meets(text, group, levels, shaping)) {
|
|
454
|
+
const whole = this.fonts._shapeCached(joined, shaping, normalizedLevels(levels, pos, groupEnd));
|
|
455
|
+
pieces = splitShapedOnce(
|
|
456
|
+
whole,
|
|
457
|
+
group.slice(1).map((g) => g.start - pos)
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
for (let i = 0; i < group.length; i++) {
|
|
462
|
+
const { span, start: fragStart, end: fragEnd } = group[i];
|
|
463
|
+
// hard-break controls only terminate the line; never shape them
|
|
464
|
+
const fragText = text.slice(fragStart, fragEnd).replace(HARD_BREAKS, '');
|
|
465
|
+
if (fragText.length === 0) continue;
|
|
466
|
+
const fragLevels = normalizedLevels(levels, fragStart, fragStart + fragText.length);
|
|
467
|
+
const shaped = pieces ? pieces[i] : this.fonts._shapeCached(fragText, shaping, fragLevels);
|
|
408
468
|
// the levels ride along so a later split can re-shape a piece at the
|
|
409
469
|
// level it actually has, rather than assuming ltr
|
|
410
|
-
fragments.push({ text: fragText, span, shaping, shaped, start:
|
|
470
|
+
fragments.push({ text: fragText, span, shaping, shaped, start: fragStart, levels: fragLevels });
|
|
411
471
|
width += shaped.width;
|
|
412
472
|
}
|
|
413
|
-
pos =
|
|
473
|
+
pos = groupEnd;
|
|
414
474
|
}
|
|
415
475
|
// trailing whitespace does not count against the container width
|
|
416
476
|
let wsWidth = 0;
|
|
@@ -1077,6 +1137,83 @@ function stripTrailingWhitespace(entries) {
|
|
|
1077
1137
|
|
|
1078
1138
|
// The levels key for a code-unit slice of a fragment. A uniform key covers
|
|
1079
1139
|
// any slice of itself; a per-character one has to be cut to match.
|
|
1140
|
+
/**
|
|
1141
|
+
* A shaped text (`shapeText`) cut at `cuts` — code-unit offsets inside it,
|
|
1142
|
+
* ascending — into as many shaped texts, each of its own piece of the text
|
|
1143
|
+
* with its runs and their glyphs, or null where a cut falls inside what a
|
|
1144
|
+
* glyph was made from: a ligature, or a cluster the font set as one, which
|
|
1145
|
+
* no cut can share out. A glyph keeps the advance shaping gave it, so the
|
|
1146
|
+
* kerning between the last letter before a cut and the first after it
|
|
1147
|
+
* stays with the first piece. A right-to-left run's glyphs are in visual
|
|
1148
|
+
* order, and are walked from their end.
|
|
1149
|
+
*/
|
|
1150
|
+
/** `splitShaped`, kept per shaped text and cut, since a shaped text is the
|
|
1151
|
+
* memo's and never changes, and a word a paragraph lays out again is cut
|
|
1152
|
+
* the same way. */
|
|
1153
|
+
const SPLITS = new WeakMap();
|
|
1154
|
+
function splitShapedOnce(shaped, cuts) {
|
|
1155
|
+
const key = cuts.join(',');
|
|
1156
|
+
let byCut = SPLITS.get(shaped);
|
|
1157
|
+
if (!byCut) {
|
|
1158
|
+
byCut = new Map();
|
|
1159
|
+
SPLITS.set(shaped, byCut);
|
|
1160
|
+
}
|
|
1161
|
+
if (byCut.has(key)) return byCut.get(key);
|
|
1162
|
+
const pieces = splitShaped(shaped, cuts);
|
|
1163
|
+
byCut.set(key, pieces);
|
|
1164
|
+
return pieces;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function splitShaped(shaped, cuts) {
|
|
1168
|
+
const bounds = [0, ...cuts, shaped.text.length];
|
|
1169
|
+
const pieces = cuts.concat(shaped.text.length).map((to, i) => ({
|
|
1170
|
+
text: shaped.text.slice(bounds[i], to),
|
|
1171
|
+
width: 0,
|
|
1172
|
+
baseLevel: shaped.baseLevel,
|
|
1173
|
+
runs: []
|
|
1174
|
+
}));
|
|
1175
|
+
for (const run of shaped.runs) {
|
|
1176
|
+
const rtl = (run.level & 1) === 1;
|
|
1177
|
+
const logical = rtl ? [...run.glyphs].reverse() : run.glyphs;
|
|
1178
|
+
let piece = 0;
|
|
1179
|
+
while (bounds[piece + 1] <= run.start) piece++;
|
|
1180
|
+
let at = run.start;
|
|
1181
|
+
let from = at;
|
|
1182
|
+
let glyphs = [];
|
|
1183
|
+
const close = () => {
|
|
1184
|
+
if (at === from && glyphs.length === 0) return;
|
|
1185
|
+
const offset = bounds[piece];
|
|
1186
|
+
let width = 0;
|
|
1187
|
+
for (const g of glyphs) width += g.ax;
|
|
1188
|
+
pieces[piece].runs.push({
|
|
1189
|
+
...run,
|
|
1190
|
+
glyphs: rtl ? glyphs.reverse() : glyphs,
|
|
1191
|
+
width,
|
|
1192
|
+
text: shaped.text.slice(from, at),
|
|
1193
|
+
start: from - offset,
|
|
1194
|
+
end: at - offset
|
|
1195
|
+
});
|
|
1196
|
+
pieces[piece].width += width;
|
|
1197
|
+
};
|
|
1198
|
+
for (const g of logical) {
|
|
1199
|
+
let units = 0;
|
|
1200
|
+
for (const cp of g.codePoints ?? []) units += cp > 0xffff ? 2 : 1;
|
|
1201
|
+
if (units > 0 && at >= bounds[piece + 1]) {
|
|
1202
|
+
close();
|
|
1203
|
+
while (at >= bounds[piece + 1]) piece++;
|
|
1204
|
+
from = at;
|
|
1205
|
+
glyphs = [];
|
|
1206
|
+
}
|
|
1207
|
+
if (at + units > bounds[piece + 1]) return null;
|
|
1208
|
+
glyphs.push(g);
|
|
1209
|
+
at += units;
|
|
1210
|
+
}
|
|
1211
|
+
if (at !== run.end) return null;
|
|
1212
|
+
close();
|
|
1213
|
+
}
|
|
1214
|
+
return pieces;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1080
1217
|
function sliceLevels(key, start, end) {
|
|
1081
1218
|
if (key === undefined) return '0';
|
|
1082
1219
|
if (!key.includes(',')) return key;
|
package/lib/text/shape.js
CHANGED
|
@@ -74,23 +74,32 @@ export function normalizedLevels(levels, start, end) {
|
|
|
74
74
|
*/
|
|
75
75
|
const OPTIONAL_LIGATURES = ['liga', 'clig', 'dlig', 'hlig'];
|
|
76
76
|
|
|
77
|
-
/**
|
|
78
|
-
* The features a letter-spaced run shapes with: the optional ligatures off,
|
|
79
|
-
* underneath whatever the caller asked for — a style that names `liga`
|
|
80
|
-
* itself still gets it. `features` is either form fontkit takes, an array of
|
|
81
|
-
* tags to turn on or an object of tag → on/off.
|
|
82
|
-
*/
|
|
83
77
|
/**
|
|
84
78
|
* A copy of `features` for fontkit, which **adds to the object it is handed**
|
|
85
79
|
* — `rvrn` and the like, as it plans the shaping. Handed a caller's own
|
|
86
80
|
* object that is a style quietly growing keys, which changes the memo key it
|
|
87
81
|
* is filed under next time, and a frozen one throws.
|
|
82
|
+
*
|
|
83
|
+
* A feature a 0 turns off, as CSS writes one (`font-feature-settings: "kern"
|
|
84
|
+
* 0`, and `font-kerning: none`), goes to fontkit as `false`. It reads a 0 as
|
|
85
|
+
* off where it plans the OpenType features, but where a face keeps its
|
|
86
|
+
* kerning in the older `kern` table — Times New Roman's, and DejaVu's beside
|
|
87
|
+
* its GPOS pairs — it asks `kern !== false`, and a 0 kerned all the same.
|
|
88
88
|
*/
|
|
89
89
|
function ownFeatures(features) {
|
|
90
90
|
if (!features) return features;
|
|
91
|
-
|
|
91
|
+
if (Array.isArray(features)) return [...features];
|
|
92
|
+
const out = {};
|
|
93
|
+
for (const tag in features) out[tag] = features[tag] === 0 ? false : features[tag];
|
|
94
|
+
return out;
|
|
92
95
|
}
|
|
93
96
|
|
|
97
|
+
/**
|
|
98
|
+
* The features a letter-spaced run shapes with: the optional ligatures off,
|
|
99
|
+
* underneath whatever the caller asked for — a style that names `liga`
|
|
100
|
+
* itself still gets it. `features` is either form fontkit takes, an array of
|
|
101
|
+
* tags to turn on or an object of tag → on/off.
|
|
102
|
+
*/
|
|
94
103
|
function spacedFeatures(features) {
|
|
95
104
|
const off = Object.fromEntries(OPTIONAL_LIGATURES.map((tag) => [tag, false]));
|
|
96
105
|
if (!features) return off;
|
|
@@ -98,7 +107,7 @@ function spacedFeatures(features) {
|
|
|
98
107
|
for (const tag of features) off[tag] = true;
|
|
99
108
|
return off;
|
|
100
109
|
}
|
|
101
|
-
return { ...off, ...features };
|
|
110
|
+
return { ...off, ...ownFeatures(features) };
|
|
102
111
|
}
|
|
103
112
|
|
|
104
113
|
/**
|