ntk 5.4.0 → 6.0.1
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/app.js +46 -0
- package/lib/fontconfig.js +70 -13
- package/lib/index.js +36 -18
- package/lib/renderingcontext_2d.js +901 -266
- package/lib/text/fontmanager.js +52 -14
- package/lib/text/fontsource.js +12 -1
- package/lib/text/layout.js +194 -33
- package/lib/text/shape.js +17 -0
- package/lib/window.js +18 -3
- package/package.json +1 -1
package/lib/text/fontmanager.js
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
detectStyle,
|
|
6
6
|
numericWeight as numWeight
|
|
7
7
|
} from './fontsource.js';
|
|
8
|
-
import { shapeText } from './shape.js';
|
|
8
|
+
import { embeddingLevels, normalizedLevels, shapeText } from './shape.js';
|
|
9
9
|
import { TextLayout } from './layout.js';
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -38,7 +38,7 @@ export default class FontManager {
|
|
|
38
38
|
this._matches = new Map(); // family|weight|style -> Font
|
|
39
39
|
this._fallbacks = new Map(); // family|weight|style -> Map(codepoint -> Font|null)
|
|
40
40
|
this._registered = []; // { font, family (lowercase), weight, italic }
|
|
41
|
-
this._shapeCache = new Map(); // word-level shaping memo (bounded)
|
|
41
|
+
this._shapeCache = new Map(); // word-level shaping memo (bounded, LRU)
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/** the FontSource in effect (explicit, else the process-wide default) */
|
|
@@ -232,25 +232,63 @@ export default class FontManager {
|
|
|
232
232
|
}
|
|
233
233
|
|
|
234
234
|
/**
|
|
235
|
-
* Memoized shaping used by TextLayout
|
|
236
|
-
* compact embedding-levels encoding: a single
|
|
237
|
-
* whole fragment (the common case), else
|
|
235
|
+
* Memoized shaping used by TextLayout and the canvas text path (bounded
|
|
236
|
+
* LRU). `levelsKey` is a compact embedding-levels encoding: a single
|
|
237
|
+
* number when uniform for the whole fragment (the common case), else
|
|
238
|
+
* comma-separated per-char levels.
|
|
238
239
|
*/
|
|
239
240
|
_shapeCached(text, style, levelsKey = '0') {
|
|
240
241
|
const font = style.font;
|
|
241
242
|
const key = `${font ? font.key : style.family}|${style.size}|${style.weight}|${style.style}|${levelsKey}|${text}`;
|
|
242
243
|
let shaped = this._shapeCache.get(key);
|
|
243
|
-
if (
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
} else {
|
|
248
|
-
levels = new Uint8Array(text.length).fill(Number(levelsKey));
|
|
249
|
-
}
|
|
250
|
-
shaped = shapeText(this, text, style, levels);
|
|
251
|
-
if (this._shapeCache.size > 4000) this._shapeCache.clear();
|
|
244
|
+
if (shaped) {
|
|
245
|
+
// Map iterates in insertion order: re-inserting a hit moves it to the
|
|
246
|
+
// tail, so the eviction sweep below walks least-recently-used first
|
|
247
|
+
this._shapeCache.delete(key);
|
|
252
248
|
this._shapeCache.set(key, shaped);
|
|
249
|
+
return shaped;
|
|
250
|
+
}
|
|
251
|
+
let levels;
|
|
252
|
+
if (levelsKey.includes(',')) {
|
|
253
|
+
levels = levelsKey.split(',').map(Number);
|
|
254
|
+
} else {
|
|
255
|
+
levels = new Uint8Array(text.length).fill(Number(levelsKey));
|
|
253
256
|
}
|
|
257
|
+
shaped = shapeText(this, text, style, levels);
|
|
258
|
+
if (this._shapeCache.size > 4000) {
|
|
259
|
+
// drop the stale half in one sweep rather than one entry per insert —
|
|
260
|
+
// a live UI's working set sits at the recent end and survives, where
|
|
261
|
+
// the old wholesale clear() re-shaped everything on screen
|
|
262
|
+
let drop = this._shapeCache.size >> 1;
|
|
263
|
+
for (const k of this._shapeCache.keys()) {
|
|
264
|
+
if (drop-- <= 0) break;
|
|
265
|
+
this._shapeCache.delete(k);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
this._shapeCache.set(key, shaped);
|
|
254
269
|
return shaped;
|
|
255
270
|
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Memoized whole-string shaping — what `ctx.fillText` draws through, so
|
|
274
|
+
* repainting the same label hits the same entries TextLayout populates
|
|
275
|
+
* instead of re-running bidi + OpenType shaping every frame.
|
|
276
|
+
*
|
|
277
|
+
* Bidi is resolved here because the memo key needs the embedding levels;
|
|
278
|
+
* the paragraph level rides back on the result separately, since per-char
|
|
279
|
+
* levels cannot recover it (RTL text in an RTL paragraph carries level 1,
|
|
280
|
+
* which reads as an even base) and start/end alignment depends on it.
|
|
281
|
+
*
|
|
282
|
+
* `features` and `language` change shaped output but are not part of the
|
|
283
|
+
* memo key (styles reaching it via TextLayout never carry them into the
|
|
284
|
+
* key either), so a style with either shapes uncached rather than
|
|
285
|
+
* poisoning entries other callers share.
|
|
286
|
+
*/
|
|
287
|
+
_shapeCachedWhole(text, style) {
|
|
288
|
+
if (style.features || style.language) return shapeText(this, text, style);
|
|
289
|
+
const emb = embeddingLevels(text, style.direction);
|
|
290
|
+
const baseLevel = emb.paragraphs.length ? emb.paragraphs[0].level : 0;
|
|
291
|
+
const shaped = this._shapeCached(text, style, normalizedLevels(emb.levels, 0, text.length));
|
|
292
|
+
return shaped.baseLevel === baseLevel ? shaped : { ...shaped, baseLevel };
|
|
293
|
+
}
|
|
256
294
|
}
|
package/lib/text/fontsource.js
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
// than imported, because this file is the one the browser bundle keeps — its
|
|
35
35
|
// whole purpose is running the text stack without a filesystem. Do not turn
|
|
36
36
|
// that into a static import (test/packaging.test.js fails if you do).
|
|
37
|
-
import { charsetHas, matchSortedSync, noFontsError, supported } from '../fontconfig.js';
|
|
37
|
+
import { charsetHas, matchSortedSync, noFontsError, prewarm, supported } from '../fontconfig.js';
|
|
38
38
|
import Font from './font.js';
|
|
39
39
|
|
|
40
40
|
const WEIGHTS = { normal: 400, bold: 700 };
|
|
@@ -95,6 +95,17 @@ export function parseFamilies(family) {
|
|
|
95
95
|
* self-contained alternative.
|
|
96
96
|
*/
|
|
97
97
|
export class FontconfigFontSource {
|
|
98
|
+
constructor() {
|
|
99
|
+
// Construction is the moment fontconfig is chosen as the lookup path, so
|
|
100
|
+
// the fc-match spawn for the pattern every widget default resolves to —
|
|
101
|
+
// sans-serif at regular weight, exactly as FontManager.match normalizes
|
|
102
|
+
// it — starts here, asynchronously, instead of stalling the first text
|
|
103
|
+
// layout for ~50ms. Fire-and-forget: prewarm never rejects, and a source
|
|
104
|
+
// constructed where fc-match is missing stays silent until (unless) text
|
|
105
|
+
// is actually rendered.
|
|
106
|
+
prewarm({ family: 'sans-serif', weight: 400, style: 'normal' });
|
|
107
|
+
}
|
|
108
|
+
|
|
98
109
|
matchSorted(pattern) {
|
|
99
110
|
return matchSortedSync(pattern);
|
|
100
111
|
}
|
package/lib/text/layout.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import LineBreaker from 'linebreak';
|
|
2
2
|
|
|
3
3
|
import { drawGlyphRuns } from './glyphs.js';
|
|
4
|
-
import { embeddingLevels, reorderRuns } from './shape.js';
|
|
4
|
+
import { embeddingLevels, normalizedLevels, reorderRuns } from './shape.js';
|
|
5
5
|
|
|
6
6
|
const WS = new Set([0x20, 0x09, 0xa0]);
|
|
7
7
|
const HARD_BREAKS = /[\n\r\u2028\u2029]+$/;
|
|
@@ -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,15 +849,10 @@ function stripTrailingWhitespace(entries) {
|
|
|
683
849
|
return result();
|
|
684
850
|
}
|
|
685
851
|
|
|
686
|
-
//
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
break;
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
if (uniform) return String(levels[start] ?? 0);
|
|
696
|
-
return Array.prototype.slice.call(levels, start, end).join(',');
|
|
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(',');
|
|
697
858
|
}
|
package/lib/text/shape.js
CHANGED
|
@@ -28,6 +28,23 @@ export function embeddingLevels(text, direction = 'auto') {
|
|
|
28
28
|
return bidi.getEmbeddingLevels(text, explicit);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Compact embedding-levels encoding for the shaping memo key: a single
|
|
33
|
+
* number when uniform over [start, end) — the common case — else
|
|
34
|
+
* comma-separated per-char levels.
|
|
35
|
+
*/
|
|
36
|
+
export function normalizedLevels(levels, start, end) {
|
|
37
|
+
let uniform = true;
|
|
38
|
+
for (let i = start + 1; i < end; i++) {
|
|
39
|
+
if (levels[i] !== levels[start]) {
|
|
40
|
+
uniform = false;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (uniform) return String(levels[start] ?? 0);
|
|
45
|
+
return Array.prototype.slice.call(levels, start, end).join(',');
|
|
46
|
+
}
|
|
47
|
+
|
|
31
48
|
/**
|
|
32
49
|
* Shape a string through the full pipeline:
|
|
33
50
|
*
|
package/lib/window.js
CHANGED
|
@@ -27,6 +27,19 @@ const MAX_DIRTY_RECTS = 8;
|
|
|
27
27
|
*/
|
|
28
28
|
const SPLIT_SAVING = 0.75;
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Backing pixmap dimensions are rounded up to this granularity.
|
|
32
|
+
*
|
|
33
|
+
* An interactive enlarge delivers a resize per frame, and an exact grow-only
|
|
34
|
+
* allocation crosses its previous maximum on every one of them — each frame
|
|
35
|
+
* paying for a CreatePixmap, a full white clear, a FreePixmap and a picture
|
|
36
|
+
* rebind. With headroom the reuse check absorbs the intermediate steps and a
|
|
37
|
+
* continuous drag reallocates O(log) times. The spare pixels are never
|
|
38
|
+
* presented — every blit is clamped to window ∩ backing — they just sit
|
|
39
|
+
* white until the window grows into them.
|
|
40
|
+
*/
|
|
41
|
+
const BACKING_GRANULARITY = 128;
|
|
42
|
+
|
|
30
43
|
function rectArea(r) {
|
|
31
44
|
return Math.max(0, r.w) * Math.max(0, r.h);
|
|
32
45
|
}
|
|
@@ -639,12 +652,14 @@ export default class Window extends Drawable {
|
|
|
639
652
|
});
|
|
640
653
|
}
|
|
641
654
|
|
|
642
|
-
// grow-only backing pixmap (re)allocation
|
|
655
|
+
// grow-only backing pixmap (re)allocation, with headroom (see
|
|
656
|
+
// BACKING_GRANULARITY); new area is cleared to white
|
|
643
657
|
_allocBacking(w, h) {
|
|
644
658
|
const cur = this._backing;
|
|
645
659
|
if (cur && cur.width >= w && cur.height >= h) return;
|
|
646
|
-
const
|
|
647
|
-
const
|
|
660
|
+
const roundUp = (v) => Math.ceil(v / BACKING_GRANULARITY) * BACKING_GRANULARITY;
|
|
661
|
+
const newW = roundUp(Math.max(w, cur ? cur.width : 0));
|
|
662
|
+
const newH = roundUp(Math.max(h, cur ? cur.height : 0));
|
|
648
663
|
const pixmap = new Pixmap(this.app, {
|
|
649
664
|
parent: this,
|
|
650
665
|
width: newW,
|