Skip to content

Commit 30eb2e9

Browse files
util: add getStringWidth()
Expose the string width helper that readline, the REPL and the test runner already use. It returns the number of terminal columns a string occupies: full-width characters count as two, zero-width characters as zero, and ANSI escape sequences are ignored. The API is experimental. Refs: #40214 Signed-off-by: marcopiraccini <marco.piraccini@gmail.com>
1 parent 29dd655 commit 30eb2e9

3 files changed

Lines changed: 109 additions & 0 deletions

File tree

‎doc/api/util.md‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -946,6 +946,41 @@ const callSites = getCallSites({ sourceMap: true });
946946
// Column Number: 26
947947
```
948948

949+
## `util.getStringWidth(str)`
950+
951+
<!-- YAML
952+
added: REPLACEME
953+
-->
954+
955+
> Stability: 1 - Experimental
956+
957+
* `str` {string}
958+
* Returns: {integer} An estimate of the number of columns needed to display
959+
`str`.
960+
961+
Returns an estimate of the width of `str` as displayed in a terminal, counted
962+
in columns. Full-width characters, such as CJK ideographs and most emoji, count
963+
as two columns. Zero-width characters, such as combining marks, control
964+
characters and zero-width joiners, count as zero. ANSI escape sequences, as
965+
produced by [`util.styleText()`][], are ignored.
966+
967+
```js
968+
console.log(util.getStringWidth('hello'));
969+
// Prints: 5
970+
console.log(util.getStringWidth('你好'));
971+
// Prints: 4
972+
console.log(util.getStringWidth(util.styleText('red', 'hi')));
973+
// Prints: 2
974+
```
975+
976+
The result is an estimate because terminals differ in how they render some
977+
characters. In particular, the emoji of a joined sequence are counted
978+
separately, so a sequence that a terminal renders as a single glyph can be
979+
overcounted, with or without ICU: `'\u{1F469}\u200D\u{1F469}\u200D\u{1F467}'`
980+
is counted as 6 columns. When Node.js is built without ICU, a simpler table of
981+
full-width and zero-width code points is used, which is less accurate for less
982+
common scripts.
983+
949984
## `util.getSystemErrorName(err)`
950985

951986
<!-- YAML
@@ -4176,6 +4211,7 @@ npx codemod@latest @nodejs/util-is
41764211
[`util.format()`]: #utilformatformat-args
41774212
[`util.inspect()`]: #utilinspectobject-options
41784213
[`util.promisify()`]: #utilpromisifyoriginal
4214+
[`util.styleText()`]: #utilstyletextformat-text-options
41794215
[`util.types.isAnyArrayBuffer()`]: #utiltypesisanyarraybuffervalue
41804216
[`util.types.isArrayBuffer()`]: #utiltypesisarraybuffervalue
41814217
[`util.types.isSharedArrayBuffer()`]: #utiltypesissharedarraybuffervalue

‎lib/util.js‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ const { Buffer } = require('buffer');
5858
const {
5959
format,
6060
formatWithOptions,
61+
getStringWidth: internalGetStringWidth,
6162
inspect,
6263
stripVTControlCharacters,
6364
} = require('internal/util/inspect');
@@ -236,6 +237,16 @@ function rgbToAnsi24Bit(r, g, b) {
236237
return `38;2;${r};${g};${b}`;
237238
}
238239

240+
/**
241+
* Returns the number of columns needed to display `str` in a terminal.
242+
* @param {string} str
243+
* @returns {number}
244+
*/
245+
function getStringWidth(str) {
246+
validateString(str, 'str');
247+
return internalGetStringWidth(str);
248+
}
249+
239250
/**
240251
* @param {string | string[]} format
241252
* @param {string} text
@@ -612,6 +623,7 @@ module.exports = {
612623
styleText,
613624
formatWithOptions,
614625
getCallSites,
626+
getStringWidth,
615627
getSystemErrorMap,
616628
getSystemErrorName,
617629
getSystemErrorMessage,
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
const assert = require('assert');
5+
const util = require('util');
6+
7+
const { getStringWidth } = util;
8+
9+
assert.strictEqual(getStringWidth(''), 0);
10+
assert.strictEqual(getStringWidth('a'), 1);
11+
assert.strictEqual(getStringWidth('hello'), 5);
12+
assert.strictEqual(getStringWidth('a你b'), 4);
13+
14+
// Full-width characters count as two columns.
15+
assert.strictEqual(getStringWidth('丁'), 2);
16+
assert.strictEqual(getStringWidth('你好'), 4);
17+
assert.strictEqual(getStringWidth('가'), 2);
18+
assert.strictEqual(getStringWidth('👅'), 2);
19+
assert.strictEqual(getStringWidth('F'), 2); // Fullwidth Latin letter.
20+
assert.strictEqual(getStringWidth('ア'), 1); // Halfwidth katakana.
21+
assert.strictEqual(getStringWidth('\u{1D49C}'), 1); // Astral, not wide.
22+
assert.strictEqual(getStringWidth('\u{20000}'), 2); // CJK Extension B.
23+
24+
// Zero-width characters count as zero.
25+
assert.strictEqual(getStringWidth('\u0000'), 0);
26+
assert.strictEqual(getStringWidth('\u0007'), 0);
27+
assert.strictEqual(getStringWidth('\n'), 0);
28+
assert.strictEqual(getStringWidth('\r\n'), 0);
29+
assert.strictEqual(getStringWidth('\t'), 0);
30+
assert.strictEqual(getStringWidth('́'), 0);
31+
assert.strictEqual(getStringWidth('é'), 1);
32+
assert.strictEqual(getStringWidth('​'), 0);
33+
assert.strictEqual(getStringWidth('a‏b'), 2); // Right-to-left mark.
34+
35+
// ANSI escape sequences are ignored.
36+
assert.strictEqual(getStringWidth('\u001B[31mred\u001B[39m'), 3);
37+
assert.strictEqual(getStringWidth(util.styleText('bold', 'hi')), 2);
38+
assert.strictEqual(getStringWidth(util.styleText(['bold', 'red'], 'ok')), 2);
39+
assert.strictEqual(
40+
getStringWidth('\u001B]8;;https://nodejs.org\u0007text\u001B]8;;\u0007'), 4);
41+
42+
// Lone surrogates take one column.
43+
assert.strictEqual(getStringWidth('\uD83D'), 1);
44+
45+
if (common.hasIntl) {
46+
assert.strictEqual(getStringWidth(' '), 1);
47+
assert.strictEqual(getStringWidth('กิ'), 1); // Thai with a vowel mark.
48+
// Joined sequences count each emoji; a terminal may render them narrower.
49+
assert.strictEqual(getStringWidth('\u{1F469}‍\u{1F469}‍\u{1F467}‍\u{1F467}'), 8);
50+
assert.strictEqual(getStringWidth('\u{1F44D}\u{1F3FD}'), 4); // Skin tone modifier.
51+
assert.strictEqual(getStringWidth('\u{1F1EE}\u{1F1F9}'), 4); // Flag.
52+
assert.strictEqual(getStringWidth('❤️'), 1);
53+
assert.strictEqual(getStringWidth('1️⃣'), 1); // Keycap.
54+
}
55+
56+
for (const value of [undefined, null, 1, true, {}, [], Symbol('s')]) {
57+
assert.throws(() => getStringWidth(value), {
58+
code: 'ERR_INVALID_ARG_TYPE',
59+
name: 'TypeError',
60+
});
61+
}

0 commit comments

Comments
 (0)