@cjser/string-width 8.2.2-cjser.2 → 9.0.0-cjser.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,7 +26,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
26
26
  ));
27
27
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
28
28
 
29
- // packages/@cjser/string-width.tmp-26-1783614088733/index.js
29
+ // packages/@cjser/string-width.tmp-24-1794504182864/index.js
30
30
  var index_exports = {};
31
31
  __export(index_exports, {
32
32
  default: () => stringWidth
@@ -35,88 +35,106 @@ module.exports = __toCommonJS(index_exports);
35
35
  var import_strip_ansi = __toESM(require("@cjser/strip-ansi"), 1);
36
36
  var import_get_east_asian_width = require("@cjser/get-east-asian-width");
37
37
  var segmenter = new Intl.Segmenter();
38
- var zeroWidthClusterRegex = new RegExp("^(?:\\p{Default_Ignorable_Code_Point}|\\p{Control}|\\p{Format}|\\p{Nonspacing_Mark}|\\p{Enclosing_Mark}|\\p{Surrogate})+$", "v");
39
- var leadingNonPrintingRegex = new RegExp("^[\\p{Default_Ignorable_Code_Point}\\p{Control}\\p{Format}\\p{Nonspacing_Mark}\\p{Enclosing_Mark}\\p{Surrogate}]+", "v");
40
- var spacingMarkRegex = new RegExp("\\p{Spacing_Mark}", "v");
41
- var rgiEmojiRegex = new RegExp("^\\p{RGI_Emoji}$", "v");
42
- var unqualifiedKeycapRegex = /^[\d#*]\u20E3$/;
43
- var extendedPictographicRegex = new RegExp("\\p{Extended_Pictographic}", "gu");
44
- function isDoubleWidthNonRgiEmojiSequence(segment) {
45
- if (segment.length > 50) {
46
- return false;
47
- }
48
- if (unqualifiedKeycapRegex.test(segment)) {
49
- return true;
50
- }
51
- if (segment.includes("\u200D")) {
52
- const pictographics = segment.match(extendedPictographicRegex);
53
- return pictographics !== null && pictographics.length >= 2;
54
- }
55
- return false;
38
+ var nonAsciiOrControlRegex = new RegExp("[^\\u{20}-\\u{7E}]", "v");
39
+ var prependedConcatenationMarks = String.raw`\u{600}-\u{605}\u{6DD}\u{890}\u{891}\u{8E2}\u{110BD}\u{110CD}`;
40
+ var prependedConcatenationMarksRegex = new RegExp(`^[${prependedConcatenationMarks}]+`, "v");
41
+ var visibleCharacterRegex = new RegExp(String.raw`[^\p{Default_Ignorable_Code_Point}\p{Control}\p{Enclosing_Mark}\p{Nonspacing_Mark}[\p{Format}--[${prependedConcatenationMarks}]]]`, "v");
42
+ var emojiPrefixRegex = new RegExp("^(?:\\p{RGI_Emoji}|\\p{Regional_Indicator}{1,2}|[\\d#*]\\u{FE0F})", "v");
43
+ var textPresentationEmojiRegex = new RegExp("^[\\p{Emoji_Presentation}--[\\u{10000}-\\u{10FFFF}]]\\u{FE0E}", "v");
44
+ function strayEmojiModifierCount(string, fromIndex) {
45
+ if (fromIndex >= string.length) {
46
+ return 0;
47
+ }
48
+ const modifierRegex = new RegExp("(?<![\\p{Emoji_Modifier_Base}\\u{200D}])\\p{Emoji_Modifier}", "gv");
49
+ modifierRegex.lastIndex = fromIndex;
50
+ let count = 0;
51
+ while (modifierRegex.test(string)) {
52
+ count++;
53
+ }
54
+ return count;
56
55
  }
56
+ var emojiZwjSequenceRegex = new RegExp("\\u{200D}\\p{Extended_Pictographic}", "v");
57
57
  function baseVisible(segment) {
58
- return segment.replace(leadingNonPrintingRegex, "");
59
- }
60
- function isZeroWidthCluster(segment) {
61
- return zeroWidthClusterRegex.test(segment);
62
- }
63
- function isHangulLeadingJamo(codePoint) {
64
- return codePoint >= 4352 && codePoint <= 4447 || codePoint >= 43360 && codePoint <= 43388;
65
- }
66
- function isHangulVowelJamo(codePoint) {
67
- return codePoint >= 4448 && codePoint <= 4519 || codePoint >= 55216 && codePoint <= 55238;
58
+ const index = segment.search(visibleCharacterRegex);
59
+ return index === -1 ? void 0 : segment.slice(index);
68
60
  }
69
- function isHangulTrailingJamo(codePoint) {
70
- return codePoint >= 4520 && codePoint <= 4607 || codePoint >= 55243 && codePoint <= 55291;
61
+ function isHangul(codePoint) {
62
+ return codePoint >= 4352 && codePoint <= 4607 || codePoint >= 43360 && codePoint <= 43388 || codePoint >= 44032 && codePoint <= 55203 || codePoint >= 55216 && codePoint <= 55238 || codePoint >= 55243 && codePoint <= 55291;
71
63
  }
72
- function isHangulJamo(codePoint) {
73
- return isHangulLeadingJamo(codePoint) || isHangulVowelJamo(codePoint) || isHangulTrailingJamo(codePoint);
74
- }
75
- function hangulClusterWidth(visibleSegment, eastAsianWidthOptions) {
76
- const codePoints = [];
64
+ function visibleCodePointsWidth(visibleSegment, eastAsianWidthOptions) {
65
+ let width = 0;
77
66
  for (const character of visibleSegment) {
78
- if (zeroWidthClusterRegex.test(character)) {
79
- continue;
67
+ if (character === "\u200D") {
68
+ break;
80
69
  }
81
- codePoints.push(character.codePointAt(0));
70
+ if (visibleCharacterRegex.test(character)) {
71
+ width += (0, import_get_east_asian_width.eastAsianWidth)(character.codePointAt(0), eastAsianWidthOptions);
72
+ }
73
+ }
74
+ return width;
75
+ }
76
+ function clusterWidth(segment, eastAsianWidthOptions) {
77
+ const firstCodePoint = segment.codePointAt(0);
78
+ const marks = firstCodePoint === 69821 || firstCodePoint === 69837 || firstCodePoint >= 1536 && firstCodePoint <= 2303 ? prependedConcatenationMarksRegex.exec(segment) : void 0;
79
+ if (marks) {
80
+ const rest = segment.slice(marks[0].length);
81
+ const restWidth = clusterWidth(rest, eastAsianWidthOptions);
82
+ const visibleRest = baseVisible(rest);
83
+ const isWideGlyph = restWidth >= 2 && ((0, import_get_east_asian_width.eastAsianWidth)(visibleRest.codePointAt(0), eastAsianWidthOptions) === 2 || emojiPrefixRegex.test(visibleRest) || emojiZwjSequenceRegex.test(visibleRest));
84
+ return isWideGlyph ? restWidth : [...marks[0]].length + restWidth;
85
+ }
86
+ const visibleSegment = baseVisible(segment);
87
+ if (visibleSegment === void 0) {
88
+ return 0;
82
89
  }
83
- if (codePoints.length === 0) {
84
- return void 0;
90
+ if (isHangul(firstCodePoint)) {
91
+ return (0, import_get_east_asian_width.eastAsianWidth)(firstCodePoint, eastAsianWidthOptions) + strayEmojiModifierCount(segment, 1) * 2;
85
92
  }
93
+ if (textPresentationEmojiRegex.test(visibleSegment)) {
94
+ return 1;
95
+ }
96
+ const emoji = emojiPrefixRegex.exec(visibleSegment);
97
+ if (emoji) {
98
+ return 2 + strayEmojiModifierCount(visibleSegment, emoji[0].length) * 2;
99
+ }
100
+ const zwjSequence = emojiZwjSequenceRegex.exec(visibleSegment);
101
+ return zwjSequence ? 2 + strayEmojiModifierCount(visibleSegment, zwjSequence.index + zwjSequence[0].length) * 2 : visibleCodePointsWidth(visibleSegment, eastAsianWidthOptions);
102
+ }
103
+ function unicodeWidth(string, ambiguousIsNarrow) {
86
104
  let width = 0;
87
- for (let index = 0; index < codePoints.length; index++) {
88
- const codePoint = codePoints[index];
89
- if (!isHangulJamo(codePoint)) {
90
- if (width === 0) {
91
- return void 0;
92
- }
93
- for (let remaining = index; remaining < codePoints.length; remaining++) {
94
- width += (0, import_get_east_asian_width.eastAsianWidth)(codePoints[remaining], eastAsianWidthOptions);
95
- }
96
- return width;
97
- }
98
- if (isHangulLeadingJamo(codePoint) && isHangulVowelJamo(codePoints[index + 1])) {
99
- width += 2;
100
- index += isHangulTrailingJamo(codePoints[index + 2]) ? 2 : 1;
101
- continue;
105
+ const firstNonAsciiIndex = string.search(nonAsciiOrControlRegex);
106
+ if (firstNonAsciiIndex > 1) {
107
+ width = firstNonAsciiIndex - 1;
108
+ string = string.slice(width);
109
+ }
110
+ let suffixStart = string.length;
111
+ while (suffixStart > 0) {
112
+ const codePoint = string.codePointAt(suffixStart - 1);
113
+ if (codePoint < 32 || codePoint > 126) {
114
+ break;
102
115
  }
103
- width += (0, import_get_east_asian_width.eastAsianWidth)(codePoint, eastAsianWidthOptions);
116
+ suffixStart--;
104
117
  }
105
- return width;
106
- }
107
- function trailingWidth(visibleSegment, eastAsianWidthOptions) {
108
- let extra = 0;
109
- let first = true;
110
- for (const character of visibleSegment) {
111
- if (first) {
112
- first = false;
118
+ if (suffixStart + 1 < string.length) {
119
+ width += string.length - suffixStart - 1;
120
+ string = string.slice(0, suffixStart + 1);
121
+ }
122
+ if (!new RegExp("[^\\u{20}-\\u{7E}\\u{4E00}-\\u{9FFF}\\u{AC00}-\\u{D7A3}]", "v").test(string)) {
123
+ return width + string.length + string.replaceAll(new RegExp("[\\u{20}-\\u{7E}]", "gv"), "").length;
124
+ }
125
+ string = string.toWellFormed();
126
+ string = string.replaceAll(new RegExp("(?<=[\\u{16D63}-\\u{16D6A}])[\\u{16D63}-\\u{16D6A}]+", "gv"), "");
127
+ const eastAsianWidthOptions = { ambiguousAsWide: !ambiguousIsNarrow };
128
+ for (const { segment } of segmenter.segment(string)) {
129
+ if (segment.length === 1) {
130
+ if (visibleCharacterRegex.test(segment)) {
131
+ width += (0, import_get_east_asian_width.eastAsianWidth)(segment.codePointAt(0), eastAsianWidthOptions);
132
+ }
113
133
  continue;
114
134
  }
115
- if (spacingMarkRegex.test(character) || character >= "\uFF00" && character <= "\uFFEF") {
116
- extra += (0, import_get_east_asian_width.eastAsianWidth)(character.codePointAt(0), eastAsianWidthOptions);
117
- }
135
+ width += clusterWidth(segment, eastAsianWidthOptions);
118
136
  }
119
- return extra;
137
+ return width;
120
138
  }
121
139
  function stringWidth(input, options = {}) {
122
140
  if (typeof input !== "string" || input.length === 0) {
@@ -130,31 +148,5 @@ function stringWidth(input, options = {}) {
130
148
  if (!countAnsiEscapeCodes && (string.includes("\x1B") || string.includes("\x9B"))) {
131
149
  string = (0, import_strip_ansi.default)(string);
132
150
  }
133
- if (string.length === 0) {
134
- return 0;
135
- }
136
- if (/^[\u0020-\u007E]*$/.test(string)) {
137
- return string.length;
138
- }
139
- let width = 0;
140
- const eastAsianWidthOptions = { ambiguousAsWide: !ambiguousIsNarrow };
141
- for (const { segment } of segmenter.segment(string)) {
142
- if (isZeroWidthCluster(segment)) {
143
- continue;
144
- }
145
- if (rgiEmojiRegex.test(segment) || isDoubleWidthNonRgiEmojiSequence(segment)) {
146
- width += 2;
147
- continue;
148
- }
149
- const visibleSegment = baseVisible(segment);
150
- const hangulWidth = hangulClusterWidth(visibleSegment, eastAsianWidthOptions);
151
- if (hangulWidth !== void 0) {
152
- width += hangulWidth;
153
- continue;
154
- }
155
- const codePoint = visibleSegment.codePointAt(0);
156
- width += (0, import_get_east_asian_width.eastAsianWidth)(codePoint, eastAsianWidthOptions);
157
- width += trailingWidth(visibleSegment, eastAsianWidthOptions);
158
- }
159
- return width;
151
+ return nonAsciiOrControlRegex.test(string) ? unicodeWidth(string, ambiguousIsNarrow) : string.length;
160
152
  }
package/index.d.ts CHANGED
@@ -5,7 +5,7 @@ export type Options = {
5
5
  @default true
6
6
 
7
7
  > Ambiguous characters behave like wide or narrow characters depending on the context (language tag, script identification, associated font, source of data, or explicit markup; all can provide the context). __If the context cannot be established reliably, they should be treated as narrow characters by default.__
8
- > - http://www.unicode.org/reports/tr11/
8
+ > - https://www.unicode.org/reports/tr11/
9
9
  */
10
10
  readonly ambiguousIsNarrow?: boolean;
11
11
 
@@ -20,7 +20,7 @@ export type Options = {
20
20
  /**
21
21
  Get the visual width of a string - the number of columns required to display it.
22
22
 
23
- Some Unicode characters are [fullwidth](https://en.wikipedia.org/wiki/Halfwidth_and_fullwidth_forms) and use double the normal width. [ANSI escape codes](https://en.wikipedia.org/wiki/ANSI_escape_code) are stripped and doesn't affect the width.
23
+ Some Unicode characters are [fullwidth](https://en.wikipedia.org/wiki/Halfwidth_and_fullwidth_forms) and use double the normal width. [ANSI escape codes](https://en.wikipedia.org/wiki/ANSI_escape_code) are stripped and do not affect the width.
24
24
 
25
25
  @example
26
26
  ```
package/index.js CHANGED
@@ -5,146 +5,189 @@ import {eastAsianWidth} from '@cjser/get-east-asian-width';
5
5
  Logic:
6
6
  - Segment graphemes to match how terminals render clusters.
7
7
  - Width rules:
8
- 1. Skip non-printing clusters (Default_Ignorable, Control, pure nonspacing/enclosing Mark, lone Surrogates). Tabs are ignored by design.
9
- 2. RGI emoji clusters (\p{RGI_Emoji}) are double-width.
10
- 3. Minimally-qualified/unqualified emoji clusters (ZWJ sequences with 2+ Extended_Pictographic, or keycap sequences) are double-width.
11
- 4. Hangul jamo collapse each standard modern Hangul L+V or L+V+T syllable piece to width 2.
12
- Unmatched repeated leading/vowel/trailing jamo stay additive because that matches how the terminals we target render them.
13
- 5. Otherwise use East Asian Width of the cluster's first visible code point, and add widths for trailing spacing marks and Halfwidth/Fullwidth Forms within the same cluster (e.g., dakuten/handakuten/prolonged sound mark).
8
+ 1. Skip non-printing clusters (Default_Ignorable, Control, Format except spacing prepended concatenation marks like ARABIC NUMBER SIGN, pure nonspacing/enclosing Mark). Tabs are ignored by design. Lone surrogates are measured as U+FFFD, which is how they are written out.
9
+ 2. Clusters starting with an RGI emoji (\p{RGI_Emoji}), regional indicators, or a digit/#/* with VS16 are double-width, even with trailing extenders (e.g., a dangling ZWJ, an extra variation selector, or a spacing mark). Only a stray emoji modifier adds its own swatch width.
10
+ A wide BMP emoji followed by VS15 (text presentation) is one column; anything after the VS15 is ignored.
11
+ 3. Minimally-qualified emoji ZWJ sequences (2+ Extended_Pictographic) are double-width, except that a stray emoji modifier after the sequence adds its own swatch width. An unqualified keycap (no VS16) is text, so it uses the width of its base.
12
+ 4. A cluster starting with Hangul (jamo or a precomposed syllable) is one syllable block with the width of its first code point (plus stray emoji modifiers), as in iTerm, Ghostty, and kitty.
13
+ 5. Otherwise sum the East Asian Width of every visible code point in the cluster: the base plus trailing spacing marks (including Thai/Lao AM), stray emoji modifiers, Indic conjunct consonants, and halfwidth dakuten/handakuten. Nothing after a ZWJ is counted.
14
+ 6. Spacing prepended concatenation marks (e.g., ARABIC NUMBER SIGN before digits) are one column each, unless the following glyph is wide (East Asian Wide or emoji, but not in VS15 text style), which takes them into its own cell.
14
15
  */
15
16
 
16
17
  const segmenter = new Intl.Segmenter();
18
+ const nonAsciiOrControlRegex = /[^\u{20}-\u{7E}]/v;
17
19
 
18
- // Whole-cluster zero-width
19
- const zeroWidthClusterRegex = /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Format}|\p{Nonspacing_Mark}|\p{Enclosing_Mark}|\p{Surrogate})+$/v;
20
+ // Spacing Prepended_Concatenation_Mark format characters (e.g., ARABIC NUMBER SIGN) that are visible signs spanning the following characters. Excludes the zero-width U+070F SYRIAC ABBREVIATION MARK.
21
+ const prependedConcatenationMarks = String.raw`\u{600}-\u{605}\u{6DD}\u{890}\u{891}\u{8E2}\u{110BD}\u{110CD}`;
22
+ const prependedConcatenationMarksRegex = new RegExp(`^[${prependedConcatenationMarks}]+`, 'v');
20
23
 
21
- // Pick the base scalar if the cluster starts with Prepend/Format/Marks
22
- const leadingNonPrintingRegex = /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Nonspacing_Mark}\p{Enclosing_Mark}\p{Surrogate}]+/v;
23
- const spacingMarkRegex = /\p{Spacing_Mark}/v;
24
+ // Matches a visible (printing) character. Used to find the base scalar of a cluster (skipping leading Format characters and marks), to detect whole-cluster zero-width, and to skip invisible code points when summing widths.
25
+ // Intentionally a single negated class without a quantifier: a repeated pattern like `^(?:…|…)+$` backtracks exponentially on overlapping properties, and even `^[…]+$` overflows V8's backtrack stack (RangeError) on clusters with millions of marks.
26
+ // Line and paragraph separators (U+2028, U+2029) are intentionally not skipped: iTerm, Terminal.app, and kitty show them as one column.
27
+ const visibleCharacterRegex = new RegExp(String.raw`[^\p{Default_Ignorable_Code_Point}\p{Control}\p{Enclosing_Mark}\p{Nonspacing_Mark}[\p{Format}--[${prependedConcatenationMarks}]]]`, 'v');
24
28
 
25
- // RGI emoji sequences
26
- const rgiEmojiRegex = /^\p{RGI_Emoji}$/v;
29
+ // Clusters starting with an RGI emoji sequence. Not anchored at the end, so trailing extenders (ZWJ, marks, variation selectors) in the same cluster do not hide the emoji.
30
+ // Regional indicators that do not form a valid flag (alone, or an unknown pair) are also shown as one wide symbol, and so is a digit, number sign, or asterisk with VS16 (an emoji presentation sequence that is not RGI on its own).
31
+ const emojiPrefixRegex = /^(?:\p{RGI_Emoji}|\p{Regional_Indicator}{1,2}|[\d#*]\u{FE0F})/v;
27
32
 
28
- // Detect minimally-qualified/unqualified emoji sequences (missing VS16 but still render as double-width)
29
- const unqualifiedKeycapRegex = /^[\d#*]\u20E3$/;
30
- const extendedPictographicRegex = /\p{Extended_Pictographic}/gu;
33
+ // A wide BMP emoji followed by VS15 asks for its narrow text style. Every BMP Emoji_Presentation character has a standardized text variation sequence.
34
+ // Limitation: astral emoji (e.g., 👍︎) keep their width, because only some of them have a text variation sequence and terminals disagree.
35
+ const textPresentationEmojiRegex = /^[\p{Emoji_Presentation}--[\u{10000}-\u{10FFFF}]]\u{FE0E}/v;
31
36
 
32
- function isDoubleWidthNonRgiEmojiSequence(segment) {
33
- // Real emoji clusters are < 30 chars; guard against pathological input
34
- if (segment.length > 50) {
35
- return false;
37
+ // Counts stray emoji modifiers from `fromIndex`, keeping the whole string for lookbehind context.
38
+ function strayEmojiModifierCount(string, fromIndex) {
39
+ // Most emoji clusters are fully consumed by the emoji match
40
+ if (fromIndex >= string.length) {
41
+ return 0;
36
42
  }
37
43
 
38
- if (unqualifiedKeycapRegex.test(segment)) {
39
- return true;
40
- }
44
+ // An emoji modifier that does not follow an emoji modifier base (or a ZWJ) is shown as a standalone color swatch (UTS #51)
45
+ const modifierRegex = /(?<![\p{Emoji_Modifier_Base}\u{200D}])\p{Emoji_Modifier}/gv;
46
+ modifierRegex.lastIndex = fromIndex;
41
47
 
42
- // ZWJ sequences with 2+ Extended_Pictographic
43
- if (segment.includes('\u200D')) {
44
- const pictographics = segment.match(extendedPictographicRegex);
45
- return pictographics !== null && pictographics.length >= 2;
48
+ let count = 0;
49
+ while (modifierRegex.test(string)) {
50
+ count++;
46
51
  }
47
52
 
48
- return false;
53
+ return count;
49
54
  }
50
55
 
56
+ // Detect minimally-qualified emoji ZWJ sequences (missing VS16 but still render as double-width)
57
+ // A ZWJ only joins a following Extended_Pictographic into the cluster when an Extended_Pictographic comes before it (GB11), so this means 2+ Extended_Pictographic joined by a ZWJ.
58
+ const emojiZwjSequenceRegex = /\u{200D}\p{Extended_Pictographic}/v;
59
+
60
+ // Returns the cluster starting at its first visible character, or `undefined` if the whole cluster is zero-width.
51
61
  function baseVisible(segment) {
52
- return segment.replace(leadingNonPrintingRegex, '');
62
+ const index = segment.search(visibleCharacterRegex);
63
+ return index === -1 ? undefined : segment.slice(index);
53
64
  }
54
65
 
55
- function isZeroWidthCluster(segment) {
56
- return zeroWidthClusterRegex.test(segment);
66
+ // Hangul jamo (U+1100..U+11FF, Hangul Jamo Extended-A and -B) and precomposed syllables. Includes the Default_Ignorable HANGUL CHOSEONG/JUNGSEONG FILLER (U+115F, U+1160), which start a syllable block in place of a missing jamo.
67
+ function isHangul(codePoint) {
68
+ return (codePoint >= 0x11_00 && codePoint <= 0x11_FF)
69
+ || (codePoint >= 0xA9_60 && codePoint <= 0xA9_7C)
70
+ || (codePoint >= 0xAC_00 && codePoint <= 0xD7_A3)
71
+ || (codePoint >= 0xD7_B0 && codePoint <= 0xD7_C6)
72
+ || (codePoint >= 0xD7_CB && codePoint <= 0xD7_FB);
57
73
  }
58
74
 
59
- function isHangulLeadingJamo(codePoint) {
60
- return (codePoint >= 0x11_00 && codePoint <= 0x11_5F)
61
- || (codePoint >= 0xA9_60 && codePoint <= 0xA9_7C);
62
- }
75
+ // Sums the East Asian Width of the visible code points of a cluster.
76
+ // Everything visible after the base still takes columns: spacing marks (Grapheme_Cluster_Break=SpacingMark, including Thai/Lao AM which are Lo), an emoji modifier not consumed by an emoji modifier sequence (shown as a standalone swatch), the consonants of an Indic conjunct joined by a virama (GB9c), and halfwidth dakuten/handakuten. A ZWJ ends the counting.
77
+ function visibleCodePointsWidth(visibleSegment, eastAsianWidthOptions) {
78
+ let width = 0;
63
79
 
64
- function isHangulVowelJamo(codePoint) {
65
- return (codePoint >= 0x11_60 && codePoint <= 0x11_A7)
66
- || (codePoint >= 0xD7_B0 && codePoint <= 0xD7_C6);
67
- }
80
+ for (const character of visibleSegment) {
81
+ // Match terminal handling of explicit Indic half-forms by counting only the visible characters before a ZWJ in the cluster
82
+ if (character === '\u{200D}') {
83
+ break;
84
+ }
68
85
 
69
- function isHangulTrailingJamo(codePoint) {
70
- return (codePoint >= 0x11_A8 && codePoint <= 0x11_FF)
71
- || (codePoint >= 0xD7_CB && codePoint <= 0xD7_FB);
72
- }
86
+ if (visibleCharacterRegex.test(character)) {
87
+ width += eastAsianWidth(character.codePointAt(0), eastAsianWidthOptions);
88
+ }
89
+ }
73
90
 
74
- function isHangulJamo(codePoint) {
75
- return isHangulLeadingJamo(codePoint)
76
- || isHangulVowelJamo(codePoint)
77
- || isHangulTrailingJamo(codePoint);
91
+ return width;
78
92
  }
79
93
 
80
- function hangulClusterWidth(visibleSegment, eastAsianWidthOptions) {
81
- const codePoints = [];
94
+ function clusterWidth(segment, eastAsianWidthOptions) {
95
+ // Spacing prepended concatenation marks (e.g., ARABIC NUMBER SIGN) are visible one-column signs that join the next character. A wide glyph (East Asian Wide or emoji, but not a VS15 text style) takes them into its own cell.
96
+ // The marks are in U+0600..U+08FF plus U+110BD and U+110CD, so most clusters skip the regex
97
+ const firstCodePoint = segment.codePointAt(0);
98
+ const marks = firstCodePoint === 0x1_10_BD || firstCodePoint === 0x1_10_CD || (firstCodePoint >= 0x6_00 && firstCodePoint <= 0x8_FF) ? prependedConcatenationMarksRegex.exec(segment) : undefined;
99
+ if (marks) {
100
+ const rest = segment.slice(marks[0].length);
101
+ const restWidth = clusterWidth(rest, eastAsianWidthOptions);
102
+ const visibleRest = baseVisible(rest);
103
+ const isWideGlyph = restWidth >= 2 && (
104
+ eastAsianWidth(visibleRest.codePointAt(0), eastAsianWidthOptions) === 2
105
+ || emojiPrefixRegex.test(visibleRest)
106
+ || emojiZwjSequenceRegex.test(visibleRest)
107
+ );
108
+ return isWideGlyph ? restWidth : [...marks[0]].length + restWidth;
109
+ }
82
110
 
83
- for (const character of visibleSegment) {
84
- if (zeroWidthClusterRegex.test(character)) {
85
- continue;
86
- }
111
+ // Zero-width / non-printing clusters (including a Hangul filler with only invisible extenders)
112
+ const visibleSegment = baseVisible(segment);
113
+ if (visibleSegment === undefined) {
114
+ return 0;
115
+ }
87
116
 
88
- codePoints.push(character.codePointAt(0));
117
+ // Checked on the raw cluster, since it can start with a Default_Ignorable filler
118
+ if (isHangul(firstCodePoint)) {
119
+ return eastAsianWidth(firstCodePoint, eastAsianWidthOptions) + (strayEmojiModifierCount(segment, 1) * 2);
89
120
  }
90
121
 
91
- if (codePoints.length === 0) {
92
- return undefined;
122
+ if (textPresentationEmojiRegex.test(visibleSegment)) {
123
+ return 1;
93
124
  }
94
125
 
95
- let width = 0;
126
+ // Emoji width logic. Uses the visible part, so leading invisible characters do not hide the emoji.
127
+ const emoji = emojiPrefixRegex.exec(visibleSegment);
128
+ if (emoji) {
129
+ return 2 + (strayEmojiModifierCount(visibleSegment, emoji[0].length) * 2);
130
+ }
96
131
 
97
- for (let index = 0; index < codePoints.length; index++) {
98
- const codePoint = codePoints[index];
99
- if (!isHangulJamo(codePoint)) {
100
- if (width === 0) {
101
- return undefined;
102
- }
132
+ // Minimally-qualified emoji ZWJ sequences are double-width, like RGI emoji, except that a stray emoji modifier after the sequence is shown as its own swatch. Everything else: EAW of the cluster’s visible scalars, including trailing spacing marks and Halfwidth/Fullwidth Forms (e.g., ゙, ゚)
133
+ const zwjSequence = emojiZwjSequenceRegex.exec(visibleSegment);
134
+ return zwjSequence
135
+ ? 2 + (strayEmojiModifierCount(visibleSegment, zwjSequence.index + zwjSequence[0].length) * 2)
136
+ : visibleCodePointsWidth(visibleSegment, eastAsianWidthOptions);
137
+ }
103
138
 
104
- // Mixed cluster (e.g., L + precomposed syllable): use EAW for non-jamo remainder
105
- for (let remaining = index; remaining < codePoints.length; remaining++) {
106
- width += eastAsianWidth(codePoints[remaining], eastAsianWidthOptions);
107
- }
139
+ function unicodeWidth(string, ambiguousIsNarrow) {
140
+ // Count the initial printable ASCII directly, keeping its last character as a possible grapheme base.
141
+ let width = 0;
142
+ const firstNonAsciiIndex = string.search(nonAsciiOrControlRegex);
143
+ if (firstNonAsciiIndex > 1) {
144
+ width = firstNonAsciiIndex - 1;
145
+ string = string.slice(width);
146
+ }
108
147
 
109
- return width;
148
+ // Keep the first trailing ASCII character with the Unicode prefix.
149
+ let suffixStart = string.length;
150
+ while (suffixStart > 0) {
151
+ const codePoint = string.codePointAt(suffixStart - 1);
152
+ if (codePoint < 0x20 || codePoint > 0x7E) {
153
+ break;
110
154
  }
111
155
 
112
- // Modern Hangul L+V(+T) shapes as one syllable block. Unmatched jamo stay additive:
113
- // U+1100 U+1100 U+1161 => U+1100 + (U+1100 U+1161) => 2 + 2.
114
- if (
115
- isHangulLeadingJamo(codePoint)
116
- && isHangulVowelJamo(codePoints[index + 1])
117
- ) {
118
- width += 2;
119
- index += isHangulTrailingJamo(codePoints[index + 2]) ? 2 : 1;
120
- continue;
121
- }
156
+ suffixStart--;
157
+ }
122
158
 
123
- width += eastAsianWidth(codePoint, eastAsianWidthOptions);
159
+ if (suffixStart + 1 < string.length) {
160
+ width += string.length - suffixStart - 1;
161
+ string = string.slice(0, suffixStart + 1);
124
162
  }
125
163
 
126
- return width;
127
- }
164
+ // Printable ASCII, common Han, and precomposed Hangul have fixed widths and cannot form multi-character graphemes together.
165
+ if (!/[^\u{20}-\u{7E}\u{4E00}-\u{9FFF}\u{AC00}-\u{D7A3}]/v.test(string)) {
166
+ return width + string.length + string.replaceAll(/[\u{20}-\u{7E}]/gv, '').length;
167
+ }
128
168
 
129
- function trailingWidth(visibleSegment, eastAsianWidthOptions) {
130
- let extra = 0;
131
- let first = true;
169
+ // A lone surrogate is written out as U+FFFD REPLACEMENT CHARACTER
170
+ string = string.toWellFormed();
171
+
172
+ // Kirat Rai vowel runs form one glyph, including canonically decomposed vowels. Collapse them before segmentation so older Unicode tables give the same width.
173
+ string = string.replaceAll(/(?<=[\u{16D63}-\u{16D6A}])[\u{16D63}-\u{16D6A}]+/gv, '');
174
+
175
+ const eastAsianWidthOptions = {ambiguousAsWide: !ambiguousIsNarrow};
176
+
177
+ for (const {segment} of segmenter.segment(string)) {
178
+ // Fast path: a single visible UTF-16 code unit has its East Asian Width under every rule above (a single-code-point RGI emoji is East Asian Wide, and a Hangul cluster uses the width of its first code point).
179
+ if (segment.length === 1) {
180
+ if (visibleCharacterRegex.test(segment)) {
181
+ width += eastAsianWidth(segment.codePointAt(0), eastAsianWidthOptions);
182
+ }
132
183
 
133
- for (const character of visibleSegment) {
134
- if (first) {
135
- first = false;
136
184
  continue;
137
185
  }
138
186
 
139
- if (
140
- spacingMarkRegex.test(character)
141
- || (character >= '\uFF00' && character <= '\uFFEF')
142
- ) {
143
- extra += eastAsianWidth(character.codePointAt(0), eastAsianWidthOptions);
144
- }
187
+ width += clusterWidth(segment, eastAsianWidthOptions);
145
188
  }
146
189
 
147
- return extra;
190
+ return width;
148
191
  }
149
192
 
150
193
  export default function stringWidth(input, options = {}) {
@@ -160,48 +203,11 @@ export default function stringWidth(input, options = {}) {
160
203
  let string = input;
161
204
 
162
205
  // Avoid calling stripAnsi when there are no ANSI escape sequences (ESC = 0x1B, CSI = 0x9B)
163
- if (!countAnsiEscapeCodes && (string.includes('\u001B') || string.includes('\u009B'))) {
206
+ if (!countAnsiEscapeCodes && (string.includes('\u{1B}') || string.includes('\u{9B}'))) {
164
207
  string = stripAnsi(string);
165
208
  }
166
209
 
167
- if (string.length === 0) {
168
- return 0;
169
- }
170
-
171
- // Fast path: printable ASCII (0x20–0x7E) needs no segmenter, regex, or EAW lookup — width equals length.
172
- if (/^[\u0020-\u007E]*$/.test(string)) {
173
- return string.length;
174
- }
175
-
176
- let width = 0;
177
- const eastAsianWidthOptions = {ambiguousAsWide: !ambiguousIsNarrow};
178
-
179
- for (const {segment} of segmenter.segment(string)) {
180
- // Zero-width / non-printing clusters
181
- if (isZeroWidthCluster(segment)) {
182
- continue;
183
- }
184
-
185
- // Emoji width logic
186
- if (rgiEmojiRegex.test(segment) || isDoubleWidthNonRgiEmojiSequence(segment)) {
187
- width += 2;
188
- continue;
189
- }
190
-
191
- const visibleSegment = baseVisible(segment);
192
- const hangulWidth = hangulClusterWidth(visibleSegment, eastAsianWidthOptions);
193
- if (hangulWidth !== undefined) {
194
- width += hangulWidth;
195
- continue;
196
- }
197
-
198
- // Everything else: EAW of the cluster’s first visible scalar
199
- const codePoint = visibleSegment.codePointAt(0);
200
- width += eastAsianWidth(codePoint, eastAsianWidthOptions);
201
-
202
- // Add width for trailing spacing marks and Halfwidth/Fullwidth Forms (e.g., ゙, ゚, ー)
203
- width += trailingWidth(visibleSegment, eastAsianWidthOptions);
204
- }
205
-
206
- return width;
210
+ // Fast path: printable ASCII (0x20–0x7E) needs no segmenter, regex, or EAW lookup. Its width equals its length.
211
+ // A negated class without a quantifier, since `^[…]*$` overflows V8's backtrack stack (RangeError) on strings with millions of characters.
212
+ return nonAsciiOrControlRegex.test(string) ? unicodeWidth(string, ambiguousIsNarrow) : string.length;
207
213
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjser/string-width",
3
- "version": "8.2.2-cjser.2",
3
+ "version": "9.0.0-cjser.2",
4
4
  "description": "Get the visual width of a string - the number of columns required to display it",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -8,11 +8,7 @@
8
8
  "url": "https://code.moenext.com/3rdeye/cjser.git"
9
9
  },
10
10
  "funding": "https://github.com/sponsors/sindresorhus",
11
- "author": {
12
- "name": "Sindre Sorhus",
13
- "email": "sindresorhus@gmail.com",
14
- "url": "https://sindresorhus.com"
15
- },
11
+ "author": "Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)",
16
12
  "type": "module",
17
13
  "exports": {
18
14
  "types": "./index.d.ts",
@@ -21,10 +17,10 @@
21
17
  },
22
18
  "sideEffects": false,
23
19
  "engines": {
24
- "node": ">=20"
20
+ "node": ">=22"
25
21
  },
26
22
  "scripts": {
27
- "test": "xo && ava && tsd"
23
+ "test": "xo && node --test"
28
24
  },
29
25
  "files": [
30
26
  "index.js",
@@ -59,37 +55,35 @@
59
55
  "east-asian-width"
60
56
  ],
61
57
  "dependencies": {
62
- "@cjser/get-east-asian-width": "1.6.0-cjser.2",
58
+ "@cjser/get-east-asian-width": "1.7.0-cjser.2",
63
59
  "@cjser/strip-ansi": "7.2.0-cjser.2"
64
60
  },
65
61
  "devDependencies": {
66
- "ava": "^6.4.1",
67
- "tsd": "^0.33.0",
68
- "xo": "^1.2.3"
62
+ "xo": "^5.0.1"
69
63
  },
70
64
  "types": "./index.d.ts",
71
65
  "main": "./dist-cjser/index.cjs",
72
66
  "cjser": {
73
- "sourceVersion": "8.2.2",
67
+ "sourceVersion": "9.0.0",
74
68
  "cjserVersion": 2,
75
69
  "original": {
76
70
  "name": "string-width",
77
- "version": "8.2.2",
71
+ "version": "9.0.0",
78
72
  "exports": {
79
73
  "types": "./index.d.ts",
80
74
  "default": "./index.js"
81
75
  },
82
76
  "repository": "sindresorhus/string-width",
83
77
  "dependencies": {
84
- "get-east-asian-width": "^1.5.0",
85
- "strip-ansi": "^7.1.2"
78
+ "get-east-asian-width": "^1.7.0",
79
+ "strip-ansi": "^7.2.0"
86
80
  },
87
81
  "files": [
88
82
  "index.js",
89
83
  "index.d.ts"
90
84
  ],
91
85
  "scripts": {
92
- "test": "xo && ava && tsd"
86
+ "test": "xo && node --test"
93
87
  }
94
88
  }
95
89
  }
package/readme.md CHANGED
@@ -49,7 +49,7 @@ Default: `true`
49
49
  Count [ambiguous width characters](https://www.unicode.org/reports/tr11/#Ambiguous) as having narrow width (count of 1) instead of wide width (count of 2).
50
50
 
51
51
  > Ambiguous characters behave like wide or narrow characters depending on the context (language tag, script identification, associated font, source of data, or explicit markup; all can provide the context). **If the context cannot be established reliably, they should be treated as narrow characters by default.**
52
- > - http://www.unicode.org/reports/tr11/
52
+ > - https://www.unicode.org/reports/tr11/
53
53
 
54
54
  ##### countAnsiEscapeCodes
55
55