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.
Files changed (52) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/DESIGN.md +235 -0
  3. package/LICENSE +21 -0
  4. package/README.md +200 -2
  5. package/dist/cut-D7qi2yZl.js +356 -0
  6. package/dist/cut-D7qi2yZl.js.map +1 -0
  7. package/dist/cut.d.ts +75 -0
  8. package/dist/dom-CPL0-qc3.js +52 -0
  9. package/dist/dom-CPL0-qc3.js.map +1 -0
  10. package/dist/dom.d.ts +22 -0
  11. package/dist/dom.js +1 -0
  12. package/dist/face-pICHAjvk.js +68 -0
  13. package/dist/face-pICHAjvk.js.map +1 -0
  14. package/dist/face.d.ts +46 -0
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.js +3 -0
  17. package/dist/lines-DOj2DQLl.js +334 -0
  18. package/dist/lines-DOj2DQLl.js.map +1 -0
  19. package/dist/lines.d.ts +32 -0
  20. package/dist/lines.js +1 -0
  21. package/dist/metrics-C2jv4JVL.js +49 -0
  22. package/dist/metrics-C2jv4JVL.js.map +1 -0
  23. package/dist/metrics-cli.js +19 -0
  24. package/dist/metrics-cli.js.map +1 -0
  25. package/dist/metrics.d.ts +36 -0
  26. package/dist/metrics.js +73 -0
  27. package/dist/metrics.js.map +1 -0
  28. package/dist/react-CnQfjWyS.js +156 -0
  29. package/dist/react-CnQfjWyS.js.map +1 -0
  30. package/dist/react-lines.d.ts +27 -0
  31. package/dist/react-lines.js +74 -0
  32. package/dist/react-lines.js.map +1 -0
  33. package/dist/react.d.ts +81 -0
  34. package/dist/react.js +1 -0
  35. package/dist/solid-BWC32VMg.js +155 -0
  36. package/dist/solid-BWC32VMg.js.map +1 -0
  37. package/dist/solid-lines.d.ts +27 -0
  38. package/dist/solid-lines.js +90 -0
  39. package/dist/solid-lines.js.map +1 -0
  40. package/dist/solid.d.ts +77 -0
  41. package/dist/solid.js +1 -0
  42. package/package.json +145 -3
  43. package/src/cut.ts +596 -0
  44. package/src/dom.ts +59 -0
  45. package/src/face.ts +112 -0
  46. package/src/index.ts +15 -0
  47. package/src/lines.ts +585 -0
  48. package/src/metrics.ts +106 -0
  49. package/src/react-lines.ts +99 -0
  50. package/src/react.ts +251 -0
  51. package/src/solid-lines.tsx +89 -0
  52. 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
+ });