ntk 8.12.5 → 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/font.js CHANGED
@@ -69,8 +69,49 @@ export function variationKey(coords) {
69
69
  * Fonts are cheap-ish to open (tables parse lazily) and are cached by the
70
70
  * FontManager — always obtain them via `app.fonts`.
71
71
  */
72
+ /**
73
+ * fontkit keeps one Glyph object per id and gives it the characters of
74
+ * whichever call made it first. A glyph asked for by id alone — its extents,
75
+ * its outline, or a component of a composite, which fontkit makes from inside
76
+ * the composite's path with no characters — is made with none, and every
77
+ * later shaping of that letter was handed the same object: a run read `e` as
78
+ * no characters at all, so every caret, selection edge and hit test after it
79
+ * on the line was a character over. Two code points that share a glyph (a
80
+ * space and a no-break space) had the same trouble the other way round.
81
+ *
82
+ * Asked with characters the cached glyph does not carry, the face answers a
83
+ * view of it that does — its prototype is the cached glyph, so the outline,
84
+ * the metrics and every cache on it stay shared — one view per glyph and
85
+ * characters. Installed once per fontkit font, on the instance, which is
86
+ * where fontkit's own shaping looks it up.
87
+ */
88
+ function answerWithCharacters(fk) {
89
+ if (fk.__ntkCharacters) return;
90
+ const getGlyph = fk.getGlyph;
91
+ const views = new Map();
92
+ fk.getGlyph = function (id, characters = []) {
93
+ const glyph = getGlyph.call(this, id, characters);
94
+ if (!glyph || characters.length === 0) return glyph;
95
+ const had = glyph.codePoints;
96
+ if (had.length === characters.length && had.every((cp, i) => cp === characters[i])) {
97
+ return glyph;
98
+ }
99
+ const key = `${id}:${characters.join(',')}`;
100
+ let view = views.get(key);
101
+ if (view === undefined) {
102
+ view = Object.create(glyph, {
103
+ codePoints: { value: characters.slice(), enumerable: true }
104
+ });
105
+ views.set(key, view);
106
+ }
107
+ return view;
108
+ };
109
+ Object.defineProperty(fk, '__ntkCharacters', { value: true });
110
+ }
111
+
72
112
  export default class Font {
73
113
  constructor(fkFont, path, postscriptName) {
114
+ answerWithCharacters(fkFont);
74
115
  this.fk = fkFont;
75
116
  this.path = path;
76
117
  /** stable cache key for glyph pages and font caches */
@@ -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
 
@@ -52,79 +52,20 @@ export class TextLayout {
52
52
  this.options = options;
53
53
  const maxWidth = options.maxWidth ?? Infinity;
54
54
 
55
- // ---- normalize spans, resolve fonts eagerly ----
56
- // unknown span fields ride along untouched, so a caller can attach its
57
- // own markers to a span and read them back off the line runs
58
- // An empty span list is a legitimate thing to lay out — a document view
59
- // reaches it for a blank paragraph while one is being typed —
60
- // and every line still needs a style to take its metrics from, so
61
- // stand one in rather than crashing on the first empty line.
62
- const given = typeof content === 'string' ? [{ text: content }] : content;
63
- const spans = (given.length ? given : [{ text: '' }]).map((s) => {
64
- const merged = {
65
- ...s,
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
- const emb = embeddingLevels(text, options.direction);
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.12.5",
3
+ "version": "8.12.7",
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",