ntk 8.12.6 → 8.12.8
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/fontconfig.js +36 -2
- package/lib/text/fontmanager.js +35 -10
- package/lib/text/layout.js +100 -71
- package/lib/text/paragraphs.js +157 -0
- package/package.json +1 -1
package/lib/fontconfig.js
CHANGED
|
@@ -16,6 +16,40 @@ function execFileSync(...args) {
|
|
|
16
16
|
return cp.execFileSync(...args);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
let fcMatchFound = null;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* fc-match's path, looked up once per PATH. Spawned by its bare name, the
|
|
23
|
+
* system searches PATH for it, and on macOS that is `posix_spawnp`, which
|
|
24
|
+
* attempts a spawn in every directory ahead of the one that has it: with
|
|
25
|
+
* fc-match seventh on a developer's PATH, each spawn took 45 ms where the
|
|
26
|
+
* path itself takes 15 — and the synchronous ones stall the first text
|
|
27
|
+
* layout. A directory search here is a few stats. Not found, the bare name
|
|
28
|
+
* is spawned as before, so a missing fontconfig reports exactly as it did.
|
|
29
|
+
*/
|
|
30
|
+
export function fcMatchFile() {
|
|
31
|
+
const p = globalThis.process;
|
|
32
|
+
const path = p?.env?.PATH ?? '';
|
|
33
|
+
if (fcMatchFound !== null && fcMatchFound.path === path) return fcMatchFound.file;
|
|
34
|
+
let file = 'fc-match';
|
|
35
|
+
const fs = p?.platform === 'win32' ? undefined : builtin('node:fs');
|
|
36
|
+
if (fs) {
|
|
37
|
+
for (const dir of path.split(':')) {
|
|
38
|
+
const candidate = `${dir || '.'}/fc-match`;
|
|
39
|
+
try {
|
|
40
|
+
if (!fs.statSync(candidate).isFile()) continue;
|
|
41
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
42
|
+
file = candidate;
|
|
43
|
+
break;
|
|
44
|
+
} catch {
|
|
45
|
+
// not here, or not ours to run: the next directory
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
fcMatchFound = { path, file };
|
|
50
|
+
return file;
|
|
51
|
+
}
|
|
52
|
+
|
|
19
53
|
const DOCS = 'https://github.com/sidorares/ntk/blob/master/docs/fonts.md#environments-without-fontconfig';
|
|
20
54
|
|
|
21
55
|
/**
|
|
@@ -279,7 +313,7 @@ function runFcMatch(fc) {
|
|
|
279
313
|
|
|
280
314
|
const entry = { promise: null, base: null };
|
|
281
315
|
entry.promise = new Promise((resolve, reject) => {
|
|
282
|
-
cp.execFile(
|
|
316
|
+
cp.execFile(fcMatchFile(), [...fcMatchArgs, fc], fcMatchOpts, (err, out, stderr) => {
|
|
283
317
|
if (inflight.get(fc) === entry) inflight.delete(fc);
|
|
284
318
|
if (!err) return resolve(out);
|
|
285
319
|
// execFile hands stderr to the callback; execFileSync hangs it on the
|
|
@@ -576,7 +610,7 @@ export function matchSortedSync(pattern) {
|
|
|
576
610
|
}
|
|
577
611
|
let out;
|
|
578
612
|
try {
|
|
579
|
-
out = execFileSync(
|
|
613
|
+
out = execFileSync(fcMatchFile(), [...fcMatchArgs, fc], fcMatchOpts);
|
|
580
614
|
} catch (err) {
|
|
581
615
|
throw fcMatchError(err);
|
|
582
616
|
}
|
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;
|
|
@@ -181,6 +182,27 @@ function instantiate(font, weight, variations, opticalSize) {
|
|
|
181
182
|
* fontconfig, e.g. a StaticFontSource in a browser bundle. `source` also
|
|
182
183
|
* takes a font spec: a path, a directory of faces, or font bytes.
|
|
183
184
|
*/
|
|
185
|
+
/** A CSS family list's names, unquoted. */
|
|
186
|
+
function familiesOf(family) {
|
|
187
|
+
return String(family)
|
|
188
|
+
.split(',')
|
|
189
|
+
.map((f) => f.trim().replace(/^["']|["']$/g, ''))
|
|
190
|
+
.filter(Boolean);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The pattern a family and face are matched with — one spelling for both
|
|
195
|
+
* `match` and `fallbackFor`, since a source caches its lists by pattern and
|
|
196
|
+
* the two ask about the same face.
|
|
197
|
+
*/
|
|
198
|
+
function patternOf(family, weight, italic) {
|
|
199
|
+
return {
|
|
200
|
+
family: familiesOf(family).join(','),
|
|
201
|
+
weight,
|
|
202
|
+
style: italic ? 'italic' : 'normal'
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
184
206
|
export default class FontManager {
|
|
185
207
|
constructor({ source } = {}) {
|
|
186
208
|
// coerced here rather than in the getter, which is on the match path;
|
|
@@ -195,6 +217,9 @@ export default class FontManager {
|
|
|
195
217
|
this._shapeCache = new Map();
|
|
196
218
|
this._shapeCacheBefore = new Map();
|
|
197
219
|
this._shapeCount = 0;
|
|
220
|
+
// a paragraph's layout up to the line fill, for its next width
|
|
221
|
+
// (text/paragraphs.js, TextLayout)
|
|
222
|
+
this._paragraphs = new ParagraphCache();
|
|
198
223
|
}
|
|
199
224
|
|
|
200
225
|
/** the FontSource in effect (explicit, else the process-wide default) */
|
|
@@ -248,6 +273,8 @@ export default class FontManager {
|
|
|
248
273
|
});
|
|
249
274
|
this._matches.clear();
|
|
250
275
|
this._fallbacks.clear();
|
|
276
|
+
// a kept paragraph's spans hold the faces their families matched before
|
|
277
|
+
this._paragraphs.clear();
|
|
251
278
|
return font;
|
|
252
279
|
}
|
|
253
280
|
|
|
@@ -298,10 +325,7 @@ export default class FontManager {
|
|
|
298
325
|
return instantiate(face, weight, opts.variations, opticalSizeFor(opts));
|
|
299
326
|
}
|
|
300
327
|
|
|
301
|
-
const families =
|
|
302
|
-
.split(',')
|
|
303
|
-
.map((f) => f.trim().replace(/^["']|["']$/g, ''))
|
|
304
|
-
.filter(Boolean);
|
|
328
|
+
const families = familiesOf(family);
|
|
305
329
|
|
|
306
330
|
face = this._matchRegistered(
|
|
307
331
|
families.map((f) => f.toLowerCase()),
|
|
@@ -310,11 +334,7 @@ export default class FontManager {
|
|
|
310
334
|
);
|
|
311
335
|
if (!face) {
|
|
312
336
|
// sources understand comma-separated family lists natively
|
|
313
|
-
const candidates = this.source.matchSorted(
|
|
314
|
-
family: families.join(','),
|
|
315
|
-
weight,
|
|
316
|
-
style: italic ? 'italic' : 'normal'
|
|
317
|
-
});
|
|
337
|
+
const candidates = this.source.matchSorted(patternOf(family, weight, italic));
|
|
318
338
|
face = this._open(candidates[0]);
|
|
319
339
|
}
|
|
320
340
|
this._matches.set(cacheKey, face);
|
|
@@ -363,7 +383,12 @@ export default class FontManager {
|
|
|
363
383
|
const source = this.source;
|
|
364
384
|
let candidates;
|
|
365
385
|
try {
|
|
366
|
-
|
|
386
|
+
// The pattern `match` asks for this family and face, so the list the
|
|
387
|
+
// match or a prewarm already fetched answers: asked with the span's
|
|
388
|
+
// raw weight — undefined for regular text — it was another pattern,
|
|
389
|
+
// and the first character to need a fallback waited on a fc-match
|
|
390
|
+
// of its own.
|
|
391
|
+
candidates = source.matchSorted(patternOf(family, numWeight(opts.weight), !!opts.style?.includes('italic')));
|
|
367
392
|
} catch (err) {
|
|
368
393
|
// no system fonts to fall back to is an answer, not a crash; a source
|
|
369
394
|
// that failed for any other reason is a real bug and still propagates
|
package/lib/text/layout.js
CHANGED
|
@@ -3,6 +3,7 @@ import LineBreaker from 'linebreak';
|
|
|
3
3
|
import { rasterizePolys } from '../rasterize.js';
|
|
4
4
|
import { drawGlyphRuns } from './glyphs.js';
|
|
5
5
|
import { embeddingLevels, normalizedLevels, reorderRuns } from './shape.js';
|
|
6
|
+
import { worthKeeping } from './paragraphs.js';
|
|
6
7
|
|
|
7
8
|
const WS = new Set([0x20, 0x09, 0xa0]);
|
|
8
9
|
const HARD_BREAKS = /[\n\r\u2028\u2029]+$/;
|
|
@@ -52,79 +53,22 @@ export class TextLayout {
|
|
|
52
53
|
this.options = options;
|
|
53
54
|
const maxWidth = options.maxWidth ?? Infinity;
|
|
54
55
|
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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('');
|
|
56
|
+
// Everything up to the line fill is the same at every width, and a
|
|
57
|
+
// font manager keeps it (text/paragraphs.js): a relayout at a new width
|
|
58
|
+
// starts at the fill.
|
|
59
|
+
// A short text skips it (`PARAGRAPH_MIN_CHARS`): a label is cheap to
|
|
60
|
+
// prepare and seldom laid out at another width.
|
|
61
|
+
const kept = worthKeeping(content) ? fonts._paragraphs : undefined;
|
|
62
|
+
const key = kept ? kept.keyOf(content) : null;
|
|
63
|
+
let paragraph = kept?.find(key, options.direction, content, style);
|
|
64
|
+
if (!paragraph) {
|
|
65
|
+
paragraph = this._prepare(content, style, options.direction);
|
|
66
|
+
kept?.keep(key, options.direction, content, style, paragraph);
|
|
67
|
+
}
|
|
68
|
+
const { spans, text, tokens } = paragraph;
|
|
85
69
|
this._text = text;
|
|
86
70
|
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
|
-
}
|
|
71
|
+
this.baseLevel = paragraph.baseLevel;
|
|
128
72
|
|
|
129
73
|
// ---- greedy line fill ----
|
|
130
74
|
const lineTokens = [];
|
|
@@ -299,6 +243,91 @@ export class TextLayout {
|
|
|
299
243
|
}
|
|
300
244
|
}
|
|
301
245
|
|
|
246
|
+
/**
|
|
247
|
+
* The part of a layout the width does not decide: the spans normalised
|
|
248
|
+
* and their faces resolved, the embedding levels, and the text cut into
|
|
249
|
+
* tokens at UAX#14 break opportunities, each shaped. Kept per font manager
|
|
250
|
+
* and shared by every layout of the same paragraph (text/paragraphs.js), so
|
|
251
|
+
* nothing after this may change what it returns — a layout's lines are
|
|
252
|
+
* built from new objects over it.
|
|
253
|
+
*/
|
|
254
|
+
_prepare(content, style, direction) {
|
|
255
|
+
const fonts = this.fonts;
|
|
256
|
+
// ---- normalize spans, resolve fonts eagerly ----
|
|
257
|
+
// unknown span fields ride along untouched, so a caller can attach its
|
|
258
|
+
// own markers to a span and read them back off the line runs
|
|
259
|
+
// An empty span list is a legitimate thing to lay out — a document view
|
|
260
|
+
// reaches it for a blank paragraph while one is being typed —
|
|
261
|
+
// and every line still needs a style to take its metrics from, so
|
|
262
|
+
// stand one in rather than crashing on the first empty line.
|
|
263
|
+
const given = typeof content === 'string' ? [{ text: content }] : content;
|
|
264
|
+
const spans = (given.length ? given : [{ text: '' }]).map((s) => {
|
|
265
|
+
const merged = {
|
|
266
|
+
...s,
|
|
267
|
+
text: String(s.text ?? ''),
|
|
268
|
+
family: s.family ?? style.family ?? 'sans-serif',
|
|
269
|
+
size: s.size ?? style.size ?? 16,
|
|
270
|
+
weight: s.weight ?? style.weight,
|
|
271
|
+
style: s.style ?? style.style,
|
|
272
|
+
variations: s.variations ?? style.variations,
|
|
273
|
+
opticalSize: s.opticalSize ?? style.opticalSize,
|
|
274
|
+
opticalSizing: s.opticalSizing ?? style.opticalSizing,
|
|
275
|
+
textRendering: s.textRendering ?? style.textRendering,
|
|
276
|
+
features: s.features ?? style.features,
|
|
277
|
+
language: s.language ?? style.language,
|
|
278
|
+
letterSpacing: s.letterSpacing ?? style.letterSpacing,
|
|
279
|
+
color: s.color ?? style.color ?? null
|
|
280
|
+
};
|
|
281
|
+
merged.font = s.font ?? style.font ?? fonts.match(merged.family, merged);
|
|
282
|
+
return merged;
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const text = spans.map((s) => s.text).join('');
|
|
286
|
+
const emb = embeddingLevels(text, direction);
|
|
287
|
+
const levels = emb.levels;
|
|
288
|
+
const baseLevel = emb.paragraphs.length ? emb.paragraphs[0].level & 1 : 0;
|
|
289
|
+
|
|
290
|
+
// span boundaries, for binary search by char position
|
|
291
|
+
const spanStarts = [];
|
|
292
|
+
{
|
|
293
|
+
let pos = 0;
|
|
294
|
+
for (const s of spans) {
|
|
295
|
+
spanStarts.push(pos);
|
|
296
|
+
pos += s.text.length;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const spanAt = (pos) => {
|
|
300
|
+
let lo = 0;
|
|
301
|
+
let hi = spanStarts.length - 1;
|
|
302
|
+
while (lo < hi) {
|
|
303
|
+
const mid = (lo + hi + 1) >> 1;
|
|
304
|
+
if (spanStarts[mid] <= pos) lo = mid;
|
|
305
|
+
else hi = mid - 1;
|
|
306
|
+
}
|
|
307
|
+
return {
|
|
308
|
+
span: spans[lo],
|
|
309
|
+
end: lo + 1 < spanStarts.length ? spanStarts[lo + 1] : text.length
|
|
310
|
+
};
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// ---- tokenize at UAX#14 break opportunities ----
|
|
314
|
+
const tokens = [];
|
|
315
|
+
{
|
|
316
|
+
const breaker = new LineBreaker(text);
|
|
317
|
+
let prev = 0;
|
|
318
|
+
let bk;
|
|
319
|
+
while ((bk = breaker.nextBreak())) {
|
|
320
|
+
tokens.push(this._makeToken(text, prev, bk.position, levels, spanAt, bk.required));
|
|
321
|
+
prev = bk.position;
|
|
322
|
+
}
|
|
323
|
+
if (prev < text.length || tokens.length === 0) {
|
|
324
|
+
tokens.push(this._makeToken(text, prev, text.length, levels, spanAt, false));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return { spans, text, baseLevel, tokens };
|
|
329
|
+
}
|
|
330
|
+
|
|
302
331
|
_makeToken(text, start, end, levels, spanAt, required) {
|
|
303
332
|
const fragments = [];
|
|
304
333
|
let width = 0;
|
|
@@ -0,0 +1,157 @@
|
|
|
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
|
+
/**
|
|
25
|
+
* The shortest paragraph worth keeping, in code units. Shorter text does not
|
|
26
|
+
* ask the cache at all: a label's preparation is a few microseconds and the
|
|
27
|
+
* lookup and copies about one of them, while a label is seldom laid out at
|
|
28
|
+
* a second width. Kept, a UI's labels mounted a quarter slower and a ticking
|
|
29
|
+
* counter redrew a quarter slower, to save nothing; the paragraphs a resize
|
|
30
|
+
* lays out again are a document's, and those are longer than this.
|
|
31
|
+
*/
|
|
32
|
+
export const PARAGRAPH_MIN_CHARS = 64;
|
|
33
|
+
|
|
34
|
+
/** Whether `content` is long enough to keep (`PARAGRAPH_MIN_CHARS`), counted
|
|
35
|
+
* without building anything. */
|
|
36
|
+
export function worthKeeping(content) {
|
|
37
|
+
if (typeof content === 'string') return content.length >= PARAGRAPH_MIN_CHARS;
|
|
38
|
+
let chars = 0;
|
|
39
|
+
for (const span of content) {
|
|
40
|
+
const text = span.text;
|
|
41
|
+
chars += typeof text === 'string' ? text.length : String(text ?? '').length;
|
|
42
|
+
if (chars >= PARAGRAPH_MIN_CHARS) return true;
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class ParagraphCache {
|
|
48
|
+
constructor(limit = PARAGRAPH_GENERATION) {
|
|
49
|
+
this._limit = limit;
|
|
50
|
+
// direction -> text -> the paragraphs of that text, in two generations
|
|
51
|
+
this._now = new Map();
|
|
52
|
+
this._before = new Map();
|
|
53
|
+
this._chars = 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What a paragraph is filed under within its direction: its text. A span
|
|
58
|
+
* list's text is joined once per list and remembered by the list — a
|
|
59
|
+
* caller that lays one paragraph out at many widths hands over the same
|
|
60
|
+
* list each time, and the same string then hashes once, not once a
|
|
61
|
+
* lookup. A list changed in place keeps its old key and misses: `find`
|
|
62
|
+
* compares every field before it answers, so a key can only lose a
|
|
63
|
+
* paragraph, never find the wrong one.
|
|
64
|
+
*/
|
|
65
|
+
keyOf(content) {
|
|
66
|
+
if (typeof content === 'string') return content;
|
|
67
|
+
let text = joined.get(content);
|
|
68
|
+
if (text === undefined) {
|
|
69
|
+
text = '';
|
|
70
|
+
for (const span of content) text += span.text;
|
|
71
|
+
joined.set(content, text);
|
|
72
|
+
}
|
|
73
|
+
return text;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The kept paragraph made from exactly these inputs, or undefined. */
|
|
77
|
+
find(key, direction, content, style) {
|
|
78
|
+
const dir = direction ?? '';
|
|
79
|
+
const now = this._now.get(dir)?.get(key);
|
|
80
|
+
const hit = now && pick(now, content, style);
|
|
81
|
+
if (hit) return hit.paragraph;
|
|
82
|
+
const kept = pick(this._before.get(dir)?.get(key), content, style);
|
|
83
|
+
if (!kept) return undefined;
|
|
84
|
+
this._add(dir, key, kept);
|
|
85
|
+
return kept.paragraph;
|
|
86
|
+
}
|
|
87
|
+
|
|
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. */
|
|
90
|
+
keep(key, direction, content, style, paragraph) {
|
|
91
|
+
const spans = typeof content === 'string' ? content : content.map((span) => ({ ...span }));
|
|
92
|
+
this._add(direction ?? '', key, { spans, style: { ...style }, paragraph });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Drop everything — the faces a paragraph resolved may not be the ones
|
|
96
|
+
* its family matches now. */
|
|
97
|
+
clear() {
|
|
98
|
+
this._now = new Map();
|
|
99
|
+
this._before = new Map();
|
|
100
|
+
this._chars = 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
_add(dir, key, entry) {
|
|
104
|
+
const chars = entry.paragraph.text.length + 1;
|
|
105
|
+
if (this._chars + chars > this._limit) {
|
|
106
|
+
this._before = this._now;
|
|
107
|
+
this._now = new Map();
|
|
108
|
+
this._chars = 0;
|
|
109
|
+
}
|
|
110
|
+
let texts = this._now.get(dir);
|
|
111
|
+
if (!texts) {
|
|
112
|
+
texts = new Map();
|
|
113
|
+
this._now.set(dir, texts);
|
|
114
|
+
}
|
|
115
|
+
const list = texts.get(key);
|
|
116
|
+
if (list) list.push(entry);
|
|
117
|
+
else texts.set(key, [entry]);
|
|
118
|
+
this._chars += chars;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// a span list's text, joined once (`ParagraphCache#keyOf`)
|
|
123
|
+
const joined = new WeakMap();
|
|
124
|
+
|
|
125
|
+
/** The entry among `list` made from these spans and this base style. */
|
|
126
|
+
function pick(list, content, style) {
|
|
127
|
+
if (!list) return undefined;
|
|
128
|
+
outer: for (const entry of list) {
|
|
129
|
+
if (typeof content === 'string') {
|
|
130
|
+
if (entry.spans !== content) continue;
|
|
131
|
+
} else {
|
|
132
|
+
const spans = entry.spans;
|
|
133
|
+
if (typeof spans === 'string' || spans.length !== content.length) continue;
|
|
134
|
+
for (let i = 0; i < content.length; i++) {
|
|
135
|
+
if (!sameFields(spans[i], content[i])) continue outer;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (sameFields(entry.style, style)) return entry;
|
|
139
|
+
}
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Whether two objects have the same own fields with the same values. Every
|
|
145
|
+
* field, so that one added to a span later cannot be left out; the values a
|
|
146
|
+
* span holds are compared as they are — a face, a feature map or a marker
|
|
147
|
+
* object by identity — which can only miss, never find the wrong paragraph.
|
|
148
|
+
*/
|
|
149
|
+
function sameFields(a, b) {
|
|
150
|
+
let fields = 0;
|
|
151
|
+
for (const name in a) {
|
|
152
|
+
if (!(name in b) || !Object.is(a[name], b[name])) return false;
|
|
153
|
+
fields++;
|
|
154
|
+
}
|
|
155
|
+
for (const _ in b) fields--;
|
|
156
|
+
return fields === 0;
|
|
157
|
+
}
|