ntk 8.23.2 → 8.23.3
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 +182 -47
- package/lib/text/paragraphs.js +11 -2
- package/lib/text/shape.js +12 -1
- package/package.json +1 -1
package/lib/text/layout.js
CHANGED
|
@@ -2,7 +2,7 @@ import LineBreaker from 'linebreak';
|
|
|
2
2
|
|
|
3
3
|
import { rasterizePolys } from '../rasterize.js';
|
|
4
4
|
import { drawGlyphRuns } from './glyphs.js';
|
|
5
|
-
import { embeddingLevels, normalizedLevels, reorderRuns } from './shape.js';
|
|
5
|
+
import { embeddingLevels, levelZeroThroughout, normalizedLevels, reorderRuns } from './shape.js';
|
|
6
6
|
import { worthKeeping } from './paragraphs.js';
|
|
7
7
|
|
|
8
8
|
// The white space a line may end on and hang past its edge: spaces and tabs,
|
|
@@ -307,57 +307,29 @@ export class TextLayout {
|
|
|
307
307
|
const kept = worthKeeping(content) ? fonts._paragraphs : undefined;
|
|
308
308
|
const key = kept ? kept.keyOf(content) : null;
|
|
309
309
|
let paragraph = kept?.find(key, options.direction, content, style);
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
310
|
+
const round = itemRounding(options);
|
|
311
|
+
// the lines of a paragraph asked for its first few, filled from the start
|
|
312
|
+
// of it alone (`_firstLines`) — the start a layout before this one kept,
|
|
313
|
+
// where it holds them — or null
|
|
314
|
+
let first = null;
|
|
315
|
+
if (!paragraph || paragraph.partial) {
|
|
316
|
+
first = this._firstLines(content, style, options, maxWidth, round, paragraph);
|
|
317
|
+
if (first) {
|
|
318
|
+
if (first.paragraph !== paragraph) kept?.keep(key, options.direction, content, style, first.paragraph);
|
|
319
|
+
paragraph = first.paragraph;
|
|
320
|
+
} else {
|
|
321
|
+
paragraph = this._prepare(content, style, options.direction);
|
|
322
|
+
kept?.keep(key, options.direction, content, style, paragraph);
|
|
323
|
+
}
|
|
313
324
|
}
|
|
314
325
|
const { spans, text, tokens } = paragraph;
|
|
315
|
-
|
|
326
|
+
// the whole text, which a caret is placed in, however much was prepared
|
|
327
|
+
this._text = first ? first.text : text;
|
|
316
328
|
this._cpOffsets = null; // lazy code-point index -> code-unit offset table
|
|
317
329
|
this.baseLevel = paragraph.baseLevel;
|
|
318
330
|
|
|
319
|
-
// ---- greedy line fill ----
|
|
320
|
-
const lineTokens = [];
|
|
321
|
-
// a word wider than the line stays whole, past the line's end, where
|
|
322
|
-
// the options say so; cut inside itself where they do not — and in a
|
|
323
|
-
// script written without spaces, where the breaker's "word" is a phrase
|
|
324
|
-
// it had no dictionary to cut, cut anyway (CSS Text 3, 5.1)
|
|
325
|
-
const whole = options.overflowWrap === 'normal';
|
|
326
|
-
// text that does not wrap is a line to each forced break, however wide
|
|
327
331
|
const wraps = options.wrap !== false;
|
|
328
|
-
const
|
|
329
|
-
{
|
|
330
|
-
let cur = [];
|
|
331
|
-
const fit = new LineFit(round);
|
|
332
|
-
const flush = () => {
|
|
333
|
-
lineTokens.push(cur);
|
|
334
|
-
cur = [];
|
|
335
|
-
fit.reset();
|
|
336
|
-
};
|
|
337
|
-
for (let token of tokens) {
|
|
338
|
-
while (
|
|
339
|
-
wraps &&
|
|
340
|
-
fit.with(token, cur.length ? kernBefore(token) : 0) > maxWidth + FIT_SLACK
|
|
341
|
-
) {
|
|
342
|
-
if (cur.length > 0) {
|
|
343
|
-
flush();
|
|
344
|
-
} else if (whole && !UNSPACED.test(text.slice(token.start, token.end))) {
|
|
345
|
-
break;
|
|
346
|
-
} else {
|
|
347
|
-
// single token wider than the container: force-break it
|
|
348
|
-
const [head, rest] = this._forceBreak(token, maxWidth);
|
|
349
|
-
if (!head) break; // not even one cluster fits: let it overflow
|
|
350
|
-
cur.push(head);
|
|
351
|
-
flush();
|
|
352
|
-
token = rest;
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
fit.add(token, cur.length ? kernBefore(token) : 0);
|
|
356
|
-
cur.push(token);
|
|
357
|
-
if (token.required) flush();
|
|
358
|
-
}
|
|
359
|
-
if (cur.length) lineTokens.push(cur);
|
|
360
|
-
}
|
|
332
|
+
const lineTokens = first ? first.lines : this._fill(tokens, text, maxWidth, options, round);
|
|
361
333
|
|
|
362
334
|
// ---- cap the line count ----
|
|
363
335
|
// The cut happens here, between filling and assembly, so the dropped
|
|
@@ -575,6 +547,112 @@ export class TextLayout {
|
|
|
575
547
|
}
|
|
576
548
|
}
|
|
577
549
|
|
|
550
|
+
/**
|
|
551
|
+
* The greedy line fill: `tokens` into lines no wider than `maxWidth`, a
|
|
552
|
+
* line ended early at each forced break.
|
|
553
|
+
*/
|
|
554
|
+
_fill(tokens, text, maxWidth, options, round) {
|
|
555
|
+
const lineTokens = [];
|
|
556
|
+
// a word wider than the line stays whole, past the line's end, where
|
|
557
|
+
// the options say so; cut inside itself where they do not — and in a
|
|
558
|
+
// script written without spaces, where the breaker's "word" is a phrase
|
|
559
|
+
// it had no dictionary to cut, cut anyway (CSS Text 3, 5.1)
|
|
560
|
+
const whole = options.overflowWrap === 'normal';
|
|
561
|
+
// text that does not wrap is a line to each forced break, however wide
|
|
562
|
+
const wraps = options.wrap !== false;
|
|
563
|
+
{
|
|
564
|
+
let cur = [];
|
|
565
|
+
const fit = new LineFit(round);
|
|
566
|
+
const flush = () => {
|
|
567
|
+
lineTokens.push(cur);
|
|
568
|
+
cur = [];
|
|
569
|
+
fit.reset();
|
|
570
|
+
};
|
|
571
|
+
for (let token of tokens) {
|
|
572
|
+
while (
|
|
573
|
+
wraps &&
|
|
574
|
+
fit.with(token, cur.length ? kernBefore(token) : 0) > maxWidth + FIT_SLACK
|
|
575
|
+
) {
|
|
576
|
+
if (cur.length > 0) {
|
|
577
|
+
flush();
|
|
578
|
+
} else if (whole && !UNSPACED.test(text.slice(token.start, token.end))) {
|
|
579
|
+
break;
|
|
580
|
+
} else {
|
|
581
|
+
// single token wider than the container: force-break it
|
|
582
|
+
const [head, rest] = this._forceBreak(token, maxWidth);
|
|
583
|
+
if (!head) break; // not even one cluster fits: let it overflow
|
|
584
|
+
cur.push(head);
|
|
585
|
+
flush();
|
|
586
|
+
token = rest;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
fit.add(token, cur.length ? kernBefore(token) : 0);
|
|
590
|
+
cur.push(token);
|
|
591
|
+
if (token.required) flush();
|
|
592
|
+
}
|
|
593
|
+
if (cur.length) lineTokens.push(cur);
|
|
594
|
+
}
|
|
595
|
+
return lineTokens;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* A layout asked for its first lines (`maxLines`) of a paragraph it has
|
|
600
|
+
* not prepared: the start of the text prepared and filled, where every
|
|
601
|
+
* line kept is decided before the start ends. `<Html>` lays a paragraph
|
|
602
|
+
* out beside a float a line at a time, the rest of the paragraph and one
|
|
603
|
+
* line of it, so each line prepared the whole rest of the paragraph again
|
|
604
|
+
* — the paragraph's square — at every width of a resize.
|
|
605
|
+
*
|
|
606
|
+
* Two lines past the last kept one are filled before the start is trusted:
|
|
607
|
+
* what a cut can change — the break opportunities and the kerning a
|
|
608
|
+
* character or two either side of it, the token it falls in — is in the
|
|
609
|
+
* line it falls in, and no kept line starts or ends there. The levels it
|
|
610
|
+
* could change are 0 throughout in a text nothing can make right-to-left
|
|
611
|
+
* (`levelZeroThroughout`), which is the only text this is tried on; other
|
|
612
|
+
* text, and a start that fills too few lines at every length short of the
|
|
613
|
+
* whole, is prepared whole, as before.
|
|
614
|
+
*
|
|
615
|
+
* The start is kept with the paragraph's (`ParagraphCache`), marked
|
|
616
|
+
* `partial`, for the next layout of the same text to fill — the same line
|
|
617
|
+
* of a paragraph at the next width of a drag — or to prepare further, or
|
|
618
|
+
* whole, where it does not hold that layout's lines.
|
|
619
|
+
*
|
|
620
|
+
* @param {object} [known] the start a layout before this one kept
|
|
621
|
+
* @returns {{paragraph, lines, text}|null} the start prepared, its lines
|
|
622
|
+
* filled, and the whole text; null where the whole is to be prepared
|
|
623
|
+
*/
|
|
624
|
+
_firstLines(content, style, options, maxWidth, round, known) {
|
|
625
|
+
const maxLines = options.maxLines;
|
|
626
|
+
if (!Number.isFinite(maxLines) || maxLines < 0) return null;
|
|
627
|
+
if (options.wrap === false || !Number.isFinite(maxWidth) || !(maxWidth > 0)) return null;
|
|
628
|
+
const text = typeof content === 'string' ? content : contentText(content);
|
|
629
|
+
if (!levelZeroThroughout(text, options.direction)) return null;
|
|
630
|
+
const keep = Math.floor(maxLines);
|
|
631
|
+
const enough = (paragraph) => {
|
|
632
|
+
const lines = this._fill(paragraph.tokens, paragraph.text, maxWidth, options, round);
|
|
633
|
+
return lines.length > keep + 2 ? { paragraph, lines, text } : null;
|
|
634
|
+
};
|
|
635
|
+
if (known) {
|
|
636
|
+
const held = enough(known);
|
|
637
|
+
if (held) return held;
|
|
638
|
+
}
|
|
639
|
+
// a guess at how much text the lines take — a quarter of an em a
|
|
640
|
+
// character — four times larger at each try that falls short
|
|
641
|
+
let length = Math.ceil(((keep + 3) * maxWidth) / (0.25 * smallestSize(content, style)));
|
|
642
|
+
if (known) length = Math.max(length, known.text.length * 4);
|
|
643
|
+
while (length < text.length) {
|
|
644
|
+
// a surrogate pair is not cut
|
|
645
|
+
const code = text.charCodeAt(length - 1);
|
|
646
|
+
if (code >= 0xd800 && code < 0xdc00) length++;
|
|
647
|
+
const paragraph = this._prepare(cutContent(content, length), style, options.direction);
|
|
648
|
+
paragraph.partial = true;
|
|
649
|
+
const held = enough(paragraph);
|
|
650
|
+
if (held) return held;
|
|
651
|
+
length *= 4;
|
|
652
|
+
}
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
|
|
578
656
|
/**
|
|
579
657
|
* The part of a layout the width does not decide: the spans normalised
|
|
580
658
|
* and their faces resolved, the embedding levels, and the text cut into
|
|
@@ -723,7 +801,7 @@ export class TextLayout {
|
|
|
723
801
|
const after = text.codePointAt(at) > 0xffff ? 2 : 1;
|
|
724
802
|
const key = normalizedLevels(levels, at - before, at + after);
|
|
725
803
|
if (key.includes(',') || Number(key) & 1) return 0;
|
|
726
|
-
return this.fonts._kernAcross(text.slice(at - before, at + after), before, shaping, key);
|
|
804
|
+
return this.fonts._kernAcross(text.slice(at - before, at + after), before, spacedByOne(shaping), key);
|
|
727
805
|
}
|
|
728
806
|
|
|
729
807
|
/**
|
|
@@ -1622,6 +1700,28 @@ const MAX_SHAPING_STYLES = 16;
|
|
|
1622
1700
|
* element's own letter spacing is a change of formatting a browser breaks
|
|
1623
1701
|
* the shaping at.
|
|
1624
1702
|
*/
|
|
1703
|
+
/**
|
|
1704
|
+
* The shaping a pair's kern is asked under: spaced by one pixel where it is
|
|
1705
|
+
* spaced at all. How far spacing sets two letters apart is no part of the
|
|
1706
|
+
* kern they make — it is added to every glyph's advance (`spaceGlyphs`), to
|
|
1707
|
+
* the pair shaped whole and to each half alike, and drops out of
|
|
1708
|
+
* `_kernAcross`'s difference — but whether they are spaced is, since
|
|
1709
|
+
* spacing turns optional ligatures off. A justified line spaces each of its
|
|
1710
|
+
* spaces by its own share, and a share is new at each line and each width,
|
|
1711
|
+
* so the kern memo, keyed by the shaping's spacing, missed at every pair of
|
|
1712
|
+
* every justified line laid out at a new width, and shaped three strings
|
|
1713
|
+
* for each: most of what laying out a justified paragraph beside a float
|
|
1714
|
+
* cost at each step of a resize.
|
|
1715
|
+
*/
|
|
1716
|
+
function spacedByOne(shaping) {
|
|
1717
|
+
if (!shaping.letterSpacing || shaping.letterSpacing === 1) return shaping;
|
|
1718
|
+
let one = SPACED_BY_ONE.get(shaping);
|
|
1719
|
+
if (!one) SPACED_BY_ONE.set(shaping, (one = { ...shaping, letterSpacing: 1 }));
|
|
1720
|
+
return one;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
const SPACED_BY_ONE = new WeakMap();
|
|
1724
|
+
|
|
1625
1725
|
function pairShaping(fa, fb) {
|
|
1626
1726
|
const a = fa.shaping;
|
|
1627
1727
|
const b = fb.shaping;
|
|
@@ -1689,3 +1789,38 @@ function shapingStyleOf(known, span, given, fonts) {
|
|
|
1689
1789
|
if (known.length < MAX_SHAPING_STYLES) known.push(k);
|
|
1690
1790
|
return k;
|
|
1691
1791
|
}
|
|
1792
|
+
|
|
1793
|
+
/** A span list's text, joined (`_firstLines`). */
|
|
1794
|
+
function contentText(spans) {
|
|
1795
|
+
let text = '';
|
|
1796
|
+
for (const span of spans) text += String(span.text ?? '');
|
|
1797
|
+
return text;
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
/** The first `length` code units of a layout's content: a string, or the
|
|
1801
|
+
* spans that hold them, the last cut short (`_firstLines`). */
|
|
1802
|
+
function cutContent(content, length) {
|
|
1803
|
+
if (typeof content === 'string') return content.slice(0, length);
|
|
1804
|
+
const out = [];
|
|
1805
|
+
let at = 0;
|
|
1806
|
+
for (const span of content) {
|
|
1807
|
+
if (at >= length) break;
|
|
1808
|
+
const text = String(span.text ?? '');
|
|
1809
|
+
out.push(at + text.length <= length ? span : { ...span, text: text.slice(0, length - at) });
|
|
1810
|
+
at += text.length;
|
|
1811
|
+
}
|
|
1812
|
+
return out;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
/** The smallest size any of a layout's text is set at, for a guess at how
|
|
1816
|
+
* much of it a line takes (`_firstLines`). */
|
|
1817
|
+
function smallestSize(content, style) {
|
|
1818
|
+
const base = style.size ?? 16;
|
|
1819
|
+
if (typeof content === 'string') return base > 0 ? base : 16;
|
|
1820
|
+
let least = Infinity;
|
|
1821
|
+
for (const span of content) {
|
|
1822
|
+
const size = span.size ?? base;
|
|
1823
|
+
if (size > 0 && size < least) least = size;
|
|
1824
|
+
}
|
|
1825
|
+
return least < Infinity ? least : 16;
|
|
1826
|
+
}
|
package/lib/text/paragraphs.js
CHANGED
|
@@ -86,10 +86,19 @@ export class ParagraphCache {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
/** Keep a paragraph `TextLayout` made, with copies of what it was made
|
|
89
|
-
* from: nothing done to the inputs afterwards can change what finds it.
|
|
89
|
+
* from: nothing done to the inputs afterwards can change what finds it.
|
|
90
|
+
* One kept from the same inputs already — the start of the paragraph
|
|
91
|
+
* alone, prepared for its first lines (`partial`) — is replaced. */
|
|
90
92
|
keep(key, direction, content, style, paragraph) {
|
|
93
|
+
const dir = direction ?? '';
|
|
94
|
+
const known = pick(this._now.get(dir)?.get(key), content, style);
|
|
95
|
+
if (known) {
|
|
96
|
+
this._chars += paragraph.text.length - known.paragraph.text.length;
|
|
97
|
+
known.paragraph = paragraph;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
91
100
|
const spans = typeof content === 'string' ? content : content.map((span) => ({ ...span }));
|
|
92
|
-
this._add(
|
|
101
|
+
this._add(dir, key, { spans, style: { ...style }, paragraph });
|
|
93
102
|
}
|
|
94
103
|
|
|
95
104
|
/** Drop everything — the faces a paragraph resolved may not be the ones
|
package/lib/text/shape.js
CHANGED
|
@@ -41,7 +41,7 @@ export function embeddingLevels(text, direction = 'auto') {
|
|
|
41
41
|
// Text that nothing can make right-to-left is level 0 throughout, which
|
|
42
42
|
// is what the algorithm would work out a character at a time: every
|
|
43
43
|
// layout asks, and most text is Latin, CJK or digits with no bidi at all.
|
|
44
|
-
if (
|
|
44
|
+
if (levelZeroThroughout(text, explicit)) {
|
|
45
45
|
return {
|
|
46
46
|
levels: new Uint8Array(text.length),
|
|
47
47
|
paragraphs: [{ start: 0, end: text.length - 1, level: 0 }]
|
|
@@ -61,6 +61,17 @@ export function embeddingLevels(text, direction = 'auto') {
|
|
|
61
61
|
const MAY_REVERSE =
|
|
62
62
|
/[\u0590-\u08FF\u200F\u202A-\u202E\u2066-\u2069\uFB1D-\uFDFF\uFE70-\uFEFF]|[\uD802\uD803\uD83A\uD83B]/;
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Whether every character of `text` is at embedding level 0 in a paragraph
|
|
66
|
+
* of this direction: one not right-to-left by request, with nothing in it
|
|
67
|
+
* that can raise a level (`MAY_REVERSE`). So is every part of such a text,
|
|
68
|
+
* wherever it is cut — which is what lets a layout prepare only the start
|
|
69
|
+
* of one (`TextLayout`'s `_firstLines`).
|
|
70
|
+
*/
|
|
71
|
+
export function levelZeroThroughout(text, direction) {
|
|
72
|
+
return direction !== 'rtl' && !MAY_REVERSE.test(text);
|
|
73
|
+
}
|
|
74
|
+
|
|
64
75
|
/**
|
|
65
76
|
* Compact embedding-levels encoding for the shaping memo key: a single
|
|
66
77
|
* number when uniform over [start, end) — the common case — else
|