ntk 8.14.1 → 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/fontmanager.js +35 -0
- package/lib/text/layout.js +148 -11
- package/package.json +1 -1
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);
|
|
@@ -221,6 +228,8 @@ export default class FontManager {
|
|
|
221
228
|
this._shapeCache = new Map();
|
|
222
229
|
this._shapeCacheBefore = new Map();
|
|
223
230
|
this._shapeCount = 0;
|
|
231
|
+
// whether two letters shape otherwise together than apart (`_meets`)
|
|
232
|
+
this._meetings = new Map();
|
|
224
233
|
// a paragraph's layout up to the line fill, for its next width
|
|
225
234
|
// (text/paragraphs.js, TextLayout)
|
|
226
235
|
this._paragraphs = new ParagraphCache();
|
|
@@ -528,6 +537,32 @@ export default class FontManager {
|
|
|
528
537
|
* embedding-levels encoding: a single number when uniform for the whole
|
|
529
538
|
* fragment (the common case), else comma-separated per-char levels.
|
|
530
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
|
+
|
|
531
566
|
_shapeCached(text, style, levelsKey = '0') {
|
|
532
567
|
// A hit in this generation is the whole cost of a word a paragraph has
|
|
533
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;
|