pretrunc 0.0.0-stage → 0.1.0
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/CHANGELOG.md +7 -0
- package/DESIGN.md +235 -0
- package/LICENSE +21 -0
- package/README.md +200 -2
- package/dist/cut-D7qi2yZl.js +356 -0
- package/dist/cut-D7qi2yZl.js.map +1 -0
- package/dist/cut.d.ts +75 -0
- package/dist/dom-CPL0-qc3.js +52 -0
- package/dist/dom-CPL0-qc3.js.map +1 -0
- package/dist/dom.d.ts +22 -0
- package/dist/dom.js +1 -0
- package/dist/face-pICHAjvk.js +68 -0
- package/dist/face-pICHAjvk.js.map +1 -0
- package/dist/face.d.ts +46 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/lines-DOj2DQLl.js +334 -0
- package/dist/lines-DOj2DQLl.js.map +1 -0
- package/dist/lines.d.ts +32 -0
- package/dist/lines.js +1 -0
- package/dist/metrics-C2jv4JVL.js +49 -0
- package/dist/metrics-C2jv4JVL.js.map +1 -0
- package/dist/metrics-cli.js +19 -0
- package/dist/metrics-cli.js.map +1 -0
- package/dist/metrics.d.ts +36 -0
- package/dist/metrics.js +73 -0
- package/dist/metrics.js.map +1 -0
- package/dist/react-CnQfjWyS.js +156 -0
- package/dist/react-CnQfjWyS.js.map +1 -0
- package/dist/react-lines.d.ts +27 -0
- package/dist/react-lines.js +74 -0
- package/dist/react-lines.js.map +1 -0
- package/dist/react.d.ts +81 -0
- package/dist/react.js +1 -0
- package/dist/solid-BWC32VMg.js +155 -0
- package/dist/solid-BWC32VMg.js.map +1 -0
- package/dist/solid-lines.d.ts +27 -0
- package/dist/solid-lines.js +90 -0
- package/dist/solid-lines.js.map +1 -0
- package/dist/solid.d.ts +77 -0
- package/dist/solid.js +1 -0
- package/package.json +145 -3
- package/src/cut.ts +596 -0
- package/src/dom.ts +59 -0
- package/src/face.ts +112 -0
- package/src/index.ts +15 -0
- package/src/lines.ts +585 -0
- package/src/metrics.ts +106 -0
- package/src/react-lines.ts +99 -0
- package/src/react.ts +251 -0
- package/src/solid-lines.tsx +89 -0
- package/src/solid.tsx +225 -0
package/src/cut.ts
ADDED
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
import { type Face, type FaceData, faceOf, pairOf, unitOf, widthOf } from "./face.ts";
|
|
2
|
+
|
|
3
|
+
/** Text measured once, ready to cut at any width. */
|
|
4
|
+
export interface Prepared {
|
|
5
|
+
/** The text as `white-space: normal` paints it; every offset refers to this string. */
|
|
6
|
+
readonly text: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface Internal extends Prepared {
|
|
10
|
+
readonly f: FaceData;
|
|
11
|
+
/** Graphemes. */
|
|
12
|
+
readonly n: number;
|
|
13
|
+
/**
|
|
14
|
+
* Each grapheme's start offset, then the text's length. Simple text shares one
|
|
15
|
+
* array of 0, 1, 2…, which can run past its length.
|
|
16
|
+
*/
|
|
17
|
+
readonly b: number[];
|
|
18
|
+
/**
|
|
19
|
+
* `w[i]`: the width of the first `i` graphemes, kerning between them included,
|
|
20
|
+
* except kerning that narrows across a possible line break.
|
|
21
|
+
*/
|
|
22
|
+
readonly w: Float64Array;
|
|
23
|
+
/** `d[i]`: the narrowing kerning `w` leaves out of the first `i` graphemes. */
|
|
24
|
+
readonly d: Float64Array;
|
|
25
|
+
/** `a[i]`: grapheme `i`'s width alone, letter spacing included. */
|
|
26
|
+
readonly a: Float64Array;
|
|
27
|
+
/** Per grapheme: 1 when a word can end beside it, 2 when a cut trims it. */
|
|
28
|
+
readonly c: Uint8Array;
|
|
29
|
+
/** Whether `w - d` matches canvas for the whole text, so cuts can trust `w` without measuring. */
|
|
30
|
+
readonly x: boolean;
|
|
31
|
+
/** The whole text's measured width. */
|
|
32
|
+
readonly y: number;
|
|
33
|
+
/**
|
|
34
|
+
* Whole protected matches as grapheme pairs, start then end. An end past the text's is
|
|
35
|
+
* one past its last grapheme, so a cut at the end of a range of a longer text still
|
|
36
|
+
* sees the match run on.
|
|
37
|
+
* Empty without `protect`. A few numbers, where marking every unit took an array as
|
|
38
|
+
* long as the text, which in Chromium cost more than the regex.
|
|
39
|
+
*/
|
|
40
|
+
readonly k: number[];
|
|
41
|
+
/**
|
|
42
|
+
* Every protected match, in the order a middle cut keeps them: highest weight first,
|
|
43
|
+
* then as given. Grapheme triples: start, end, and 1 for a whole match.
|
|
44
|
+
*/
|
|
45
|
+
readonly q: number[];
|
|
46
|
+
/** Whether every UTF-16 unit is a grapheme below U+0300, so widths are found by code. */
|
|
47
|
+
readonly u: boolean;
|
|
48
|
+
/** Letter spacing in px, counted per UTF-16 unit: WebKit spaces glyphs, several to a grapheme in Indic and Thai. */
|
|
49
|
+
readonly s: number;
|
|
50
|
+
/** How much wider a tab lays out than canvas measures it, in `pre-wrap`; 0 otherwise. */
|
|
51
|
+
readonly g: number;
|
|
52
|
+
/**
|
|
53
|
+
* Kerning beside each ellipsis cut with, by ellipsis: `[i]` between grapheme `i` and the
|
|
54
|
+
* ellipsis after it, `[n + i]` between the ellipsis and grapheme `i`. NaN until needed.
|
|
55
|
+
*/
|
|
56
|
+
j: Map<string, Float64Array> | undefined;
|
|
57
|
+
/** Where the last head search ended, and the last tail search: the next one starts there. */
|
|
58
|
+
v: number;
|
|
59
|
+
z: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A pattern whose matches a middle cut keeps before the rest of the text. */
|
|
63
|
+
export interface Protection {
|
|
64
|
+
readonly pattern: RegExp;
|
|
65
|
+
/** From 0 to 1: a match with a higher weight is kept first. Default 1; ties go in order. */
|
|
66
|
+
readonly weight?: number;
|
|
67
|
+
/**
|
|
68
|
+
* Shown whole or not at all (true), or as much as fits, cut on its side toward the
|
|
69
|
+
* ellipsis (false). Default false. A cut never splits a whole match.
|
|
70
|
+
*/
|
|
71
|
+
readonly whole?: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** What `protect` takes: a pattern, a `Protection`, or several, in order. */
|
|
75
|
+
export type Protect = RegExp | Protection | ReadonlyArray<RegExp | Protection>;
|
|
76
|
+
|
|
77
|
+
export interface PrepareOptions {
|
|
78
|
+
/**
|
|
79
|
+
* Matches a middle cut keeps first, such as a file name, `/\/[^/]+$/`, before a
|
|
80
|
+
* leading folder, `{ pattern: /^~\/[^/]+\//, weight: 0.5 }`. A match keeps the side
|
|
81
|
+
* nearer its middle. Start and end cuts only keep whole matches whole.
|
|
82
|
+
*/
|
|
83
|
+
readonly protect?: Protect;
|
|
84
|
+
/** CSS `letter-spacing` in px, which every grapheme carries after it. */
|
|
85
|
+
readonly letterSpacing?: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface CutOptions {
|
|
89
|
+
/** Where the ellipsis goes. Default "end". */
|
|
90
|
+
readonly at?: "start" | "middle" | "end";
|
|
91
|
+
/** Middle: the head's share of the room protected matches leave. Default 0.5. */
|
|
92
|
+
readonly ratio?: number;
|
|
93
|
+
/** Middle: the tail keeps this many graphemes, when they fit. */
|
|
94
|
+
readonly tailGraphemes?: number;
|
|
95
|
+
/**
|
|
96
|
+
* Back off to a word boundary within a quarter of what that side keeps (true), within
|
|
97
|
+
* `within` px, or never (false). Default true.
|
|
98
|
+
*/
|
|
99
|
+
readonly words?: boolean | { readonly within: number };
|
|
100
|
+
/** Drop spaces and `,;:` beside the ellipsis. Default true. */
|
|
101
|
+
readonly trim?: boolean;
|
|
102
|
+
/** The ellipsis text, or the width in px of an element you render in its place. Default "…". */
|
|
103
|
+
readonly ellipsis?: string | number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Kept text is `text.slice(0, head(c)) + ellipsis + text.slice(tail(c))`. Two cuts are
|
|
108
|
+
* `===` when they keep the same head and tail, so a memo can compare them.
|
|
109
|
+
*/
|
|
110
|
+
export type Cut = number & { readonly cut: unique symbol };
|
|
111
|
+
|
|
112
|
+
const SHIFT = 2 ** 26;
|
|
113
|
+
// Browsers lay text out in 1/64 px units and can round a run's width up by one.
|
|
114
|
+
const SLACK = 1 / 64;
|
|
115
|
+
|
|
116
|
+
let segmenter: Intl.Segmenter | undefined;
|
|
117
|
+
// `breaks` and `trims` per character below U+0300, as grapheme flags; built on first use.
|
|
118
|
+
let flags: Uint8Array | undefined;
|
|
119
|
+
// Offsets 0, 1, 2…: every simple text's grapheme starts. It only grows, and is shared.
|
|
120
|
+
const identity = [0];
|
|
121
|
+
const none: number[] = [];
|
|
122
|
+
|
|
123
|
+
/** A cut from its two offsets; for pretrunc's own entries, not re-exported. */
|
|
124
|
+
export const pack = (head: number, tail: number): Cut => (head * SHIFT + tail) as Cut;
|
|
125
|
+
export const head = (c: Cut): number => Math.floor(c / SHIFT);
|
|
126
|
+
export const tail = (c: Cut): number => c % SHIFT;
|
|
127
|
+
export const truncated = (c: Cut): boolean => head(c) < tail(c);
|
|
128
|
+
export const toText = (p: Prepared, c: Cut, ellipsis = "…"): string =>
|
|
129
|
+
truncated(c) ? p.text.slice(0, head(c)) + ellipsis + p.text.slice(tail(c)) : p.text;
|
|
130
|
+
|
|
131
|
+
// A word can end beside white space, a hyphen, a slash, an underscore, a full stop or an
|
|
132
|
+
// ellipsis, an en or em dash. From U+2E80 up (CJK, emoji) every character is a word of its own.
|
|
133
|
+
const breaks = /[\s\-/_.…–—⺀-]/;
|
|
134
|
+
const trims = /[\s,;:]/;
|
|
135
|
+
// WebKit lays out the text between two break opportunities as a run of its own, so
|
|
136
|
+
// kerning across one, after a hyphen, a dash, CJK or an ellipsis, never reaches the
|
|
137
|
+
// page there, while Chrome and Firefox keep it. Across a possible break, only kerning
|
|
138
|
+
// that widens counts: never narrower than any engine lays it out. WebKit also turns
|
|
139
|
+
// kerning off under letter spacing, so with it no narrowing kerning counts anywhere.
|
|
140
|
+
const across = (kerning: number, broken: unknown) => (broken ? Math.max(0, kerning) : kerning);
|
|
141
|
+
|
|
142
|
+
export const prepare = (
|
|
143
|
+
text: string,
|
|
144
|
+
font: string | Face,
|
|
145
|
+
options: PrepareOptions = {},
|
|
146
|
+
): Prepared =>
|
|
147
|
+
build(
|
|
148
|
+
// Most text has no run of white space to collapse, so one test saves two replaces.
|
|
149
|
+
/[\t\n\f\r]| {2}|^ | $/.test(text)
|
|
150
|
+
? text.replace(/[\t\n\f\r ]+/g, " ").replace(/^ | $/g, "")
|
|
151
|
+
: text,
|
|
152
|
+
typeof font === "string" ? faceOf(font) : (font as FaceData),
|
|
153
|
+
options,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Prepares `full.slice(from, to)` as it stands, white space kept, with protected
|
|
158
|
+
* matches found in all of `full`, and a tab `tab` px wide when that's given: the most
|
|
159
|
+
* a tab stop can take. For pretrunc's own entries.
|
|
160
|
+
*/
|
|
161
|
+
export const prepareRange = (
|
|
162
|
+
full: string,
|
|
163
|
+
from: number,
|
|
164
|
+
to: number,
|
|
165
|
+
font: string,
|
|
166
|
+
options: PrepareOptions,
|
|
167
|
+
tab = 0,
|
|
168
|
+
): Prepared => build(full.slice(from, to), faceOf(font), options, full, from, tab);
|
|
169
|
+
|
|
170
|
+
const build = (
|
|
171
|
+
t: string,
|
|
172
|
+
f: FaceData,
|
|
173
|
+
options: PrepareOptions,
|
|
174
|
+
full = t,
|
|
175
|
+
from = 0,
|
|
176
|
+
tab = 0,
|
|
177
|
+
): Prepared => {
|
|
178
|
+
const spacing = options.letterSpacing ?? 0;
|
|
179
|
+
// Below U+0300 every UTF-16 unit is a grapheme, CRLF aside, which white space
|
|
180
|
+
// handling removes or a hard break ends a line at, so most text never meets the
|
|
181
|
+
// segmenter. The class is anything from U+0300 up.
|
|
182
|
+
const simple = !/[^\0-\u02ff]/.test(t);
|
|
183
|
+
let b = identity;
|
|
184
|
+
if (simple) while (b.length <= t.length) b.push(b.length);
|
|
185
|
+
else {
|
|
186
|
+
b = Array.from((segmenter ??= new Intl.Segmenter()).segment(t), (g) => g.index);
|
|
187
|
+
b.push(t.length);
|
|
188
|
+
}
|
|
189
|
+
const n = simple ? t.length : b.length - 1;
|
|
190
|
+
const w = new Float64Array(n + 1);
|
|
191
|
+
const d = new Float64Array(n + 1);
|
|
192
|
+
const a = new Float64Array(n);
|
|
193
|
+
const c = new Uint8Array(n);
|
|
194
|
+
// Whether each word, measured whole, matches the sum of its pairs (`w - d`, kerning
|
|
195
|
+
// uncapped, letter spacing left out). Then the whole text does, since nothing shapes
|
|
196
|
+
// across a space, and every cut of it is arithmetic. Words repeat across texts in one
|
|
197
|
+
// font, as names and folders do, so most are measured once for a whole table.
|
|
198
|
+
let x = true;
|
|
199
|
+
let word = 0;
|
|
200
|
+
const check = (end: number) => {
|
|
201
|
+
if (x && end - word > 1) {
|
|
202
|
+
const sum =
|
|
203
|
+
w[end]! - d[end]! - w[word + 1]! + d[word + 1]! + a[word]! - spacing * (b[end]! - b[word]!);
|
|
204
|
+
x = Math.abs(widthOf(f, t.slice(b[word], b[end])) - sum) < 0.01;
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
if (!flags) {
|
|
208
|
+
flags = new Uint8Array(768);
|
|
209
|
+
for (let code = 0; code < 768; code++) {
|
|
210
|
+
const ch = String.fromCharCode(code);
|
|
211
|
+
flags[code] = +breaks.test(ch) | (+trims.test(ch) << 1);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
let previous = "";
|
|
215
|
+
let before = 0;
|
|
216
|
+
for (let i = 0; i < n; i++) {
|
|
217
|
+
// A grapheme adds its own advance, the kerning from its pair and its letter
|
|
218
|
+
// spacing. The step is never below 0, so a binary search stays valid.
|
|
219
|
+
let own: number;
|
|
220
|
+
let kerning = 0;
|
|
221
|
+
let space = false;
|
|
222
|
+
if (simple) {
|
|
223
|
+
const code = t.charCodeAt(i);
|
|
224
|
+
own = tab && code === 9 ? tab : unitOf(f, code);
|
|
225
|
+
c[i] = flags[code]!;
|
|
226
|
+
if (i && !(tab && (code === 9 || t.charCodeAt(i - 1) === 9))) {
|
|
227
|
+
kerning = pairOf(f, t.charCodeAt(i - 1), code) - before - own;
|
|
228
|
+
}
|
|
229
|
+
space = code === 32;
|
|
230
|
+
} else {
|
|
231
|
+
const g = t.slice(b[i], b[i + 1]);
|
|
232
|
+
own = tab && g === "\t" ? tab : widthOf(f, g);
|
|
233
|
+
c[i] = +breaks.test(g) | (+trims.test(g) << 1);
|
|
234
|
+
if (i && !(tab && (g === "\t" || previous === "\t"))) {
|
|
235
|
+
kerning = widthOf(f, previous + g) - before - own;
|
|
236
|
+
}
|
|
237
|
+
space = g === " ";
|
|
238
|
+
previous = g;
|
|
239
|
+
}
|
|
240
|
+
const spaced = spacing * (b[i + 1]! - b[i]!);
|
|
241
|
+
a[i] = own + spaced;
|
|
242
|
+
const counted = i ? across(kerning, spacing || c[i - 1]! & 1) : 0;
|
|
243
|
+
w[i + 1] = w[i]! + Math.max(0, own + counted) + spaced;
|
|
244
|
+
d[i + 1] = d[i]! + counted - kerning;
|
|
245
|
+
if (space) {
|
|
246
|
+
check(i);
|
|
247
|
+
word = i + 1;
|
|
248
|
+
}
|
|
249
|
+
before = own;
|
|
250
|
+
}
|
|
251
|
+
check(n);
|
|
252
|
+
// Text the model can't be trusted with, such as ligatures or joined scripts, is
|
|
253
|
+
// measured whole.
|
|
254
|
+
// Canvas has no tab stops, so a measured tab is widened to the most a stop can take.
|
|
255
|
+
const g = tab && tab - widthOf(f, "\t");
|
|
256
|
+
// The width that has to fit for the text to show whole, kerning counted as for a cut.
|
|
257
|
+
const y = x ? w[n]! : f.m(t) + spacing * t.length + g * (t.split("\t").length - 1) + d[n]!;
|
|
258
|
+
let k = none;
|
|
259
|
+
let q = none;
|
|
260
|
+
const { protect } = options;
|
|
261
|
+
if (protect) {
|
|
262
|
+
// The first grapheme starting at or after offset `o`, or with `back`, the one that
|
|
263
|
+
// holds it.
|
|
264
|
+
const at = (o: number, back: boolean) => {
|
|
265
|
+
const i = Math.min(Math.max(o, 0), t.length);
|
|
266
|
+
if (simple) return i;
|
|
267
|
+
let lo = 0;
|
|
268
|
+
let hi = n;
|
|
269
|
+
while (lo < hi) {
|
|
270
|
+
const mid = (lo + hi) >> 1;
|
|
271
|
+
if (b[mid]! < i) lo = mid + 1;
|
|
272
|
+
else hi = mid;
|
|
273
|
+
}
|
|
274
|
+
return back && b[lo]! > i ? lo - 1 : lo;
|
|
275
|
+
};
|
|
276
|
+
const spans: number[][] = [];
|
|
277
|
+
k = [];
|
|
278
|
+
const list = "length" in protect ? protect : [protect];
|
|
279
|
+
for (let i = 0; i < list.length; i++) {
|
|
280
|
+
const given = list[i]!;
|
|
281
|
+
const entry = given instanceof RegExp ? { pattern: given } : given;
|
|
282
|
+
const { pattern } = entry;
|
|
283
|
+
// `matchAll` throws on a pattern without the g flag, so one is given it.
|
|
284
|
+
for (const match of full.matchAll(
|
|
285
|
+
pattern.global ? pattern : new RegExp(pattern, pattern.flags + "g"),
|
|
286
|
+
)) {
|
|
287
|
+
const start = match.index - from;
|
|
288
|
+
const end = start + match[0].length;
|
|
289
|
+
if (start < t.length && end > 0 && end > start) {
|
|
290
|
+
const s = at(start, true);
|
|
291
|
+
const e = at(end, false);
|
|
292
|
+
if (entry.whole) k.push(s, end > t.length ? n + 1 : e);
|
|
293
|
+
spans.push([-(entry.weight ?? 1), i, s, e, +!!entry.whole]);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// By weight alone: the sort is stable, and matches come in pattern order, then text order.
|
|
298
|
+
spans.sort((x, y) => x[0]! - y[0]!);
|
|
299
|
+
q = [];
|
|
300
|
+
for (const x of spans) q.push(x[2]!, x[3]!, x[4]!);
|
|
301
|
+
}
|
|
302
|
+
const p: Internal = {
|
|
303
|
+
text: t,
|
|
304
|
+
f,
|
|
305
|
+
n,
|
|
306
|
+
b,
|
|
307
|
+
w,
|
|
308
|
+
d,
|
|
309
|
+
a,
|
|
310
|
+
c,
|
|
311
|
+
x,
|
|
312
|
+
y,
|
|
313
|
+
k,
|
|
314
|
+
q,
|
|
315
|
+
u: simple,
|
|
316
|
+
s: spacing,
|
|
317
|
+
g,
|
|
318
|
+
j: undefined,
|
|
319
|
+
v: 0,
|
|
320
|
+
z: n,
|
|
321
|
+
};
|
|
322
|
+
return p;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// The cut in progress. A cut runs to its end without calling out except to a face's
|
|
326
|
+
// measure, so its helpers read this state instead of closing over it: a resize frame
|
|
327
|
+
// over thousands of rows then allocates nothing per cut.
|
|
328
|
+
let P: Internal;
|
|
329
|
+
let N = 0;
|
|
330
|
+
let O = 0;
|
|
331
|
+
let room = 0;
|
|
332
|
+
let E: string | number = "";
|
|
333
|
+
let EW = 0;
|
|
334
|
+
let J: Float64Array | undefined;
|
|
335
|
+
let at: CutOptions["at"] = "end";
|
|
336
|
+
|
|
337
|
+
const grapheme = (i: number) => P.text.slice(P.b[i], P.b[i + 1]);
|
|
338
|
+
// The head from grapheme O to `h` alone: its first grapheme without kerning before it.
|
|
339
|
+
const headWidth = (h: number) => (h > O ? P.w[h]! - P.w[O + 1]! + P.a[O]! : 0);
|
|
340
|
+
// The tail alone: its first grapheme's own advance, without the kerning before it.
|
|
341
|
+
const tailWidth = (t: number) => (t < N ? P.w[N]! - P.w[t + 1]! + P.a[t]! : 0);
|
|
342
|
+
// A word boundary inside the text: backing off to either end would keep nothing.
|
|
343
|
+
const isBreak = (i: number) => i > O && i < N && ((P.c[i - 1]! | P.c[i]!) & 1) !== 0;
|
|
344
|
+
// Whether a cut before grapheme `i` falls inside a whole protected match.
|
|
345
|
+
const inside = (i: number) => {
|
|
346
|
+
const k = P.k;
|
|
347
|
+
for (let j = 0; j < k.length; j += 2) if (k[j]! < i && i < k[j + 1]!) return true;
|
|
348
|
+
return false;
|
|
349
|
+
};
|
|
350
|
+
const kern = (left: string, right: string) =>
|
|
351
|
+
widthOf(P.f, left + right) - widthOf(P.f, left) - widthOf(P.f, right);
|
|
352
|
+
// The kerning between grapheme `i` and the ellipsis after it (`side` 0) or before it (1).
|
|
353
|
+
const beside = (i: number, side: number) => {
|
|
354
|
+
// Simple text shares its face's kerning by character code; other text keeps its own.
|
|
355
|
+
const at = P.u ? side * 768 + P.text.charCodeAt(i) : side * N + i;
|
|
356
|
+
let kerning = J![at]!;
|
|
357
|
+
// NaN, not yet measured.
|
|
358
|
+
if (kerning !== kerning) {
|
|
359
|
+
kerning = side ? kern(E as string, grapheme(i)) : kern(grapheme(i), E as string);
|
|
360
|
+
J![at] = kerning;
|
|
361
|
+
}
|
|
362
|
+
return kerning;
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
// The largest `h` whose head fits `r` px, and the smallest `t` whose tail does. A resize
|
|
366
|
+
// moves a cut a grapheme or two, so each search gallops from where the last one on this
|
|
367
|
+
// text ended, doubling its step until it brackets the answer, then bisects the bracket.
|
|
368
|
+
// The widths only grow with `h`, so the answer is the one a plain bisection finds.
|
|
369
|
+
const maxHead = (r: number) => {
|
|
370
|
+
let lo = O;
|
|
371
|
+
let hi = N;
|
|
372
|
+
let i = Math.max(P.v, O);
|
|
373
|
+
// `lo` always fits, as the empty head does even in no room, so it is never measured.
|
|
374
|
+
for (let step = 1; lo < hi; step *= 2) {
|
|
375
|
+
if (i === lo || headWidth(i) <= r) {
|
|
376
|
+
lo = i;
|
|
377
|
+
i += step;
|
|
378
|
+
if (i > hi) break;
|
|
379
|
+
} else {
|
|
380
|
+
hi = i - 1;
|
|
381
|
+
i -= step;
|
|
382
|
+
if (i < lo) break;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
while (lo < hi) {
|
|
386
|
+
const mid = (lo + hi + 1) >> 1;
|
|
387
|
+
if (headWidth(mid) <= r) lo = mid;
|
|
388
|
+
else hi = mid - 1;
|
|
389
|
+
}
|
|
390
|
+
return (P.v = lo);
|
|
391
|
+
};
|
|
392
|
+
const minTail = (r: number) => {
|
|
393
|
+
let lo = 0;
|
|
394
|
+
let hi = N;
|
|
395
|
+
let i = P.z;
|
|
396
|
+
for (let step = 1; lo < hi; step *= 2) {
|
|
397
|
+
if (i === hi || tailWidth(i) <= r) {
|
|
398
|
+
hi = i;
|
|
399
|
+
i -= step;
|
|
400
|
+
if (i < lo) break;
|
|
401
|
+
} else {
|
|
402
|
+
lo = i + 1;
|
|
403
|
+
i += step;
|
|
404
|
+
if (i > hi) break;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
while (lo < hi) {
|
|
408
|
+
const mid = (lo + hi) >> 1;
|
|
409
|
+
if (tailWidth(mid) <= r) hi = mid;
|
|
410
|
+
else lo = mid + 1;
|
|
411
|
+
}
|
|
412
|
+
return (P.z = lo);
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
// How wide a cut paints. When the model matched canvas over the whole text it is
|
|
416
|
+
// trusted; otherwise the cut is measured for real, plus the kerning that a break
|
|
417
|
+
// opportunity drops in WebKit. Either way the kerning beside the ellipsis is added,
|
|
418
|
+
// and the one after it always crosses a break.
|
|
419
|
+
const measure = (h: number, t: number) => {
|
|
420
|
+
const { text, f, b, d, c, x } = P;
|
|
421
|
+
let painted = headWidth(h) + EW + tailWidth(t);
|
|
422
|
+
if (!x) {
|
|
423
|
+
const before = text.slice(b[O], b[h]);
|
|
424
|
+
const after = text.slice(b[t]);
|
|
425
|
+
painted = J
|
|
426
|
+
? f.m(before + E + after) + EW - widthOf(f, E as string)
|
|
427
|
+
: f.m(before) + EW + f.m(after);
|
|
428
|
+
painted += P.s * (b[h]! - b[O]! + b[N]! - b[t]!);
|
|
429
|
+
if (P.g) painted += P.g * ((before + after).split("\t").length - 1);
|
|
430
|
+
if (h > O) painted += d[h]! - d[O + 1]!;
|
|
431
|
+
if (t < N) painted += d[N]! - d[t + 1]!;
|
|
432
|
+
}
|
|
433
|
+
if (J && h > O) {
|
|
434
|
+
const kerning = beside(h - 1, 0);
|
|
435
|
+
painted += across(kerning, P.s || c[h - 1]! & 1) - (x ? 0 : kerning);
|
|
436
|
+
}
|
|
437
|
+
if (J && t < N) {
|
|
438
|
+
const kerning = beside(t, 1);
|
|
439
|
+
painted += across(kerning, 1) - (x ? 0 : kerning);
|
|
440
|
+
}
|
|
441
|
+
return painted;
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
// What a side paints beside the ellipsis: its text and the kerning across to it.
|
|
445
|
+
const headSide = (h: number) =>
|
|
446
|
+
headWidth(h) + (J && h > O ? across(beside(h - 1, 0), P.s || P.c[h - 1]! & 1) : 0);
|
|
447
|
+
const tailSide = (t: number) => tailWidth(t) + (J && t < N ? across(beside(t, 1), 1) : 0);
|
|
448
|
+
// The most a side keeps within `budget`: the search, then a step or two for the
|
|
449
|
+
// kerning beside the ellipsis, which the search leaves out.
|
|
450
|
+
const fitHead = (budget: number) => {
|
|
451
|
+
let i = maxHead(budget);
|
|
452
|
+
while (i > O && headSide(i) > budget) i--;
|
|
453
|
+
while (i < N && headSide(i + 1) <= budget) i++;
|
|
454
|
+
return i;
|
|
455
|
+
};
|
|
456
|
+
const fitTail = (budget: number) => {
|
|
457
|
+
let i = minTail(budget);
|
|
458
|
+
while (i < N && tailSide(i) > budget) i++;
|
|
459
|
+
while (i > 0 && tailSide(i - 1) <= budget) i--;
|
|
460
|
+
return i;
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
let h = 0;
|
|
464
|
+
let t = 0;
|
|
465
|
+
// Shrink the cut a grapheme at a time until it fits, from the side that keeps more.
|
|
466
|
+
const shrink = () => {
|
|
467
|
+
while ((h > O || t < N) && measure(h, t) > room) {
|
|
468
|
+
if (at === "end" || (at === "middle" && h > N - t)) h--;
|
|
469
|
+
else t++;
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* `cut`, keeping the text before grapheme `origin` out of the cut, the head starting
|
|
475
|
+
* there; with `more`, the text goes on after its end, so even text that fits leaves
|
|
476
|
+
* room for the ellipsis. For pretrunc's own entries: only an end cut uses them.
|
|
477
|
+
*/
|
|
478
|
+
export const cutFrom = (
|
|
479
|
+
prepared: Prepared,
|
|
480
|
+
origin: number,
|
|
481
|
+
width: number,
|
|
482
|
+
options: CutOptions = {},
|
|
483
|
+
more = false,
|
|
484
|
+
): Cut => {
|
|
485
|
+
const p = prepared as Internal;
|
|
486
|
+
const { b, w } = p;
|
|
487
|
+
const { ratio = 0.5, words = true, trim = true, ellipsis = "…" } = options;
|
|
488
|
+
P = p;
|
|
489
|
+
N = p.n;
|
|
490
|
+
O = origin;
|
|
491
|
+
room = width - SLACK;
|
|
492
|
+
if (!N || (!O && !more && p.y <= room)) return pack(b[N]!, b[N]!);
|
|
493
|
+
at = options.at ?? "end";
|
|
494
|
+
E = ellipsis;
|
|
495
|
+
if (typeof ellipsis === "string") {
|
|
496
|
+
// Its letter spacing counted per UTF-16 unit, never less than per grapheme.
|
|
497
|
+
EW = widthOf(p.f, ellipsis) + p.s * ellipsis.length;
|
|
498
|
+
const kerns = p.u ? p.f.e : (p.j ??= new Map());
|
|
499
|
+
J = kerns.get(ellipsis);
|
|
500
|
+
if (!J) kerns.set(ellipsis, (J = new Float64Array(p.u ? 1536 : 2 * N).fill(Number.NaN)));
|
|
501
|
+
} else {
|
|
502
|
+
EW = ellipsis;
|
|
503
|
+
J = undefined;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// The head keeps graphemes [O, h) and the tail [t, N), in `r` px of text. Each side
|
|
507
|
+
// keeps the most that fits its own share and never takes the other's slack, so as the
|
|
508
|
+
// width moves, each side only ever grows or only ever shrinks: the ellipsis doesn't
|
|
509
|
+
// drift a grapheme one way and back.
|
|
510
|
+
const r = room - EW;
|
|
511
|
+
h = N;
|
|
512
|
+
t = N;
|
|
513
|
+
// Kept whole: the word backoff stops at the head's end of these, and the tail's start.
|
|
514
|
+
let keepH = O;
|
|
515
|
+
let keepT = N;
|
|
516
|
+
if (at === "start") {
|
|
517
|
+
h = O;
|
|
518
|
+
t = fitTail(r);
|
|
519
|
+
} else if (at === "end") {
|
|
520
|
+
h = fitHead(r);
|
|
521
|
+
} else {
|
|
522
|
+
const { tailGraphemes } = options;
|
|
523
|
+
const fixed = tailGraphemes === undefined ? -1 : Math.max(0, N - tailGraphemes);
|
|
524
|
+
if (fixed >= 0 && fixed < N && tailSide(fixed) <= r) {
|
|
525
|
+
t = keepT = fixed;
|
|
526
|
+
h = Math.min(t, fitHead(r - tailSide(t)));
|
|
527
|
+
} else {
|
|
528
|
+
// Protected matches, highest weight first, take the room they need: a whole one
|
|
529
|
+
// all of it or none, any other as much as fits, cut on its side toward the
|
|
530
|
+
// ellipsis, and then nothing after it gets room. A match keeps the side nearer its
|
|
531
|
+
// middle, so a file name keeps the tail and a leading folder the head. The text
|
|
532
|
+
// outside them shares what's left by ratio. As the width grows, a cut match only
|
|
533
|
+
// gains and the rest only grows, so each side moves one way; a whole match
|
|
534
|
+
// switches the cut once, at the width where it starts to fit.
|
|
535
|
+
const { q } = p;
|
|
536
|
+
let H = O;
|
|
537
|
+
let T = N;
|
|
538
|
+
// Room for the rest of the text, until a match is cut to fit: then none, since the
|
|
539
|
+
// slack beside a cut match moves with every grapheme it gains.
|
|
540
|
+
let spare = true;
|
|
541
|
+
for (let j = 0; j < q.length; j += 3) {
|
|
542
|
+
const s = q[j]!;
|
|
543
|
+
const e = q[j + 1]!;
|
|
544
|
+
const toTail = e === N || (s > 0 && w[s]! + w[e]! > 2 * ratio * w[N]!);
|
|
545
|
+
if (toTail ? s >= T : e <= H) continue;
|
|
546
|
+
if ((toTail ? headSide(H) + tailSide(s) : headSide(e) + tailSide(T)) <= r) {
|
|
547
|
+
if (toTail) T = keepT = s;
|
|
548
|
+
else H = keepH = e;
|
|
549
|
+
} else if (!q[j + 2]) {
|
|
550
|
+
if (toTail) T = fitTail(r - headSide(H));
|
|
551
|
+
else H = fitHead(r - tailSide(T));
|
|
552
|
+
spare = false;
|
|
553
|
+
break;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
const rest = spare ? r - headSide(H) - tailSide(T) : 0;
|
|
557
|
+
t = fitTail(tailSide(T) + rest * (1 - ratio));
|
|
558
|
+
let headRoom = headSide(H) + rest * ratio;
|
|
559
|
+
// A whole match the tail's share would split goes whole, and leaves the head its room.
|
|
560
|
+
if (inside(t)) {
|
|
561
|
+
while (t < N && inside(t)) t++;
|
|
562
|
+
headRoom = r - tailSide(t);
|
|
563
|
+
}
|
|
564
|
+
h = Math.min(t, fitHead(headRoom));
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
// Text the model can't be trusted with settles on real measurements: shrink until it
|
|
568
|
+
// fits, then each side grows while one more fits, which also finds a longer cut that a
|
|
569
|
+
// ligature paints narrower.
|
|
570
|
+
if (!p.x) {
|
|
571
|
+
shrink();
|
|
572
|
+
if (at !== "start") while (h < t && measure(h + 1, t) <= room) h++;
|
|
573
|
+
if (at !== "end") while (t > h && measure(h, t - 1) <= room) t--;
|
|
574
|
+
}
|
|
575
|
+
if (words !== false) {
|
|
576
|
+
// A quarter of what the side keeps, not of the room: a limit that grew with the width
|
|
577
|
+
// while the cut point held still would cross the distance to a break mid-grapheme and
|
|
578
|
+
// snap the side back a word, then forward again at the next grapheme.
|
|
579
|
+
let limit = words === true ? headWidth(h) / 4 : words.within;
|
|
580
|
+
let i = h;
|
|
581
|
+
while (i > keepH && !isBreak(i) && w[h]! - w[i - 1]! <= limit) i--;
|
|
582
|
+
if (isBreak(i)) h = i;
|
|
583
|
+
if (words === true) limit = tailWidth(t) / 4;
|
|
584
|
+
i = t;
|
|
585
|
+
while (i < keepT && !isBreak(i) && w[i + 1]! - w[t]! <= limit) i++;
|
|
586
|
+
if (isBreak(i)) t = i;
|
|
587
|
+
}
|
|
588
|
+
while (h > O && (inside(h) || (trim && p.c[h - 1]! & 2))) h--;
|
|
589
|
+
while (t < N && (inside(t) || (trim && p.c[t]! & 2))) t++;
|
|
590
|
+
// Dropping graphemes can widen shaped text, as when a letter loses its joining form.
|
|
591
|
+
shrink();
|
|
592
|
+
return pack(b[h]!, b[t]!);
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
export const cut = (prepared: Prepared, width: number, options: CutOptions = {}): Cut =>
|
|
596
|
+
cutFrom(prepared, 0, width, options);
|
package/src/dom.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { forget } from "./face.ts";
|
|
2
|
+
|
|
3
|
+
const watched = new WeakMap<Element, (width: number) => void>();
|
|
4
|
+
let observer: ResizeObserver | undefined;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Calls `listener` with the element's content-box width, first before the element
|
|
8
|
+
* paints and then on every change, through one ResizeObserver shared by every element.
|
|
9
|
+
* Returns the function that stops it.
|
|
10
|
+
*/
|
|
11
|
+
export const observe = (element: Element, listener: (width: number) => void): (() => void) => {
|
|
12
|
+
observer ??= new ResizeObserver((entries) => {
|
|
13
|
+
for (const entry of entries) watched.get(entry.target)?.(entry.contentRect.width);
|
|
14
|
+
});
|
|
15
|
+
watched.set(element, listener);
|
|
16
|
+
observer.observe(element);
|
|
17
|
+
return () => {
|
|
18
|
+
watched.delete(element);
|
|
19
|
+
observer!.unobserve(element);
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The canvas font for an element's computed style. Built from the longhands: the
|
|
25
|
+
* `font` shorthand reads back empty in some engines, and `font-stretch` reads back as
|
|
26
|
+
* a percentage, which canvas rejects, so it is left out. Reading the font and the
|
|
27
|
+
* letter spacing, pass one `getComputedStyle(element)` to both: computing it is most of
|
|
28
|
+
* the cost, and twice takes twice as long.
|
|
29
|
+
*/
|
|
30
|
+
export const fontOf = (element: Element, style = getComputedStyle(element)): string =>
|
|
31
|
+
`${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
|
|
32
|
+
|
|
33
|
+
/** An element's computed `letter-spacing` in px; "normal" is 0. */
|
|
34
|
+
export const spacingOf = (element: Element, style = getComputedStyle(element)): number =>
|
|
35
|
+
parseFloat(style.letterSpacing) || 0;
|
|
36
|
+
|
|
37
|
+
const loaded = new Set<() => void>();
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Calls `listener` whenever web fonts finish loading, after dropping every width
|
|
41
|
+
* measured so far: those were the fallback font's. Prepare the text again in it.
|
|
42
|
+
* Returns the function that stops it.
|
|
43
|
+
*/
|
|
44
|
+
export const onFonts = (listener: () => void): (() => void) => {
|
|
45
|
+
if (!loaded.size) document.fonts.addEventListener("loading", loading);
|
|
46
|
+
loaded.add(listener);
|
|
47
|
+
return () => {
|
|
48
|
+
loaded.delete(listener);
|
|
49
|
+
if (!loaded.size) document.fonts.removeEventListener("loading", loading);
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// WebKit fires no `loadingdone` for a face loaded from script, but every engine fires
|
|
54
|
+
// `loading`, and `ready` settles once every pending load is done.
|
|
55
|
+
const loading = () =>
|
|
56
|
+
void document.fonts.ready.then(() => {
|
|
57
|
+
forget();
|
|
58
|
+
for (const listener of loaded) listener();
|
|
59
|
+
});
|