Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions doc/api/util.md
Original file line number Diff line number Diff line change
Expand Up @@ -946,6 +946,41 @@ const callSites = getCallSites({ sourceMap: true });
// Column Number: 26
```

## `util.getStringWidth(str)`

<!-- YAML
added: REPLACEME
-->

> Stability: 1 - Experimental

* `str` {string}
* Returns: {integer} An estimate of the number of columns needed to display
`str`.

Returns an estimate of the width of `str` as displayed in a terminal, counted
in columns. Full-width characters, such as CJK ideographs and most emoji, count
as two columns. Zero-width characters, such as combining marks, control
characters and zero-width joiners, count as zero. ANSI escape sequences, as
produced by [`util.styleText()`][], are ignored.

```js
console.log(util.getStringWidth('hello'));
// Prints: 5
console.log(util.getStringWidth('你好'));
// Prints: 4
console.log(util.getStringWidth(util.styleText('red', 'hi')));
// Prints: 2
```

The result is an estimate because terminals differ in how they render some
characters. In particular, the emoji of a joined sequence are counted
separately, so a sequence that a terminal renders as a single glyph can be
overcounted, with or without ICU: `'\u{1F469}\u200D\u{1F469}\u200D\u{1F467}'`
is counted as 6 columns. When Node.js is built without ICU, a simpler table of
full-width and zero-width code points is used, which is less accurate for less
common scripts.

## `util.getSystemErrorName(err)`

<!-- YAML
Expand Down Expand Up @@ -4176,6 +4211,7 @@ npx codemod@latest @nodejs/util-is
[`util.format()`]: #utilformatformat-args
[`util.inspect()`]: #utilinspectobject-options
[`util.promisify()`]: #utilpromisifyoriginal
[`util.styleText()`]: #utilstyletextformat-text-options
[`util.types.isAnyArrayBuffer()`]: #utiltypesisanyarraybuffervalue
[`util.types.isArrayBuffer()`]: #utiltypesisarraybuffervalue
[`util.types.isSharedArrayBuffer()`]: #utiltypesissharedarraybuffervalue
Expand Down
12 changes: 12 additions & 0 deletions lib/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const { Buffer } = require('buffer');
const {
format,
formatWithOptions,
getStringWidth: internalGetStringWidth,
inspect,
stripVTControlCharacters,
} = require('internal/util/inspect');
Expand Down Expand Up @@ -236,6 +237,16 @@ function rgbToAnsi24Bit(r, g, b) {
return `38;2;${r};${g};${b}`;
}

/**
* Returns the number of columns needed to display `str` in a terminal.
* @param {string} str
* @returns {number}
*/
function getStringWidth(str) {
validateString(str, 'str');
return internalGetStringWidth(str);
}

/**
* @param {string | string[]} format
* @param {string} text
Expand Down Expand Up @@ -612,6 +623,7 @@ module.exports = {
styleText,
formatWithOptions,
getCallSites,
getStringWidth,
getSystemErrorMap,
getSystemErrorName,
getSystemErrorMessage,
Expand Down
61 changes: 61 additions & 0 deletions test/parallel/test-util-get-string-width.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
'use strict';

const common = require('../common');
const assert = require('assert');
const util = require('util');

const { getStringWidth } = util;

assert.strictEqual(getStringWidth(''), 0);
assert.strictEqual(getStringWidth('a'), 1);
assert.strictEqual(getStringWidth('hello'), 5);
assert.strictEqual(getStringWidth('a你b'), 4);

// Full-width characters count as two columns.
assert.strictEqual(getStringWidth('丁'), 2);
assert.strictEqual(getStringWidth('你好'), 4);
assert.strictEqual(getStringWidth('가'), 2);
assert.strictEqual(getStringWidth('👅'), 2);
assert.strictEqual(getStringWidth('F'), 2); // Fullwidth Latin letter.
assert.strictEqual(getStringWidth('ア'), 1); // Halfwidth katakana.
assert.strictEqual(getStringWidth('\u{1D49C}'), 1); // Astral, not wide.
assert.strictEqual(getStringWidth('\u{20000}'), 2); // CJK Extension B.

// Zero-width characters count as zero.
assert.strictEqual(getStringWidth('\u0000'), 0);
assert.strictEqual(getStringWidth('\u0007'), 0);
assert.strictEqual(getStringWidth('\n'), 0);
assert.strictEqual(getStringWidth('\r\n'), 0);
assert.strictEqual(getStringWidth('\t'), 0);
assert.strictEqual(getStringWidth('́'), 0);
assert.strictEqual(getStringWidth('é'), 1);
assert.strictEqual(getStringWidth('​'), 0);
assert.strictEqual(getStringWidth('a‏b'), 2); // Right-to-left mark.

// ANSI escape sequences are ignored.
assert.strictEqual(getStringWidth('\u001B[31mred\u001B[39m'), 3);
assert.strictEqual(getStringWidth(util.styleText('bold', 'hi')), 2);
assert.strictEqual(getStringWidth(util.styleText(['bold', 'red'], 'ok')), 2);
assert.strictEqual(
getStringWidth('\u001B]8;;https://nodejs.org\u0007text\u001B]8;;\u0007'), 4);

// Lone surrogates take one column.
assert.strictEqual(getStringWidth('\uD83D'), 1);

if (common.hasIntl) {
assert.strictEqual(getStringWidth(' '), 1);
assert.strictEqual(getStringWidth('กิ'), 1); // Thai with a vowel mark.
// Joined sequences count each emoji; a terminal may render them narrower.
assert.strictEqual(getStringWidth('\u{1F469}‍\u{1F469}‍\u{1F467}‍\u{1F467}'), 8);
assert.strictEqual(getStringWidth('\u{1F44D}\u{1F3FD}'), 4); // Skin tone modifier.
assert.strictEqual(getStringWidth('\u{1F1EE}\u{1F1F9}'), 4); // Flag.
assert.strictEqual(getStringWidth('❤️'), 1);
assert.strictEqual(getStringWidth('1️⃣'), 1); // Keycap.
}

for (const value of [undefined, null, 1, true, {}, [], Symbol('s')]) {
assert.throws(() => getStringWidth(value), {
code: 'ERR_INVALID_ARG_TYPE',
name: 'TypeError',
});
}
Loading