ntk 8.14.7 → 8.15.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/text/layout.js +109 -20
- package/package.json +1 -1
package/lib/text/layout.js
CHANGED
|
@@ -18,6 +18,17 @@ const TRAILING_WS = /[ \t]+$/;
|
|
|
18
18
|
const UNSPACED =
|
|
19
19
|
/[\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}\p{Script=Tai_Le}\p{Script=New_Tai_Lue}\p{Script=Tai_Tham}\p{Script=Tai_Viet}\p{Script=Ahom}\p{Script=Javanese}\p{Script=Balinese}\p{Script=Buginese}]/u;
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* How far a line's content may reach past its width and still fit it: a
|
|
23
|
+
* 64th of a pixel. The advances of a line are summed in floating point and
|
|
24
|
+
* come out a hair over a width they were set to fill — a line of Verdana at
|
|
25
|
+
* 12.8px measures 410.0125px in the 410px column it was written for — and
|
|
26
|
+
* a browser takes the line as fitting, comparing in 64ths of a pixel with
|
|
27
|
+
* that much to spare (Blink's line breaker adds a LayoutUnit's epsilon to
|
|
28
|
+
* the width it fits to).
|
|
29
|
+
*/
|
|
30
|
+
const FIT_SLACK = 1 / 64;
|
|
31
|
+
|
|
21
32
|
/** How wide a line's tokens are, the white space it ends on left out. */
|
|
22
33
|
function tokensWidth(toks) {
|
|
23
34
|
let width = 0;
|
|
@@ -46,7 +57,7 @@ function isWsGlyph(g) {
|
|
|
46
57
|
*
|
|
47
58
|
* Content is a plain string or an array of spans
|
|
48
59
|
* `{ text, family?, size?, weight?, style?, features?, language?,
|
|
49
|
-
* letterSpacing?, color?, nowrap?, shapeApart? }`;
|
|
60
|
+
* letterSpacing?, color?, nowrap?, shapeApart?, kernAcross? }`;
|
|
50
61
|
* span fields override the base style. Spans that share a truthy `nowrap` —
|
|
51
62
|
* `true`, or any value a caller tells its groups apart by — have no break
|
|
52
63
|
* opportunity inside them or between them, as the text of an element with
|
|
@@ -55,7 +66,12 @@ function isWsGlyph(g) {
|
|
|
55
66
|
* apart — are shaped as one text, so a word across them keeps its kerning
|
|
56
67
|
* and its joining; a span with a truthy `shapeApart` is shaped on its own,
|
|
57
68
|
* as CSS breaks the shaping at an inline box with a margin, border or
|
|
58
|
-
* padding.
|
|
69
|
+
* padding. A span of another letter spacing is shaped on its own too, as a
|
|
70
|
+
* browser shapes an element's `letter-spacing`, but one with a truthy
|
|
71
|
+
* `kernAcross` keeps the kerning its letters make with its neighbours':
|
|
72
|
+
* the spacing a justified line or CSS's `word-spacing` adds to a space is
|
|
73
|
+
* in addition to kerning, and no element's (CSS Text 3, 7.2, 7.3).
|
|
74
|
+
* Options:
|
|
59
75
|
*
|
|
60
76
|
* - `maxWidth` — target container width (default: unlimited)
|
|
61
77
|
* - `align` — 'left' | 'right' | 'center' | 'start' | 'end'
|
|
@@ -127,7 +143,10 @@ export class TextLayout {
|
|
|
127
143
|
curWidth = 0;
|
|
128
144
|
};
|
|
129
145
|
for (let token of tokens) {
|
|
130
|
-
while (
|
|
146
|
+
while (
|
|
147
|
+
wraps &&
|
|
148
|
+
curWidth + (cur.length ? kernBefore(token) : 0) + token.width - token.wsWidth > maxWidth + FIT_SLACK
|
|
149
|
+
) {
|
|
131
150
|
if (cur.length > 0) {
|
|
132
151
|
flush();
|
|
133
152
|
} else if (whole && !UNSPACED.test(text.slice(token.start, token.end))) {
|
|
@@ -180,7 +199,7 @@ export class TextLayout {
|
|
|
180
199
|
// Elision is the last kept line's business, or a line's that does not
|
|
181
200
|
// wrap and runs past the width, and it happens before entries exist,
|
|
182
201
|
// because dropping content means re-shaping the tail.
|
|
183
|
-
const cut = (elide && li === lineTokens.length - 1) || (cutWide && tokensWidth(toks) > maxWidth);
|
|
202
|
+
const cut = (elide && li === lineTokens.length - 1) || (cutWide && tokensWidth(toks) > maxWidth + FIT_SLACK);
|
|
184
203
|
const ellipsis = cut ? this._elide(toks, baseSpan) : null;
|
|
185
204
|
if (ellipsis) {
|
|
186
205
|
toks = this._fitBefore(toks, maxWidth - ellipsis.width);
|
|
@@ -195,7 +214,13 @@ export class TextLayout {
|
|
|
195
214
|
const token = toks[t];
|
|
196
215
|
// the kerning with the token before it goes before its first run
|
|
197
216
|
let kern = t ? kernBefore(token) : 0;
|
|
198
|
-
for (
|
|
217
|
+
for (let f = 0; f < token.fragments.length; f++) {
|
|
218
|
+
const frag = token.fragments[f];
|
|
219
|
+
// and a fragment's with the one before it after that one's last
|
|
220
|
+
// run, where shaping puts a pair's kerning: on its first letter,
|
|
221
|
+
// which keeps it when the second is a space the line ends on
|
|
222
|
+
const before = entries[entries.length - 1];
|
|
223
|
+
if (f && frag.kern && before) before.kernAfter = (before.kernAfter ?? 0) + frag.kern;
|
|
199
224
|
for (const run of frag.shaped.runs) {
|
|
200
225
|
const entry = {
|
|
201
226
|
run,
|
|
@@ -219,6 +244,9 @@ export class TextLayout {
|
|
|
219
244
|
? entries[entries.length - 1].end
|
|
220
245
|
: lineStart;
|
|
221
246
|
if (ellipsis) {
|
|
247
|
+
// the letter the ellipsis follows is kerned with nothing after it
|
|
248
|
+
const last = entries[entries.length - 1];
|
|
249
|
+
if (last?.kernAfter) entries[entries.length - 1] = { ...last, kernAfter: 0 };
|
|
222
250
|
// The ellipsis takes the *paragraph* level, which is what a neutral
|
|
223
251
|
// at the end of a paragraph resolves to under UAX#9 — so reorderRuns
|
|
224
252
|
// puts it at the right edge of an LTR line and the left edge of an
|
|
@@ -253,6 +281,7 @@ export class TextLayout {
|
|
|
253
281
|
if (e.ellipsis) positioned.ellipsis = true;
|
|
254
282
|
runs.push(positioned);
|
|
255
283
|
x += e.run.width;
|
|
284
|
+
if (e.kernAfter) x += e.kernAfter;
|
|
256
285
|
const m = e.run.font.metrics(e.run.size);
|
|
257
286
|
if (m.ascent > ascent) ascent = m.ascent;
|
|
258
287
|
if (m.descent > descent) descent = m.descent;
|
|
@@ -425,15 +454,27 @@ export class TextLayout {
|
|
|
425
454
|
* pair either side of a break opportunity was never kerned — Trebuchet MS
|
|
426
455
|
* sets a space closer to an A, a T or a Y — and a line of it came out
|
|
427
456
|
* wider than a browser's, which shapes a line whole: shaping breaks only
|
|
428
|
-
* at an inline box's margin, border or padding (CSS Text 3, 7.3).
|
|
429
|
-
*
|
|
430
|
-
* breaks there never meets it.
|
|
457
|
+
* at an inline box's margin, border or padding (CSS Text 3, 7.3). A line
|
|
458
|
+
* that breaks there never meets it.
|
|
431
459
|
*/
|
|
432
460
|
_kernBefore(text, prev, next, levels) {
|
|
433
461
|
if (prev.required) return 0;
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
462
|
+
return this._kernBetween(text, prev.fragments[prev.fragments.length - 1], next.fragments[0], levels);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* The kerning between the last letter of fragment `a` and the first of
|
|
467
|
+
* `b`, which follows it: at one left-to-right level, between letters
|
|
468
|
+
* shaped alike, or alike but for the spacing a `kernAcross` span adds
|
|
469
|
+
* (`pairShaping`). A justified line spaces its spaces and nothing else,
|
|
470
|
+
* so each is a span apart, and the pairs a space makes (Arial's space and
|
|
471
|
+
* T, its L and space) were dropped there, where the same line unjustified
|
|
472
|
+
* kept them: it came out wider and broke a word before a browser does.
|
|
473
|
+
*/
|
|
474
|
+
_kernBetween(text, a, b, levels) {
|
|
475
|
+
if (!a || !b || a.span.shapeApart || b.span.shapeApart) return 0;
|
|
476
|
+
const shaping = pairShaping(a, b);
|
|
477
|
+
if (!shaping) return 0;
|
|
437
478
|
const at = a.start + a.text.length;
|
|
438
479
|
if (at !== b.start) return 0;
|
|
439
480
|
// two glyphs the face's tables cannot move against each other are set
|
|
@@ -450,7 +491,7 @@ export class TextLayout {
|
|
|
450
491
|
const after = text.codePointAt(at) > 0xffff ? 2 : 1;
|
|
451
492
|
const key = normalizedLevels(levels, at - before, at + after);
|
|
452
493
|
if (key.includes(',') || Number(key) & 1) return 0;
|
|
453
|
-
return this.fonts._kernAcross(text.slice(at - before, at + after), before,
|
|
494
|
+
return this.fonts._kernAcross(text.slice(at - before, at + after), before, shaping, key);
|
|
454
495
|
}
|
|
455
496
|
|
|
456
497
|
/**
|
|
@@ -523,7 +564,17 @@ export class TextLayout {
|
|
|
523
564
|
const shaped = pieces ? pieces[i] : this.fonts._shapeCached(fragText, shaping, fragLevels);
|
|
524
565
|
// the levels ride along so a later split can re-shape a piece at the
|
|
525
566
|
// level it actually has, rather than assuming ltr
|
|
526
|
-
|
|
567
|
+
const frag = { text: fragText, span, shaping, shaped, start: fragStart, levels: fragLevels };
|
|
568
|
+
// a group shaped apart from the one before it for the spacing a
|
|
569
|
+
// `kernAcross` span adds still kerns against it (`_kernBetween`)
|
|
570
|
+
if (i === 0 && fragments.length) {
|
|
571
|
+
const kern = this._kernBetween(text, fragments[fragments.length - 1], frag, levels);
|
|
572
|
+
if (kern) {
|
|
573
|
+
frag.kern = kern;
|
|
574
|
+
width += kern;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
fragments.push(frag);
|
|
527
578
|
width += shaped.width;
|
|
528
579
|
}
|
|
529
580
|
pos = groupEnd;
|
|
@@ -591,7 +642,7 @@ export class TextLayout {
|
|
|
591
642
|
// trailing whitespace does not count against the budget, exactly as it
|
|
592
643
|
// does not count during the greedy fill
|
|
593
644
|
const kern = kept.length ? kernBefore(token) : 0;
|
|
594
|
-
if (used + kern + token.width - token.wsWidth <= budget) {
|
|
645
|
+
if (used + kern + token.width - token.wsWidth <= budget + FIT_SLACK) {
|
|
595
646
|
kept.push(token);
|
|
596
647
|
used += kern + token.width;
|
|
597
648
|
continue;
|
|
@@ -609,9 +660,12 @@ export class TextLayout {
|
|
|
609
660
|
let used = 0;
|
|
610
661
|
for (let i = 0; i < token.fragments.length; i++) {
|
|
611
662
|
const frag = token.fragments[i];
|
|
612
|
-
|
|
663
|
+
// the kerning with the fragment before it, which a token's first has
|
|
664
|
+
// none of: it starts a line
|
|
665
|
+
const kern = i && frag.kern ? frag.kern : 0;
|
|
666
|
+
if (used + kern + frag.shaped.width <= maxWidth + FIT_SLACK) {
|
|
613
667
|
headFrags.push(frag);
|
|
614
|
-
used += frag.shaped.width;
|
|
668
|
+
used += kern + frag.shaped.width;
|
|
615
669
|
continue;
|
|
616
670
|
}
|
|
617
671
|
// shaped at the bidi level this text actually has: assuming level 0
|
|
@@ -626,7 +680,7 @@ export class TextLayout {
|
|
|
626
680
|
// and its combining mark, or inside an emoji ZWJ sequence, leaves a
|
|
627
681
|
// dotted circle or a pair of half-emoji on the two sides of the break.
|
|
628
682
|
const lead = firstGrapheme(frag.text);
|
|
629
|
-
if (used + shape(lead, 0, lead.length).width <= maxWidth) {
|
|
683
|
+
if (used + kern + shape(lead, 0, lead.length).width <= maxWidth + FIT_SLACK) {
|
|
630
684
|
// binary search the longest grapheme prefix of this fragment that fits
|
|
631
685
|
const cps = graphemes(frag.text);
|
|
632
686
|
let lo = 0;
|
|
@@ -635,7 +689,7 @@ export class TextLayout {
|
|
|
635
689
|
const mid = (lo + hi) >> 1;
|
|
636
690
|
const prefix = cps.slice(0, mid + 1).join('');
|
|
637
691
|
const shaped = shape(prefix, 0, prefix.length);
|
|
638
|
-
if (used + shaped.width <= maxWidth) {
|
|
692
|
+
if (used + kern + shaped.width <= maxWidth + FIT_SLACK) {
|
|
639
693
|
best = { len: prefix.length, shaped, text: prefix };
|
|
640
694
|
lo = mid + 1;
|
|
641
695
|
} else {
|
|
@@ -657,7 +711,8 @@ export class TextLayout {
|
|
|
657
711
|
shaping,
|
|
658
712
|
shaped: best.shaped,
|
|
659
713
|
start: frag.start,
|
|
660
|
-
levels: sliceLevels(frag.levels, 0, best.len)
|
|
714
|
+
levels: sliceLevels(frag.levels, 0, best.len),
|
|
715
|
+
...(kern ? { kern } : null)
|
|
661
716
|
});
|
|
662
717
|
}
|
|
663
718
|
|
|
@@ -676,7 +731,7 @@ export class TextLayout {
|
|
|
676
731
|
});
|
|
677
732
|
}
|
|
678
733
|
restFrags.push(...token.fragments.slice(i + 1));
|
|
679
|
-
const sum = (frags) => frags.reduce((w, f) => w + f.shaped.width, 0);
|
|
734
|
+
const sum = (frags) => frags.reduce((w, f, j) => w + f.shaped.width + (j && f.kern ? f.kern : 0), 0);
|
|
680
735
|
const splitAt = restFrags.length ? restFrags[0].start : token.end;
|
|
681
736
|
const head = headFrags.length
|
|
682
737
|
? { fragments: headFrags, width: sum(headFrags), wsWidth: 0, required: false, start: token.start, end: splitAt }
|
|
@@ -1192,6 +1247,8 @@ function stripTrailingWhitespace(entries) {
|
|
|
1192
1247
|
}
|
|
1193
1248
|
entries[i] = {
|
|
1194
1249
|
...entries[i],
|
|
1250
|
+
// its kerning with what came after it went with its last letter
|
|
1251
|
+
kernAfter: 0,
|
|
1195
1252
|
end: entries[i].end - cuStripped,
|
|
1196
1253
|
run: {
|
|
1197
1254
|
...run,
|
|
@@ -1318,6 +1375,38 @@ function visibleRows(ctx, y) {
|
|
|
1318
1375
|
*/
|
|
1319
1376
|
const MAX_SHAPING_STYLES = 16;
|
|
1320
1377
|
|
|
1378
|
+
/**
|
|
1379
|
+
* What the letters either side of fragments `a` and `b` are shaped with as
|
|
1380
|
+
* a pair: the style both were shaped with, or where the two differ in
|
|
1381
|
+
* their letter spacing alone and one is a `kernAcross` span's, the spaced
|
|
1382
|
+
* one — spacing is in addition to kerning, and a pair with spacing between
|
|
1383
|
+
* it takes no optional ligature (CSS Text 3, 7.2). None otherwise: an
|
|
1384
|
+
* element's own letter spacing is a change of formatting a browser breaks
|
|
1385
|
+
* the shaping at.
|
|
1386
|
+
*/
|
|
1387
|
+
function pairShaping(fa, fb) {
|
|
1388
|
+
const a = fa.shaping;
|
|
1389
|
+
const b = fb.shaping;
|
|
1390
|
+
if (a === b) return a;
|
|
1391
|
+
if (!fa.span.kernAcross && !fb.span.kernAcross) return null;
|
|
1392
|
+
if (
|
|
1393
|
+
a.given !== b.given ||
|
|
1394
|
+
a.font !== b.font ||
|
|
1395
|
+
a.family !== b.family ||
|
|
1396
|
+
a.size !== b.size ||
|
|
1397
|
+
a.weight !== b.weight ||
|
|
1398
|
+
a.style !== b.style ||
|
|
1399
|
+
a.variations !== b.variations ||
|
|
1400
|
+
a.opticalSize !== b.opticalSize ||
|
|
1401
|
+
a.opticalSizing !== b.opticalSizing ||
|
|
1402
|
+
a.features !== b.features ||
|
|
1403
|
+
a.language !== b.language
|
|
1404
|
+
) {
|
|
1405
|
+
return null;
|
|
1406
|
+
}
|
|
1407
|
+
return a.letterSpacing ? a : b;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1321
1410
|
/**
|
|
1322
1411
|
* The style `span` is shaped with: one already made for a span whose every
|
|
1323
1412
|
* property shaping reads is the same (`===`), or a new one. Its font is the
|