linegauge 1.0.0 → 1.0.1

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/dist/index.d.ts CHANGED
@@ -25,9 +25,9 @@
25
25
  *
26
26
  * R2's fast path: locked by `differential.test.ts`.
27
27
  */
28
- export { lineCount, measure, width, width as default, type WidthOptions, type WidthOptions as Options } from './width.js';
29
28
  export * from './slice.js';
29
+ export { lineCount, measure, width, width as default, type WidthOptions, type WidthOptions as Options } from './width.js';
30
30
  export { strip } from './strip.js';
31
- export { truncate, type TruncateOptions } from './truncate.js';
31
+ export * from './truncate.js';
32
32
  export { widest } from './widest.js';
33
33
  export { wrap, type WrapOptions } from './wrap.js';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- export { lineCount, measure, width, width as default } from './width.js';
2
1
  export * from './slice.js';
2
+ export { lineCount, measure, width, width as default } from './width.js';
3
3
  export { strip } from './strip.js';
4
- export { truncate } from './truncate.js';
4
+ export * from './truncate.js';
5
5
  export { widest } from './widest.js';
6
6
  export { wrap } from './wrap.js';
package/dist/slice.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { applyParameters, applyToken, ASCII_PRINTABLE, closingSequence, segmenter, sgrTokens } from './style.js';
2
- import { measure } from './width.js';
2
+ import { clusterColumns } from './width.js';
3
3
  const ESC = '\u001B';
4
4
  const BELL = '\u0007';
5
5
  const C1_DCS = '\u0090';
@@ -12,7 +12,6 @@ const C1_APC = '\u009F';
12
12
  const ST = `${ESC}\\`;
13
13
  const ESC_CSI = `${ESC}[`;
14
14
  const LINK_PREFIXES = [`${ESC}]8;`, `${C1_OSC}8;`];
15
- const INTRODUCERS = new Set([ESC, C1_DCS, C1_SOS, C1_CSI, C1_ST, C1_OSC, C1_PM, C1_APC]);
16
15
  const STRING_COMMANDS = new Set([']', 'P', 'X', '^', '_']);
17
16
  const C1_STRINGS = new Set([C1_DCS, C1_SOS, C1_PM, C1_APC]);
18
17
  const CSI_PARAMETER = /[0-?]/u;
@@ -100,44 +99,66 @@ function parseEscape(string, index) {
100
99
  }
101
100
  const LONE_REGIONAL_INDICATOR = /^[\u{1F1E6}-\u{1F1FF}]$/u;
102
101
  function positions(cluster) {
103
- const columns = measure(cluster);
102
+ const columns = clusterColumns(cluster);
104
103
  if (columns === 0)
105
104
  return 1;
106
105
  return LONE_REGIONAL_INDICATOR.test(cluster) ? 2 : columns;
107
106
  }
108
- function tokenize(string) {
109
- const tokens = [];
110
- const visible = [];
107
+ const INTRODUCER = /[\u001B\u0090\u0098\u009B-\u009F]/g;
108
+ function walk(string, visit) {
109
+ const parts = [];
111
110
  let text = '';
112
- let index = 0;
113
- while (index < string.length) {
114
- const escape = INTRODUCERS.has(string[index]) ? parseEscape(string, index) : undefined;
115
- if (escape) {
116
- tokens.push(escape);
117
- index += escape.code.length;
111
+ for (let index = 0, from = 0; index <= string.length;) {
112
+ INTRODUCER.lastIndex = index;
113
+ const at = INTRODUCER.exec(string)?.index ?? string.length;
114
+ const escape = at < string.length ? parseEscape(string, at) : undefined;
115
+ if (at < string.length && escape === undefined) {
116
+ index = at + 1;
118
117
  continue;
119
118
  }
120
- const value = String.fromCodePoint(string.codePointAt(index));
121
- const token = { kind: 'text', value, columns: 1, continuation: false };
122
- tokens.push(token);
123
- visible.push(token);
124
- text += value;
125
- index += value.length;
119
+ const run = string.slice(from, at);
120
+ if (run !== '')
121
+ parts.push(run);
122
+ text += run;
123
+ if (escape === undefined)
124
+ break;
125
+ parts.push(escape);
126
+ index = from = at + escape.code.length;
126
127
  }
127
- if (ASCII_PRINTABLE.test(text))
128
- return tokens;
129
- let at = 0;
130
- for (const { segment } of segmenter().segment(text)) {
131
- const points = [...segment].length;
132
- const columns = positions(segment);
133
- for (let offset = 0; offset < points; offset += 1) {
134
- const token = visible[at + offset];
135
- token.columns = offset === 0 ? columns : 0;
136
- token.continuation = offset > 0;
128
+ const clusters = ASCII_PRINTABLE.test(text) ? undefined : segmenter().segment(text)[Symbol.iterator]();
129
+ let part = 0;
130
+ let run;
131
+ const unit = () => {
132
+ for (;;) {
133
+ const point = run?.next();
134
+ if (point?.done === false)
135
+ return point.value;
136
+ const current = parts[part++];
137
+ if (typeof current !== 'string')
138
+ return current;
139
+ run = current[Symbol.iterator]();
140
+ }
141
+ };
142
+ let next = unit();
143
+ const escapes = (ahead) => {
144
+ for (; next !== undefined && typeof next !== 'string'; next = unit())
145
+ visit({ ...next, ahead });
146
+ };
147
+ for (escapes(false); next !== undefined; escapes(false)) {
148
+ const cluster = clusters?.next();
149
+ const whole = cluster?.done === false;
150
+ const segment = whole ? cluster.value.segment : next;
151
+ const columns = whole ? positions(segment) : 1;
152
+ let offset = 0;
153
+ for (const _ of segment) {
154
+ if (offset > 0)
155
+ escapes(true);
156
+ if (visit({ kind: 'text', value: next, columns: offset === 0 ? columns : 0, continuation: offset > 0 }))
157
+ return;
158
+ next = unit();
159
+ offset += 1;
137
160
  }
138
- at += points;
139
161
  }
140
- return tokens;
141
162
  }
142
163
  function opensStyle(parameters) {
143
164
  return sgrTokens(parameters).some((token) => {
@@ -151,22 +172,9 @@ function closesStyle(parameters, active) {
151
172
  applyParameters(parameters, after);
152
173
  return active.some((style) => !after.includes(style));
153
174
  }
154
- function continuationAhead(tokens) {
155
- const ahead = [];
156
- let next = false;
157
- for (let index = tokens.length - 1; index >= 0; index -= 1) {
158
- ahead[index] = next;
159
- const token = tokens[index];
160
- if (token?.kind === 'text')
161
- next = token.continuation;
162
- }
163
- return ahead;
164
- }
165
175
  export function slice(string, start = 0, end = Number.POSITIVE_INFINITY) {
166
176
  if (end <= start || string.length === 0)
167
177
  return '';
168
- const tokens = tokenize(string);
169
- const ahead = continuationAhead(tokens);
170
178
  let active = [];
171
179
  let link;
172
180
  let linked = false;
@@ -248,17 +256,17 @@ export function slice(string, start = 0, end = Number.POSITIVE_INFINITY) {
248
256
  }
249
257
  column += token.columns;
250
258
  };
251
- for (const [index, token] of tokens.entries()) {
259
+ walk(string, (token) => {
252
260
  const cluster = token.kind === 'text' && !token.continuation;
253
261
  let pastEnd = column >= end || (cluster && column + token.columns > end);
254
- if (pastEnd && token.kind !== 'text' && ahead[index] === true)
262
+ if (pastEnd && token.kind !== 'text' && token.ahead)
255
263
  pastEnd = false;
256
264
  if (pastEnd && cluster) {
257
265
  if (pendingAt !== undefined) {
258
266
  body = body.slice(0, pendingAt);
259
267
  active = pendingActive;
260
268
  }
261
- break;
269
+ return true;
262
270
  }
263
271
  if (token.kind === 'sgr')
264
272
  takeSgr(token, pastEnd);
@@ -268,7 +276,8 @@ export function slice(string, start = 0, end = Number.POSITIVE_INFINITY) {
268
276
  takeText(token);
269
277
  else if (!pastEnd && started)
270
278
  body += token.code;
271
- }
279
+ return false;
280
+ });
272
281
  if (!started)
273
282
  return '';
274
283
  if (link !== undefined)
package/dist/strip.d.ts CHANGED
@@ -5,23 +5,12 @@
5
5
  */
6
6
  /**
7
7
  * Everything a terminal would print, with the escape sequences removed: CSI (SGR and the
8
- * cursor and erase forms), OSC including `OSC 8` hyperlinks under both terminators, DCS, the
9
- * charset selections, and the single-character escapes. A lone `ESC` with nothing that parses
10
- * after it is text and is kept — the same answer `strip-ansi` gives.
11
- *
12
- * Two passes, which is the design's prescription taken literally: *"using
13
- * `util.stripVTControlCharacters` where it is exact and a local scan where it is not."*
14
- *
15
- * 1. The local scan, over `style.ts`'s `ANSI_ESCAPE`. It removes CSI and OSC **including
16
- * the colon form** of an extended colour, which is the one shape Node gets wrong.
17
- * 2. Node's stripper on what is left — the single-character escapes (`ESC c`), the charset
18
- * selections (`ESC ( B`) and a truncated sequence at the end of a string. `ANSI_ESCAPE`
19
- * matches none of those, because `wrap` never needed them: it was built to find the
20
- * sequences it has to *reopen*, and a charset selection is not one.
21
- *
22
- * Order is load-bearing. Ours runs first so the colon form is already gone by the time Node
23
- * sees the string; reversed, pass 2 would leave `:2::255:0:0m` behind as text and pass 1
24
- * would have nothing left to match.
8
+ * cursor and erase forms), OSC including `OSC 8` hyperlinks under all three terminators, the
9
+ * C1 introducers, and the single-character escapes. A lone `ESC` with nothing that parses
10
+ * after it is text and is kept — the same answer `strip-ansi` gives, because it is the same
11
+ * pattern. A string with neither `ESC` nor `0x9B` is returned as it is, without a replace —
12
+ * strip-ansi's own fast path, including what it leaves alone: a C1 OSC (`0x9D`) with no `ESC`
13
+ * or `0x9B` beside it is kept by the incumbent, and so it is kept here.
25
14
  */
26
15
  export declare function strip(string: string): string;
27
16
  /**
package/dist/strip.js CHANGED
@@ -1,12 +1,5 @@
1
- import { stripVTControlCharacters as nodeStrip } from 'node:util';
2
- import { forEachSegment } from './style.js';
1
+ const ANSI = /(?:(?:\u001B\]|\u009D)[^\u0007\u001B\u009C\u009D]*(?:\u0007|\u001B\\|\u009C))|[\u001B\u009B][[\]()#;?]*(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]/g;
3
2
  export function strip(string) {
4
- if (string === '')
5
- return '';
6
- let out = '';
7
- forEachSegment(string, (text) => {
8
- out += text;
9
- });
10
- return nodeStrip(out);
3
+ return string.includes('\u001B') || string.includes('\u009B') ? string.replace(ANSI, '') : string;
11
4
  }
12
5
  export { strip as default };
package/dist/width.d.ts CHANGED
@@ -33,6 +33,24 @@ export declare function setClaim(fn: ((code: number) => number | undefined) | un
33
33
  * give a malformed sequence a second chance to be mistaken for one.
34
34
  */
35
35
  export declare function measure(text: string, ambiguousIsWide?: boolean): number;
36
+ /**
37
+ * Columns one grapheme cluster occupies — `measure`'s body, for callers that already hold a
38
+ * cluster: `slice` and `wrap` segment their text once and used to hand each cluster back to
39
+ * `measure`, which segmented it again (B5).
40
+ */
41
+ export declare function clusterColumns(segment: string, ambiguousIsWide?: boolean): number;
42
+ /**
43
+ * Printable ASCII is one column per code unit, and nothing that makes `measure` correct can
44
+ * change that answer: there are no escape sequences, no combining marks and no emoji between
45
+ * 0x20 and 0x7E. `countAnsiEscapeCodes` cannot change it either — `ESC` is 0x1B, below the
46
+ * range, so a string this accepts has no escapes to count.
47
+ *
48
+ * It is not a micro-optimisation. `widest` over many lines is one `Intl.Segmenter` walk per
49
+ * line, and `truncate.test.ts`'s 200,000-line case — the one proving `widest` survives where
50
+ * `Math.max(...)` throws — timed out at five seconds without this. ASCII is the common line.
51
+ * A class and not a loop since 2026-09-30: the same answer in a quarter of the bytes, which
52
+ * `measure` now pays for too (B5).
53
+ */
36
54
  /**
37
55
  * What `width` accepts beyond the string. Graded against `string-width`'s own suite, so the
38
56
  * names and the defaults are its names and its defaults, not ours.
package/dist/width.js CHANGED
@@ -90,24 +90,20 @@ function isWide(codePoint) {
90
90
  function isAmbiguous(codePoint) {
91
91
  return inTable(AMBIGUOUS, codePoint);
92
92
  }
93
- let invisibleClass;
93
+ let visibleClass;
94
94
  let rgiClass;
95
95
  let spacingClass;
96
96
  let pictographicClass;
97
97
  export const INVISIBLE_CLASSES = ['\\p{Default_Ignorable_Code_Point}', '\\p{Control}', '\\p{Format}', '\\p{Nonspacing_Mark}', '\\p{Enclosing_Mark}', '\\p{Surrogate}'];
98
98
  const INVISIBLE_SET = INVISIBLE_CLASSES.join('');
99
- const INVISIBLE = () => (invisibleClass ??= new RegExp(`^[${INVISIBLE_SET}]$`, 'v'));
99
+ const ASCII = /^[ -~]*$/u;
100
+ const VISIBLE = () => (visibleClass ??= new RegExp(`[^${INVISIBLE_SET}]`, 'v'));
100
101
  const RGI_EMOJI = () => (rgiClass ??= new RegExp('^\\p{RGI_Emoji}$', 'v'));
101
102
  const SPACING_MARK = () => (spacingClass ??= new RegExp('^\\p{Spacing_Mark}$', 'v'));
102
103
  const EXTENDED_PICTOGRAPHIC = () => (pictographicClass ??= new RegExp('^\\p{Extended_Pictographic}$', 'u'));
103
104
  export function leadingInvisible(text) {
104
- let index = 0;
105
- for (const character of text) {
106
- if (!INVISIBLE().test(character))
107
- break;
108
- index += character.length;
109
- }
110
- return index;
105
+ const at = text.search(VISIBLE());
106
+ return at === -1 ? text.length : at;
111
107
  }
112
108
  const UNQUALIFIED_KEYCAP = /^[\d#*]\u20E3$/u;
113
109
  const ZWJ = '\u200D';
@@ -160,14 +156,14 @@ function isJamo(codePoint) {
160
156
  return inPairs(JAMO_LEADING, codePoint) || inPairs(JAMO_VOWEL, codePoint) || inPairs(JAMO_TRAILING, codePoint);
161
157
  }
162
158
  function hangulColumns(visible, ambiguousIsWide) {
159
+ if (!isJamo(visible.codePointAt(0) ?? 0))
160
+ return undefined;
163
161
  const codePoints = [];
164
162
  for (const character of visible) {
165
- if (INVISIBLE().test(character))
163
+ if (!VISIBLE().test(character))
166
164
  continue;
167
165
  codePoints.push(character.codePointAt(0) ?? 0);
168
166
  }
169
- if (codePoints.length === 0 || !isJamo(codePoints[0] ?? 0))
170
- return undefined;
171
167
  let columns = 0;
172
168
  for (let index = 0; index < codePoints.length; index += 1) {
173
169
  const codePoint = codePoints[index] ?? 0;
@@ -190,45 +186,31 @@ export function setClaim(fn) {
190
186
  claim = fn;
191
187
  }
192
188
  export function measure(text, ambiguousIsWide = false) {
189
+ if (claim === undefined && ASCII.test(text))
190
+ return text.length;
193
191
  let columns = 0;
194
- for (const { segment } of segmenter().segment(text)) {
195
- const claimed = claim?.(segment.codePointAt(0) ?? 0);
196
- if (claimed !== undefined) {
197
- columns += claimed;
198
- continue;
199
- }
200
- const skipped = leadingInvisible(segment);
201
- if (skipped === segment.length)
202
- continue;
203
- if (RGI_EMOJI().test(segment) || isUnqualifiedEmojiSequence(segment)) {
204
- columns += WIDE_COLUMNS;
205
- continue;
206
- }
207
- const visible = segment.slice(skipped);
208
- const hangul = hangulColumns(visible, ambiguousIsWide);
209
- if (hangul !== undefined) {
210
- columns += hangul;
211
- continue;
212
- }
213
- columns += columnsOf(visible.codePointAt(0) ?? 0, ambiguousIsWide);
214
- columns += trailingColumns(visible, ambiguousIsWide);
215
- }
192
+ for (const { segment } of segmenter().segment(text))
193
+ columns += clusterColumns(segment, ambiguousIsWide);
216
194
  return columns;
217
195
  }
218
- function asciiColumns(text) {
219
- for (let i = 0; i < text.length; i += 1) {
220
- const code = text.codePointAt(i) ?? 0;
221
- if (code < 0x20 || code > 0x7e)
222
- return undefined;
223
- }
224
- return text.length;
196
+ const MAY_BE_EMOJI = /[\u2000-\u24FF\u25FD-\u2FFF\uD800-\u{10FFFF}]/u;
197
+ export function clusterColumns(segment, ambiguousIsWide = false) {
198
+ const claimed = claim?.(segment.codePointAt(0) ?? 0);
199
+ if (claimed !== undefined)
200
+ return claimed;
201
+ const skipped = segment.search(VISIBLE());
202
+ if (skipped === -1)
203
+ return 0;
204
+ if (MAY_BE_EMOJI.test(segment) && (RGI_EMOJI().test(segment) || isUnqualifiedEmojiSequence(segment)))
205
+ return WIDE_COLUMNS;
206
+ const visible = segment.slice(skipped);
207
+ return hangulColumns(visible, ambiguousIsWide) ?? columnsOf(visible.codePointAt(0) ?? 0, ambiguousIsWide) + trailingColumns(visible, ambiguousIsWide);
225
208
  }
226
209
  export function width(input, options = {}) {
227
210
  if (typeof input !== 'string' || input === '')
228
211
  return 0;
229
- const ascii = asciiColumns(input);
230
- if (ascii !== undefined)
231
- return ascii;
212
+ if (ASCII.test(input))
213
+ return input.length;
232
214
  return measure(options.countAnsiEscapeCodes === true ? input : strip(input), options.ambiguousIsNarrow === false);
233
215
  }
234
216
  export function lineCount(text, columns) {
package/dist/wrap.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ASCII_PRINTABLE, ROW_BOUNDARY, TAB_SIZE, applyLeadingResets, applyParameters, closingSequence, forEachSegment, hyperlink, matchEscape, openingSequence, segmenter, sgr } from './style.js';
2
- import { measure } from './width.js';
2
+ import { clusterColumns, measure } from './width.js';
3
3
  export function visibleWidth(string) {
4
4
  let plainText = '';
5
5
  forEachSegment(string, (part) => {
@@ -16,7 +16,7 @@ function tokenize(string) {
16
16
  return;
17
17
  }
18
18
  for (const { segment } of segmenter().segment(plainText))
19
- tokens.push({ value: segment, width: measure(segment) });
19
+ tokens.push({ value: segment, width: clusterColumns(segment) });
20
20
  }, (escape) => tokens.push({ value: escape, width: 0 }));
21
21
  return tokens;
22
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linegauge",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
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",