string-width
Advanced tools
+67
-45
| 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 @@ |
+8
-7
| { | ||
| "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" | ||
| } | ||
| } |
8902
14.52%2
-33.33%112
17.89%- Removed
- Removed
Updated