@cjser/string-width 8.3.0-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-1790266896938/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,85 +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 visibleCharacterRegex = new RegExp("[^\\p{Default_Ignorable_Code_Point}\\p{Control}\\p{Format}\\p{Nonspacing_Mark}\\p{Enclosing_Mark}\\p{Surrogate}]", "v");
39
- var spacingMarkRegex = new RegExp("\\p{Spacing_Mark}", "v");
40
- var rgiEmojiRegex = new RegExp("^\\p{RGI_Emoji}$", "v");
41
- var unqualifiedKeycapRegex = /^[\d#*]\u20E3$/;
42
- var extendedPictographicRegex = new RegExp("\\p{Extended_Pictographic}", "gu");
43
- function isDoubleWidthNonRgiEmojiSequence(segment) {
44
- if (segment.length > 50) {
45
- return false;
46
- }
47
- if (unqualifiedKeycapRegex.test(segment)) {
48
- return true;
49
- }
50
- if (segment.includes("\u200D")) {
51
- const pictographics = segment.match(extendedPictographicRegex);
52
- return pictographics !== null && pictographics.length >= 2;
53
- }
54
- 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;
55
55
  }
56
+ var emojiZwjSequenceRegex = new RegExp("\\u{200D}\\p{Extended_Pictographic}", "v");
56
57
  function baseVisible(segment) {
57
58
  const index = segment.search(visibleCharacterRegex);
58
59
  return index === -1 ? void 0 : segment.slice(index);
59
60
  }
60
- function isHangulLeadingJamo(codePoint) {
61
- return codePoint >= 4352 && codePoint <= 4447 || codePoint >= 43360 && codePoint <= 43388;
62
- }
63
- function isHangulVowelJamo(codePoint) {
64
- return codePoint >= 4448 && codePoint <= 4519 || codePoint >= 55216 && codePoint <= 55238;
65
- }
66
- function isHangulTrailingJamo(codePoint) {
67
- return codePoint >= 4520 && codePoint <= 4607 || codePoint >= 55243 && codePoint <= 55291;
68
- }
69
- function isHangulJamo(codePoint) {
70
- return isHangulLeadingJamo(codePoint) || isHangulVowelJamo(codePoint) || isHangulTrailingJamo(codePoint);
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 hangulClusterWidth(visibleSegment, eastAsianWidthOptions) {
73
- const codePoints = [];
64
+ function visibleCodePointsWidth(visibleSegment, eastAsianWidthOptions) {
65
+ let width = 0;
74
66
  for (const character of visibleSegment) {
75
- if (!visibleCharacterRegex.test(character)) {
76
- continue;
67
+ if (character === "\u200D") {
68
+ break;
69
+ }
70
+ if (visibleCharacterRegex.test(character)) {
71
+ width += (0, import_get_east_asian_width.eastAsianWidth)(character.codePointAt(0), eastAsianWidthOptions);
77
72
  }
78
- codePoints.push(character.codePointAt(0));
79
73
  }
80
- if (codePoints.length === 0) {
81
- return void 0;
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;
82
85
  }
86
+ const visibleSegment = baseVisible(segment);
87
+ if (visibleSegment === void 0) {
88
+ return 0;
89
+ }
90
+ if (isHangul(firstCodePoint)) {
91
+ return (0, import_get_east_asian_width.eastAsianWidth)(firstCodePoint, eastAsianWidthOptions) + strayEmojiModifierCount(segment, 1) * 2;
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) {
83
104
  let width = 0;
84
- for (let index = 0; index < codePoints.length; index++) {
85
- const codePoint = codePoints[index];
86
- if (!isHangulJamo(codePoint)) {
87
- if (width === 0) {
88
- return void 0;
89
- }
90
- for (let remaining = index; remaining < codePoints.length; remaining++) {
91
- width += (0, import_get_east_asian_width.eastAsianWidth)(codePoints[remaining], eastAsianWidthOptions);
92
- }
93
- return width;
94
- }
95
- if (isHangulLeadingJamo(codePoint) && isHangulVowelJamo(codePoints[index + 1])) {
96
- width += 2;
97
- index += isHangulTrailingJamo(codePoints[index + 2]) ? 2 : 1;
98
- 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;
99
115
  }
100
- width += (0, import_get_east_asian_width.eastAsianWidth)(codePoint, eastAsianWidthOptions);
116
+ suffixStart--;
101
117
  }
102
- return width;
103
- }
104
- function trailingWidth(visibleSegment, eastAsianWidthOptions) {
105
- let extra = 0;
106
- let first = true;
107
- for (const character of visibleSegment) {
108
- if (first) {
109
- 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
+ }
110
133
  continue;
111
134
  }
112
- if (spacingMarkRegex.test(character) || character >= "\uFF00" && character <= "\uFFEF") {
113
- extra += (0, import_get_east_asian_width.eastAsianWidth)(character.codePointAt(0), eastAsianWidthOptions);
114
- }
135
+ width += clusterWidth(segment, eastAsianWidthOptions);
115
136
  }
116
- return extra;
137
+ return width;
117
138
  }
118
139
  function stringWidth(input, options = {}) {
119
140
  if (typeof input !== "string" || input.length === 0) {
@@ -127,31 +148,5 @@ function stringWidth(input, options = {}) {
127
148
  if (!countAnsiEscapeCodes && (string.includes("\x1B") || string.includes("\x9B"))) {
128
149
  string = (0, import_strip_ansi.default)(string);
129
150
  }
130
- if (string.length === 0) {
131
- return 0;
132
- }
133
- if (/^[\u0020-\u007E]*$/.test(string)) {
134
- return string.length;
135
- }
136
- let width = 0;
137
- const eastAsianWidthOptions = { ambiguousAsWide: !ambiguousIsNarrow };
138
- for (const { segment } of segmenter.segment(string)) {
139
- const visibleSegment = baseVisible(segment);
140
- if (visibleSegment === void 0) {
141
- continue;
142
- }
143
- if (rgiEmojiRegex.test(segment) || isDoubleWidthNonRgiEmojiSequence(segment)) {
144
- width += 2;
145
- continue;
146
- }
147
- const hangulWidth = hangulClusterWidth(visibleSegment, eastAsianWidthOptions);
148
- if (hangulWidth !== void 0) {
149
- width += hangulWidth;
150
- continue;
151
- }
152
- const codePoint = visibleSegment.codePointAt(0);
153
- width += (0, import_get_east_asian_width.eastAsianWidth)(codePoint, eastAsianWidthOptions);
154
- width += trailingWidth(visibleSegment, eastAsianWidthOptions);
155
- }
156
- return width;
151
+ return nonAsciiOrControlRegex.test(string) ? unicodeWidth(string, ambiguousIsNarrow) : string.length;
157
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,142 +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
- // Matches a visible (printing) character. Used to find the base scalar of a cluster (skipping leading Prepend/Format/Marks) and to detect whole-cluster zero-width.
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');
23
+
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.
19
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.
20
- const visibleCharacterRegex = /[^\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Nonspacing_Mark}\p{Enclosing_Mark}\p{Surrogate}]/v;
21
- const spacingMarkRegex = /\p{Spacing_Mark}/v;
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');
22
28
 
23
- // RGI emoji sequences
24
- 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;
25
32
 
26
- // Detect minimally-qualified/unqualified emoji sequences (missing VS16 but still render as double-width)
27
- const unqualifiedKeycapRegex = /^[\d#*]\u20E3$/;
28
- 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;
29
36
 
30
- function isDoubleWidthNonRgiEmojiSequence(segment) {
31
- // Real emoji clusters are < 30 chars; guard against pathological input
32
- if (segment.length > 50) {
33
- 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;
34
42
  }
35
43
 
36
- if (unqualifiedKeycapRegex.test(segment)) {
37
- return true;
38
- }
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;
39
47
 
40
- // ZWJ sequences with 2+ Extended_Pictographic
41
- if (segment.includes('\u200D')) {
42
- const pictographics = segment.match(extendedPictographicRegex);
43
- return pictographics !== null && pictographics.length >= 2;
48
+ let count = 0;
49
+ while (modifierRegex.test(string)) {
50
+ count++;
44
51
  }
45
52
 
46
- return false;
53
+ return count;
47
54
  }
48
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
+
49
60
  // Returns the cluster starting at its first visible character, or `undefined` if the whole cluster is zero-width.
50
61
  function baseVisible(segment) {
51
62
  const index = segment.search(visibleCharacterRegex);
52
63
  return index === -1 ? undefined : segment.slice(index);
53
64
  }
54
65
 
55
- function isHangulLeadingJamo(codePoint) {
56
- return (codePoint >= 0x11_00 && codePoint <= 0x11_5F)
57
- || (codePoint >= 0xA9_60 && codePoint <= 0xA9_7C);
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);
58
73
  }
59
74
 
60
- function isHangulVowelJamo(codePoint) {
61
- return (codePoint >= 0x11_60 && codePoint <= 0x11_A7)
62
- || (codePoint >= 0xD7_B0 && codePoint <= 0xD7_C6);
63
- }
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;
64
79
 
65
- function isHangulTrailingJamo(codePoint) {
66
- return (codePoint >= 0x11_A8 && codePoint <= 0x11_FF)
67
- || (codePoint >= 0xD7_CB && codePoint <= 0xD7_FB);
68
- }
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
+ }
85
+
86
+ if (visibleCharacterRegex.test(character)) {
87
+ width += eastAsianWidth(character.codePointAt(0), eastAsianWidthOptions);
88
+ }
89
+ }
69
90
 
70
- function isHangulJamo(codePoint) {
71
- return isHangulLeadingJamo(codePoint)
72
- || isHangulVowelJamo(codePoint)
73
- || isHangulTrailingJamo(codePoint);
91
+ return width;
74
92
  }
75
93
 
76
- function hangulClusterWidth(visibleSegment, eastAsianWidthOptions) {
77
- 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
+ }
78
110
 
79
- for (const character of visibleSegment) {
80
- if (!visibleCharacterRegex.test(character)) {
81
- continue;
82
- }
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
+ }
83
116
 
84
- 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);
85
120
  }
86
121
 
87
- if (codePoints.length === 0) {
88
- return undefined;
122
+ if (textPresentationEmojiRegex.test(visibleSegment)) {
123
+ return 1;
89
124
  }
90
125
 
91
- 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
+ }
92
131
 
93
- for (let index = 0; index < codePoints.length; index++) {
94
- const codePoint = codePoints[index];
95
- if (!isHangulJamo(codePoint)) {
96
- if (width === 0) {
97
- return undefined;
98
- }
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
+ }
99
138
 
100
- // Mixed cluster (e.g., L + precomposed syllable): use EAW for non-jamo remainder
101
- for (let remaining = index; remaining < codePoints.length; remaining++) {
102
- width += eastAsianWidth(codePoints[remaining], eastAsianWidthOptions);
103
- }
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
+ }
104
147
 
105
- 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;
106
154
  }
107
155
 
108
- // Modern Hangul L+V(+T) shapes as one syllable block. Unmatched jamo stay additive:
109
- // U+1100 U+1100 U+1161 => U+1100 + (U+1100 U+1161) => 2 + 2.
110
- if (
111
- isHangulLeadingJamo(codePoint)
112
- && isHangulVowelJamo(codePoints[index + 1])
113
- ) {
114
- width += 2;
115
- index += isHangulTrailingJamo(codePoints[index + 2]) ? 2 : 1;
116
- continue;
117
- }
156
+ suffixStart--;
157
+ }
118
158
 
119
- width += eastAsianWidth(codePoint, eastAsianWidthOptions);
159
+ if (suffixStart + 1 < string.length) {
160
+ width += string.length - suffixStart - 1;
161
+ string = string.slice(0, suffixStart + 1);
120
162
  }
121
163
 
122
- return width;
123
- }
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
+ }
124
168
 
125
- function trailingWidth(visibleSegment, eastAsianWidthOptions) {
126
- let extra = 0;
127
- 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
+ }
128
183
 
129
- for (const character of visibleSegment) {
130
- if (first) {
131
- first = false;
132
184
  continue;
133
185
  }
134
186
 
135
- if (
136
- spacingMarkRegex.test(character)
137
- || (character >= '\uFF00' && character <= '\uFFEF')
138
- ) {
139
- extra += eastAsianWidth(character.codePointAt(0), eastAsianWidthOptions);
140
- }
187
+ width += clusterWidth(segment, eastAsianWidthOptions);
141
188
  }
142
189
 
143
- return extra;
190
+ return width;
144
191
  }
145
192
 
146
193
  export default function stringWidth(input, options = {}) {
@@ -156,48 +203,11 @@ export default function stringWidth(input, options = {}) {
156
203
  let string = input;
157
204
 
158
205
  // Avoid calling stripAnsi when there are no ANSI escape sequences (ESC = 0x1B, CSI = 0x9B)
159
- if (!countAnsiEscapeCodes && (string.includes('\u001B') || string.includes('\u009B'))) {
206
+ if (!countAnsiEscapeCodes && (string.includes('\u{1B}') || string.includes('\u{9B}'))) {
160
207
  string = stripAnsi(string);
161
208
  }
162
209
 
163
- if (string.length === 0) {
164
- return 0;
165
- }
166
-
167
- // Fast path: printable ASCII (0x20–0x7E) needs no segmenter, regex, or EAW lookup — width equals length.
168
- if (/^[\u0020-\u007E]*$/.test(string)) {
169
- return string.length;
170
- }
171
-
172
- let width = 0;
173
- const eastAsianWidthOptions = {ambiguousAsWide: !ambiguousIsNarrow};
174
-
175
- for (const {segment} of segmenter.segment(string)) {
176
- // Zero-width / non-printing clusters
177
- const visibleSegment = baseVisible(segment);
178
- if (visibleSegment === undefined) {
179
- continue;
180
- }
181
-
182
- // Emoji width logic
183
- if (rgiEmojiRegex.test(segment) || isDoubleWidthNonRgiEmojiSequence(segment)) {
184
- width += 2;
185
- continue;
186
- }
187
-
188
- const hangulWidth = hangulClusterWidth(visibleSegment, eastAsianWidthOptions);
189
- if (hangulWidth !== undefined) {
190
- width += hangulWidth;
191
- continue;
192
- }
193
-
194
- // Everything else: EAW of the cluster’s first visible scalar
195
- const codePoint = visibleSegment.codePointAt(0);
196
- width += eastAsianWidth(codePoint, eastAsianWidthOptions);
197
-
198
- // Add width for trailing spacing marks and Halfwidth/Fullwidth Forms (e.g., ゙, ゚, ー)
199
- width += trailingWidth(visibleSegment, eastAsianWidthOptions);
200
- }
201
-
202
- 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;
203
213
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjser/string-width",
3
- "version": "8.3.0-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",
@@ -63,33 +59,31 @@
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.3.0",
67
+ "sourceVersion": "9.0.0",
74
68
  "cjserVersion": 2,
75
69
  "original": {
76
70
  "name": "string-width",
77
- "version": "8.3.0",
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