ntk 8.14.7 → 8.15.1
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 +190 -28
- package/package.json +1 -1
package/lib/text/layout.js
CHANGED
|
@@ -18,11 +18,95 @@ 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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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). What it compares is the line's items, each
|
|
29
|
+
* rounded up to a 64th first (`fittedWidth`).
|
|
30
|
+
*/
|
|
31
|
+
const FIT_SLACK = 1 / 64;
|
|
32
|
+
|
|
33
|
+
/** A width rounded up to a 64th of a pixel, as Blink rounds an item's
|
|
34
|
+
* shaped width up to a LayoutUnit (`ShapeResult::SnappedWidth`). The
|
|
35
|
+
* nudge keeps a sum that lands on a 64th, give or take the last bit of a
|
|
36
|
+
* double, where it lands. */
|
|
37
|
+
function snapUp(width) {
|
|
38
|
+
return Math.ceil(width * 64 - 1e-7) / 64;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A line's width as a browser fits it: its spans' parts of it each rounded
|
|
43
|
+
* up to a 64th of a pixel and summed, its trailing white space left out.
|
|
44
|
+
*
|
|
45
|
+
* A browser measures a line in items — the text of one element between its
|
|
46
|
+
* edges, shaped — and rounds each one's width up to a LayoutUnit before it
|
|
47
|
+
* adds it (Blink's `ShapeResult::SnappedWidth`, which every text item's
|
|
48
|
+
* `inline_size` on a line is), so a line of three items is up to three 64ths
|
|
49
|
+
* wider than its advances. Compared with the width and its 64th to spare
|
|
50
|
+
* (`FIT_SLACK`), a line of Verdana 0.002px past its 529px, the `<abbr>` in
|
|
51
|
+
* it making three items, is 2 64ths past and breaks, as it does in Chrome;
|
|
52
|
+
* a line that is one item and a hair over still fits. A span is an item,
|
|
53
|
+
* and so is its part of a line; a `kernAcross` span is not one of its own
|
|
54
|
+
* — it is a space a justified line or `word-spacing` spaces out, which a
|
|
55
|
+
* browser spaces inside the item — and is measured with the span before it.
|
|
56
|
+
*
|
|
57
|
+
* Kept a token at a time: `add` puts one on the line, `with` is the line's
|
|
58
|
+
* width were the next one put on it.
|
|
59
|
+
*/
|
|
60
|
+
class LineFit {
|
|
61
|
+
constructor() {
|
|
62
|
+
this.reset();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
reset() {
|
|
66
|
+
/** the items closed on the line, each rounded up */
|
|
67
|
+
this.closed = 0;
|
|
68
|
+
/** the width of the item the line ends in, as far as it goes */
|
|
69
|
+
this.open = 0;
|
|
70
|
+
this.span = null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
add(token, kern) {
|
|
74
|
+
const frags = token.fragments;
|
|
75
|
+
for (let i = 0; i < frags.length; i++) {
|
|
76
|
+
const frag = frags[i];
|
|
77
|
+
const width = frag.shaped.width + (frag.kern ?? 0) + (i === 0 ? kern : 0);
|
|
78
|
+
if (this.span === null || this.span === frag.span || frag.span.kernAcross || this.span.kernAcross) {
|
|
79
|
+
this.open += width;
|
|
80
|
+
} else {
|
|
81
|
+
this.closed += snapUp(this.open);
|
|
82
|
+
this.open = width;
|
|
83
|
+
}
|
|
84
|
+
this.span = frag.span;
|
|
85
|
+
}
|
|
86
|
+
// a token of no text, a forced break's, adds nothing but its kerning
|
|
87
|
+
if (!frags.length) this.open += kern;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The line's width with `token` on it, the white space it ends on left
|
|
91
|
+
* out. */
|
|
92
|
+
with(token, kern) {
|
|
93
|
+
const { closed, open, span } = this;
|
|
94
|
+
this.add(token, kern);
|
|
95
|
+
const width = this.closed + snapUp(this.open - token.wsWidth);
|
|
96
|
+
this.closed = closed;
|
|
97
|
+
this.open = open;
|
|
98
|
+
this.span = span;
|
|
99
|
+
return width;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A line's tokens' width as a browser fits it (`LineFit`). */
|
|
104
|
+
function fittedWidth(toks) {
|
|
105
|
+
if (!toks.length) return 0;
|
|
106
|
+
const fit = new LineFit();
|
|
107
|
+
for (let i = 0; i < toks.length - 1; i++) fit.add(toks[i], i ? kernBefore(toks[i]) : 0);
|
|
108
|
+
const last = toks.length - 1;
|
|
109
|
+
return fit.with(toks[last], last ? kernBefore(toks[last]) : 0);
|
|
26
110
|
}
|
|
27
111
|
|
|
28
112
|
/** The kerning between a token and the one before it on its line
|
|
@@ -46,7 +130,7 @@ function isWsGlyph(g) {
|
|
|
46
130
|
*
|
|
47
131
|
* Content is a plain string or an array of spans
|
|
48
132
|
* `{ text, family?, size?, weight?, style?, features?, language?,
|
|
49
|
-
* letterSpacing?, color?, nowrap?, shapeApart? }`;
|
|
133
|
+
* letterSpacing?, color?, nowrap?, shapeApart?, kernAcross? }`;
|
|
50
134
|
* span fields override the base style. Spans that share a truthy `nowrap` —
|
|
51
135
|
* `true`, or any value a caller tells its groups apart by — have no break
|
|
52
136
|
* opportunity inside them or between them, as the text of an element with
|
|
@@ -55,7 +139,12 @@ function isWsGlyph(g) {
|
|
|
55
139
|
* apart — are shaped as one text, so a word across them keeps its kerning
|
|
56
140
|
* and its joining; a span with a truthy `shapeApart` is shaped on its own,
|
|
57
141
|
* as CSS breaks the shaping at an inline box with a margin, border or
|
|
58
|
-
* padding.
|
|
142
|
+
* padding. A span of another letter spacing is shaped on its own too, as a
|
|
143
|
+
* browser shapes an element's `letter-spacing`, but one with a truthy
|
|
144
|
+
* `kernAcross` keeps the kerning its letters make with its neighbours':
|
|
145
|
+
* the spacing a justified line or CSS's `word-spacing` adds to a space is
|
|
146
|
+
* in addition to kerning, and no element's (CSS Text 3, 7.2, 7.3).
|
|
147
|
+
* Options:
|
|
59
148
|
*
|
|
60
149
|
* - `maxWidth` — target container width (default: unlimited)
|
|
61
150
|
* - `align` — 'left' | 'right' | 'center' | 'start' | 'end'
|
|
@@ -120,14 +209,17 @@ export class TextLayout {
|
|
|
120
209
|
const wraps = options.wrap !== false;
|
|
121
210
|
{
|
|
122
211
|
let cur = [];
|
|
123
|
-
|
|
212
|
+
const fit = new LineFit();
|
|
124
213
|
const flush = () => {
|
|
125
214
|
lineTokens.push(cur);
|
|
126
215
|
cur = [];
|
|
127
|
-
|
|
216
|
+
fit.reset();
|
|
128
217
|
};
|
|
129
218
|
for (let token of tokens) {
|
|
130
|
-
while (
|
|
219
|
+
while (
|
|
220
|
+
wraps &&
|
|
221
|
+
fit.with(token, cur.length ? kernBefore(token) : 0) > maxWidth + FIT_SLACK
|
|
222
|
+
) {
|
|
131
223
|
if (cur.length > 0) {
|
|
132
224
|
flush();
|
|
133
225
|
} else if (whole && !UNSPACED.test(text.slice(token.start, token.end))) {
|
|
@@ -141,7 +233,7 @@ export class TextLayout {
|
|
|
141
233
|
token = rest;
|
|
142
234
|
}
|
|
143
235
|
}
|
|
144
|
-
|
|
236
|
+
fit.add(token, cur.length ? kernBefore(token) : 0);
|
|
145
237
|
cur.push(token);
|
|
146
238
|
if (token.required) flush();
|
|
147
239
|
}
|
|
@@ -180,7 +272,7 @@ export class TextLayout {
|
|
|
180
272
|
// Elision is the last kept line's business, or a line's that does not
|
|
181
273
|
// wrap and runs past the width, and it happens before entries exist,
|
|
182
274
|
// because dropping content means re-shaping the tail.
|
|
183
|
-
const cut = (elide && li === lineTokens.length - 1) || (cutWide &&
|
|
275
|
+
const cut = (elide && li === lineTokens.length - 1) || (cutWide && fittedWidth(toks) > maxWidth + FIT_SLACK);
|
|
184
276
|
const ellipsis = cut ? this._elide(toks, baseSpan) : null;
|
|
185
277
|
if (ellipsis) {
|
|
186
278
|
toks = this._fitBefore(toks, maxWidth - ellipsis.width);
|
|
@@ -195,7 +287,13 @@ export class TextLayout {
|
|
|
195
287
|
const token = toks[t];
|
|
196
288
|
// the kerning with the token before it goes before its first run
|
|
197
289
|
let kern = t ? kernBefore(token) : 0;
|
|
198
|
-
for (
|
|
290
|
+
for (let f = 0; f < token.fragments.length; f++) {
|
|
291
|
+
const frag = token.fragments[f];
|
|
292
|
+
// and a fragment's with the one before it after that one's last
|
|
293
|
+
// run, where shaping puts a pair's kerning: on its first letter,
|
|
294
|
+
// which keeps it when the second is a space the line ends on
|
|
295
|
+
const before = entries[entries.length - 1];
|
|
296
|
+
if (f && frag.kern && before) before.kernAfter = (before.kernAfter ?? 0) + frag.kern;
|
|
199
297
|
for (const run of frag.shaped.runs) {
|
|
200
298
|
const entry = {
|
|
201
299
|
run,
|
|
@@ -219,6 +317,9 @@ export class TextLayout {
|
|
|
219
317
|
? entries[entries.length - 1].end
|
|
220
318
|
: lineStart;
|
|
221
319
|
if (ellipsis) {
|
|
320
|
+
// the letter the ellipsis follows is kerned with nothing after it
|
|
321
|
+
const last = entries[entries.length - 1];
|
|
322
|
+
if (last?.kernAfter) entries[entries.length - 1] = { ...last, kernAfter: 0 };
|
|
222
323
|
// The ellipsis takes the *paragraph* level, which is what a neutral
|
|
223
324
|
// at the end of a paragraph resolves to under UAX#9 — so reorderRuns
|
|
224
325
|
// puts it at the right edge of an LTR line and the left edge of an
|
|
@@ -253,6 +354,7 @@ export class TextLayout {
|
|
|
253
354
|
if (e.ellipsis) positioned.ellipsis = true;
|
|
254
355
|
runs.push(positioned);
|
|
255
356
|
x += e.run.width;
|
|
357
|
+
if (e.kernAfter) x += e.kernAfter;
|
|
256
358
|
const m = e.run.font.metrics(e.run.size);
|
|
257
359
|
if (m.ascent > ascent) ascent = m.ascent;
|
|
258
360
|
if (m.descent > descent) descent = m.descent;
|
|
@@ -425,15 +527,27 @@ export class TextLayout {
|
|
|
425
527
|
* pair either side of a break opportunity was never kerned — Trebuchet MS
|
|
426
528
|
* sets a space closer to an A, a T or a Y — and a line of it came out
|
|
427
529
|
* 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.
|
|
530
|
+
* at an inline box's margin, border or padding (CSS Text 3, 7.3). A line
|
|
531
|
+
* that breaks there never meets it.
|
|
431
532
|
*/
|
|
432
533
|
_kernBefore(text, prev, next, levels) {
|
|
433
534
|
if (prev.required) return 0;
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
535
|
+
return this._kernBetween(text, prev.fragments[prev.fragments.length - 1], next.fragments[0], levels);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* The kerning between the last letter of fragment `a` and the first of
|
|
540
|
+
* `b`, which follows it: at one left-to-right level, between letters
|
|
541
|
+
* shaped alike, or alike but for the spacing a `kernAcross` span adds
|
|
542
|
+
* (`pairShaping`). A justified line spaces its spaces and nothing else,
|
|
543
|
+
* so each is a span apart, and the pairs a space makes (Arial's space and
|
|
544
|
+
* T, its L and space) were dropped there, where the same line unjustified
|
|
545
|
+
* kept them: it came out wider and broke a word before a browser does.
|
|
546
|
+
*/
|
|
547
|
+
_kernBetween(text, a, b, levels) {
|
|
548
|
+
if (!a || !b || a.span.shapeApart || b.span.shapeApart) return 0;
|
|
549
|
+
const shaping = pairShaping(a, b);
|
|
550
|
+
if (!shaping) return 0;
|
|
437
551
|
const at = a.start + a.text.length;
|
|
438
552
|
if (at !== b.start) return 0;
|
|
439
553
|
// two glyphs the face's tables cannot move against each other are set
|
|
@@ -450,7 +564,7 @@ export class TextLayout {
|
|
|
450
564
|
const after = text.codePointAt(at) > 0xffff ? 2 : 1;
|
|
451
565
|
const key = normalizedLevels(levels, at - before, at + after);
|
|
452
566
|
if (key.includes(',') || Number(key) & 1) return 0;
|
|
453
|
-
return this.fonts._kernAcross(text.slice(at - before, at + after), before,
|
|
567
|
+
return this.fonts._kernAcross(text.slice(at - before, at + after), before, shaping, key);
|
|
454
568
|
}
|
|
455
569
|
|
|
456
570
|
/**
|
|
@@ -523,7 +637,17 @@ export class TextLayout {
|
|
|
523
637
|
const shaped = pieces ? pieces[i] : this.fonts._shapeCached(fragText, shaping, fragLevels);
|
|
524
638
|
// the levels ride along so a later split can re-shape a piece at the
|
|
525
639
|
// level it actually has, rather than assuming ltr
|
|
526
|
-
|
|
640
|
+
const frag = { text: fragText, span, shaping, shaped, start: fragStart, levels: fragLevels };
|
|
641
|
+
// a group shaped apart from the one before it for the spacing a
|
|
642
|
+
// `kernAcross` span adds still kerns against it (`_kernBetween`)
|
|
643
|
+
if (i === 0 && fragments.length) {
|
|
644
|
+
const kern = this._kernBetween(text, fragments[fragments.length - 1], frag, levels);
|
|
645
|
+
if (kern) {
|
|
646
|
+
frag.kern = kern;
|
|
647
|
+
width += kern;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
fragments.push(frag);
|
|
527
651
|
width += shaped.width;
|
|
528
652
|
}
|
|
529
653
|
pos = groupEnd;
|
|
@@ -591,7 +715,7 @@ export class TextLayout {
|
|
|
591
715
|
// trailing whitespace does not count against the budget, exactly as it
|
|
592
716
|
// does not count during the greedy fill
|
|
593
717
|
const kern = kept.length ? kernBefore(token) : 0;
|
|
594
|
-
if (used + kern + token.width - token.wsWidth <= budget) {
|
|
718
|
+
if (used + kern + token.width - token.wsWidth <= budget + FIT_SLACK) {
|
|
595
719
|
kept.push(token);
|
|
596
720
|
used += kern + token.width;
|
|
597
721
|
continue;
|
|
@@ -609,9 +733,12 @@ export class TextLayout {
|
|
|
609
733
|
let used = 0;
|
|
610
734
|
for (let i = 0; i < token.fragments.length; i++) {
|
|
611
735
|
const frag = token.fragments[i];
|
|
612
|
-
|
|
736
|
+
// the kerning with the fragment before it, which a token's first has
|
|
737
|
+
// none of: it starts a line
|
|
738
|
+
const kern = i && frag.kern ? frag.kern : 0;
|
|
739
|
+
if (used + kern + frag.shaped.width <= maxWidth + FIT_SLACK) {
|
|
613
740
|
headFrags.push(frag);
|
|
614
|
-
used += frag.shaped.width;
|
|
741
|
+
used += kern + frag.shaped.width;
|
|
615
742
|
continue;
|
|
616
743
|
}
|
|
617
744
|
// shaped at the bidi level this text actually has: assuming level 0
|
|
@@ -626,7 +753,7 @@ export class TextLayout {
|
|
|
626
753
|
// and its combining mark, or inside an emoji ZWJ sequence, leaves a
|
|
627
754
|
// dotted circle or a pair of half-emoji on the two sides of the break.
|
|
628
755
|
const lead = firstGrapheme(frag.text);
|
|
629
|
-
if (used + shape(lead, 0, lead.length).width <= maxWidth) {
|
|
756
|
+
if (used + kern + shape(lead, 0, lead.length).width <= maxWidth + FIT_SLACK) {
|
|
630
757
|
// binary search the longest grapheme prefix of this fragment that fits
|
|
631
758
|
const cps = graphemes(frag.text);
|
|
632
759
|
let lo = 0;
|
|
@@ -635,7 +762,7 @@ export class TextLayout {
|
|
|
635
762
|
const mid = (lo + hi) >> 1;
|
|
636
763
|
const prefix = cps.slice(0, mid + 1).join('');
|
|
637
764
|
const shaped = shape(prefix, 0, prefix.length);
|
|
638
|
-
if (used + shaped.width <= maxWidth) {
|
|
765
|
+
if (used + kern + shaped.width <= maxWidth + FIT_SLACK) {
|
|
639
766
|
best = { len: prefix.length, shaped, text: prefix };
|
|
640
767
|
lo = mid + 1;
|
|
641
768
|
} else {
|
|
@@ -657,7 +784,8 @@ export class TextLayout {
|
|
|
657
784
|
shaping,
|
|
658
785
|
shaped: best.shaped,
|
|
659
786
|
start: frag.start,
|
|
660
|
-
levels: sliceLevels(frag.levels, 0, best.len)
|
|
787
|
+
levels: sliceLevels(frag.levels, 0, best.len),
|
|
788
|
+
...(kern ? { kern } : null)
|
|
661
789
|
});
|
|
662
790
|
}
|
|
663
791
|
|
|
@@ -676,7 +804,7 @@ export class TextLayout {
|
|
|
676
804
|
});
|
|
677
805
|
}
|
|
678
806
|
restFrags.push(...token.fragments.slice(i + 1));
|
|
679
|
-
const sum = (frags) => frags.reduce((w, f) => w + f.shaped.width, 0);
|
|
807
|
+
const sum = (frags) => frags.reduce((w, f, j) => w + f.shaped.width + (j && f.kern ? f.kern : 0), 0);
|
|
680
808
|
const splitAt = restFrags.length ? restFrags[0].start : token.end;
|
|
681
809
|
const head = headFrags.length
|
|
682
810
|
? { fragments: headFrags, width: sum(headFrags), wsWidth: 0, required: false, start: token.start, end: splitAt }
|
|
@@ -1192,6 +1320,8 @@ function stripTrailingWhitespace(entries) {
|
|
|
1192
1320
|
}
|
|
1193
1321
|
entries[i] = {
|
|
1194
1322
|
...entries[i],
|
|
1323
|
+
// its kerning with what came after it went with its last letter
|
|
1324
|
+
kernAfter: 0,
|
|
1195
1325
|
end: entries[i].end - cuStripped,
|
|
1196
1326
|
run: {
|
|
1197
1327
|
...run,
|
|
@@ -1318,6 +1448,38 @@ function visibleRows(ctx, y) {
|
|
|
1318
1448
|
*/
|
|
1319
1449
|
const MAX_SHAPING_STYLES = 16;
|
|
1320
1450
|
|
|
1451
|
+
/**
|
|
1452
|
+
* What the letters either side of fragments `a` and `b` are shaped with as
|
|
1453
|
+
* a pair: the style both were shaped with, or where the two differ in
|
|
1454
|
+
* their letter spacing alone and one is a `kernAcross` span's, the spaced
|
|
1455
|
+
* one — spacing is in addition to kerning, and a pair with spacing between
|
|
1456
|
+
* it takes no optional ligature (CSS Text 3, 7.2). None otherwise: an
|
|
1457
|
+
* element's own letter spacing is a change of formatting a browser breaks
|
|
1458
|
+
* the shaping at.
|
|
1459
|
+
*/
|
|
1460
|
+
function pairShaping(fa, fb) {
|
|
1461
|
+
const a = fa.shaping;
|
|
1462
|
+
const b = fb.shaping;
|
|
1463
|
+
if (a === b) return a;
|
|
1464
|
+
if (!fa.span.kernAcross && !fb.span.kernAcross) return null;
|
|
1465
|
+
if (
|
|
1466
|
+
a.given !== b.given ||
|
|
1467
|
+
a.font !== b.font ||
|
|
1468
|
+
a.family !== b.family ||
|
|
1469
|
+
a.size !== b.size ||
|
|
1470
|
+
a.weight !== b.weight ||
|
|
1471
|
+
a.style !== b.style ||
|
|
1472
|
+
a.variations !== b.variations ||
|
|
1473
|
+
a.opticalSize !== b.opticalSize ||
|
|
1474
|
+
a.opticalSizing !== b.opticalSizing ||
|
|
1475
|
+
a.features !== b.features ||
|
|
1476
|
+
a.language !== b.language
|
|
1477
|
+
) {
|
|
1478
|
+
return null;
|
|
1479
|
+
}
|
|
1480
|
+
return a.letterSpacing ? a : b;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1321
1483
|
/**
|
|
1322
1484
|
* The style `span` is shaped with: one already made for a span whose every
|
|
1323
1485
|
* property shaping reads is the same (`===`), or a new one. Its font is the
|