linegauge 0.0.1 → 0.2.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/LICENSE +21 -0
- package/README.md +95 -12
- package/dist/index.d.ts +31 -5
- package/dist/index.js +31 -5
- package/dist/slice.d.ts +10 -0
- package/dist/slice.js +100 -0
- package/dist/strip.d.ts +26 -0
- package/dist/strip.js +62 -0
- package/dist/style.d.ts +71 -0
- package/dist/style.js +276 -0
- package/dist/truncate.d.ts +12 -0
- package/dist/truncate.js +42 -0
- package/dist/widest.d.ts +6 -0
- package/dist/widest.js +25 -0
- package/dist/width.d.ts +45 -0
- package/dist/width.js +226 -0
- package/dist/wrap.d.ts +12 -0
- package/dist/wrap.js +271 -0
- package/package.json +29 -3
package/dist/wrap.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { ASCII_PRINTABLE, ROW_BOUNDARY, TAB_SIZE, applyLeadingResets, applyParameters, closingSequence, forEachSegment, hyperlink, matchEscape, openingSequence, segmenter, sgr } from './style.js';
|
|
2
|
+
/**
|
|
3
|
+
* Wrapping text that carries ANSI, ported from wrap-ansi 10 — the third dependency the
|
|
4
|
+
* render façades share, after the spinner corpus and the width function (R7, R10).
|
|
5
|
+
*
|
|
6
|
+
* The hard part is not the arithmetic, it is that a style opened on one row must not leak
|
|
7
|
+
* into the next: a terminal that reflows, a pager, or an agent reading one line at a time
|
|
8
|
+
* all see rows independently. So every row closes the styles it inherited and the next row
|
|
9
|
+
* reopens them, which has a second use here — after wrapping, **each row is self-contained**,
|
|
10
|
+
* and dropping leading rows needs no ANSI state tracking at all. `flagstaff/log-update`
|
|
11
|
+
* relies on exactly that, which is why it carries no port of `slice-ansi`.
|
|
12
|
+
*
|
|
13
|
+
* Graded differentially against the real `wrap-ansi` in `wrap.test.ts`, the way `width.ts`
|
|
14
|
+
* is graded against `string-width`: the incumbent is the specification.
|
|
15
|
+
*/
|
|
16
|
+
import { measure } from './width.js';
|
|
17
|
+
/** The visible width of a string, escape sequences ignored. */
|
|
18
|
+
export function visibleWidth(string) {
|
|
19
|
+
let plainText = '';
|
|
20
|
+
forEachSegment(string, (part) => {
|
|
21
|
+
plainText += part;
|
|
22
|
+
});
|
|
23
|
+
return measure(plainText);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Escape sequences, which are zero width and must never be split, and grapheme clusters.
|
|
27
|
+
* A sequence written *inside* a cluster splits it — the supported boundary is between
|
|
28
|
+
* clusters and sequences, not within one.
|
|
29
|
+
*/
|
|
30
|
+
function tokenize(string) {
|
|
31
|
+
const tokens = [];
|
|
32
|
+
forEachSegment(string, (plainText) => {
|
|
33
|
+
if (ASCII_PRINTABLE.test(plainText)) {
|
|
34
|
+
for (const character of plainText)
|
|
35
|
+
tokens.push({ value: character, width: 1 });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
for (const { segment } of segmenter.segment(plainText))
|
|
39
|
+
tokens.push({ value: segment, width: measure(segment) });
|
|
40
|
+
}, (escape) => tokens.push({ value: escape, width: 0 }));
|
|
41
|
+
return tokens;
|
|
42
|
+
}
|
|
43
|
+
/** Split on spaces, ignoring spaces that appear inside a recognised sequence. */
|
|
44
|
+
function splitWords(string) {
|
|
45
|
+
let current = { value: '', plainText: '', width: 0 };
|
|
46
|
+
const words = [current];
|
|
47
|
+
forEachSegment(string, (plainText) => {
|
|
48
|
+
const parts = plainText.split(' ');
|
|
49
|
+
current.value += parts[0] ?? '';
|
|
50
|
+
current.plainText += parts[0] ?? '';
|
|
51
|
+
for (let index = 1; index < parts.length; index += 1) {
|
|
52
|
+
const part = parts[index] ?? '';
|
|
53
|
+
current = { value: part, plainText: part, width: 0 };
|
|
54
|
+
words.push(current);
|
|
55
|
+
}
|
|
56
|
+
}, (escape) => {
|
|
57
|
+
current.value += escape;
|
|
58
|
+
});
|
|
59
|
+
// Measured once per word rather than per run, so a cluster an escape splits counts once.
|
|
60
|
+
for (const word of words)
|
|
61
|
+
word.width = measure(word.plainText);
|
|
62
|
+
return words;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Break one long word across rows. Takes the visible width of the row it starts on and
|
|
66
|
+
* returns the width of the row it ends on, so the caller never measures a row itself.
|
|
67
|
+
*/
|
|
68
|
+
function wrapWord(rows, word, columns, rowWidth) {
|
|
69
|
+
const tokens = tokenize(word);
|
|
70
|
+
let visible = rowWidth;
|
|
71
|
+
for (const [index, token] of tokens.entries()) {
|
|
72
|
+
// Sequences and combining marks are zero width, so they stay on the current row.
|
|
73
|
+
if (token.width > 0 && visible > 0 && visible + token.width > columns) {
|
|
74
|
+
rows.push('');
|
|
75
|
+
visible = 0;
|
|
76
|
+
}
|
|
77
|
+
rows[rows.length - 1] += token.value;
|
|
78
|
+
visible += token.width;
|
|
79
|
+
if (visible === columns && index < tokens.length - 1) {
|
|
80
|
+
rows.push('');
|
|
81
|
+
visible = 0;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// The last row copied over can be nothing but escape characters.
|
|
85
|
+
const last = rows.at(-1) ?? '';
|
|
86
|
+
if (!visible && last.length > 0 && rows.length > 1)
|
|
87
|
+
rows[rows.length - 2] += rows.pop() ?? '';
|
|
88
|
+
// Tokens are measured one at a time, so a cluster an escape splits counts once per part.
|
|
89
|
+
// Only the finished row gives the true width, and it is at most one row to measure.
|
|
90
|
+
return visibleWidth(rows.at(-1) ?? '');
|
|
91
|
+
}
|
|
92
|
+
/** Drop the spaces trailing the last visible character, keeping the sequences among them. */
|
|
93
|
+
function trimVisibleEnd(string) {
|
|
94
|
+
if (!string.includes(' '))
|
|
95
|
+
return string;
|
|
96
|
+
const segments = [];
|
|
97
|
+
forEachSegment(string, (plainText) => segments.push({ value: plainText, isEscape: false }), (escape) => segments.push({ value: escape, isEscape: true }));
|
|
98
|
+
for (let index = segments.length - 1; index >= 0; index -= 1) {
|
|
99
|
+
const segment = segments[index];
|
|
100
|
+
if (segment === undefined || segment.isEscape)
|
|
101
|
+
continue;
|
|
102
|
+
// Scanned rather than matched: a trailing-space pattern backtracks quadratically.
|
|
103
|
+
let end = segment.value.length;
|
|
104
|
+
while (end > 0 && segment.value[end - 1] === ' ')
|
|
105
|
+
end -= 1;
|
|
106
|
+
segment.value = segment.value.slice(0, end);
|
|
107
|
+
if (measure(segment.value) > 0)
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
return segments.map((segment) => segment.value).join('');
|
|
111
|
+
}
|
|
112
|
+
function expandTabs(line) {
|
|
113
|
+
if (!line.includes('\t'))
|
|
114
|
+
return line;
|
|
115
|
+
let visible = 0;
|
|
116
|
+
let expanded = '';
|
|
117
|
+
let sinceTab = '';
|
|
118
|
+
forEachSegment(line, (plainText) => {
|
|
119
|
+
const parts = plainText.split('\t');
|
|
120
|
+
for (const [index, part] of parts.entries()) {
|
|
121
|
+
expanded += part;
|
|
122
|
+
sinceTab += part;
|
|
123
|
+
if (index < parts.length - 1) {
|
|
124
|
+
visible += measure(sinceTab);
|
|
125
|
+
sinceTab = '';
|
|
126
|
+
const spaces = TAB_SIZE - (visible % TAB_SIZE);
|
|
127
|
+
expanded += ' '.repeat(spaces);
|
|
128
|
+
visible += spaces;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}, (escape) => {
|
|
132
|
+
expanded += escape;
|
|
133
|
+
});
|
|
134
|
+
return expanded;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Close the active styles and hyperlink before every row break and reopen them after, so
|
|
138
|
+
* each row stands on its own. Only sequences and newlines matter, so the string is scanned
|
|
139
|
+
* directly rather than segmented.
|
|
140
|
+
*/
|
|
141
|
+
function restoreStylesAcrossRows(preString) {
|
|
142
|
+
let out = '';
|
|
143
|
+
let activeHyperlink;
|
|
144
|
+
const active = [];
|
|
145
|
+
let index = 0;
|
|
146
|
+
let copied = 0;
|
|
147
|
+
while (index < preString.length) {
|
|
148
|
+
ROW_BOUNDARY.lastIndex = index;
|
|
149
|
+
const boundary = ROW_BOUNDARY.exec(preString);
|
|
150
|
+
if (boundary === null)
|
|
151
|
+
break;
|
|
152
|
+
index = boundary.index;
|
|
153
|
+
if (boundary[0] !== '\n') {
|
|
154
|
+
const escape = matchEscape(preString, index);
|
|
155
|
+
if (escape === undefined) {
|
|
156
|
+
index += 1;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const groups = escape.groups ?? {};
|
|
160
|
+
if (groups['sgr'] !== undefined)
|
|
161
|
+
applyParameters(groups['sgr'], active);
|
|
162
|
+
else if (groups['uri'] !== undefined)
|
|
163
|
+
activeHyperlink = groups['uri'].length === 0 ? undefined : { parameters: groups['parameters'] ?? '', uri: groups['uri'] };
|
|
164
|
+
index += escape[0].length;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
// Everything up to the row break is copied verbatim, sequences included.
|
|
168
|
+
out += preString.slice(copied, index);
|
|
169
|
+
// An empty row never reopened anything, so there is nothing to close.
|
|
170
|
+
if (index > copied) {
|
|
171
|
+
if (activeHyperlink !== undefined)
|
|
172
|
+
out += hyperlink('');
|
|
173
|
+
out += closingSequence(active);
|
|
174
|
+
}
|
|
175
|
+
out += '\n';
|
|
176
|
+
index += 1;
|
|
177
|
+
copied = index;
|
|
178
|
+
// An empty row has nothing to style, so the styles stay closed until the next row with
|
|
179
|
+
// content; a trailing row break leaves no row at all.
|
|
180
|
+
if (index < preString.length && preString[index] !== '\n') {
|
|
181
|
+
const opening = [...active];
|
|
182
|
+
applyLeadingResets(preString, index, opening);
|
|
183
|
+
out += openingSequence(opening);
|
|
184
|
+
if (activeHyperlink !== undefined)
|
|
185
|
+
out += hyperlink(activeHyperlink.uri, activeHyperlink.parameters);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return out + preString.slice(copied);
|
|
189
|
+
}
|
|
190
|
+
/** Whether a word longer than the row should start on the next row instead of this one. */
|
|
191
|
+
function shouldStartLongWordOnNextRow(wordWidth, columns, rowLength) {
|
|
192
|
+
const remaining = columns - rowLength;
|
|
193
|
+
const breaksStartingThisRow = 1 + Math.floor((wordWidth - remaining - 1) / columns);
|
|
194
|
+
const breaksStartingNextRow = Math.floor((wordWidth - 1) / columns);
|
|
195
|
+
return breaksStartingNextRow < breaksStartingThisRow;
|
|
196
|
+
}
|
|
197
|
+
/** One line of input — the caller has already split on newlines. */
|
|
198
|
+
function wrapLine(string, columns, options) {
|
|
199
|
+
const trim = options.trim !== false;
|
|
200
|
+
if (trim && string.trim() === '')
|
|
201
|
+
return '';
|
|
202
|
+
const words = splitWords(string);
|
|
203
|
+
let rows = [''];
|
|
204
|
+
// Tracked as rows are built: remeasuring per word makes wrapping quadratic in the line.
|
|
205
|
+
let rowLength = 0;
|
|
206
|
+
// A row that already starts with content can never become trimmable again.
|
|
207
|
+
let trimmedRowIndex = -1;
|
|
208
|
+
let isFirstWord = true;
|
|
209
|
+
for (const word of words) {
|
|
210
|
+
const rowIndex = rows.length - 1;
|
|
211
|
+
if (trim && trimmedRowIndex !== rowIndex) {
|
|
212
|
+
const row = rows[rowIndex] ?? '';
|
|
213
|
+
const trimmedRow = row.trimStart();
|
|
214
|
+
if (trimmedRow.length !== row.length) {
|
|
215
|
+
rows[rowIndex] = trimmedRow;
|
|
216
|
+
rowLength = visibleWidth(trimmedRow);
|
|
217
|
+
}
|
|
218
|
+
if (trimmedRow.length > 0)
|
|
219
|
+
trimmedRowIndex = rowIndex;
|
|
220
|
+
}
|
|
221
|
+
if (isFirstWord) {
|
|
222
|
+
isFirstWord = false;
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
if (rowLength >= columns && (options.wordWrap === false || !trim)) {
|
|
226
|
+
rows.push('');
|
|
227
|
+
rowLength = 0;
|
|
228
|
+
}
|
|
229
|
+
if (rowLength > 0 || !trim) {
|
|
230
|
+
rows[rows.length - 1] += ' ';
|
|
231
|
+
rowLength += 1;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// 'hard': a row is never allowed to extend past `columns`.
|
|
235
|
+
if (options.hard === true && options.wordWrap !== false && word.width > columns) {
|
|
236
|
+
if (shouldStartLongWordOnNextRow(word.width, columns, rowLength)) {
|
|
237
|
+
rows.push('');
|
|
238
|
+
rowLength = 0;
|
|
239
|
+
}
|
|
240
|
+
rowLength = wrapWord(rows, word.value, columns, rowLength);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (rowLength + word.width > columns && rowLength > 0 && word.width > 0) {
|
|
244
|
+
if (options.wordWrap === false && rowLength < columns) {
|
|
245
|
+
rowLength = wrapWord(rows, word.value, columns, rowLength);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
rows.push('');
|
|
249
|
+
rowLength = 0;
|
|
250
|
+
}
|
|
251
|
+
if (rowLength + word.width > columns && options.wordWrap === false) {
|
|
252
|
+
rowLength = wrapWord(rows, word.value, columns, rowLength);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
rows[rows.length - 1] += word.value;
|
|
256
|
+
rowLength += word.width;
|
|
257
|
+
}
|
|
258
|
+
if (trim)
|
|
259
|
+
rows = rows.map((row) => trimVisibleEnd(row));
|
|
260
|
+
return restoreStylesAcrossRows(rows.join('\n'));
|
|
261
|
+
}
|
|
262
|
+
/** Wrap `string` to `columns`, keeping its ANSI intact and each row self-contained. */
|
|
263
|
+
export function wrap(string, columns, options = {}) {
|
|
264
|
+
return String(string)
|
|
265
|
+
.normalize()
|
|
266
|
+
.replaceAll('\r\n', '\n')
|
|
267
|
+
.split('\n')
|
|
268
|
+
.map((line) => wrapLine(expandTabs(line), columns, options))
|
|
269
|
+
.join('\n');
|
|
270
|
+
}
|
|
271
|
+
//# sourceMappingURL=wrap.js.map
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "linegauge",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "A printer's line gauge \u2014 the steel rule marked in picas and points. Measuring, wrapping, truncating and slicing styled terminal text without the edge fraying \u2014 grapheme-correct over Intl.Segmenter. Drop-in paths for string-width, wrap-ansi, strip-ansi and slice-ansi. Zero dependencies.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"engines": {
|
|
@@ -12,6 +12,31 @@
|
|
|
12
12
|
"types": "./dist/index.d.ts",
|
|
13
13
|
"import": "./dist/index.js",
|
|
14
14
|
"default": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./wrap": {
|
|
17
|
+
"types": "./dist/wrap.d.ts",
|
|
18
|
+
"import": "./dist/wrap.js",
|
|
19
|
+
"default": "./dist/wrap.js"
|
|
20
|
+
},
|
|
21
|
+
"./slice": {
|
|
22
|
+
"types": "./dist/slice.d.ts",
|
|
23
|
+
"import": "./dist/slice.js",
|
|
24
|
+
"default": "./dist/slice.js"
|
|
25
|
+
},
|
|
26
|
+
"./truncate": {
|
|
27
|
+
"types": "./dist/truncate.d.ts",
|
|
28
|
+
"import": "./dist/truncate.js",
|
|
29
|
+
"default": "./dist/truncate.js"
|
|
30
|
+
},
|
|
31
|
+
"./widest": {
|
|
32
|
+
"types": "./dist/widest.d.ts",
|
|
33
|
+
"import": "./dist/widest.js",
|
|
34
|
+
"default": "./dist/widest.js"
|
|
35
|
+
},
|
|
36
|
+
"./strip": {
|
|
37
|
+
"types": "./dist/strip.d.ts",
|
|
38
|
+
"import": "./dist/strip.js",
|
|
39
|
+
"default": "./dist/strip.js"
|
|
15
40
|
}
|
|
16
41
|
},
|
|
17
42
|
"files": [
|
|
@@ -23,6 +48,7 @@
|
|
|
23
48
|
"build": "tsc -p tsconfig.build.json",
|
|
24
49
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
25
50
|
"test": "vitest run --passWithNoTests",
|
|
51
|
+
"coverage": "vitest run --coverage.enabled",
|
|
26
52
|
"lint": "eslint src"
|
|
27
53
|
},
|
|
28
54
|
"repository": {
|
|
@@ -49,6 +75,6 @@
|
|
|
49
75
|
"unicode"
|
|
50
76
|
],
|
|
51
77
|
"devDependencies": {
|
|
52
|
-
"vitest": "^
|
|
78
|
+
"vitest": "^5.0.0"
|
|
53
79
|
}
|
|
54
80
|
}
|