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/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # pretrunc
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 034afd8: First release: one-line cuts at the start, middle or end, multi-line clamps through pretext, Solid 2 and React 19 adapters, and metrics tables for measuring without a canvas, written from a font file by the `pretrunc-metrics` command.
package/DESIGN.md ADDED
@@ -0,0 +1,235 @@
1
+ # pretrunc design
2
+
3
+ Cut text at its start, middle or end so it fits a width, on one line or across N lines. Framework-agnostic TypeScript core, a Solid 2 adapter, and pretext for multi-line only.
4
+
5
+ ## Decisions
6
+
7
+ | Question | Decision |
8
+ | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
9
+ | Shapes | Single line: start, middle, end. Multi-line: end clamp, start, middle. |
10
+ | Pretext | Tiered entries. `pretrunc` never imports pretext; `pretrunc/lines` does. Pretext is 38.8 KB gzipped and doesn't tree-shake, so only multi-line callers pay for it. |
11
+ | Accuracy | Never overflow. A result always fits as the browser paints it, and may keep one grapheme less than the tightest fit. |
12
+ | Workloads | Thousands of rows re-cut during a live column resize; no flash on first paint; server rendering later. |
13
+ | Cut points | Any grapheme as the floor; word boundaries preferred; caller-protected spans kept first by weight, cut to fit only when they must be, and never split when whole; whitespace and dangling punctuation trimmed at the cut. |
14
+ | Middle split | Protected spans first, by weight, then a head ratio (default 0.5) of the room they leave, or a tail of N graphemes. |
15
+ | Output | UTF-16 offsets, with the string built on demand. |
16
+ | Ellipsis | Any string, measured in the text's font; a width in px for an inline element the caller renders; a "… more" / "less" toggle. |
17
+ | Hidden text | The core returns offsets; the adapter renders either the kept text with a `title`, or the full text with the cut range collapsed. |
18
+ | Adapters | Solid 2 and React 19, each with a one-line and a multi-line entry. |
19
+ | Width and font | Explicit `width` and `font` win; leave them out and the adapter observes the element. |
20
+ | SSR | A font metrics table, read from the font file at build time: `pretrunc/metrics` measures with it where there is no canvas. |
21
+ | Size budget (min + brotli) | `pretrunc` ≤ 2.8 KB (1 KB was the first aim; every option above cost 1.4 KB, the speed work 1.8 KB, letter spacing, tabs and ranges 2.1 KB, cuts that hold still on resize 2.3 KB, galloping searches 2.4 KB, 2,472 bytes once `forget` also dropped pretext's widths, 2,512 once option names said what they count and `protect` took a pattern without the g flag, 2,768 once protected spans were kept by weight and cut to fit), `pretrunc/dom` ≤ 0.4 KB, `pretrunc/solid` ≤ 1.1 KB beyond the core (0.6 KB was the first aim, 1 KB until `FontProvider`), `pretrunc/react` ≤ 1.1 KB and `pretrunc/react/lines` ≤ 0.75 KB beyond the core, `pretrunc/lines` ≤ 2.5 KB plus pretext (1 KB held until `pre-wrap`, letter spacing and boxes, 1.5 KB until tail starts were kept per text, 1.7 KB until middle clamps stopped showing less in a wider box, 1.9 KB until tabs counted half a `ch` past a stop, 2 KB until a word longer than a line was measured as the engines measure it, #8 and #10). A test fails on a breach. |
22
+
23
+ ## Prior art and what this does differently
24
+
25
+ - `pretext-truncate` scans the last line backwards one character at a time, calling `prepare` twice on a fresh substring per step: O(n) measurement passes per cut. React only. End only.
26
+ - `pretext-ellipsis` hides the text until measured, then clips the last line with CSS `overflow: hidden` instead of computing a cut. React only.
27
+ - pretext's `pages/demos/ellipsis.model.ts` is the reference for browser behaviour: a line clamp ends the way `-webkit-line-clamp` does (the line breaks where it would without the clamp, the ellipsis follows if both fit, otherwise the line is cut after the last grapheme that leaves it room), and its middle cut keeps a path's file name. It finds cuts by asking for lines one candidate at a time.
28
+
29
+ pretrunc replaces candidate-by-candidate measuring with arithmetic over widths it already holds, confirmed by at most one or two real measurements, and in the common case none.
30
+
31
+ ## Result: one number
32
+
33
+ A cut is two UTF-16 offsets into the prepared text: kept text is `[0, head) + [tail, length)`. One shape covers every mode: an end cut has `tail === length`, a start cut has `head === 0`, a middle cut has both, and text that fits has `head === tail === length`.
34
+
35
+ The two offsets pack into one number, `head * 2**26 + tail`, exact in a double for text under 2^26 UTF-16 units. `cut()` therefore allocates nothing, and a reactive memo over it compares by value: a resize that doesn't move the cut re-renders nothing in Solid, and in React a `useSyncExternalStore` snapshot stays equal. Helpers unpack it:
36
+
37
+ ```ts
38
+ head(c); // Math.floor(c / 2**26)
39
+ tail(c); // c % 2**26
40
+ truncated(c); // head(c) < tail(c)
41
+ toText(p, c, ellipsis); // allocates; call it where you render
42
+ ```
43
+
44
+ ## Core API (`pretrunc`)
45
+
46
+ ```ts
47
+ const p = prepare(text, font, {
48
+ protect: [/\/[^/]+$/, { pattern: /^~\/[^/]+\//, weight: 0.5 }], // kept first, by weight; { whole: true } for all or nothing
49
+ letterSpacing: 0.5,
50
+ });
51
+ const c = cut(p, width, {
52
+ at: "middle", // "end" (default) | "start" | "middle"
53
+ ratio: 0.5, // middle: the head's share of the room protected spans leave
54
+ tailGraphemes: 8, // middle: keep the last 8 graphemes instead
55
+ words: true, // back off to a word boundary within a quarter of what that side keeps; { within: 24 } for 24 px; false never
56
+ trim: true, // drop spaces and `,;:` beside the ellipsis
57
+ ellipsis: "…", // a string, or a width in px for an element you render
58
+ });
59
+ ```
60
+
61
+ `prepare` collapses white space the way `white-space: normal` paints it, and offsets refer to `p.text`, the collapsed string. Protected spans are matches of each pattern against that string, since offsets into the caller's text would shift when white space collapses; a pattern without the g flag is copied with it, since `matchAll` throws on it otherwise. Each has a weight from 0 to 1, default 1, and is whole or not, default not. `font` is a CSS font shorthand, measured with canvas, or a `Face`: `canvasFace(font)`, `tableFace` from a metrics table, or `face(measure)` over node-canvas or a test double. A `Face` is opaque: its caches are typed only inside pretrunc, so they can change without breaking anyone. `letterSpacing` is CSS `letter-spacing` in px.
62
+
63
+ ### Names
64
+
65
+ Names are judged by how a call reads where it's made. A number or a boolean says nothing there, so each carries its role: `clamp` takes its line count as `lines` in its options, as `<Clamp>` does, since `clamp(p, 3, 240)` type-checked as readily as `clamp(p, 240, 3)`; the middle split's option is `tailGraphemes`, which names what it counts and keeps clear of `tail(c)`, an offset; `words: { within: 24 }` says px where `words: 24` read as a count of words; `createCut` takes `separateEllipsis` among its props instead of a second, positional boolean. The canvas face is `canvasFace`, beside `tableFace` and clear of the DOM's `FontFace`. A component that hands values down through context is a provider in JSX, so the one that gives a list its font is `FontProvider`. Option names are kept whole in the bundle, so each costs its letters: the renames took the core 11 bytes.
66
+
67
+ ### Measuring without measuring
68
+
69
+ The expensive operations are `Intl.Segmenter` and `measureText`. The core avoids both where it can.
70
+
71
+ 1. **No segmenter for simple text.** Below U+0300 every UTF-16 unit is one grapheme, CRLF aside, and collapsing white space removes CRLF. One regex test decides whether the text needs `Intl.Segmenter` at all; one lazily created segmenter serves the rest.
72
+ 2. **Shared widths per font.** Each font's face caches every width it measures. Characters below U+0300 are cached by code: a typed array for single characters, a flat 128 × 128 table for ASCII pairs, a numeric-keyed map for other pairs, and per ellipsis the kerning on each side of it. So preparing and cutting simple text builds no strings once the face is warm; graphemes, words and ellipses are cached by string. Simple text also shares one array of offsets 0, 1, 2… instead of allocating its own. A grapheme adds its advance plus the kerning from its pair to the running total. A table of 5,000 names in one font measures each distinct character and pair once, a few hundred `measureText` calls in all, not one per row per resize.
73
+ 3. **Trust per word.** `prepare` measures each word whole, cached in the face, and compares it with the sum of its pairs. Within 0.01 px for every word, the pair model holds for the text, since nothing shapes across a space, and every cut of it is arithmetic: pair widths, plus the kerning on each side of the ellipsis. Otherwise (ligatures, Arabic joining, Indic shaping) the text is measured whole and each candidate cut is measured for real. Measuring each whole text instead was the first version; it was most of a cold `prepare`'s time (63 of 71 ms for 5,000 names in WebKit), and words repeat across rows where texts don't.
74
+
75
+ Canvas measures small emoji wider than the DOM paints them on macOS, as pretext documents. Wider means a shorter cut, so the contract holds without pretext's emoji correction.
76
+
77
+ ### The cut
78
+
79
+ `prepare` stores grapheme start offsets, each grapheme's own width and a prefix sum `W[i]` of the first `i` graphemes, kerning included. Each step of the sum is clamped at 0, so binary search stays valid even with negative kerning. A tail's width is exact: `W[n] - W[t + 1]` plus grapheme `t` alone, since nothing precedes it but the ellipsis.
80
+
81
+ - **End:** the largest `h` with `W[h] ≤ room`. A galloping binary search, O(log d) for a cut d graphemes from the last.
82
+ - **Start:** the smallest `t` whose tail fits.
83
+ - **Middle (ratio):** the head takes the largest `h` within `ratio × room` and the tail the smallest `t` within the rest of the room, each against its own share. Neither takes the slack the other's rounding left: when the head borrowed it, a step of the width that let the tail grow by a grapheme took a grapheme back from the head, so the ellipsis wandered a grapheme one way and back as a box was dragged.
84
+ - **Middle (tail graphemes):** fix `t`, then cut the head into the rest. If the fixed tail alone doesn't fit, fall back to the ratio. A fixed tail never backs off to a word.
85
+ - **Middle (protected spans):** spans claim room before the rest of the text, highest weight first, then in the order the patterns were given. A span keeps the side nearer its middle, so a file name keeps the tail and a leading folder the head; keeping one on the head means keeping everything before it, and on the tail everything after it. A span that fits whole takes it. A whole span (`whole: true`) that doesn't fit is skipped, and the next span gets its chance. Any other span that doesn't fit takes as much as fits, cut on its side toward the ellipsis, so a file name loses letters from its start, and then nothing after it gets room. The rest of the text shares what the spans leave, `ratio` to the head; `ratio: 1` gives the head all of it, as pinning the tail to the file name once did. While a span is being cut to fit, the other side gets none of the slack beside it: that slack moves with every grapheme the span gains, and giving it away made the other side shrink as the box widened (a property walking the width found it in `W ffi`). So as the width grows a cut span only gains, the spans before it stay whole, and the rest only grows: each side moves one way. A whole span switches the cut once, at the width where it starts to fit: the old behaviour of a protected file name, which `whole: true` keeps. A whole span that the tail's share would split goes whole and leaves the head the room. Word backoff never goes back into a span kept whole. Clamps count only whole spans, on their last line.
86
+
87
+ Each side's fit counts the kerning beside the ellipsis, a step or two past the binary search. Measured text then settles one grapheme at a time on real measurements, for the shaping across graphemes: shrink until it fits, then grow each side while one more fits, which also finds a longer cut that a ligature paints narrower. Trusted text needs no settling.
88
+
89
+ Engines differ on kerning across a line-break opportunity. WebKit lays out the text between two break opportunities as a run of its own, so kerning after a hyphen, a dash, CJK or the ellipsis never reaches the page, while Chrome, Firefox and canvas keep it: in Inter at 15 px, canvas puts "…To" at 29.96 px and WebKit lays it out at 30.47. Across a possible break the model counts kerning only when it widens, so it is never narrower than any engine's layout, and in Chrome and Firefox at most a fraction of a pixel loose. The trust check still compares canvas with the exact sum, and measured text adds back the kerning WebKit drops. Chrome also rounds a laid-out width up to the next 1/64 px, which the cut's 1/64 px slack covers.
90
+
91
+ Letter spacing is counted after every UTF-16 unit, the last included: WebKit spaces glyphs, several to a grapheme in Indic and Thai, so per grapheme ran 4 px over there, and per unit is never less than any engine. WebKit also turns kerning off under letter spacing while Chrome keeps it, so with letter spacing only kerning that widens counts, anywhere. The width that decides whether text fits whole is the same conservative sum, which can only cut a hair early.
92
+
93
+ The cut's helpers are module-level functions over module state, not closures: a cut runs to its end without calling out except to a face's measure, so a resize frame over thousands of rows allocates nothing per cut. Text with graphemes above U+0300 caches the kerning beside each ellipsis per text; simple text shares its face's. Per-grapheme word and trim flags come from `prepare`.
94
+
95
+ `prepare` runs `protect` once over the text as it will paint, and keeps the matches as offset pairs in order. Each pair's start is clipped to the text, but an end past it stays, so the last line's cut in `pretrunc/lines` still sees a match run on past its range. Marking every UTF-16 unit, as at first, took an array as long as the text, and in Chromium that allocation cost more than the regex. For 5,000 paths `protect` added a median 2.3 ms with marks and 0.8 ms with pairs. The regex itself is 0.5 to 0.8 ms of that, about 0.1 µs a text and once per `prepare`, never per cut. A regex-free `protect` was considered: ranges from the caller would refer to the text as given, not as `prepare` collapses its white space, and a function returning ranges would need checking for order and overlap. Either would save at most that 0.1 µs, so `protect` stays a regex.
96
+
97
+ Then the boundary passes, in order: back off to a word boundary inside the text if one lies within the backoff limit, snap out of whole protected matches (always towards keeping less), trim spaces and `,;:` beside the ellipsis. Each pass only removes graphemes. Removing a grapheme can widen shaped text, as when an Arabic letter loses its joining form, so the cut shrinks again once more at the end.
98
+
99
+ The backoff limit is a quarter of what that side keeps, not of the room. A limit tied to the room grew while a side's cut point held still on one grapheme, crossed the distance to a break partway, and snapped the side back a word, then forward again at the next grapheme: the flicker seen while dragging a box. Tied to the side, the limit holds still with the cut point, and each grapheme the side gains moves it a grapheme further from the break while the limit grows by a quarter of one, so a side that stops backing off never starts again until it passes a new break.
100
+
101
+ Each search gallops from where the last one on the same prepared text ended: it doubles its step until it brackets the answer, then bisects the bracket. A resize moves a cut a grapheme or two, so most searches end in two or three comparisons instead of five or six. Widths only move one way along the text, so the answer is the one a plain bisection finds, and a property checks that a text cut at a run of widths gives what a freshly prepared copy gives at each. The end each search takes as given, the empty head or the empty tail, is never measured, as in plain bisection. Over a drag of 5,000 rows through 40 one-pixel steps, it took end cuts from 24 to 22 ms in Chromium, 55 to 52 in Firefox and 17 to 16 in WebKit, and middle cuts, which search twice, from 57 to 49, 98 to 92 and 36 to 33. It cost 99 bytes. One search for both sides, the tail asked as the last start still too wide, came out 10 bytes larger: brotli packs two near-copies better than one with branches.
102
+
103
+ A secant search over the room was tried and dropped: it stalled on misses smaller than a grapheme, and one number cannot correct both sides of a middle cut.
104
+
105
+ ## Multi-line API (`pretrunc/lines`)
106
+
107
+ ```ts
108
+ const p = prepareLines(text, font, { whiteSpace: "pre-wrap", wordBreak, letterSpacing, protect });
109
+ const c = clamp(p, width, { lines: 3, at: "end", words: true, ellipsis: "…", after: moreWidth });
110
+ roomAfter(p, width); // px after the last line once expanded, where a "less" link would go
111
+ ```
112
+
113
+ pretext collapses white space the way the core does, or keeps it in `pre-wrap` (with CRLF as one break), so its segments join to the text every offset refers to, `p.text`, and the same `head`, `tail` and `toText` helpers apply. `after` keeps room in px free after the text's end, for a "more" link; text that fits shows whole with nothing after it, so `after` takes no room then.
114
+
115
+ - **End:** walk the first N−1 lines with `layoutNextLineRange`, which builds no strings, then run the core's end cut from line N's start, `after` px narrower. The core prepares only the region a cut can keep, from line N's start to the end of the line after or to a hard break, and the region stays cached for the next cut at the same place. The text always goes on past the region, so the ellipsis always gets its room, even where line N ends at a hard break. Kept text rendered in a wrapping block re-wraps to the same first N−1 lines, or fewer when the cut shortens line N's first word enough to fit on the line before.
116
+ - **Start and middle:** the head keeps the lines before the one at `ratio × N` and, on that line, an end cut of what's left of `ratio × N × width` (nothing, for a start cut). The tail's first line gets the rest of that line, whatever the head kept of its share. The tail's candidate starts are word starts after the head, or grapheme starts without word preference or when no word start fits. Both are tables of typed arrays kept on the prepared text: offset, segment, grapheme index and a trim flag per start. Word starts are pretext's segment starts, so their table needs no segmenter; the grapheme table segments the text once, the first time a clamp needs it. A clamp binary-searches the first start after the head and lists the candidates in one shared, growable `Int32Array`. Segmenting on every clamp, as at first, was 80% of a warm start or middle clamp; the tables made 500 paragraphs clamp 5 times faster warm in Chromium and WebKit and 1.4 times in Firefox, and 2.5 times faster cold in Chromium and WebKit. pretext's line streams accept any cursor, including one partway through a word, and a width per line, so each candidate is a probe of the lines left, the first beside the head and the ellipsis, laid out from that cursor over the prepared handle: no new `prepare`, pure arithmetic. A binary search finds the first candidate that fits. If even no tail runs over, as when `after` leaves less room than the head took, the head gives up a grapheme and the search runs again.
117
+
118
+ The model lays head and tail out apart, and the joined line can wrap differently: a tail that begins mid-word can't break where a line start could, and an ellipsis can end the line before. So start and middle cuts settle on a real layout of the joined text: later while it runs over, and for a start cut, earlier while the start before still fits. A middle cut doesn't move earlier, which would take the head's slack. Every layout, the model's and the check's, is at the box's own width; the check used to run 1/64 px narrower, and a line that fit the model but not the check shifted the tail at some widths and not the next. That layout is the stricter of pretext's plain and rich-inline layouts, which disagree in some mixes, with a text ellipsis in one run with the text, as it renders, and a width-only ellipsis and `after` as boxes. A tail never starts at the head's end, since a cut that removes nothing isn't a cut. When no tail fits even with the head back at its line's start, the head shows alone, and it too settles on the real layout, giving up graphemes until it fits: Firefox breaks after a slash and not before an ellipsis, as UAX #14 does where pretext, Chrome and WebKit don't, so it wrapped `~/Projects/…` and a box after it to a line more than the model (#7).
119
+
120
+ In a middle clamp the head's last word and the tail can wrap together: one more letter of head can push the ellipsis or the tail's first word to the next line and cost the tail more than the head gained, so a wider box showed less. When the real layout refuses the model's tail start, which is when they wrap together, the clamp also tries every shorter head back to the start of its line or of its last word, whichever is earlier, and keeps the one that shows the most text, white space aside, the longest on a tie. Each try is a tail search with real layouts, but only clamps where the layout slipped pay for it; the benchmark's middle clamps didn't move. A rarer shape remains, where the ellipsis or the tail's first word wraps and the better cut lies outside those heads, losing part of one word in about one case in 100,000 (#1). Taking the best of every head would end it but trade it for flicker, since on ordinary text many heads tie within a letter; a break opportunity after the ellipsis would end it too, but every caller building strings from `pretrunc/lines` would have to add one or risk overflow.
121
+
122
+ In `pre-wrap` the core counts a tab as the widest it can advance, and the layout check also tries each tab as that many no-break spaces held to the text around it: Chrome and WebKit let a tab hang at a line's end and Firefox keeps it with the word before. The widest isn't eight spaces. CSS Text says a tab less than half a `ch` from its next stop goes to the stop after; Firefox and WebKit do that and Chrome doesn't, so in Inter at 15 px `中文⇥` puts the text after the tab at 67.5 px in WebKit and 33.75 in Chrome. So a tab counts eight spaces and half a "0". pretext's model knows neither, so tabbed text shows whole only when it also fits with its tabs at their widest, a clamp works within the lines the model has when the model fits the text in fewer, and an end clamp of tabbed text gives up graphemes until the real layout check passes, as start and middle clamps already did.
123
+
124
+ A word longer than a line breaks between graphemes, and pretext sums the parts grapheme by grapheme, where the engines measure each part as text: WebKit alone, Chrome and Firefox within the whole word, the last grapheme kerned against the next. A part can come out more than a pixel wider than pretext has it: in Inter at 15 px with 1 px letter spacing, "ARpok" lays out at 51.42 px where pretext has 50.14, and "eakerSuperc" within its word at 90.16 px in Chrome where pretext fills 90 px with it at 89.99. The engine then moves a grapheme or a box to the next line, and every line after shifts, so a clamp ran a line over (#8). So every line the clamp lays out, the model's and the check's, that starts or ends inside a word is measured both ways, and while either runs over it is laid out again in less room, a break at a time: the strictest engine's line, never ending after any engine's. Other lines cost a branch.
125
+
126
+ pretext can also sum a whole line's parts narrower than an engine lays them out together: Firefox puts "文 😀 ~/…" at 72.03 px where pretext has 71.45, each part measuring the same in both, and the line lost "~/…" to the next (#10, a pretext bug). So the final layout checks, not the model, also measure each line within 1 px of the edge as text and settle it the same way. That's a `measureText` for a few lines in a hundred, and costs start and middle clamps 5 to 9% in Chromium, 11.7 ms warm for 500 start clamps where they took 10.8; end clamps never run the check. pretext also gives WebKit half a space as the least a tab advances, where WebKit takes half a "0", so a tab can skip a stop in WebKit that the model keeps (#9). The fix belongs in pretext; pretrunc would have to reach into its prepared objects, so it's left open.
127
+
128
+ ## No canvas (`pretrunc/metrics`)
129
+
130
+ ```ts
131
+ // vp run metrics node_modules/@fontsource-variable/inter/files/inter-latin-wght-normal.woff2 src/inter.ts "Inter Variable"
132
+ import inter from "./inter.ts";
133
+ const p = prepare(name, tableFace(inter, 16)); // a server, a worker, or before the font loads
134
+ globalThis.OffscreenCanvas = tableCanvas([inter]); // pretext, and so pretrunc/lines, without a browser
135
+ ```
136
+
137
+ The `pretrunc-metrics` command (`scripts/metrics-cli.ts`, built into `dist/` and named in `bin`) reads a font with fontkit, an optional peer that only the command needs, which opens WOFF2 and applies the font's GPOS kerning, and writes a module: each character's advance, every kerned pair among them, and the units per em. Inter's latin file gives 229 characters and 5,481 pairs: 63 KB, 18 KB gzipped, in 2.5 s. A variable font is read at its default instance, since fontkit's instancing loses the character map of a variable WOFF2.
138
+
139
+ A cut made from the table has to fit the text the browser lays out, so the table is never narrower than an engine. Contextual rules reach past a pair (Inter spaces a colon out between digits, 1.3 px at 15 px), so each character is laid out between each two of a set of neighbours, and whatever the three take beyond their pairs goes on its advance; kerning is measured against each glyph alone, so it never takes the widening back. A character the table doesn't cover counts as 1.5 em. Firefox rounds each glyph's advance, kerning to the next glyph included, to the nearest 1/60 px, its layout unit, so summed exactly the table ran narrow there by about 0.001 px a character, 0.4 px over 352 characters of Inter at 15 px, past the 1/64 px a cut keeps spare (#5). Chromium on Linux goes further: it rounds each glyph's advance to a whole pixel, in canvas and layout alike, so `caféline-breaker` lays out at 120 px where the table says 117.03 (#3). Rounding every glyph up to a pixel would cost every engine up to a pixel a glyph, so a table-made cut asks the text for `text-rendering: geometricPrecision` instead, which turns fractional advances back on there; the metrics engine tests ask for it in every engine. Canvas cuts need nothing, since canvas and layout round alike. For Firefox, `measure` rounds each glyph up to 1/60 px, which costs a cut at most that much a glyph in Chrome and WebKit, about 0.2%. The core's pair model still adds up: each pair is its first glyph's rounded advance and kerning plus the second's rounded advance, so a text's pairs less the glyphs between them sum to the text measured whole, and `prepare` still trusts it. The engine tests check that the table measures Latin text no narrower than each engine's canvas and within 10% of it, and that cuts made from it fit the engine's layout.
140
+
141
+ `tableCanvas` reads the size and the first family it has a table for from the context's `font`, line height and all, and falls back to the first table.
142
+
143
+ ## DOM helpers (`pretrunc/dom`)
144
+
145
+ Shared by every adapter, so the React adapter later is thin.
146
+
147
+ - **One ResizeObserver** for all elements, dispatching through a `WeakMap<Element, callback>`. Its first notification arrives after layout and before paint, so an observed element is cut before its first frame.
148
+ - **Font from computed style,** read once per element and assembled from `font-style`, `font-weight`, `font-size` and `font-family`. The `font` shorthand reads back empty in some engines, and `font-stretch` reads back as a percentage, which canvas rejects.
149
+ - **Font epoch.** On `document.fonts` `loading`, once `document.fonts.ready` settles, every cached width is dropped and subscribers prepare and cut again. That includes pretext's: it keeps widths by font string, so a clamp prepared again after the font loaded still laid its text out in the fallback's widths, and a 2-line clamp in Inter at 14 px painted 3 (#4). `pretrunc/lines` hands pretext's `clearCache` to `forget` when it loads, so the core never imports pretext, and since pretext can't drop one font, a `forget` of one font drops all of pretext's widths. WebKit fires no `loadingdone` for a face loaded from script, so that event can't be the trigger. Before a web font loads, cuts use the fallback's metrics, which is right for what's on screen at that moment.
150
+
151
+ ## Solid adapter (`pretrunc/solid`)
152
+
153
+ ```tsx
154
+ <Truncate text={name()} at="middle" /> // observes width and font
155
+ <Truncate text={title()} at="end" width={column()} font={BODY_FONT} /> // no observer, no style reads
156
+ const handle = createCut({ text: name(), at: "middle" }) // ref, prepared and cut accessors
157
+ <Clamp text={body()} lines={3} expanded={open()} after={<button>{open() ? "less" : "more"}</button>} />
158
+ <FontProvider font={BODY_FONT} letterSpacing={0}><For each={rows()}>{(row) => <Truncate text={row.name} />}</For></FontProvider> // one font for the list
159
+ ```
160
+
161
+ - `render="kept"` (default) paints the kept text and ellipsis, with the full text in `title`.
162
+ - `render="full"` paints all the text: the cut range sits in a `font-size: 0` span and the ellipsis in an `aria-hidden`, unselectable span, so copy, find-in-page and screen readers get the full text. The cut measures the ellipsis as a run of its own, since no kerning crosses into its element; `createCut` does the same with `separateEllipsis`, for markup of your own. Browser tests check in Chromium, Firefox and WebKit that it lays out within its box and that selecting it copies every character.
163
+ - `<Clamp>` lives in `pretrunc/solid/lines`, so a single-line page never loads pretext. Its `after` element, such as a "more" button, shows whenever the text is cut or expanded; Clamp observes its width and keeps that room free on the last line. Expanded, it shows every line, and `after` wraps to a line of its own when it doesn't fit.
164
+ - `createBox` is the part both share: the width, font and letter spacing given, then a `FontProvider`'s, or an element's, observed.
165
+ - Reading an element's font costs one `getComputedStyle` per row on mount, which for 5,000 rows took 4.5 ms in Chromium, 4 in Firefox and 5 in WebKit. A box reads font and letter spacing from one call; two calls, as it first did, took 8, 8 and 10 ms. `<FontProvider>` gives a list's font and letter spacing once, so its rows read nothing. It is the caller's word that the rows render in that font, and cuts follow it as they follow a `font` prop; a wrong one cuts wrongly, so it is opt-in, never inferred.
166
+ - Hints about the text itself, such as Latin only or no emoji, were measured and left out. pretrunc finds the script with one regex scan, 0.3 ms or less for 5,000 texts, and the costly work, measuring each new word once so cuts can be arithmetic, is the same in every script. A wrong hint would overflow to save about 20 ns.
167
+
168
+ ## React adapter (`pretrunc/react`)
169
+
170
+ ```tsx
171
+ <Truncate text={name} at="middle" /> // observes width and font
172
+ const { ref, prepared, cut } = useCut({ text: name, at: "middle" }) // markup of your own
173
+ <Clamp text={body} lines={3} expanded={open} after={<button>{open ? "less" : "more"}</button>} />
174
+ <FontProvider font={BODY_FONT} letterSpacing={0}>{rows}</FontProvider> // one font for the list
175
+ ```
176
+
177
+ - The same components, options and renderings as the Solid adapter, with `className`, over the same `pretrunc/dom` helpers. `useBox` is `createBox`; `useCut`, `createCut`.
178
+ - Written with `createElement` in plain `.ts`, not JSX: every `.tsx` file here compiles as Solid, and a second JSX setup beside it would be one more thing to keep apart.
179
+ - The width is a store, not state. `useBox` gives `subscribe` and `width()`, and `<Truncate>` reads its cut through `useSyncExternalStore` with the cut as the snapshot: a number, so a resize that doesn't move a row's cut renders nothing, where width in state would render every row of a table on every frame of a drag. A browser test resizes a row's box without React and counts commits, and keeping the width in state fails it. `<Clamp>`, of which a page has few, takes the width as its snapshot and clamps in a memo.
180
+ - The style is read in the ref callback, once per element as it mounts and before it paints, and only for what wasn't given or provided.
181
+ - A `protect` pattern is keyed by its source and flags, so an inline literal, a new `RegExp` on every render, doesn't prepare the text again.
182
+ - The prepare memo depends on the font epoch, which its body never reads: the epoch changes when a web font loads, after `forget()` has dropped the widths the last prepare used. The hooks lint calls that dependency unnecessary, so it is suppressed there with that reason.
183
+ - `observe` keeps one listener per element, so one cut, or one box, observes an element. A second subscription to the same element replaces the first.
184
+ - On a server there is no element to measure, so the server snapshot is undefined and the text renders whole, with the browser's ellipsis standing in until the client cuts it. A Node test renders both components to a string.
185
+ - React 19 only, an optional peer dependency.
186
+
187
+ ## Not in v1
188
+
189
+ A middle clamp can still show part of a word less at a wider width, in about one case in 100,000; the fixes cost flicker or a break every caller would have to add, so it stays (#1, closed). RTL text cuts in logical order and renders with the element's `dir`; mixed bidi text can reorder around a middle ellipsis, and v1 only documents that.
190
+
191
+ Kept for later, with no issue: features a caller opts into by import, so a bundler drops the rest. Bundling the core with each one cut out (2026-10-17) gave its share of the 2,430 bytes: protected matches 165, middle cuts 146, word backoff 95, letter spacing 48, trimming 18, an element ellipsis 7; all of them at once, 409, leaving 2,021. Text above U+0300 is 92 more but isn't optional, since cutting emoji or combining marks wrongly breaks the never-overflow promise. A plugin registry would spend part of the 409 on its own plumbing and add a call per cut. The cheaper shape is exports that tree-shake, such as a separate `cutMiddle` and `protect: marks(/[^/]+$/g)` from an imported helper. Either way it saves nothing through `<Truncate>`, which takes every option as a prop. Worth deciding once real users' bundles show whether 400 bytes matters.
192
+
193
+ ## Proof
194
+
195
+ - **Properties** with effect's `Arbitrary` through `@effect/vitest`. In Node, against a fake font with known kerning and shaping, and pretext behind a fake `OffscreenCanvas`: never overflows; tight (with `words` and `trim` off, one more grapheme on a start or end cut overflows, and each side of a middle cut fills its share, where widths add up); steady (walking 20 px wider in quarter-pixel steps, with every option, neither side ever keeps less); history-free (a text cut at a run of widths cuts as a fresh copy would at each); identity; no grapheme split; whole protected matches whole or gone; with spans cut to fit, each side still moves one way as the width grows; clamps never take more lines than asked, fill the last line, start a start clamp's tail as early as fits, and hold steady as the width grows (at the start and end each end moves one way; in the middle, where head and tail trade on a shared line, a wider box shows no less text but for part of one word in rare cases, #1). In Chromium, Firefox and WebKit, against each engine's own layout: single-line cuts never lay out wider than the width, letter spacing included, Latin text keeps all but at most one grapheme of the widest fit, Latin text holds steady over quarter-pixel steps (a script that joins can paint a longer cut narrower, so no cut can promise it there), a protected path's middle cut switches once as its box widens, trusted text re-cuts without `measureText`, and clamps never wrap to more lines than asked, in both white space modes, with letter spacing, a width-only ellipsis and room after, and with words glued into one longer than a line.
196
+ - **Where:** `vp run test` runs every engine test in Chromium, Firefox and WebKit. CI runs on Linux in Chromium and Firefox only: WebKit's Linux port shapes with HarfBuzz over FreeType where Safari uses CoreText, and the WebKit layout pretrunc works around is the same in both, so the macOS runs are the WebKit that matters. CI skips pushes that touch only Markdown or the demo, which the pre-commit hook type-checks.
197
+ - **Demo code:** each example's Solid and React code is a file in `demo/snippets/`, which `demo/snippets.ts` highlights with Shiki and Twoslash for the demo's Solid and React tabs, with each name's type and JSDoc on hover. Twoslash type-checks each snippet against `src/` as it does, and `demo/snippets.test.ts` runs that in the tests, so the examples can't fall behind the API. Twoslash needs TypeScript's JavaScript API, which 7 doesn't have, so it runs 5.9 from the `typescript-twoslash` alias; the repo's own checks stay on 7. The snippets format at 42 columns, the width of the narrowest frame's code.
198
+ - **Adapters:** one browser test per behaviour a person sees: resizing, the title, copying a full render, re-cutting after a web font loads, and a clamp keeping room for its button and expanding.
199
+ - **Mutants:** each test was checked by breaking the code it guards and watching it go red.
200
+ - **Size:** a test bundles each entry with Vite and rolldown, minified, compresses it with brotli and fails over budget.
201
+ - **Benchmarks:** against `pretext-truncate`'s `findTruncationPoint` and the pretext demo's `clampLines` and `layoutMiddle`, on the same inputs: a cold cut including `prepare`, a warm re-cut at a new width, and one resize frame over 5,000 rows.
202
+
203
+ `vp run test` runs every test in Node, Chromium, Firefox and WebKit, the bench aside.
204
+
205
+ ## Benchmark results
206
+
207
+ `vp run bench` writes `bench/results.<engine>.md`, one engine at a time: run side by side, they take each other's CPU and the numbers swing by half. On an Apple M1 Pro, headless, median of 7 runs, ms per batch, every input distinct (2026-10-18). Each cold run measures a set of inputs no run has seen, built before the clock starts: engines keep their own cache of text widths, below anything `forget` or `clearCache` reaches, and rerunning one set, as the bench first did, let later runs find their unique words already measured. That favoured the demo and made WebKit's cold end cuts look slow; with fresh sets, and alternating which runs first, pretrunc is ahead there.
208
+
209
+ | Scenario | Engine | pretrunc cold / warm | pretext demo cold / warm | pretext-truncate cold | Overflows: pretrunc / demo / pretext-truncate |
210
+ | ------------------------------------------ | -------- | ---------------------- | ------------------------ | --------------------- | --------------------------------------------- |
211
+ | 5,000 names, end of one line | Chromium | 27.8 / 0.7 | 26.7 / 0.9 | 28.7 | 0 / 0 / 16 |
212
+ | | Firefox | 26 / 1 | 52 / 1 | 55 | 0 / 0 / 16 |
213
+ | | WebKit | 20 / 0 | 23 / 1 | 27 | 0 / 0 / 16 |
214
+ | 5,000 paths, middle keeping the file name | Chromium | 56.7 / 1.6 | 111.3 / 3.8 | n/a | 0 / 0 / n/a |
215
+ | | Firefox | 73 / 3 | 166 / 8 | n/a | 0 / 6 / n/a |
216
+ | | WebKit | 101 / 1 | 275 / 6 | n/a | 0 / 6 / n/a |
217
+ | 500 paragraphs, end of 3 lines | Chromium | 12.6 / 0.4 | 8.3 / 0.4 | 10.2 | 0 / 0 / 107 |
218
+ | | Firefox | 20 / 1 | 17 / 1 | 24 | 0 / 0 / 109 |
219
+ | | WebKit | 17 / 1 | 11 / 1 | 15 | 0 / 0 / 107 |
220
+ | 500 paragraphs, start or middle of 3 lines | Chromium | 19.3 / 9.8, 20.6 / 7.0 | n/a | n/a | 0 / n/a / n/a |
221
+ | | Firefox | 40 / 19, 36 / 13 | n/a | n/a | 0 / n/a / n/a |
222
+ | | WebKit | 26 / 10, 27 / 7 | n/a | n/a | 0 / n/a / n/a |
223
+
224
+ pretrunc is the only one that never overflows. Cold end cuts are level with the demo in Chromium and ahead in Firefox and WebKit, middle cuts are 2 to 3 times faster cold and 2 to 6 warm, and warm cuts are level or better everywhere. Cold end clamps take 1.2 to 1.6 times the demo's. That isn't the never-overflow check, which end clamps never run: the demo lays the last line out again from pretext's word widths and cuts at a word, while pretrunc builds a per-character table of the last line, measuring characters, pairs and each new word once, so it can cut at any character with the kerning beside the ellipsis, letter spacing, `protect` and room after. Every paragraph here carries a number of its own, so each cold clamp measures at least one new word. Warm, both are level. The word measurements repeat pretext's own, but pretext's public `widths` fold in space kerning, measuring with the following space and emoji corrections, so a trust check against them could pass text canvas wouldn't, and its raw widths are internal. Profiling it found a clamp measuring the ellipsis afresh on every call, end clamps included, and `render="full"` doing the same on every cut: 500 of 1,219 `measureText` calls in 500 cold clamps. Both now use the cached width, and browser tests check that warm clamps and resizes measure nothing.
225
+
226
+ ## Build order
227
+
228
+ 1. Package scaffold: subpath exports, size test, browser test project with three engines.
229
+ 2. Measurer: char-code fast path, advance and kerning tables, calibration, tiers.
230
+ 3. Single-line cut: end, start, middle and its three splits, protected spans, words, trim. Properties.
231
+ 4. `pretrunc/dom`: shared observer, computed font, font epoch.
232
+ 5. `pretrunc/solid`: `createCut`, `<Truncate>` with both renderings.
233
+ 6. `pretrunc/lines`: end, start, middle, confirmation, `roomAfter`, and `pretrunc/solid/lines`.
234
+ 7. Benchmarks against the prior art.
235
+ 8. `pretrunc/react` and `pretrunc/react/lines` (#2).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lewis Alderman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,201 @@
1
- # Temporary Holding Version
1
+ # pretrunc
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![CI](https://github.com/LewisAlderman/pretrunc/actions/workflows/ci.yml/badge.svg)](https://github.com/LewisAlderman/pretrunc/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/pretrunc)](https://www.npmjs.com/package/pretrunc)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
6
+
7
+ Cut text at its start, middle or end so it fits a width, on one line or across N lines, measured before it paints. A cut never lays out wider than its box in Chromium, Firefox or WebKit. Try it in the [demo](https://lewisalderman.github.io/pretrunc/).
8
+
9
+ pretrunc is before 1.0: a minor version can still change the API, and the [changelog](CHANGELOG.md) says how.
10
+
11
+ In Inter at 15 px:
12
+
13
+ ```text
14
+ ~/Proje…src/text/line-breaker.test.ts middle at 260 px, the file name protected
15
+ Alexandra Richardson-… end at 180 px, backed off to the hyphen
16
+ …atherstonehaugh start at 140 px
17
+ ```
18
+
19
+ - **Small.** The single-line core is <!-- size src/index.ts -->2.8 kB minified and brotli-compressed, with no dependencies. Multi-line cuts add <!-- size src/lines.ts -->2.5 kB plus [pretext](https://github.com/chenglou/pretext), which only `pretrunc/lines` imports.
20
+ - **Fast.** Text is measured once, then cut at any width by arithmetic: a resize re-cuts 5,000 rows in 1 to 3 ms. Middle cuts are 2 to 3 times faster than pretext's ellipsis demo cold.
21
+ - **Exact.** It counts kerning beside the ellipsis, WebKit's dropped kerning at break opportunities, letter spacing, ligatures and joined scripts, and never splits a grapheme.
22
+ - **Steady.** As a box is dragged, each end of a cut moves one way; the ellipsis doesn't flicker.
23
+
24
+ ## How it compares
25
+
26
+ [pretext](https://github.com/chenglou/pretext) is a layout engine, and its [ellipsis demo](https://chenglou.me/pretext/) shows how to cut text with it, as code to copy into an app. [pretext-truncate](https://github.com/ShipItAndPray/pretext-truncate) packages an end clamp built on it. pretrunc focuses on cutting text alone, and the benchmark in `bench/` runs all three on the same inputs in Chromium, Firefox and WebKit.
27
+
28
+ | | pretrunc | pretext's ellipsis demo | pretext-truncate |
29
+ | --------------------------------- | ------------------------------------------------------- | ----------------------------------------------- | ----------------------------------------------- |
30
+ | Ellipsis at the end | one line or several | one line or several | one line or several |
31
+ | At the start or in the middle | one line or several | the middle of one line | no |
32
+ | What a middle cut keeps | any patterns you give, by priority, whole or cut to fit | a path's file name | no middle cut |
33
+ | Ran over its box in the benchmark | never, in any of the three engines | 6 of 5,000 middle cuts in Firefox and in WebKit | 16 of 5,000 names, 107 to 109 of 500 paragraphs |
34
+ | Cutting again at a new width | mostly arithmetic over widths it already holds | lays the lines out again | measures each step afresh |
35
+
36
+ On top of that, pretrunc counts letter spacing and tabs in `pre-wrap`, takes an element's width as its ellipsis, keeps room after the text for a "more" link, backs off to a word or hyphen and trims dangling punctuation, keeps the whole text in the page for copy, find and screen readers, and can measure from a font's metrics table where there is no canvas.
37
+
38
+ pretrunc is faster in most of the benchmark, and slower where it checks its own work so that a cut never overflows.
39
+
40
+ <details>
41
+ <summary>The benchmark numbers, and where pretrunc gives up speed</summary>
42
+
43
+ In the benchmark, middle cuts of 5,000 paths take 57 ms cold in Chromium where the demo takes 111, and are 2.3 to 2.7 times faster in Firefox and WebKit; re-cut at a new width they are 2.4 to 6 times faster. End cuts of 5,000 names are level with both in Chromium and twice as fast in Firefox.
44
+
45
+ Where speed and never overflowing pull apart, pretrunc takes the slower road. The demo sets out to show what pretext makes possible and pretext-truncate keeps to one straightforward loop, so neither promises that a cut fits its box in every engine; that promise is what pretrunc is built around, and keeping it costs time. Start and middle clamps check their result against a full layout, and a line with a word broken across it, or within a pixel of its edge, is measured again the way the engines measure it. A cold end clamp of 500 paragraphs takes 12.6 ms in Chromium against the demo's 8.3, since pretrunc builds a table of every character on the last line so it can cut at any of them, with kerning, letter spacing, `protect` and room after. Warm, they're level. Every number, per engine, is in `bench/results.<engine>.md`.
46
+
47
+ </details>
48
+
49
+ ### When plain pretext is the better choice
50
+
51
+ - **You need layout, not only a cut.** pretext gives line positions and heights for virtualised lists, text that flows around shapes, and text drawn to canvas, SVG or WebGL. pretrunc only decides what to keep.
52
+ - **You draw the text yourself.** pretrunc's checks are against how Chromium, Firefox and WebKit lay out text in the page. When your own renderer draws pretext's lines, pretext's line breaks are already the truth, and those checks buy nothing.
53
+ - **You already use pretext and want only an ellipsis at the end.** The demo's `clampLines` is a few dozen lines you can own, and it clamps cold paragraphs faster.
54
+
55
+ And when an ellipsis at the end of the box is all you need and the browser may measure as it paints, CSS `text-overflow: ellipsis` and `line-clamp` need no JavaScript at all.
56
+
57
+ ## Install
58
+
59
+ ```sh
60
+ pnpm add pretrunc
61
+ ```
62
+
63
+ `@chenglou/pretext` is an optional peer dependency, needed only for `pretrunc/lines`; `solid-js` and `@solidjs/web` only for the Solid adapter; `react` 19 only for the React adapter.
64
+
65
+ ## One line
66
+
67
+ ```ts
68
+ import { cut, prepare, toText, truncated } from "pretrunc";
69
+
70
+ const p = prepare("~/Projects/atlas/src/text/line-breaker.test.ts", '15px "Inter Variable"', {
71
+ protect: /\/[^/]+$/, // the file name keeps the tail, cut to fit only if it must be
72
+ });
73
+ const c = cut(p, 240, { at: "middle" });
74
+ label.textContent = toText(p, c);
75
+ if (truncated(c)) label.title = p.text;
76
+ ```
77
+
78
+ A cut is one number, the kept head and tail offsets packed together, so re-cutting thousands of rows allocates nothing; `head(c)`, `tail(c)` and `toText(p, c)` read it. Two cuts are `===` when they keep the same head and tail, so a memo can compare them. Options: `at` (`"end"`, `"start"`, `"middle"`), `ratio`, `tailGraphemes`, `words` (`true`, `false` or `{ within: px }`), `trim` and `ellipsis`, a string or the width of an element you render in its place.
79
+
80
+ <details>
81
+ <summary>Keeping parts of the text with <code>protect</code></summary>
82
+
83
+ `protect` takes a pattern, a `{ pattern, weight, whole }`, or an array of them. A middle cut keeps protected matches before the rest of the text, the highest weight first (0 to 1, default 1), each on the side nearer its middle; `ratio` shares only the room they leave. A match shows whole while it fits, then as much as fits, cut on its side toward the ellipsis; with `whole: true` it shows whole or not at all, and no cut ever splits it.
84
+
85
+ ```ts
86
+ // The file name first, then the first folder: "~/Projects/…/line-breaker.test.ts",
87
+ // then "~/Pr…/line-breaker.test.ts", then "…breaker.test.ts".
88
+ prepare(path, font, { protect: [/\/[^/]+$/, { pattern: /^~\/[^/]+\//, weight: 0.5 }] });
89
+ // A mention shows whole or not at all.
90
+ prepare(message, font, { protect: { pattern: /@[\w.]+/g, whole: true } });
91
+ ```
92
+
93
+ </details>
94
+
95
+ `font` is a CSS font shorthand or a `Face`: `canvasFace(font)`, `tableFace` from `pretrunc/metrics`, or `face(measure)` over your own.
96
+
97
+ ## Several lines
98
+
99
+ ```ts
100
+ import { clamp, prepareLines } from "pretrunc/lines";
101
+
102
+ const p = prepareLines(story, '16px "Inter Variable"', { whiteSpace: "pre-wrap" });
103
+ const c = clamp(p, width, { lines: 3, at: "end", after: moreButtonWidth });
104
+ ```
105
+
106
+ ## Components
107
+
108
+ `Truncate` and `Clamp` follow their element's width and font, and cut again on resize and when web fonts load. With `render="full"` they keep the whole text in the page for copying and screen readers.
109
+
110
+ <details>
111
+ <summary>Solid</summary>
112
+
113
+ ```tsx
114
+ import { FontProvider, Truncate } from "pretrunc/solid";
115
+ import { Clamp } from "pretrunc/solid/lines";
116
+
117
+ <Truncate text={path} at="middle" protect={/\/[^/]+$/} />
118
+ <Clamp text={story} lines={3} expanded={open()} after={<button>more</button>} />
119
+ <FontProvider font='14px "Inter Variable"' letterSpacing={0}>
120
+ <For each={rows()}>{(row) => <Truncate text={row.name} />}</For>
121
+ </FontProvider>
122
+ ```
123
+
124
+ `FontProvider` gives a list its font once, so its rows read no computed style.
125
+
126
+ The package exports a `solid` condition pointing at its TypeScript source, which Solid's Vite plugin compiles with the app; other bundlers get the built JavaScript in `dist/`.
127
+
128
+ </details>
129
+
130
+ <details>
131
+ <summary>React</summary>
132
+
133
+ ```tsx
134
+ import { FontProvider, Truncate, useCut } from "pretrunc/react";
135
+ import { Clamp } from "pretrunc/react/lines";
136
+
137
+ <Truncate text={path} at="middle" protect={/\/[^/]+$/} />
138
+ <Clamp text={story} lines={3} expanded={open} after={<button>more</button>} />
139
+ <FontProvider font='14px "Inter Variable"' letterSpacing={0}>
140
+ {rows.map((row) => <Truncate key={row.id} text={row.name} />)}
141
+ </FontProvider>
142
+ ```
143
+
144
+ The components take the same props as the Solid ones, with `className`. `<Truncate>` reads its cut through `useSyncExternalStore`, and a cut is one number, so a resize that doesn't move a row's cut doesn't render that row. `useCut` gives `{ ref, prepared, cut }` for markup of your own, and `useBox` the width, font and letter spacing a cut follows.
145
+
146
+ </details>
147
+
148
+ ## No canvas
149
+
150
+ `pretrunc/metrics` measures from a table of a font's advances and kerning, for server rendering or a worker. The `pretrunc-metrics` command writes one from a font file, reading it with [fontkit](https://github.com/foliojs/fontkit), which only the command needs:
151
+
152
+ ```sh
153
+ pnpm add -D fontkit
154
+ pnpm exec pretrunc-metrics inter-latin.woff2 src/inter-metrics.ts "Inter Variable"
155
+ ```
156
+
157
+ ```ts
158
+ import { prepare } from "pretrunc";
159
+ import { tableFace } from "pretrunc/metrics";
160
+ import inter from "./inter-metrics.ts";
161
+
162
+ const p = prepare(name, tableFace(inter, 15));
163
+ ```
164
+
165
+ A table never measures narrower than the browser, so its cuts never overflow, but can be up to 10% wider, so they may land a little early. Give text cut from a table `text-rendering: geometricPrecision`: without it, Chromium on Linux lays each glyph on a whole pixel, wider than any table can know.
166
+
167
+ ## Known limitations
168
+
169
+ - In WebKit, a tab in `pre-wrap` text can wrap a clamp to a line more than asked, when the tab starts under half a `0` from a tab stop. pretext models WebKit's tabs with half a space there; the fix belongs in pretext ([#9](https://github.com/LewisAlderman/pretrunc/issues/9)).
170
+ - A middle clamp can show part of one word less in a wider box, in about one case in 100,000. Ending it would cost flicker on ordinary text ([#1](https://github.com/LewisAlderman/pretrunc/issues/1)).
171
+ - Right-to-left text is cut in logical order and renders with the element's `dir`. Mixed-direction text can reorder around a middle ellipsis.
172
+ - Cuts made from a metrics table (`pretrunc/metrics`) never overflow, but can land up to 10% early, since a table is never narrower than any engine.
173
+
174
+ ## Developing
175
+
176
+ <details>
177
+ <summary>Commands, and where the design is written down</summary>
178
+
179
+ [Vite+](https://viteplus.dev) runs everything: `vp install`, then `vp run setup` once for the Git hooks and the Effect lint rules.
180
+
181
+ - `vp check`: format, lint and type-check. `vp exec knip` finds unused files, exports and dependencies.
182
+ - `vp run test`: every test, in Node and in Chromium, Firefox and WebKit through Playwright.
183
+ - `vp run bench`: timings against pretext's ellipsis demo and pretext-truncate, written to `bench/results.<engine>.md`.
184
+ - `vp run demo`: the demo page, with a playground and a 2,000-row table, on `localhost:3100`.
185
+ - `vp run build`: `dist/`, JavaScript and declarations.
186
+ - `vp run demo:build`: the demo as a static site in `dist-demo/`, which GitHub Pages serves.
187
+ - `vp exec changeset`: describe a change people using pretrunc would notice, for the next release's changelog. Merging the "Version Packages" pull request publishes it.
188
+
189
+ [DESIGN.md](DESIGN.md) records the algorithm, the engine quirks it handles, the benchmarks and every decision behind them.
190
+
191
+ </details>
192
+
193
+ ## Credits
194
+
195
+ [pretext](https://github.com/chenglou/pretext) by [Cheng Lou](https://github.com/chenglou) broke the ground pretrunc stands on. It showed that text can be measured and laid out in the browser without asking the DOM, close enough to each engine's line breaking to trust and fast enough to redo on every resize. Knowing where lines fall before they paint makes layouts possible that the DOM makes slow or out of reach, and [its demos](https://chenglou.me/pretext/) show many of them, a cut with an ellipsis at the end and in the middle among them. `pretrunc/lines` lays out every multi-line cut with pretext.
196
+
197
+ [pretext-truncate](https://github.com/ShipItAndPray/pretext-truncate) by ShipItAndPray put pretext to work on truncation, clamping text to a number of lines with no flash of the full text. Both projects are the benchmark's points of comparison, run from vendored copies credited in `bench/prior-art.ts`.
198
+
199
+ ## Licence
200
+
201
+ MIT. The benchmark vendors code from pretext and pretext-truncate, both MIT, with attribution in `bench/prior-art.ts`. `fixtures/inter-latin.ts` holds metrics generated from [Inter](https://rsms.me/inter/), under the SIL Open Font License 1.1; it's used by the tests and demo and isn't part of the package.