ntk 8.12.6 → 8.12.7
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/fontmanager.js +6 -0
- package/lib/text/layout.js +97 -71
- package/lib/text/paragraphs.js +134 -0
- package/package.json +1 -1
package/lib/text/fontmanager.js
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
} from './fontsource.js';
|
|
8
8
|
import { embeddingLevels, normalizedLevels, shapeText } from './shape.js';
|
|
9
9
|
import { TextLayout } from './layout.js';
|
|
10
|
+
import { ParagraphCache } from './paragraphs.js';
|
|
10
11
|
|
|
11
12
|
// Bound on the resolved-match map. See the sweep in `match()`.
|
|
12
13
|
const MAX_MATCHES = 512;
|
|
@@ -195,6 +196,9 @@ export default class FontManager {
|
|
|
195
196
|
this._shapeCache = new Map();
|
|
196
197
|
this._shapeCacheBefore = new Map();
|
|
197
198
|
this._shapeCount = 0;
|
|
199
|
+
// a paragraph's layout up to the line fill, for its next width
|
|
200
|
+
// (text/paragraphs.js, TextLayout)
|
|
201
|
+
this._paragraphs = new ParagraphCache();
|
|
198
202
|
}
|
|
199
203
|
|
|
200
204
|
/** the FontSource in effect (explicit, else the process-wide default) */
|
|
@@ -248,6 +252,8 @@ export default class FontManager {
|
|
|
248
252
|
});
|
|
249
253
|
this._matches.clear();
|
|
250
254
|
this._fallbacks.clear();
|
|
255
|
+
// a kept paragraph's spans hold the faces their families matched before
|
|
256
|
+
this._paragraphs.clear();
|
|
251
257
|
return font;
|
|
252
258
|
}
|
|
253
259
|
|
package/lib/text/layout.js
CHANGED
|
@@ -52,79 +52,20 @@ export class TextLayout {
|
|
|
52
52
|
this.options = options;
|
|
53
53
|
const maxWidth = options.maxWidth ?? Infinity;
|
|
54
54
|
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
text: String(s.text ?? ''),
|
|
67
|
-
family: s.family ?? style.family ?? 'sans-serif',
|
|
68
|
-
size: s.size ?? style.size ?? 16,
|
|
69
|
-
weight: s.weight ?? style.weight,
|
|
70
|
-
style: s.style ?? style.style,
|
|
71
|
-
variations: s.variations ?? style.variations,
|
|
72
|
-
opticalSize: s.opticalSize ?? style.opticalSize,
|
|
73
|
-
opticalSizing: s.opticalSizing ?? style.opticalSizing,
|
|
74
|
-
textRendering: s.textRendering ?? style.textRendering,
|
|
75
|
-
features: s.features ?? style.features,
|
|
76
|
-
language: s.language ?? style.language,
|
|
77
|
-
letterSpacing: s.letterSpacing ?? style.letterSpacing,
|
|
78
|
-
color: s.color ?? style.color ?? null
|
|
79
|
-
};
|
|
80
|
-
merged.font = s.font ?? style.font ?? fonts.match(merged.family, merged);
|
|
81
|
-
return merged;
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
const text = spans.map((s) => s.text).join('');
|
|
55
|
+
// Everything up to the line fill is the same at every width, and a
|
|
56
|
+
// font manager keeps it (text/paragraphs.js): a relayout at a new width
|
|
57
|
+
// starts at the fill.
|
|
58
|
+
const kept = fonts._paragraphs;
|
|
59
|
+
const key = kept ? kept.keyOf(content) : null;
|
|
60
|
+
let paragraph = kept?.find(key, options.direction, content, style);
|
|
61
|
+
if (!paragraph) {
|
|
62
|
+
paragraph = this._prepare(content, style, options.direction);
|
|
63
|
+
kept?.keep(key, options.direction, content, style, paragraph);
|
|
64
|
+
}
|
|
65
|
+
const { spans, text, tokens } = paragraph;
|
|
85
66
|
this._text = text;
|
|
86
67
|
this._cpOffsets = null; // lazy code-point index -> code-unit offset table
|
|
87
|
-
|
|
88
|
-
const levels = emb.levels;
|
|
89
|
-
this.baseLevel = emb.paragraphs.length ? emb.paragraphs[0].level & 1 : 0;
|
|
90
|
-
|
|
91
|
-
// span boundaries, for binary search by char position
|
|
92
|
-
const spanStarts = [];
|
|
93
|
-
{
|
|
94
|
-
let pos = 0;
|
|
95
|
-
for (const s of spans) {
|
|
96
|
-
spanStarts.push(pos);
|
|
97
|
-
pos += s.text.length;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
const spanAt = (pos) => {
|
|
101
|
-
let lo = 0;
|
|
102
|
-
let hi = spanStarts.length - 1;
|
|
103
|
-
while (lo < hi) {
|
|
104
|
-
const mid = (lo + hi + 1) >> 1;
|
|
105
|
-
if (spanStarts[mid] <= pos) lo = mid;
|
|
106
|
-
else hi = mid - 1;
|
|
107
|
-
}
|
|
108
|
-
return {
|
|
109
|
-
span: spans[lo],
|
|
110
|
-
end: lo + 1 < spanStarts.length ? spanStarts[lo + 1] : text.length
|
|
111
|
-
};
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
// ---- tokenize at UAX#14 break opportunities ----
|
|
115
|
-
const tokens = [];
|
|
116
|
-
{
|
|
117
|
-
const breaker = new LineBreaker(text);
|
|
118
|
-
let prev = 0;
|
|
119
|
-
let bk;
|
|
120
|
-
while ((bk = breaker.nextBreak())) {
|
|
121
|
-
tokens.push(this._makeToken(text, prev, bk.position, levels, spanAt, bk.required));
|
|
122
|
-
prev = bk.position;
|
|
123
|
-
}
|
|
124
|
-
if (prev < text.length || tokens.length === 0) {
|
|
125
|
-
tokens.push(this._makeToken(text, prev, text.length, levels, spanAt, false));
|
|
126
|
-
}
|
|
127
|
-
}
|
|
68
|
+
this.baseLevel = paragraph.baseLevel;
|
|
128
69
|
|
|
129
70
|
// ---- greedy line fill ----
|
|
130
71
|
const lineTokens = [];
|
|
@@ -299,6 +240,91 @@ export class TextLayout {
|
|
|
299
240
|
}
|
|
300
241
|
}
|
|
301
242
|
|
|
243
|
+
/**
|
|
244
|
+
* The part of a layout the width does not decide: the spans normalised
|
|
245
|
+
* and their faces resolved, the embedding levels, and the text cut into
|
|
246
|
+
* tokens at UAX#14 break opportunities, each shaped. Kept per font manager
|
|
247
|
+
* and shared by every layout of the same paragraph (text/paragraphs.js), so
|
|
248
|
+
* nothing after this may change what it returns — a layout's lines are
|
|
249
|
+
* built from new objects over it.
|
|
250
|
+
*/
|
|
251
|
+
_prepare(content, style, direction) {
|
|
252
|
+
const fonts = this.fonts;
|
|
253
|
+
// ---- normalize spans, resolve fonts eagerly ----
|
|
254
|
+
// unknown span fields ride along untouched, so a caller can attach its
|
|
255
|
+
// own markers to a span and read them back off the line runs
|
|
256
|
+
// An empty span list is a legitimate thing to lay out — a document view
|
|
257
|
+
// reaches it for a blank paragraph while one is being typed —
|
|
258
|
+
// and every line still needs a style to take its metrics from, so
|
|
259
|
+
// stand one in rather than crashing on the first empty line.
|
|
260
|
+
const given = typeof content === 'string' ? [{ text: content }] : content;
|
|
261
|
+
const spans = (given.length ? given : [{ text: '' }]).map((s) => {
|
|
262
|
+
const merged = {
|
|
263
|
+
...s,
|
|
264
|
+
text: String(s.text ?? ''),
|
|
265
|
+
family: s.family ?? style.family ?? 'sans-serif',
|
|
266
|
+
size: s.size ?? style.size ?? 16,
|
|
267
|
+
weight: s.weight ?? style.weight,
|
|
268
|
+
style: s.style ?? style.style,
|
|
269
|
+
variations: s.variations ?? style.variations,
|
|
270
|
+
opticalSize: s.opticalSize ?? style.opticalSize,
|
|
271
|
+
opticalSizing: s.opticalSizing ?? style.opticalSizing,
|
|
272
|
+
textRendering: s.textRendering ?? style.textRendering,
|
|
273
|
+
features: s.features ?? style.features,
|
|
274
|
+
language: s.language ?? style.language,
|
|
275
|
+
letterSpacing: s.letterSpacing ?? style.letterSpacing,
|
|
276
|
+
color: s.color ?? style.color ?? null
|
|
277
|
+
};
|
|
278
|
+
merged.font = s.font ?? style.font ?? fonts.match(merged.family, merged);
|
|
279
|
+
return merged;
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const text = spans.map((s) => s.text).join('');
|
|
283
|
+
const emb = embeddingLevels(text, direction);
|
|
284
|
+
const levels = emb.levels;
|
|
285
|
+
const baseLevel = emb.paragraphs.length ? emb.paragraphs[0].level & 1 : 0;
|
|
286
|
+
|
|
287
|
+
// span boundaries, for binary search by char position
|
|
288
|
+
const spanStarts = [];
|
|
289
|
+
{
|
|
290
|
+
let pos = 0;
|
|
291
|
+
for (const s of spans) {
|
|
292
|
+
spanStarts.push(pos);
|
|
293
|
+
pos += s.text.length;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const spanAt = (pos) => {
|
|
297
|
+
let lo = 0;
|
|
298
|
+
let hi = spanStarts.length - 1;
|
|
299
|
+
while (lo < hi) {
|
|
300
|
+
const mid = (lo + hi + 1) >> 1;
|
|
301
|
+
if (spanStarts[mid] <= pos) lo = mid;
|
|
302
|
+
else hi = mid - 1;
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
span: spans[lo],
|
|
306
|
+
end: lo + 1 < spanStarts.length ? spanStarts[lo + 1] : text.length
|
|
307
|
+
};
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
// ---- tokenize at UAX#14 break opportunities ----
|
|
311
|
+
const tokens = [];
|
|
312
|
+
{
|
|
313
|
+
const breaker = new LineBreaker(text);
|
|
314
|
+
let prev = 0;
|
|
315
|
+
let bk;
|
|
316
|
+
while ((bk = breaker.nextBreak())) {
|
|
317
|
+
tokens.push(this._makeToken(text, prev, bk.position, levels, spanAt, bk.required));
|
|
318
|
+
prev = bk.position;
|
|
319
|
+
}
|
|
320
|
+
if (prev < text.length || tokens.length === 0) {
|
|
321
|
+
tokens.push(this._makeToken(text, prev, text.length, levels, spanAt, false));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return { spans, text, baseLevel, tokens };
|
|
326
|
+
}
|
|
327
|
+
|
|
302
328
|
_makeToken(text, start, end, levels, spanAt, required) {
|
|
303
329
|
const fragments = [];
|
|
304
330
|
let width = 0;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// A paragraph's layout work that does not depend on the width, kept
|
|
2
|
+
// (`TextLayout`): its spans with their faces resolved, its bidi levels, and
|
|
3
|
+
// its text cut at UAX#14 break opportunities into shaped tokens. A layout at
|
|
4
|
+
// another width — every step of a window resize, and the widths a flexbox
|
|
5
|
+
// asks a paragraph about on the way — needs all of that again, and only the
|
|
6
|
+
// line fill after it depends on the width: on a 600 KB document, 62% of a
|
|
7
|
+
// resize's layout time went on redoing it.
|
|
8
|
+
//
|
|
9
|
+
// A paragraph is filed under its text and direction, and found by comparing
|
|
10
|
+
// every field of every span and of the base style against copies of the ones
|
|
11
|
+
// it was made from. Every field, because a caller's own markers ride on its
|
|
12
|
+
// spans into the line runs (see `TextLayout`), and the runs of a kept
|
|
13
|
+
// paragraph carry the kept spans: two paragraphs that differ only in a link's
|
|
14
|
+
// target must not share one.
|
|
15
|
+
//
|
|
16
|
+
// Bounded the way the shaping memo is (`FontManager#_shapeCached`): two
|
|
17
|
+
// generations, here by characters, so a working set up to twice
|
|
18
|
+
// `PARAGRAPH_GENERATION` survives a relayout — a whole long document, laid
|
|
19
|
+
// out at a new width a frame — and what neither generation used is dropped.
|
|
20
|
+
|
|
21
|
+
/** How many characters of paragraphs a generation holds. */
|
|
22
|
+
export const PARAGRAPH_GENERATION = 1 << 19;
|
|
23
|
+
|
|
24
|
+
export class ParagraphCache {
|
|
25
|
+
constructor(limit = PARAGRAPH_GENERATION) {
|
|
26
|
+
this._limit = limit;
|
|
27
|
+
// direction -> text -> the paragraphs of that text, in two generations
|
|
28
|
+
this._now = new Map();
|
|
29
|
+
this._before = new Map();
|
|
30
|
+
this._chars = 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What a paragraph is filed under within its direction: its text. A span
|
|
35
|
+
* list's text is joined once per list and remembered by the list — a
|
|
36
|
+
* caller that lays one paragraph out at many widths hands over the same
|
|
37
|
+
* list each time, and the same string then hashes once, not once a
|
|
38
|
+
* lookup. A list changed in place keeps its old key and misses: `find`
|
|
39
|
+
* compares every field before it answers, so a key can only lose a
|
|
40
|
+
* paragraph, never find the wrong one.
|
|
41
|
+
*/
|
|
42
|
+
keyOf(content) {
|
|
43
|
+
if (typeof content === 'string') return content;
|
|
44
|
+
let text = joined.get(content);
|
|
45
|
+
if (text === undefined) {
|
|
46
|
+
text = '';
|
|
47
|
+
for (const span of content) text += span.text;
|
|
48
|
+
joined.set(content, text);
|
|
49
|
+
}
|
|
50
|
+
return text;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The kept paragraph made from exactly these inputs, or undefined. */
|
|
54
|
+
find(key, direction, content, style) {
|
|
55
|
+
const dir = direction ?? '';
|
|
56
|
+
const now = this._now.get(dir)?.get(key);
|
|
57
|
+
const hit = now && pick(now, content, style);
|
|
58
|
+
if (hit) return hit.paragraph;
|
|
59
|
+
const kept = pick(this._before.get(dir)?.get(key), content, style);
|
|
60
|
+
if (!kept) return undefined;
|
|
61
|
+
this._add(dir, key, kept);
|
|
62
|
+
return kept.paragraph;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Keep a paragraph `TextLayout` made, with copies of what it was made
|
|
66
|
+
* from: nothing done to the inputs afterwards can change what finds it. */
|
|
67
|
+
keep(key, direction, content, style, paragraph) {
|
|
68
|
+
const spans = typeof content === 'string' ? content : content.map((span) => ({ ...span }));
|
|
69
|
+
this._add(direction ?? '', key, { spans, style: { ...style }, paragraph });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Drop everything — the faces a paragraph resolved may not be the ones
|
|
73
|
+
* its family matches now. */
|
|
74
|
+
clear() {
|
|
75
|
+
this._now = new Map();
|
|
76
|
+
this._before = new Map();
|
|
77
|
+
this._chars = 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
_add(dir, key, entry) {
|
|
81
|
+
const chars = entry.paragraph.text.length + 1;
|
|
82
|
+
if (this._chars + chars > this._limit) {
|
|
83
|
+
this._before = this._now;
|
|
84
|
+
this._now = new Map();
|
|
85
|
+
this._chars = 0;
|
|
86
|
+
}
|
|
87
|
+
let texts = this._now.get(dir);
|
|
88
|
+
if (!texts) {
|
|
89
|
+
texts = new Map();
|
|
90
|
+
this._now.set(dir, texts);
|
|
91
|
+
}
|
|
92
|
+
const list = texts.get(key);
|
|
93
|
+
if (list) list.push(entry);
|
|
94
|
+
else texts.set(key, [entry]);
|
|
95
|
+
this._chars += chars;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// a span list's text, joined once (`ParagraphCache#keyOf`)
|
|
100
|
+
const joined = new WeakMap();
|
|
101
|
+
|
|
102
|
+
/** The entry among `list` made from these spans and this base style. */
|
|
103
|
+
function pick(list, content, style) {
|
|
104
|
+
if (!list) return undefined;
|
|
105
|
+
outer: for (const entry of list) {
|
|
106
|
+
if (typeof content === 'string') {
|
|
107
|
+
if (entry.spans !== content) continue;
|
|
108
|
+
} else {
|
|
109
|
+
const spans = entry.spans;
|
|
110
|
+
if (typeof spans === 'string' || spans.length !== content.length) continue;
|
|
111
|
+
for (let i = 0; i < content.length; i++) {
|
|
112
|
+
if (!sameFields(spans[i], content[i])) continue outer;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (sameFields(entry.style, style)) return entry;
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Whether two objects have the same own fields with the same values. Every
|
|
122
|
+
* field, so that one added to a span later cannot be left out; the values a
|
|
123
|
+
* span holds are compared as they are — a face, a feature map or a marker
|
|
124
|
+
* object by identity — which can only miss, never find the wrong paragraph.
|
|
125
|
+
*/
|
|
126
|
+
function sameFields(a, b) {
|
|
127
|
+
let fields = 0;
|
|
128
|
+
for (const name in a) {
|
|
129
|
+
if (!(name in b) || !Object.is(a[name], b[name])) return false;
|
|
130
|
+
fields++;
|
|
131
|
+
}
|
|
132
|
+
for (const _ in b) fields--;
|
|
133
|
+
return fields === 0;
|
|
134
|
+
}
|