pretrunc 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/DESIGN.md +235 -0
- package/LICENSE +21 -0
- package/README.md +200 -2
- package/dist/cut-D7qi2yZl.js +356 -0
- package/dist/cut-D7qi2yZl.js.map +1 -0
- package/dist/cut.d.ts +75 -0
- package/dist/dom-CPL0-qc3.js +52 -0
- package/dist/dom-CPL0-qc3.js.map +1 -0
- package/dist/dom.d.ts +22 -0
- package/dist/dom.js +1 -0
- package/dist/face-pICHAjvk.js +68 -0
- package/dist/face-pICHAjvk.js.map +1 -0
- package/dist/face.d.ts +46 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/lines-DOj2DQLl.js +334 -0
- package/dist/lines-DOj2DQLl.js.map +1 -0
- package/dist/lines.d.ts +32 -0
- package/dist/lines.js +1 -0
- package/dist/metrics-C2jv4JVL.js +49 -0
- package/dist/metrics-C2jv4JVL.js.map +1 -0
- package/dist/metrics-cli.js +19 -0
- package/dist/metrics-cli.js.map +1 -0
- package/dist/metrics.d.ts +36 -0
- package/dist/metrics.js +73 -0
- package/dist/metrics.js.map +1 -0
- package/dist/react-CnQfjWyS.js +156 -0
- package/dist/react-CnQfjWyS.js.map +1 -0
- package/dist/react-lines.d.ts +27 -0
- package/dist/react-lines.js +74 -0
- package/dist/react-lines.js.map +1 -0
- package/dist/react.d.ts +81 -0
- package/dist/react.js +1 -0
- package/dist/solid-BWC32VMg.js +155 -0
- package/dist/solid-BWC32VMg.js.map +1 -0
- package/dist/solid-lines.d.ts +27 -0
- package/dist/solid-lines.js +90 -0
- package/dist/solid-lines.js.map +1 -0
- package/dist/solid.d.ts +77 -0
- package/dist/solid.js +1 -0
- package/package.json +145 -3
- package/src/cut.ts +596 -0
- package/src/dom.ts +59 -0
- package/src/face.ts +112 -0
- package/src/index.ts +15 -0
- package/src/lines.ts +585 -0
- package/src/metrics.ts +106 -0
- package/src/react-lines.ts +99 -0
- package/src/react.ts +251 -0
- package/src/solid-lines.tsx +89 -0
- package/src/solid.tsx +225 -0
package/src/lines.ts
ADDED
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clearCache,
|
|
3
|
+
type LayoutCursor,
|
|
4
|
+
layoutNextLineRange,
|
|
5
|
+
materializeLineRange,
|
|
6
|
+
type PrepareOptions as PretextOptions,
|
|
7
|
+
prepareWithSegments,
|
|
8
|
+
type PreparedTextWithSegments,
|
|
9
|
+
walkLineRanges,
|
|
10
|
+
} from "@chenglou/pretext";
|
|
11
|
+
import {
|
|
12
|
+
layoutNextRichInlineLineRange,
|
|
13
|
+
materializeRichInlineLineRange,
|
|
14
|
+
prepareRichInline,
|
|
15
|
+
type RichInlineBox,
|
|
16
|
+
type RichInlineCursor,
|
|
17
|
+
type RichInlineItem,
|
|
18
|
+
type RichInlineLineRange,
|
|
19
|
+
walkRichInlineLineRanges,
|
|
20
|
+
} from "@chenglou/pretext/rich-inline";
|
|
21
|
+
import {
|
|
22
|
+
type Cut,
|
|
23
|
+
type CutOptions,
|
|
24
|
+
cutFrom,
|
|
25
|
+
head,
|
|
26
|
+
pack,
|
|
27
|
+
type Prepared,
|
|
28
|
+
type Protect,
|
|
29
|
+
prepareRange,
|
|
30
|
+
} from "./cut.ts";
|
|
31
|
+
import { alsoForget, type FaceData, faceOf, widthOf } from "./face.ts";
|
|
32
|
+
|
|
33
|
+
// pretext keeps its own widths by font, so a `forget` after a web font loads drops them
|
|
34
|
+
// too: kept, they are the fallback's, and a clamp can fit text in fewer lines than it
|
|
35
|
+
// paints in.
|
|
36
|
+
alsoForget(clearCache);
|
|
37
|
+
|
|
38
|
+
/** Text prepared for laying out in lines with pretext. */
|
|
39
|
+
export interface PreparedLines {
|
|
40
|
+
/** The text as its white space mode paints it; every offset refers to this string. */
|
|
41
|
+
readonly text: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PrepareLinesOptions {
|
|
45
|
+
/** CSS `white-space`. Default "normal". */
|
|
46
|
+
readonly whiteSpace?: "normal" | "pre-wrap";
|
|
47
|
+
readonly wordBreak?: "normal" | "keep-all";
|
|
48
|
+
/** CSS `letter-spacing` in px. */
|
|
49
|
+
readonly letterSpacing?: number;
|
|
50
|
+
/**
|
|
51
|
+
* As in `prepare`, but a clamp counts only whole matches, on its last line, and keeps
|
|
52
|
+
* each whole or drops it whole.
|
|
53
|
+
*/
|
|
54
|
+
readonly protect?: Protect;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ClampOptions extends CutOptions {
|
|
58
|
+
/** How many lines to keep. */
|
|
59
|
+
readonly lines: number;
|
|
60
|
+
/** Room in px to keep free after the text's end, for an element such as a "more" link. */
|
|
61
|
+
readonly after?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface Internal extends PreparedLines {
|
|
65
|
+
readonly l: PreparedTextWithSegments;
|
|
66
|
+
readonly font: string;
|
|
67
|
+
/** Each segment's start offset, then the text's length. */
|
|
68
|
+
readonly s: number[];
|
|
69
|
+
readonly o: PrepareLinesOptions;
|
|
70
|
+
/** pretext's options, as `prepareWithSegments` takes them. */
|
|
71
|
+
readonly q: PretextOptions;
|
|
72
|
+
/** A tab's width at most, in `pre-wrap`: eight spaces and half a `ch`. 0 in `normal`. */
|
|
73
|
+
readonly tab: number;
|
|
74
|
+
/** The last line region the core prepared: from, to, and its preparation. */
|
|
75
|
+
r: readonly [number, number, Prepared] | undefined;
|
|
76
|
+
/** Where a tail may start: word starts, then every grapheme start, each found once. */
|
|
77
|
+
c: Starts | undefined;
|
|
78
|
+
d: Starts | undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Tail starts in order: each one's offset, segment and grapheme index in the segment,
|
|
83
|
+
* and 1 when a trimming cut never starts a tail there, in white space or at `,;:`. `n`
|
|
84
|
+
* of them, then room to spare.
|
|
85
|
+
*/
|
|
86
|
+
interface Starts {
|
|
87
|
+
readonly at: Int32Array;
|
|
88
|
+
readonly seg: Int32Array;
|
|
89
|
+
readonly g: Int32Array;
|
|
90
|
+
readonly f: Uint8Array;
|
|
91
|
+
readonly n: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const START: LayoutCursor = { segmentIndex: 0, graphemeIndex: 0 };
|
|
95
|
+
let segmenter: Intl.Segmenter | undefined;
|
|
96
|
+
// The candidate tail starts of the clamp in progress, as indices into its `Starts`, -1
|
|
97
|
+
// for the text's end. Shared and grown as needed, so a clamp allocates no list.
|
|
98
|
+
let list = new Int32Array(64);
|
|
99
|
+
let listed = 0;
|
|
100
|
+
|
|
101
|
+
const graphemesOf = (text: string) => [...(segmenter ??= new Intl.Segmenter()).segment(text)];
|
|
102
|
+
|
|
103
|
+
// A word longer than a line breaks between graphemes, and pretext measures the parts
|
|
104
|
+
// grapheme by grapheme, where the engines measure a part as text: WebKit alone, Chromium
|
|
105
|
+
// and Firefox within the whole word, its last grapheme kerned against the one after. A
|
|
106
|
+
// part can come out over a px wider than pretext has it, so a line it fills to the edge
|
|
107
|
+
// loses its last grapheme or box to the next line in an engine, and every line after
|
|
108
|
+
// shifts (#8). This is text's widest width in an engine, measured both ways before
|
|
109
|
+
// `after`, the text after it when the line ends inside a word. White space at the end
|
|
110
|
+
// hangs, and a line with a tab is left to the tab checks. Widths are kept in the face,
|
|
111
|
+
// so a clamp again at a width it has seen measures nothing.
|
|
112
|
+
const engineWidth = (face: FaceData, spacing: number, text: string, after = "") => {
|
|
113
|
+
text = text.replace(/[ \n]+$/, "");
|
|
114
|
+
const next = graphemesOf(after.slice(0, 16))[0]?.segment;
|
|
115
|
+
return text.includes("\t")
|
|
116
|
+
? 0
|
|
117
|
+
: Math.max(widthOf(face, text), next ? widthOf(face, text + next) - widthOf(face, next) : 0) +
|
|
118
|
+
spacing * graphemesOf(text).length;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Lays `line`, from `next` in `room` px, out again in less room while it starts
|
|
122
|
+
// (`inside`) or ends inside a word, or is wider than `edge`, and runs over by `widthOf`,
|
|
123
|
+
// so it gives up a break at a time, as far as one grapheme: the strictest engine's line,
|
|
124
|
+
// never ending after any engine's.
|
|
125
|
+
const settleLine = <L extends { readonly width: number; readonly end: LayoutCursor }>(
|
|
126
|
+
line: L | null,
|
|
127
|
+
room: number,
|
|
128
|
+
inside: boolean,
|
|
129
|
+
edge: number,
|
|
130
|
+
next: (room: number) => L | null,
|
|
131
|
+
widthOf: (line: L) => number,
|
|
132
|
+
) => {
|
|
133
|
+
for (
|
|
134
|
+
let fit = room, tries = 0;
|
|
135
|
+
line && (inside || line.end.graphemeIndex || line.width > edge) && tries < 8;
|
|
136
|
+
tries++
|
|
137
|
+
) {
|
|
138
|
+
const over = Math.max(line.width, widthOf(line)) - room;
|
|
139
|
+
if (over <= 0) break;
|
|
140
|
+
line = next((fit = Math.min(fit - over, line.width) - 0.001)) ?? line;
|
|
141
|
+
}
|
|
142
|
+
return line;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// pretext's next line of `p` from `c` in `room` px, settled as `settleLine` settles it
|
|
146
|
+
// when it starts or ends inside a word, or is wider than `edge`.
|
|
147
|
+
const nextLine = (
|
|
148
|
+
p: PreparedTextWithSegments,
|
|
149
|
+
face: FaceData,
|
|
150
|
+
spacing: number,
|
|
151
|
+
room: number,
|
|
152
|
+
c: LayoutCursor,
|
|
153
|
+
edge = room,
|
|
154
|
+
) => {
|
|
155
|
+
const line = layoutNextLineRange(p, room, c);
|
|
156
|
+
return line && (c.graphemeIndex || line.end.graphemeIndex || line.width > edge)
|
|
157
|
+
? settleLine(
|
|
158
|
+
line,
|
|
159
|
+
room,
|
|
160
|
+
c.graphemeIndex > 0,
|
|
161
|
+
edge,
|
|
162
|
+
(room) => layoutNextLineRange(p, room, c),
|
|
163
|
+
(line) =>
|
|
164
|
+
engineWidth(
|
|
165
|
+
face,
|
|
166
|
+
spacing,
|
|
167
|
+
materializeLineRange(p, line).text,
|
|
168
|
+
graphemesOf(p.segments[line.end.segmentIndex] ?? "")[line.end.graphemeIndex]?.segment,
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
: line;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// Word starts are segment starts, which pretext already gives, so only a grapheme table
|
|
175
|
+
// meets the segmenter, and only once a clamp needs one: without word preference, or
|
|
176
|
+
// when no word start fits.
|
|
177
|
+
const startsOf = (p: Internal, graphemes: boolean): Starts => {
|
|
178
|
+
const known = graphemes ? p.d : p.c;
|
|
179
|
+
if (known) return known;
|
|
180
|
+
const { l, s, text } = p;
|
|
181
|
+
const size = (graphemes ? text.length : l.segments.length) + 1;
|
|
182
|
+
const at = new Int32Array(size);
|
|
183
|
+
const seg = new Int32Array(size);
|
|
184
|
+
const g = new Int32Array(size);
|
|
185
|
+
const f = new Uint8Array(size);
|
|
186
|
+
let n = 0;
|
|
187
|
+
for (let i = 0; i < l.segments.length; i++) {
|
|
188
|
+
const segment = l.segments[i]!;
|
|
189
|
+
const space = /^\s+$/.test(segment);
|
|
190
|
+
let k = 0;
|
|
191
|
+
for (const grapheme of graphemes ? graphemesOf(segment) : [{ index: 0 }]) {
|
|
192
|
+
const offset = s[i]! + grapheme.index;
|
|
193
|
+
const code = text.charCodeAt(offset);
|
|
194
|
+
at[n] = offset;
|
|
195
|
+
seg[n] = i;
|
|
196
|
+
g[n] = k++;
|
|
197
|
+
f[n++] = +(space || code === 44 || code === 58 || code === 59);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const starts = { at, seg, g, f, n };
|
|
201
|
+
return graphemes ? (p.d = starts) : (p.c = starts);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
export const prepareLines = (
|
|
205
|
+
text: string,
|
|
206
|
+
font: string,
|
|
207
|
+
options: PrepareLinesOptions = {},
|
|
208
|
+
): PreparedLines => {
|
|
209
|
+
const q: PretextOptions = {
|
|
210
|
+
...(options.whiteSpace ? { whiteSpace: options.whiteSpace } : {}),
|
|
211
|
+
...(options.wordBreak ? { wordBreak: options.wordBreak } : {}),
|
|
212
|
+
...(options.letterSpacing ? { letterSpacing: options.letterSpacing } : {}),
|
|
213
|
+
};
|
|
214
|
+
const l = prepareWithSegments(text, font, q);
|
|
215
|
+
// pretext collapses white space as the core does, or keeps it in `pre-wrap` (with CRLF
|
|
216
|
+
// as one break), and its segments join to the text every offset refers to.
|
|
217
|
+
let joined = "";
|
|
218
|
+
const s = [0];
|
|
219
|
+
for (const segment of l.segments) s.push((joined += segment).length);
|
|
220
|
+
// A tab advances to the next stop, eight spaces apart, unless that is under half a `ch`
|
|
221
|
+
// away, when Firefox and WebKit take the stop after, as CSS Text says: so up to eight
|
|
222
|
+
// spaces and half a "0".
|
|
223
|
+
const face = faceOf(font);
|
|
224
|
+
const tab =
|
|
225
|
+
options.whiteSpace === "pre-wrap"
|
|
226
|
+
? 8 * (widthOf(face, " ") + (options.letterSpacing ?? 0)) + widthOf(face, "0") / 2
|
|
227
|
+
: 0;
|
|
228
|
+
const p: Internal = {
|
|
229
|
+
text: joined,
|
|
230
|
+
l,
|
|
231
|
+
font,
|
|
232
|
+
s,
|
|
233
|
+
o: options,
|
|
234
|
+
q,
|
|
235
|
+
tab,
|
|
236
|
+
r: undefined,
|
|
237
|
+
c: undefined,
|
|
238
|
+
d: undefined,
|
|
239
|
+
};
|
|
240
|
+
return p;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/** The room in px after the last line of the whole text, where a "less" link would go. */
|
|
244
|
+
export const roomAfter = (lines: PreparedLines, width: number): number => {
|
|
245
|
+
let last = 0;
|
|
246
|
+
walkLineRanges((lines as Internal).l, width, (line) => (last = line.width));
|
|
247
|
+
return width - last;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Cuts text to `options.lines` lines of `width` px, with the ellipsis at the end of the
|
|
252
|
+
* last line, the start of the first, or inside a middle line (`ratio` of the way through).
|
|
253
|
+
*/
|
|
254
|
+
export const clamp = (lines: PreparedLines, width: number, options: ClampOptions): Cut => {
|
|
255
|
+
const p = lines as Internal;
|
|
256
|
+
const { text, l, font, s, o } = p;
|
|
257
|
+
const { at = "end", ratio = 0.5, words = true, trim = true, ellipsis = "…", after = 0 } = options;
|
|
258
|
+
const n = Math.max(1, options.lines);
|
|
259
|
+
const length = text.length;
|
|
260
|
+
const spacing = o.letterSpacing ?? 0;
|
|
261
|
+
const face = faceOf(font);
|
|
262
|
+
const ew =
|
|
263
|
+
typeof ellipsis === "number" ? ellipsis : widthOf(face, ellipsis) + spacing * ellipsis.length;
|
|
264
|
+
const next = (room: number, c: LayoutCursor) => nextLine(l, face, spacing, room, c);
|
|
265
|
+
const offset = (c: LayoutCursor) =>
|
|
266
|
+
s[c.segmentIndex]! +
|
|
267
|
+
(c.graphemeIndex ? graphemesOf(l.segments[c.segmentIndex]!)[c.graphemeIndex]!.index : 0);
|
|
268
|
+
// How many lines the text from `from` takes, the first `first` px wide, counting no
|
|
269
|
+
// further than one past `limit`, and one more when `after` doesn't fit beside the
|
|
270
|
+
// last. Arithmetic over pretext's prepared widths, but for a break inside a word.
|
|
271
|
+
const linesFrom = (from: LayoutCursor, first: number, limit: number, extra = after) => {
|
|
272
|
+
let lines = 0;
|
|
273
|
+
let c: LayoutCursor = from;
|
|
274
|
+
let room = first;
|
|
275
|
+
let last = 0;
|
|
276
|
+
while (lines <= limit) {
|
|
277
|
+
const range = next(room, c);
|
|
278
|
+
if (range === null) break;
|
|
279
|
+
lines++;
|
|
280
|
+
c = range.end;
|
|
281
|
+
last = range.width + width - room;
|
|
282
|
+
room = width;
|
|
283
|
+
}
|
|
284
|
+
return lines + +(extra > 0 && last + extra > width);
|
|
285
|
+
};
|
|
286
|
+
// The start of line `index` (from 0); the model has more lines than that.
|
|
287
|
+
const lineStart = (index: number) => {
|
|
288
|
+
let c = START;
|
|
289
|
+
for (let i = 0; i < index; i++) c = next(width, c)!.end;
|
|
290
|
+
return c;
|
|
291
|
+
};
|
|
292
|
+
// The core's end cut of the line starting at `line`, in `room` px. A cut keeps at most
|
|
293
|
+
// a line, so the core prepares only the region from there to the end of the line
|
|
294
|
+
// after, or to a hard break, and keeps the last one for the next cut.
|
|
295
|
+
const endCut = (line: LayoutCursor, room: number, e: string | number) => {
|
|
296
|
+
const from = offset(line);
|
|
297
|
+
const after = next(width, line);
|
|
298
|
+
const beyond = after && next(width, after.end);
|
|
299
|
+
let to = beyond ? offset(beyond.end) : length;
|
|
300
|
+
const hard = text.indexOf("\n", from);
|
|
301
|
+
if (hard >= 0 && hard < to) to = hard;
|
|
302
|
+
if (!p.r || p.r[0] !== from || p.r[1] !== to) {
|
|
303
|
+
p.r = [from, to, prepareRange(text, from, to, font, o, p.tab)];
|
|
304
|
+
}
|
|
305
|
+
// The text goes on past the region, so the ellipsis always needs its room.
|
|
306
|
+
return from + head(cutFrom(p.r[2], 0, room, { ...options, at: "end", ellipsis: e }, true));
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// pretext's plain and rich-inline layouts can each be the one that matches an engine,
|
|
310
|
+
// so the final check is the stricter of them. It lays out at the model's width: a
|
|
311
|
+
// line that fit the model and not a narrower check shifted the tail at some widths
|
|
312
|
+
// and not the next. The last line's cut keeps its own slack in the core.
|
|
313
|
+
// Engines lay tabs out three ways, and pretext predicts none of them exactly under
|
|
314
|
+
// letter spacing: Chrome and WebKit let a tab hang past the line's end, Firefox keeps
|
|
315
|
+
// it with the word before and wraps the two together. So text with tabs is also
|
|
316
|
+
// checked with each tab as no-break spaces, as many as reach the widest a tab can
|
|
317
|
+
// take, held to the text on both sides.
|
|
318
|
+
const tabbed = p.tab > 0 && text.includes("\t");
|
|
319
|
+
const nbsp = "\u00a0".repeat(tabbed ? Math.ceil(p.tab / (widthOf(face, "\u00a0") + spacing)) : 0);
|
|
320
|
+
const { whiteSpace, wordBreak } = p.q;
|
|
321
|
+
const layoutOptions = {
|
|
322
|
+
...(whiteSpace ? { whiteSpace } : {}),
|
|
323
|
+
...(wordBreak ? { wordBreak } : {}),
|
|
324
|
+
};
|
|
325
|
+
// The checks also settle a line within a px of the edge: pretext can sum a line's parts
|
|
326
|
+
// narrower than an engine lays them out together, as Firefox puts "文 😀 ~/…" at 72.03 px
|
|
327
|
+
// where pretext has 71.45 (#10).
|
|
328
|
+
const edge = width - 1;
|
|
329
|
+
// Whether the plain layout of `joined` takes n lines at most. Its lines are laid out
|
|
330
|
+
// again one by one, settled, only when one breaks inside a word or nears the edge.
|
|
331
|
+
const plainWithin = (joined: string) => {
|
|
332
|
+
const plain = prepareWithSegments(joined, font, p.q);
|
|
333
|
+
let broken = false;
|
|
334
|
+
let lines = walkLineRanges(
|
|
335
|
+
plain,
|
|
336
|
+
width,
|
|
337
|
+
(line) => void (broken ||= line.end.graphemeIndex > 0 || line.width > edge),
|
|
338
|
+
);
|
|
339
|
+
if (lines > n || !broken) return lines <= n;
|
|
340
|
+
lines = 0;
|
|
341
|
+
for (
|
|
342
|
+
let c = START, line;
|
|
343
|
+
lines <= n && (line = nextLine(plain, face, spacing, width, c, edge));
|
|
344
|
+
) {
|
|
345
|
+
lines++;
|
|
346
|
+
c = line.end;
|
|
347
|
+
}
|
|
348
|
+
return lines <= n;
|
|
349
|
+
};
|
|
350
|
+
// Whether the rich-inline layout of `items` takes n lines at most, each line settled
|
|
351
|
+
// as `nextLine` settles it.
|
|
352
|
+
const richWithin = (items: Array<RichInlineItem | RichInlineBox>) => {
|
|
353
|
+
const prepared = prepareRichInline(items, layoutOptions);
|
|
354
|
+
// A text part's width is its widest in an engine, or pretext's when that is wider.
|
|
355
|
+
const widest = (line: RichInlineLineRange) => {
|
|
356
|
+
let w = line.width;
|
|
357
|
+
for (const f of materializeRichInlineLineRange(prepared, line).fragments) {
|
|
358
|
+
const { text } = items[f.itemIndex] as RichInlineItem;
|
|
359
|
+
if (f.text) {
|
|
360
|
+
w += Math.max(
|
|
361
|
+
0,
|
|
362
|
+
engineWidth(
|
|
363
|
+
face,
|
|
364
|
+
spacing,
|
|
365
|
+
f.text,
|
|
366
|
+
f.sourceEnd < text.length && line.end.graphemeIndex ? text.slice(f.sourceEnd) : "",
|
|
367
|
+
) - f.occupiedWidth,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return w;
|
|
372
|
+
};
|
|
373
|
+
let broken = false;
|
|
374
|
+
let c: RichInlineCursor | undefined;
|
|
375
|
+
let lines = walkRichInlineLineRanges(
|
|
376
|
+
prepared,
|
|
377
|
+
width,
|
|
378
|
+
(line) => void (broken ||= line.end.graphemeIndex > 0 || line.width > edge),
|
|
379
|
+
);
|
|
380
|
+
if (lines > n || !broken) return lines <= n;
|
|
381
|
+
for (lines = 0; lines <= n; lines++) {
|
|
382
|
+
const from = c;
|
|
383
|
+
const next = (room: number) => layoutNextRichInlineLineRange(prepared, room, from);
|
|
384
|
+
const line = settleLine(
|
|
385
|
+
next(width),
|
|
386
|
+
width,
|
|
387
|
+
(from?.graphemeIndex ?? 0) > 0,
|
|
388
|
+
edge,
|
|
389
|
+
next,
|
|
390
|
+
widest,
|
|
391
|
+
);
|
|
392
|
+
if (!line) break;
|
|
393
|
+
c = line.end;
|
|
394
|
+
}
|
|
395
|
+
return lines <= n;
|
|
396
|
+
};
|
|
397
|
+
// Whether text, an ellipsis and text, then `after`'s room, lay out in n lines: the
|
|
398
|
+
// ellipsis given as a width and the room after as boxes of their size.
|
|
399
|
+
const within = (before: string, rest: string) => {
|
|
400
|
+
if (typeof ellipsis === "string" && !after && !plainWithin(before + ellipsis + rest)) {
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
const items: Array<RichInlineItem | RichInlineBox> = [];
|
|
404
|
+
const words = (text: string) => {
|
|
405
|
+
if (text) items.push({ text, font, ...(spacing ? { letterSpacing: spacing } : {}) });
|
|
406
|
+
};
|
|
407
|
+
// A text ellipsis is one run with the text, as it renders; apart, the layout would
|
|
408
|
+
// find a break between them that the joined string doesn't have.
|
|
409
|
+
if (typeof ellipsis === "string") words(before + ellipsis + rest);
|
|
410
|
+
else {
|
|
411
|
+
words(before);
|
|
412
|
+
items.push({ width: ellipsis });
|
|
413
|
+
words(rest);
|
|
414
|
+
}
|
|
415
|
+
if (after) items.push({ width: after });
|
|
416
|
+
return richWithin(items);
|
|
417
|
+
};
|
|
418
|
+
const held = (text: string) => text.replaceAll("\t", nbsp);
|
|
419
|
+
// Whether the cut keeping [0, h) and [at, length) really lays out in n lines. The
|
|
420
|
+
// model lays the tail out apart from the head, and the joined line can wrap
|
|
421
|
+
// differently: a tail that begins mid-word can't break where a line start could, and
|
|
422
|
+
// an ellipsis can end the line before.
|
|
423
|
+
const fits = (h: number, at: number) => {
|
|
424
|
+
const before = text.slice(0, h);
|
|
425
|
+
const rest = text.slice(at);
|
|
426
|
+
return within(before, rest) && (!tabbed || within(held(before), held(rest)));
|
|
427
|
+
};
|
|
428
|
+
// The grapheme start before `h`.
|
|
429
|
+
const back = (h: number) => {
|
|
430
|
+
const graphemes = graphemesOf(text.slice(Math.max(0, h - 32), h));
|
|
431
|
+
return h - graphemes[graphemes.length - 1]!.segment.length;
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
// Text that fits shows whole, with nothing after it, so `after` takes no room here.
|
|
435
|
+
// Tabbed text must also fit with each tab at its widest, which the model doesn't know.
|
|
436
|
+
const fitted = linesFrom(START, width, n, 0);
|
|
437
|
+
if (fitted <= n) {
|
|
438
|
+
const all = held(text);
|
|
439
|
+
if (
|
|
440
|
+
!tabbed ||
|
|
441
|
+
(plainWithin(all) &&
|
|
442
|
+
richWithin([{ text: all, font, ...(spacing ? { letterSpacing: spacing } : {}) }]))
|
|
443
|
+
) {
|
|
444
|
+
return pack(length, length);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
// The model's lines, at most n. Tabbed text the model fits in fewer can still run
|
|
448
|
+
// over with its tabs at their widest, so the cut works within the lines it has.
|
|
449
|
+
const drawn = Math.min(n, fitted);
|
|
450
|
+
|
|
451
|
+
if (at === "end") {
|
|
452
|
+
let h = endCut(lineStart(drawn - 1), width - after, ellipsis);
|
|
453
|
+
// The core cuts the last line with each tab at its widest, but the lines before come
|
|
454
|
+
// from the model, so tabbed text is checked on the real layout too, as start and
|
|
455
|
+
// middle cuts are.
|
|
456
|
+
while (tabbed && h > 0 && !fits(h, length)) h = back(h);
|
|
457
|
+
return pack(h, length);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// The text before the tail stays on lines [0, line) and `used` px of line `line`.
|
|
461
|
+
const line = at === "start" ? 0 : Math.min(drawn - 1, Math.floor(n * ratio));
|
|
462
|
+
const start = lineStart(line);
|
|
463
|
+
const from = offset(start);
|
|
464
|
+
// The head's share of its line. The tail gets the rest of that line whatever the head
|
|
465
|
+
// keeps of its share, so neither end takes the other's slack and, as the width moves,
|
|
466
|
+
// each end only ever grows or only ever shrinks.
|
|
467
|
+
const share = at === "start" ? 0 : (n * ratio - line) * width;
|
|
468
|
+
let h = at === "start" ? 0 : endCut(start, share, 0);
|
|
469
|
+
// The table the candidates index into, word starts or grapheme starts.
|
|
470
|
+
let c: Starts;
|
|
471
|
+
// Candidate `i`'s offset and line cursor; -1 is the text's end.
|
|
472
|
+
const offsetOf = (i: number) => (list[i]! < 0 ? length : c.at[list[i]!]!);
|
|
473
|
+
const cursorOf = (i: number): LayoutCursor =>
|
|
474
|
+
list[i]! < 0 ? START : { segmentIndex: c.seg[list[i]!]!, graphemeIndex: c.g[list[i]!]! };
|
|
475
|
+
const used = share + ew;
|
|
476
|
+
// Where the tail may start, after head `h` (cutting nothing is no cut): word starts,
|
|
477
|
+
// or every grapheme start without word preference. When trimming, never white space
|
|
478
|
+
// nor `,;:`. The text's end always closes the list.
|
|
479
|
+
const starts = (h: number, wordsOnly: boolean) => {
|
|
480
|
+
c = startsOf(p, !wordsOnly);
|
|
481
|
+
if (list.length < c.n + 1) list = new Int32Array(2 * (c.n + 1));
|
|
482
|
+
listed = 0;
|
|
483
|
+
let lo = 0;
|
|
484
|
+
let hi = c.n;
|
|
485
|
+
while (lo < hi) {
|
|
486
|
+
const mid = (lo + hi) >> 1;
|
|
487
|
+
if (c.at[mid]! > h) hi = mid;
|
|
488
|
+
else lo = mid + 1;
|
|
489
|
+
}
|
|
490
|
+
for (let j = lo; j < c.n; j++) if (!(trim && c.f[j])) list[listed++] = j;
|
|
491
|
+
list[listed++] = -1;
|
|
492
|
+
};
|
|
493
|
+
// The first start whose tail fits the model: its lines, the first beside the head
|
|
494
|
+
// and the ellipsis, end by line n. A later start never takes more lines.
|
|
495
|
+
const first = () => {
|
|
496
|
+
let lo = 0;
|
|
497
|
+
let hi = listed - 1;
|
|
498
|
+
while (lo < hi) {
|
|
499
|
+
const mid = (lo + hi) >> 1;
|
|
500
|
+
if (linesFrom(cursorOf(mid), width - used, n - line) <= n - line) hi = mid;
|
|
501
|
+
else lo = mid + 1;
|
|
502
|
+
}
|
|
503
|
+
return lo;
|
|
504
|
+
};
|
|
505
|
+
// Whether the real layout refused the model's start for the last tail found.
|
|
506
|
+
let slipped = false;
|
|
507
|
+
// The cut settles on the real layout: later while it runs over, and, with no head
|
|
508
|
+
// whose room it would take, earlier while one more start still fits.
|
|
509
|
+
const settle = (h: number) => {
|
|
510
|
+
const model = first();
|
|
511
|
+
let i = model;
|
|
512
|
+
while (i < listed - 1 && !fits(h, offsetOf(i))) i++;
|
|
513
|
+
if (at === "start") while (i > 0 && fits(h, offsetOf(i - 1))) i--;
|
|
514
|
+
slipped ||= i > model;
|
|
515
|
+
return i;
|
|
516
|
+
};
|
|
517
|
+
// The earliest tail start that really fits after head `h`, or -1 when none does.
|
|
518
|
+
const tailOf = (h: number) => {
|
|
519
|
+
slipped = false;
|
|
520
|
+
starts(h, words !== false);
|
|
521
|
+
let i = settle(h);
|
|
522
|
+
// With no word start that really fits, the tail starts between graphemes, as the
|
|
523
|
+
// core falls back. Only after settling: the model can pass a word start that the
|
|
524
|
+
// joined layout refuses, when the ellipsis takes a line of its own.
|
|
525
|
+
if (i === listed - 1 && words !== false) {
|
|
526
|
+
starts(h, false);
|
|
527
|
+
i = settle(h);
|
|
528
|
+
}
|
|
529
|
+
return i < listed - 1 || fits(h, length) ? offsetOf(i) : -1;
|
|
530
|
+
};
|
|
531
|
+
for (;;) {
|
|
532
|
+
let t = tailOf(h);
|
|
533
|
+
if (t >= 0 || h <= from) {
|
|
534
|
+
// No tail fits even with the head back at its line's start, so the head shows
|
|
535
|
+
// alone, and settles on the real layout like any other cut: an engine can break
|
|
536
|
+
// its lines elsewhere than the model, as Firefox breaks after a slash and not before
|
|
537
|
+
// an ellipsis, and wrap even the head alone past them (#7).
|
|
538
|
+
if (t < 0) {
|
|
539
|
+
t = length;
|
|
540
|
+
while (h > 0 && !fits(h, length)) h = back(h);
|
|
541
|
+
}
|
|
542
|
+
// When the real layout refused the model's start, the head's last word and the tail
|
|
543
|
+
// wrap together: one more letter of head can push the ellipsis or the tail's first
|
|
544
|
+
// word to the next line and cost the tail more than it gave. Then every
|
|
545
|
+
// shorter head back to the start of its line or of that word, whichever is earlier,
|
|
546
|
+
// is tried, and the one that keeps the most text in all wins, the longest on a tie,
|
|
547
|
+
// so a wider box doesn't show less. Text is counted without white space, which
|
|
548
|
+
// shows as nothing.
|
|
549
|
+
if (at === "middle" && slipped) {
|
|
550
|
+
const ink = (a: number, b: number) => {
|
|
551
|
+
let count = 0;
|
|
552
|
+
for (let i = a; i < b; i++) if (text.charCodeAt(i) > 32) count++;
|
|
553
|
+
return count;
|
|
554
|
+
};
|
|
555
|
+
const words = startsOf(p, false);
|
|
556
|
+
let lo = 0;
|
|
557
|
+
let hi = words.n;
|
|
558
|
+
while (lo < hi) {
|
|
559
|
+
const mid = (lo + hi) >> 1;
|
|
560
|
+
if (words.at[mid]! < h) lo = mid + 1;
|
|
561
|
+
else hi = mid;
|
|
562
|
+
}
|
|
563
|
+
// Spaces are segments too: the word is the last start that isn't one.
|
|
564
|
+
while (lo > 0 && words.f[lo - 1]) lo--;
|
|
565
|
+
const floor = Math.min(from, lo ? words.at[lo - 1]! : 0);
|
|
566
|
+
let best = h;
|
|
567
|
+
let bestTail = t;
|
|
568
|
+
for (let shorter = h; shorter > floor;) {
|
|
569
|
+
shorter = Math.max(floor, back(shorter));
|
|
570
|
+
const rest = tailOf(shorter);
|
|
571
|
+
if (rest >= 0 && ink(rest, bestTail) > ink(shorter, best)) {
|
|
572
|
+
best = shorter;
|
|
573
|
+
bestTail = rest;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
h = best;
|
|
577
|
+
t = bestTail;
|
|
578
|
+
}
|
|
579
|
+
return pack(h, t);
|
|
580
|
+
}
|
|
581
|
+
// With no tail at all still running over, as when the room after leaves less than
|
|
582
|
+
// the head took, the head gives up a grapheme and the tail is found again.
|
|
583
|
+
h = Math.max(from, back(h));
|
|
584
|
+
}
|
|
585
|
+
};
|
package/src/metrics.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { type Face, face } from "./face.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A font's widths, written by `scripts/metrics.ts` from the font file: what a face needs
|
|
5
|
+
* to measure text with no canvas, on a server, in a worker, or before the font loads.
|
|
6
|
+
*/
|
|
7
|
+
export interface Metrics {
|
|
8
|
+
/** The family it was read from, as CSS names it. */
|
|
9
|
+
readonly family: string;
|
|
10
|
+
readonly unitsPerEm: number;
|
|
11
|
+
/** The characters covered, in order. */
|
|
12
|
+
readonly chars: string;
|
|
13
|
+
/** Each covered character's advance, in font units, in the order of `chars`. */
|
|
14
|
+
readonly advances: ReadonlyArray<number>;
|
|
15
|
+
/** Kerned pairs as triples: first's index in `chars`, second's, adjustment in font units. */
|
|
16
|
+
readonly kerning: ReadonlyArray<number>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// A character the table doesn't cover comes from some fallback font, so it is counted
|
|
20
|
+
// wider than any fallback glyph a cut is likely to meet, CJK and emoji included.
|
|
21
|
+
const UNKNOWN = 1.5;
|
|
22
|
+
|
|
23
|
+
interface Decoded {
|
|
24
|
+
readonly advance: Map<number, number>;
|
|
25
|
+
readonly kern: Map<number, number>;
|
|
26
|
+
}
|
|
27
|
+
const decoded = new WeakMap<Metrics, Decoded>();
|
|
28
|
+
const decode = (metrics: Metrics): Decoded => {
|
|
29
|
+
let d = decoded.get(metrics);
|
|
30
|
+
if (!d) {
|
|
31
|
+
const codes = Array.from(metrics.chars, (c) => c.codePointAt(0)!);
|
|
32
|
+
const advance = new Map<number, number>();
|
|
33
|
+
for (let i = 0; i < codes.length; i++) advance.set(codes[i]!, metrics.advances[i]!);
|
|
34
|
+
const kern = new Map<number, number>();
|
|
35
|
+
const k = metrics.kerning;
|
|
36
|
+
for (let i = 0; i < k.length; i += 3) {
|
|
37
|
+
kern.set(codes[k[i]!]! * 0x110000 + codes[k[i + 1]!]!, k[i + 2]!);
|
|
38
|
+
}
|
|
39
|
+
decoded.set(metrics, (d = { advance, kern }));
|
|
40
|
+
}
|
|
41
|
+
return d;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Firefox rounds each glyph's advance, kerning to the next glyph included, to the nearest
|
|
45
|
+
// 1/60 px, its layout unit, so a run of glyphs that round up lays out wider than their
|
|
46
|
+
// sum: 0.4 px over 352 characters of Inter at 15 px. Each glyph is rounded up instead,
|
|
47
|
+
// never less than Firefox and never less than the exact widths Chrome and WebKit use.
|
|
48
|
+
const up = (units: number, scale: number) => Math.ceil(units * scale * 60) / 60;
|
|
49
|
+
|
|
50
|
+
/** Measures text in `metrics` at `px` px: each character's advance plus each pair's kerning. */
|
|
51
|
+
export const measure = (metrics: Metrics, px: number, text: string): number => {
|
|
52
|
+
const { advance, kern } = decode(metrics);
|
|
53
|
+
const em = metrics.unitsPerEm;
|
|
54
|
+
const scale = px / em;
|
|
55
|
+
let width = 0;
|
|
56
|
+
// The previous glyph's advance in font units, waiting for its kerning to this one.
|
|
57
|
+
let glyph = 0;
|
|
58
|
+
let previous = -1;
|
|
59
|
+
for (const char of text) {
|
|
60
|
+
const code = char.codePointAt(0)!;
|
|
61
|
+
if (previous >= 0) width += up(glyph + (kern.get(previous * 0x110000 + code) ?? 0), scale);
|
|
62
|
+
glyph = advance.get(code) ?? UNKNOWN * em;
|
|
63
|
+
previous = code;
|
|
64
|
+
}
|
|
65
|
+
return previous < 0 ? 0 : width + up(glyph, scale);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** A face that measures with `metrics` at `px` px, for `prepare` with no canvas. */
|
|
69
|
+
export const tableFace = (metrics: Metrics, px: number): Face =>
|
|
70
|
+
face((text) => measure(metrics, px, text));
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A stand-in for `OffscreenCanvas` that measures with tables instead of a font engine,
|
|
74
|
+
* for pretext and `pretrunc/lines` where there is no canvas: install it as
|
|
75
|
+
* `globalThis.OffscreenCanvas` before the first `prepareLines`. The context reads the
|
|
76
|
+
* size and the first family with a table from its `font`, and falls back to the first.
|
|
77
|
+
*/
|
|
78
|
+
export const tableCanvas = (tables: ReadonlyArray<Metrics>) =>
|
|
79
|
+
class {
|
|
80
|
+
getContext() {
|
|
81
|
+
let px = 16;
|
|
82
|
+
let metrics = tables[0]!;
|
|
83
|
+
return {
|
|
84
|
+
get font() {
|
|
85
|
+
return `${px}px ${metrics.family}`;
|
|
86
|
+
},
|
|
87
|
+
set font(font: string) {
|
|
88
|
+
// `[style] [weight] 15px[/line-height] "Family", fallback`
|
|
89
|
+
const parts = /([\d.]+)px(?:\/\S+)?\s+(.*)$/.exec(font);
|
|
90
|
+
px = Number.parseFloat(parts?.[1] ?? "16");
|
|
91
|
+
metrics = tables[0]!;
|
|
92
|
+
// The first family named that has a table.
|
|
93
|
+
search: for (const family of (parts?.[2] ?? "").split(",")) {
|
|
94
|
+
const name = family.trim().replace(/^["']|["']$/g, "");
|
|
95
|
+
for (const table of tables) {
|
|
96
|
+
if (table.family === name) {
|
|
97
|
+
metrics = table;
|
|
98
|
+
break search;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
measureText: (text: string) => ({ width: measure(metrics, px, text) }),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
};
|