Skip to content
Open
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
41 changes: 41 additions & 0 deletions doc/api/util.md
Original file line number Diff line number Diff line change
Expand Up @@ -946,6 +946,46 @@ 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. The string is split into grapheme clusters, the units a terminal
renders as one glyph. Full-width characters, such as CJK ideographs, count as
two columns. An emoji counts as two columns, including a sequence joined by
zero-width joiners, a flag, a keycap, or an emoji with a skin tone modifier.
Clusters without visible characters, such as control characters, zero-width
spaces and lone combining marks, count as zero. Characters of ambiguous East
Asian Width, such as `±`, count as one column. 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('👩\u200D👩\u200D👧'));
// Prints: 2
console.log(util.getStringWidth(util.styleText('red', 'hi')));
// Prints: 2
```

The result is an estimate because terminals differ in how they render some
characters. For example, a terminal that does not support an emoji sequence
displays each emoji of the sequence separately and uses more columns than
reported. 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
and symbols.

## `util.getSystemErrorName(err)`

<!-- YAML
Expand Down Expand Up @@ -4176,6 +4216,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
175 changes: 132 additions & 43 deletions lib/internal/util/inspect.js
Original file line number Diff line number Diff line change
Expand Up @@ -2954,6 +2954,120 @@ function isZeroWidthCodePoint(code) {
(code >= 0xE0100 && code <= 0xE01EF); // Variation Selectors
}

/**
* Returns true if the character represented by a given
* Unicode code point is full-width. Otherwise returns false.
* @param {string} code
* @returns {boolean}
*/
function isFullWidthCodePoint(code) {
// Code points are partially derived from:
// https://www.unicode.org/Public/UNIDATA/EastAsianWidth.txt
return code >= 0x1100 && (
code <= 0x115f || // Hangul Jamo
code === 0x2329 || // LEFT-POINTING ANGLE BRACKET
code === 0x232a || // RIGHT-POINTING ANGLE BRACKET
// CJK Radicals Supplement .. Enclosed CJK Letters and Months
(code >= 0x2e80 && code <= 0x3247 && code !== 0x303f) ||
// Enclosed CJK Letters and Months .. CJK Unified Ideographs Extension A
(code >= 0x3250 && code <= 0x4dbf) ||
// CJK Unified Ideographs .. Yi Radicals
(code >= 0x4e00 && code <= 0xa4c6) ||
// Hangul Jamo Extended-A
(code >= 0xa960 && code <= 0xa97c) ||
// Hangul Syllables
(code >= 0xac00 && code <= 0xd7a3) ||
// CJK Compatibility Ideographs
(code >= 0xf900 && code <= 0xfaff) ||
// Vertical Forms
(code >= 0xfe10 && code <= 0xfe19) ||
// CJK Compatibility Forms .. Small Form Variants
(code >= 0xfe30 && code <= 0xfe6b) ||
// Halfwidth and Fullwidth Forms
(code >= 0xff01 && code <= 0xff60) ||
(code >= 0xffe0 && code <= 0xffe6) ||
// Kana Supplement
(code >= 0x1b000 && code <= 0x1b001) ||
// Enclosed Ideographic Supplement
(code >= 0x1f200 && code <= 0x1f251) ||
// Miscellaneous Symbols and Pictographs 0x1f300 - 0x1f5ff
// Emoticons 0x1f600 - 0x1f64f
(code >= 0x1f300 && code <= 0x1f64f) ||
// CJK Unified Ideographs Extension B .. Tertiary Ideographic Plane
(code >= 0x20000 && code <= 0x3fffd)
);
}

// Emoji blocks missing from isFullWidthCodePoint(), which is shared with
// the cursor movement of readline and is left unchanged.
function isWideEmojiCodePoint(code) {
return (code >= 0x1f680 && code <= 0x1f6c5) || // Transport and Map Symbols
(code >= 0x1f90c && code <= 0x1f9ff) || // Supplemental Symbols and Pictographs
(code >= 0x1fa70 && code <= 0x1faff); // Symbols and Pictographs Extended-A
}

// Approximation of the grapheme cluster based width of the ICU build, used
// when Node.js is built without ICU: an emoji or symbol joined to an emoji,
// a skin tone modifier and a lone surrogate take no columns of their own,
// and an emoji presentation selector or a combining keycap turns a narrow
// character into a two column emoji.
function getStringDisplayWidthFallback(str) {
str = StringPrototypeNormalize(stripVTControlCharacters(str), 'NFC');
let width = 0;
let lastWidth = 0;
let lastCode = 0;
let lastEmoji = false;
let joined = false;
for (const char of new SafeStringIterator(str)) {
const code = StringPrototypeCodePointAt(char, 0);
if (code === 0x200D) {
joined = lastEmoji;
continue;
}
const fullWidth = isFullWidthCodePoint(code) || isWideEmojiCodePoint(code);
if (joined) {
joined = false;
// Emoji, and the symbols used in emoji sequences, such as the gender
// signs and the heart.
if ((fullWidth && code >= 0x1F000) ||
(code >= 0x2600 && code <= 0x27BF)) {
continue;
}
}
// Text style symbols that become emoji with a presentation selector are
// all above U+2000, except COPYRIGHT SIGN and REGISTERED SIGN.
if (code === 0x20E3 || (code === 0xFE0F &&
(lastCode >= 0x2000 || lastCode === 0xA9 || lastCode === 0xAE))) {
if (lastWidth === 1) {
width++;
lastWidth = 2;
lastEmoji = true;
}
continue;
}
if ((code >= 0x1F3FB && code <= 0x1F3FF) ||
(code >= 0xD800 && code <= 0xDFFF)) {
continue;
}
if (fullWidth) {
width += 2;
lastWidth = 2;
lastEmoji = code >= 0x1F000;
} else if (!isZeroWidthCodePoint(code)) {
width++;
lastWidth = 1;
lastEmoji = false;
} else {
continue;
}
lastCode = code;
}
return width;
}

// The build with ICU replaces this with the grapheme cluster based width.
let getStringDisplayWidth = getStringDisplayWidthFallback;

if (internalBinding('config').hasIntl) {
const icu = internalBinding('icu');
// icu.getStringWidth(string, ambiguousAsFullWidth, expandEmojiSequence)
Expand All @@ -2979,6 +3093,22 @@ if (internalBinding('config').hasIntl) {
}
return width;
};

// Width as rendered by a terminal, counted per grapheme cluster so that an
// emoji sequence counts as one glyph. Used by util.getStringWidth(). The
// function above counts code points and is used for cursor movement.
getStringDisplayWidth = function getStringDisplayWidth(str) {
str = stripVTControlCharacters(str);
let width = 0;
for (let i = 0; i < str.length; i++) {
const code = StringPrototypeCharCodeAt(str, i);
if (code >= 127) {
return icu.getGraphemeStringWidth(str);
}
width += code >= 32 ? 1 : 0;
}
return width;
};
} else {
/**
* @param {string} str
Expand All @@ -3003,49 +3133,6 @@ if (internalBinding('config').hasIntl) {
return width;
};

/**
* Returns true if the character represented by a given
* Unicode code point is full-width. Otherwise returns false.
* @param {string} code
* @returns {boolean}
*/
const isFullWidthCodePoint = (code) => {
// Code points are partially derived from:
// https://www.unicode.org/Public/UNIDATA/EastAsianWidth.txt
return code >= 0x1100 && (
code <= 0x115f || // Hangul Jamo
code === 0x2329 || // LEFT-POINTING ANGLE BRACKET
code === 0x232a || // RIGHT-POINTING ANGLE BRACKET
// CJK Radicals Supplement .. Enclosed CJK Letters and Months
(code >= 0x2e80 && code <= 0x3247 && code !== 0x303f) ||
// Enclosed CJK Letters and Months .. CJK Unified Ideographs Extension A
(code >= 0x3250 && code <= 0x4dbf) ||
// CJK Unified Ideographs .. Yi Radicals
(code >= 0x4e00 && code <= 0xa4c6) ||
// Hangul Jamo Extended-A
(code >= 0xa960 && code <= 0xa97c) ||
// Hangul Syllables
(code >= 0xac00 && code <= 0xd7a3) ||
// CJK Compatibility Ideographs
(code >= 0xf900 && code <= 0xfaff) ||
// Vertical Forms
(code >= 0xfe10 && code <= 0xfe19) ||
// CJK Compatibility Forms .. Small Form Variants
(code >= 0xfe30 && code <= 0xfe6b) ||
// Halfwidth and Fullwidth Forms
(code >= 0xff01 && code <= 0xff60) ||
(code >= 0xffe0 && code <= 0xffe6) ||
// Kana Supplement
(code >= 0x1b000 && code <= 0x1b001) ||
// Enclosed Ideographic Supplement
(code >= 0x1f200 && code <= 0x1f251) ||
// Miscellaneous Symbols and Pictographs 0x1f300 - 0x1f5ff
// Emoticons 0x1f600 - 0x1f64f
(code >= 0x1f300 && code <= 0x1f64f) ||
// CJK Unified Ideographs Extension B .. Tertiary Ideographic Plane
(code >= 0x20000 && code <= 0x3fffd)
);
};

}

Expand Down Expand Up @@ -3074,6 +3161,8 @@ module.exports = {
stylizeWithColor,
format,
formatWithOptions,
getStringDisplayWidth,
getStringDisplayWidthFallback,
getStringWidth,
stripVTControlCharacters,
isZeroWidthCodePoint,
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,
getStringDisplayWidth,
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 getStringDisplayWidth(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
Loading
Loading