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.
Files changed (2) hide show
  1. package/lib/text/layout.js +190 -28
  2. package/package.json +1 -1
@@ -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
- /** How wide a line's tokens are, the white space it ends on left out. */
22
- function tokensWidth(toks) {
23
- let width = 0;
24
- for (let i = 0; i < toks.length; i++) width += toks[i].width + (i ? kernBefore(toks[i]) : 0);
25
- return toks.length ? width - toks[toks.length - 1].wsWidth : 0;
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. Options:
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
- let curWidth = 0;
212
+ const fit = new LineFit();
124
213
  const flush = () => {
125
214
  lineTokens.push(cur);
126
215
  cur = [];
127
- curWidth = 0;
216
+ fit.reset();
128
217
  };
129
218
  for (let token of tokens) {
130
- while (wraps && curWidth + (cur.length ? kernBefore(token) : 0) + token.width - token.wsWidth > maxWidth) {
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
- curWidth += token.width + (cur.length ? kernBefore(token) : 0);
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 && tokensWidth(toks) > maxWidth);
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 (const frag of token.fragments) {
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). Only
429
- * between letters shaped alike at one left-to-right level; a line that
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
- const a = prev.fragments[prev.fragments.length - 1];
435
- const b = next.fragments[0];
436
- if (!a || !b || a.shaping !== b.shaping || a.span.shapeApart || b.span.shapeApart) return 0;
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, a.shaping, key);
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
- fragments.push({ text: fragText, span, shaping, shaped, start: fragStart, levels: fragLevels });
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
- if (used + frag.shaped.width <= maxWidth) {
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.14.7",
3
+ "version": "8.15.1",
4
4
  "description": "Desktop UI toolkit for X11 with canvas-like 2d and OpenGL rendering",
5
5
  "author": "Andrey Sidorov <sidorares@yandex.ru>",
6
6
  "license": "MIT",