You're Invited:Meet the Socket Team at RSAC and BSidesSF 2026, March 23–26.RSVP
Socket
Book a DemoSign in
Socket

string-width

Package Overview
Dependencies
Maintainers
1
Versions
28
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

string-width - npm Package Compare versions

Comparing version
7.2.0
to
8.0.0
+67
-45
index.js
import stripAnsi from 'strip-ansi';
import {eastAsianWidth} from 'get-east-asian-width';
import emojiRegex from 'emoji-regex';
/**
Logic:
- Segment graphemes to match how terminals render clusters.
- Width rules:
1. Skip non-printing clusters (Default_Ignorable, Control, pure Mark, lone Surrogates). Tabs are ignored by design.
2. Emoji clusters are double-width only when VS16 is present, the base has Emoji_Presentation (and not VS15), or the cluster has multiple scalars (flags, ZWJ, keycaps, tags, etc.).
3. Otherwise use East Asian Width of the cluster’s first visible code point, and add widths for trailing Halfwidth/Fullwidth Forms within the same cluster (e.g., dakuten/handakuten/prolonged sound mark).
*/
const segmenter = new Intl.Segmenter();
const defaultIgnorableCodePointRegex = /^\p{Default_Ignorable_Code_Point}$/u;
// Whole-cluster zero-width
const zeroWidthClusterRegex = /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Mark}|\p{Surrogate})+$/v;
export default function stringWidth(string, options = {}) {
if (typeof string !== 'string' || string.length === 0) {
// Pick the base scalar if the cluster starts with Prepend/Format/Marks
const leadingNonPrintingRegex = /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v;
// RGI emoji sequences
const rgiEmojiRegex = /^\p{RGI_Emoji}$/v;
// Default emoji presentation (single-scalar emoji without VS16)
const emojiPresentationRegex = /^\p{Emoji_Presentation}$/v;
function baseVisible(segment) {
return segment.replace(leadingNonPrintingRegex, '');
}
function isZeroWidthCluster(segment) {
return zeroWidthClusterRegex.test(segment);
}
function isDoubleWidthEmojiCluster(segment) {
const visible = baseVisible(segment);
const baseScalar = visible.codePointAt(0);
const baseChar = String.fromCodePoint(baseScalar);
const baseIsEmojiPresentation = emojiPresentationRegex.test(baseChar);
const hasVs16 = segment.includes('\uFE0F');
const hasVs15 = segment.includes('\uFE0E');
const codePointCount = [...segment].length;
const multiScalarMeaningful = codePointCount > 1 && !(codePointCount === 2 && hasVs15 && !hasVs16);
return hasVs16 || (baseIsEmojiPresentation && !hasVs15) || multiScalarMeaningful;
}
function trailingHalfwidthWidth(segment, eastAsianWidthOptions) {
let extra = 0;
if (segment.length > 1) {
for (const char of segment.slice(1)) {
if (char >= '\uFF00' && char <= '\uFFEF') {
extra += eastAsianWidth(char.codePointAt(0), eastAsianWidthOptions);
}
}
}
return extra;
}
export default function stringWidth(input, options = {}) {
if (typeof input !== 'string' || input.length === 0) {
return 0;

@@ -19,2 +70,4 @@ }

let string = input;
if (!countAnsiEscapeCodes) {

@@ -31,46 +84,10 @@ string = stripAnsi(string);

for (const {segment: character} of segmenter.segment(string)) {
const codePoint = character.codePointAt(0);
// Ignore control characters
if (codePoint <= 0x1F || (codePoint >= 0x7F && codePoint <= 0x9F)) {
for (const {segment} of segmenter.segment(string)) {
// Zero-width / non-printing clusters
if (isZeroWidthCluster(segment)) {
continue;
}
// Ignore zero-width characters
if (
(codePoint >= 0x20_0B && codePoint <= 0x20_0F) // Zero-width space, non-joiner, joiner, left-to-right mark, right-to-left mark
|| codePoint === 0xFE_FF // Zero-width no-break space
) {
continue;
}
// Ignore combining characters
if (
(codePoint >= 0x3_00 && codePoint <= 0x3_6F) // Combining diacritical marks
|| (codePoint >= 0x1A_B0 && codePoint <= 0x1A_FF) // Combining diacritical marks extended
|| (codePoint >= 0x1D_C0 && codePoint <= 0x1D_FF) // Combining diacritical marks supplement
|| (codePoint >= 0x20_D0 && codePoint <= 0x20_FF) // Combining diacritical marks for symbols
|| (codePoint >= 0xFE_20 && codePoint <= 0xFE_2F) // Combining half marks
) {
continue;
}
// Ignore surrogate pairs
if (codePoint >= 0xD8_00 && codePoint <= 0xDF_FF) {
continue;
}
// Ignore variation selectors
if (codePoint >= 0xFE_00 && codePoint <= 0xFE_0F) {
continue;
}
// This covers some of the above cases, but we still keep them for performance reasons.
if (defaultIgnorableCodePointRegex.test(character)) {
continue;
}
// TODO: Use `/\p{RGI_Emoji}/v` when targeting Node.js 20.
if (emojiRegex().test(character)) {
// Emoji width logic
if (rgiEmojiRegex.test(segment) && isDoubleWidthEmojiCluster(segment)) {
width += 2;

@@ -80,3 +97,8 @@ continue;

// Everything else: EAW of the cluster’s first visible scalar
const codePoint = baseVisible(segment).codePointAt(0);
width += eastAsianWidth(codePoint, eastAsianWidthOptions);
// Add width for trailing Halfwidth and Fullwidth Forms (e.g., ゙, ゚, ー)
width += trailingHalfwidthWidth(segment, eastAsianWidthOptions);
}

@@ -83,0 +105,0 @@

{
"name": "string-width",
"version": "7.2.0",
"version": "8.0.0",
"description": "Get the visual width of a string - the number of columns required to display it",

@@ -20,3 +20,3 @@ "license": "MIT",

"engines": {
"node": ">=18"
"node": ">=20"
},

@@ -40,2 +40,4 @@ "scripts": {

"full-width",
"wcwidth",
"wcswidth",
"full",

@@ -57,11 +59,10 @@ "ansi",

"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"get-east-asian-width": "^1.3.0",
"strip-ansi": "^7.1.0"
},
"devDependencies": {
"ava": "^5.3.1",
"tsd": "^0.29.0",
"xo": "^0.56.0"
"ava": "^6.4.1",
"tsd": "^0.33.0",
"xo": "^1.2.2"
}
}