ntk 5.4.0 → 6.0.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 +195 -21
- package/package.json +1 -1
package/lib/text/layout.js
CHANGED
|
@@ -28,11 +28,17 @@ function isWsGlyph(g) {
|
|
|
28
28
|
* - `align` — 'left' | 'right' | 'center' | 'start' | 'end'
|
|
29
29
|
* - `lineHeight` — multiplier over natural font line height (default 1)
|
|
30
30
|
* - `direction` — 'ltr' | 'rtl' | 'auto' base paragraph direction
|
|
31
|
+
* - `maxLines` — cap the number of lines (default: unlimited)
|
|
32
|
+
* - `overflow` — 'clip' (default) or 'ellipsis', what a `maxLines` cut looks
|
|
33
|
+
* like. See `_elide`.
|
|
31
34
|
*
|
|
32
|
-
* The result is inspectable before/without drawing: `width`, `height`,
|
|
33
|
-
* `lines[] = { x, y, baseline, width, ascent,
|
|
34
|
-
*
|
|
35
|
-
*
|
|
35
|
+
* The result is inspectable before/without drawing: `width`, `height`,
|
|
36
|
+
* `truncated`, and `lines[] = { x, y, height, baseline, width, ascent,
|
|
37
|
+
* descent, runs, start, end }` with
|
|
38
|
+
* `runs[] = { x, width, run, span, start, end }` in visual order
|
|
39
|
+
* (`start`/`end` are logical UTF-16 ranges into the full text). A line's
|
|
40
|
+
* box is `y` to `y + height`; its glyphs sit centred in it, from
|
|
41
|
+
* `baseline - ascent` to `baseline + descent`.
|
|
36
42
|
*
|
|
37
43
|
* `caretPosition(index)` / `indexAt(x, y)` map logical code-point indices
|
|
38
44
|
* to visual caret geometry and back (bidi/ligature/trailing-whitespace
|
|
@@ -143,6 +149,17 @@ export class TextLayout {
|
|
|
143
149
|
if (cur.length) lineTokens.push(cur);
|
|
144
150
|
}
|
|
145
151
|
|
|
152
|
+
// ---- cap the line count ----
|
|
153
|
+
// The cut happens here, between filling and assembly, so the dropped
|
|
154
|
+
// lines cost nothing to position and `height` counts only what is kept.
|
|
155
|
+
const maxLines = Number.isFinite(options.maxLines)
|
|
156
|
+
? Math.max(0, Math.floor(options.maxLines))
|
|
157
|
+
: Infinity;
|
|
158
|
+
/** did `maxLines` drop content? */
|
|
159
|
+
this.truncated = lineTokens.length > maxLines;
|
|
160
|
+
if (this.truncated) lineTokens.length = maxLines;
|
|
161
|
+
const elide = this.truncated && options.overflow === 'ellipsis';
|
|
162
|
+
|
|
146
163
|
// ---- assemble lines: strip trailing ws, bidi-reorder, position ----
|
|
147
164
|
const baseSpan = spans[0];
|
|
148
165
|
const lineHeightMul = options.lineHeight ?? 1;
|
|
@@ -150,7 +167,16 @@ export class TextLayout {
|
|
|
150
167
|
let y = 0;
|
|
151
168
|
let layoutWidth = 0;
|
|
152
169
|
|
|
153
|
-
for (
|
|
170
|
+
for (let li = 0; li < lineTokens.length; li++) {
|
|
171
|
+
let toks = lineTokens[li];
|
|
172
|
+
const lineStart = toks.length ? toks[0].start : 0;
|
|
173
|
+
let lineEnd = toks.length ? toks[toks.length - 1].end : lineStart;
|
|
174
|
+
|
|
175
|
+
// Elision is the last kept line's business only, and it happens before
|
|
176
|
+
// entries exist, because dropping content means re-shaping the tail.
|
|
177
|
+
const ellipsis = elide && li === lineTokens.length - 1 ? this._elide(toks, baseSpan) : null;
|
|
178
|
+
if (ellipsis) toks = this._fitBefore(toks, maxWidth - ellipsis.width);
|
|
179
|
+
|
|
154
180
|
// entries carry .level so reorderRuns can order them (UAX#9 L2);
|
|
155
181
|
// .start/.end are absolute code-unit ranges into the full text, kept
|
|
156
182
|
// for caret positioning (caretPosition / indexAt)
|
|
@@ -168,14 +194,34 @@ export class TextLayout {
|
|
|
168
194
|
}
|
|
169
195
|
}
|
|
170
196
|
}
|
|
171
|
-
|
|
172
|
-
const lineEnd = toks[toks.length - 1].end;
|
|
173
|
-
const trailing = stripTrailingWhitespace(entries);
|
|
197
|
+
let trailing = stripTrailingWhitespace(entries);
|
|
174
198
|
const contentEnd = trailing
|
|
175
199
|
? trailing.start
|
|
176
200
|
: entries.length
|
|
177
201
|
? entries[entries.length - 1].end
|
|
178
202
|
: lineStart;
|
|
203
|
+
if (ellipsis) {
|
|
204
|
+
// The ellipsis takes the *paragraph* level, which is what a neutral
|
|
205
|
+
// at the end of a paragraph resolves to under UAX#9 — so reorderRuns
|
|
206
|
+
// puts it at the right edge of an LTR line and the left edge of an
|
|
207
|
+
// RTL one, without a special case here. Its logical range is empty
|
|
208
|
+
// and pinned at the cut, so caret mapping treats it as the end of
|
|
209
|
+
// the visible text rather than as characters of its own.
|
|
210
|
+
for (const run of ellipsis.shaped.runs) {
|
|
211
|
+
entries.push({
|
|
212
|
+
run,
|
|
213
|
+
span: ellipsis.span,
|
|
214
|
+
level: this.baseLevel,
|
|
215
|
+
start: contentEnd,
|
|
216
|
+
end: contentEnd,
|
|
217
|
+
ellipsis: true
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
// whatever whitespace was stripped is gone for good, not merely
|
|
221
|
+
// pushed past the line edge: there is no wrap for it to precede
|
|
222
|
+
trailing = null;
|
|
223
|
+
lineEnd = contentEnd;
|
|
224
|
+
}
|
|
179
225
|
entries = reorderRuns(entries);
|
|
180
226
|
|
|
181
227
|
let ascent = 0;
|
|
@@ -184,7 +230,9 @@ export class TextLayout {
|
|
|
184
230
|
const runs = [];
|
|
185
231
|
let x = 0;
|
|
186
232
|
for (const e of entries) {
|
|
187
|
-
|
|
233
|
+
const positioned = { x, width: e.run.width, run: e.run, span: e.span, start: e.start, end: e.end };
|
|
234
|
+
if (e.ellipsis) positioned.ellipsis = true;
|
|
235
|
+
runs.push(positioned);
|
|
188
236
|
x += e.run.width;
|
|
189
237
|
const m = e.run.font.metrics(e.run.size);
|
|
190
238
|
if (m.ascent > ascent) ascent = m.ascent;
|
|
@@ -199,10 +247,23 @@ export class TextLayout {
|
|
|
199
247
|
natural = m.lineHeight;
|
|
200
248
|
}
|
|
201
249
|
if (x > layoutWidth) layoutWidth = x;
|
|
250
|
+
// Half-leading (CSS Inline Layout 3): the slack between the glyphs and
|
|
251
|
+
// the line box is split evenly above and below, rather than all of it
|
|
252
|
+
// landing under the text. This is what makes a single line sit
|
|
253
|
+
// centred in a box measured from `layout.height`, and it applies at
|
|
254
|
+
// `lineHeight: 1` too — a font's natural line height includes its line
|
|
255
|
+
// gap, which is 8px at 16px for some UI faces.
|
|
256
|
+
//
|
|
257
|
+
// A multiplier small enough to make the box shorter than the glyphs
|
|
258
|
+
// gives negative leading and the text overflows evenly on both sides,
|
|
259
|
+
// which is also what CSS does.
|
|
260
|
+
const box = natural * lineHeightMul;
|
|
261
|
+
const leading = (box - (ascent + descent)) / 2;
|
|
202
262
|
this.lines.push({
|
|
203
263
|
x: 0,
|
|
204
264
|
y,
|
|
205
|
-
|
|
265
|
+
height: box,
|
|
266
|
+
baseline: y + leading + ascent,
|
|
206
267
|
width: x,
|
|
207
268
|
ascent,
|
|
208
269
|
descent,
|
|
@@ -212,7 +273,7 @@ export class TextLayout {
|
|
|
212
273
|
_contentEnd: contentEnd,
|
|
213
274
|
_trailing: trailing
|
|
214
275
|
});
|
|
215
|
-
y +=
|
|
276
|
+
y += box;
|
|
216
277
|
}
|
|
217
278
|
|
|
218
279
|
this.width = layoutWidth;
|
|
@@ -243,7 +304,9 @@ export class TextLayout {
|
|
|
243
304
|
if (fragText.length > 0) {
|
|
244
305
|
const fragLevels = normalizedLevels(levels, pos, pos + fragText.length);
|
|
245
306
|
const shaped = this.fonts._shapeCached(fragText, span, fragLevels);
|
|
246
|
-
|
|
307
|
+
// the levels ride along so a later split can re-shape a piece at the
|
|
308
|
+
// level it actually has, rather than assuming ltr
|
|
309
|
+
fragments.push({ text: fragText, span, shaped, start: pos, levels: fragLevels });
|
|
247
310
|
width += shaped.width;
|
|
248
311
|
}
|
|
249
312
|
pos = fragEnd;
|
|
@@ -261,6 +324,67 @@ export class TextLayout {
|
|
|
261
324
|
return { fragments, width, wsWidth, required, start, end };
|
|
262
325
|
}
|
|
263
326
|
|
|
327
|
+
/**
|
|
328
|
+
* The ellipsis to append to a line that was cut short: `{ text, span,
|
|
329
|
+
* shaped, width }`.
|
|
330
|
+
*
|
|
331
|
+
* Shaped in the style of the line's **logically trailing** span, so an
|
|
332
|
+
* elided line ending in a large or bold word gets a matching ellipsis
|
|
333
|
+
* rather than one in the paragraph's base style. That span is chosen
|
|
334
|
+
* before the cut, not after: choosing it after would make the ellipsis
|
|
335
|
+
* width depend on a cut that depends on the ellipsis width.
|
|
336
|
+
*
|
|
337
|
+
* U+2026 is not universal — a subsetted icon or maths face may well lack
|
|
338
|
+
* it — so when neither the span's font nor any fallback covers it, three
|
|
339
|
+
* periods stand in. Shaping the real character through a font that has no
|
|
340
|
+
* glyph for it would draw a .notdef box, which is a worse way to say
|
|
341
|
+
* "there is more text".
|
|
342
|
+
*/
|
|
343
|
+
_elide(toks, baseSpan) {
|
|
344
|
+
let span = baseSpan;
|
|
345
|
+
for (let i = toks.length - 1; i >= 0; i--) {
|
|
346
|
+
const frags = toks[i].fragments;
|
|
347
|
+
if (frags.length) {
|
|
348
|
+
span = frags[frags.length - 1].span;
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const covered =
|
|
353
|
+
span.font.hasGlyph(0x2026) || this.fonts.fallbackFor(0x2026, span.family, span) !== null;
|
|
354
|
+
const text = covered ? '…' : '...';
|
|
355
|
+
const shaped = this.fonts._shapeCached(text, span, String(this.baseLevel));
|
|
356
|
+
return { text, span, shaped, width: shaped.width };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* The longest prefix of a line's tokens whose width fits `budget` — whole
|
|
361
|
+
* tokens while they fit, then a grapheme-boundary cut into the first one
|
|
362
|
+
* that does not.
|
|
363
|
+
*
|
|
364
|
+
* Cutting has to happen here rather than by slicing the text, because the
|
|
365
|
+
* tail is re-shaped: kerning and ligatures across the cut change widths,
|
|
366
|
+
* and in a mixed-direction line the visually-last run is not the logically
|
|
367
|
+
* last one. Working in tokens keeps both facts inside the machinery that
|
|
368
|
+
* already knows them.
|
|
369
|
+
*/
|
|
370
|
+
_fitBefore(toks, budget) {
|
|
371
|
+
const kept = [];
|
|
372
|
+
let used = 0;
|
|
373
|
+
for (const token of toks) {
|
|
374
|
+
// trailing whitespace does not count against the budget, exactly as it
|
|
375
|
+
// does not count during the greedy fill
|
|
376
|
+
if (used + token.width - token.wsWidth <= budget) {
|
|
377
|
+
kept.push(token);
|
|
378
|
+
used += token.width;
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
const [head] = this._forceBreak(token, budget - used);
|
|
382
|
+
if (head && head.fragments.length) kept.push(head);
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
return kept;
|
|
386
|
+
}
|
|
387
|
+
|
|
264
388
|
// split an over-wide token at the widest cluster boundary that fits
|
|
265
389
|
_forceBreak(token, maxWidth) {
|
|
266
390
|
const headFrags = [];
|
|
@@ -272,15 +396,22 @@ export class TextLayout {
|
|
|
272
396
|
used += frag.shaped.width;
|
|
273
397
|
continue;
|
|
274
398
|
}
|
|
275
|
-
// binary search the longest
|
|
276
|
-
|
|
399
|
+
// binary search the longest grapheme prefix of this fragment that fits.
|
|
400
|
+
// Graphemes rather than code points: cutting between a base character
|
|
401
|
+
// and its combining mark, or inside an emoji ZWJ sequence, leaves a
|
|
402
|
+
// dotted circle or a pair of half-emoji on the two sides of the break.
|
|
403
|
+
const cps = graphemes(frag.text);
|
|
277
404
|
let lo = 0;
|
|
278
405
|
let hi = cps.length - 1;
|
|
279
406
|
let best = null;
|
|
280
407
|
while (lo <= hi) {
|
|
281
408
|
const mid = (lo + hi) >> 1;
|
|
282
409
|
const prefix = cps.slice(0, mid + 1).join('');
|
|
283
|
-
|
|
410
|
+
// shaped at the bidi level this text actually has: assuming level 0
|
|
411
|
+
// here re-shapes an rtl word as ltr, which lays its glyphs out
|
|
412
|
+
// backwards and hands reorderRuns an even level that stops it from
|
|
413
|
+
// being reordered at all
|
|
414
|
+
const shaped = this.fonts._shapeCached(prefix, frag.span, sliceLevels(frag.levels, 0, prefix.length));
|
|
284
415
|
if (used + shaped.width <= maxWidth) {
|
|
285
416
|
best = { len: prefix.length, shaped, text: prefix };
|
|
286
417
|
lo = mid + 1;
|
|
@@ -289,17 +420,26 @@ export class TextLayout {
|
|
|
289
420
|
}
|
|
290
421
|
}
|
|
291
422
|
if (best) {
|
|
292
|
-
headFrags.push({
|
|
423
|
+
headFrags.push({
|
|
424
|
+
text: best.text,
|
|
425
|
+
span: frag.span,
|
|
426
|
+
shaped: best.shaped,
|
|
427
|
+
start: frag.start,
|
|
428
|
+
levels: sliceLevels(frag.levels, 0, best.len)
|
|
429
|
+
});
|
|
293
430
|
}
|
|
294
431
|
|
|
295
432
|
const restFrags = [];
|
|
296
|
-
const
|
|
433
|
+
const cut = best ? best.len : 0;
|
|
434
|
+
const restText = frag.text.slice(cut);
|
|
297
435
|
if (restText) {
|
|
436
|
+
const restLevels = sliceLevels(frag.levels, cut, frag.text.length);
|
|
298
437
|
restFrags.push({
|
|
299
438
|
text: restText,
|
|
300
439
|
span: frag.span,
|
|
301
|
-
shaped: this.fonts._shapeCached(restText, frag.span,
|
|
302
|
-
start: frag.start +
|
|
440
|
+
shaped: this.fonts._shapeCached(restText, frag.span, restLevels),
|
|
441
|
+
start: frag.start + cut,
|
|
442
|
+
levels: restLevels
|
|
303
443
|
});
|
|
304
444
|
}
|
|
305
445
|
restFrags.push(...token.fragments.slice(i + 1));
|
|
@@ -417,8 +557,13 @@ export class TextLayout {
|
|
|
417
557
|
* `[0, codePointCount]` (out-of-range indices clamp).
|
|
418
558
|
*
|
|
419
559
|
* @returns {{ x, y, height, line }} `x` is the caret's visual x within
|
|
420
|
-
* the layout box (alignment included), `y` the top of the
|
|
560
|
+
* the layout box (alignment included), `y` the top of the **glyphs**,
|
|
421
561
|
* `height` = ascent + descent, `line` the line index.
|
|
562
|
+
*
|
|
563
|
+
* `y` tracks the text rather than the line box, so a caret drawn from it
|
|
564
|
+
* stays locked to the glyphs whatever the leading. For a full-height
|
|
565
|
+
* selection band instead, the line box is `line.y` to `line.y +
|
|
566
|
+
* line.height`.
|
|
422
567
|
*/
|
|
423
568
|
caretPosition(index) {
|
|
424
569
|
const offs = this._offsets();
|
|
@@ -435,7 +580,7 @@ export class TextLayout {
|
|
|
435
580
|
const line = this.lines[li];
|
|
436
581
|
return {
|
|
437
582
|
x: this._caretXInLine(line, cu),
|
|
438
|
-
y: line.
|
|
583
|
+
y: line.baseline - line.ascent,
|
|
439
584
|
height: line.ascent + line.descent,
|
|
440
585
|
line: li
|
|
441
586
|
};
|
|
@@ -484,6 +629,9 @@ export class TextLayout {
|
|
|
484
629
|
break;
|
|
485
630
|
}
|
|
486
631
|
}
|
|
632
|
+
// the ellipsis stands for text that is not displayed, so anywhere on it
|
|
633
|
+
// is the end of what is: it has no indices of its own to land in
|
|
634
|
+
if (target.ellipsis) return this._cpOf(line._contentEnd);
|
|
487
635
|
return this._cpOf(this._runIndexAt(target, cx));
|
|
488
636
|
}
|
|
489
637
|
|
|
@@ -623,6 +771,24 @@ export class TextLayout {
|
|
|
623
771
|
}
|
|
624
772
|
}
|
|
625
773
|
|
|
774
|
+
// Grapheme clusters (UAX#29), for cut points that never land inside one.
|
|
775
|
+
// Intl.Segmenter is in node >= 16 and every current browser; where it is
|
|
776
|
+
// somehow absent, code points are the old behaviour and still safe for the
|
|
777
|
+
// scripts that reach a force-break most often.
|
|
778
|
+
let segmenter;
|
|
779
|
+
function graphemes(text) {
|
|
780
|
+
if (segmenter === undefined) {
|
|
781
|
+
segmenter =
|
|
782
|
+
typeof Intl !== 'undefined' && Intl.Segmenter
|
|
783
|
+
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
784
|
+
: null;
|
|
785
|
+
}
|
|
786
|
+
if (!segmenter) return Array.from(text);
|
|
787
|
+
const out = [];
|
|
788
|
+
for (const { segment } of segmenter.segment(text)) out.push(segment);
|
|
789
|
+
return out;
|
|
790
|
+
}
|
|
791
|
+
|
|
626
792
|
// UTF-16 length of a glyph cluster's codePoints array
|
|
627
793
|
function cuLength(codePoints) {
|
|
628
794
|
let len = 0;
|
|
@@ -683,6 +849,14 @@ function stripTrailingWhitespace(entries) {
|
|
|
683
849
|
return result();
|
|
684
850
|
}
|
|
685
851
|
|
|
852
|
+
// The levels key for a code-unit slice of a fragment. A uniform key covers
|
|
853
|
+
// any slice of itself; a per-character one has to be cut to match.
|
|
854
|
+
function sliceLevels(key, start, end) {
|
|
855
|
+
if (key === undefined) return '0';
|
|
856
|
+
if (!key.includes(',')) return key;
|
|
857
|
+
return key.split(',').slice(start, end).join(',');
|
|
858
|
+
}
|
|
859
|
+
|
|
686
860
|
// compact levels key for the shaping cache: single char when uniform
|
|
687
861
|
function normalizedLevels(levels, start, end) {
|
|
688
862
|
let uniform = true;
|