@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.
- package/dist-cjser/index.cjs +88 -96
- package/index.d.ts +2 -2
- package/index.js +141 -135
- package/package.json +11 -17
- package/readme.md +1 -1
package/dist-cjser/index.cjs
CHANGED
|
@@ -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-
|
|
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
|
|
39
|
-
var
|
|
40
|
-
var
|
|
41
|
-
var
|
|
42
|
-
var
|
|
43
|
-
var
|
|
44
|
-
function
|
|
45
|
-
if (
|
|
46
|
-
return
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
|
70
|
-
return 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
|
|
73
|
-
|
|
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 (
|
|
79
|
-
|
|
67
|
+
if (character === "\u200D") {
|
|
68
|
+
break;
|
|
80
69
|
}
|
|
81
|
-
|
|
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 (
|
|
84
|
-
return
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
116
|
+
suffixStart--;
|
|
104
117
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
116
|
-
extra += (0, import_get_east_asian_width.eastAsianWidth)(character.codePointAt(0), eastAsianWidthOptions);
|
|
117
|
-
}
|
|
135
|
+
width += clusterWidth(segment, eastAsianWidthOptions);
|
|
118
136
|
}
|
|
119
|
-
return
|
|
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
|
-
|
|
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
|
-
> -
|
|
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
|
|
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
|
|
9
|
-
2. RGI emoji
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
5. Otherwise
|
|
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
|
-
//
|
|
19
|
-
const
|
|
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
|
-
//
|
|
22
|
-
|
|
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.
|
|
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
|
|
26
|
-
|
|
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
|
-
//
|
|
29
|
-
|
|
30
|
-
const
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return pictographics !== null && pictographics.length >= 2;
|
|
48
|
+
let count = 0;
|
|
49
|
+
while (modifierRegex.test(string)) {
|
|
50
|
+
count++;
|
|
46
51
|
}
|
|
47
52
|
|
|
48
|
-
return
|
|
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
|
-
|
|
62
|
+
const index = segment.search(visibleCharacterRegex);
|
|
63
|
+
return index === -1 ? undefined : segment.slice(index);
|
|
53
64
|
}
|
|
54
65
|
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
86
|
+
if (visibleCharacterRegex.test(character)) {
|
|
87
|
+
width += eastAsianWidth(character.codePointAt(0), eastAsianWidthOptions);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
73
90
|
|
|
74
|
-
|
|
75
|
-
return isHangulLeadingJamo(codePoint)
|
|
76
|
-
|| isHangulVowelJamo(codePoint)
|
|
77
|
-
|| isHangulTrailingJamo(codePoint);
|
|
91
|
+
return width;
|
|
78
92
|
}
|
|
79
93
|
|
|
80
|
-
function
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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 (
|
|
92
|
-
return
|
|
122
|
+
if (textPresentationEmojiRegex.test(visibleSegment)) {
|
|
123
|
+
return 1;
|
|
93
124
|
}
|
|
94
125
|
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
159
|
+
if (suffixStart + 1 < string.length) {
|
|
160
|
+
width += string.length - suffixStart - 1;
|
|
161
|
+
string = string.slice(0, suffixStart + 1);
|
|
124
162
|
}
|
|
125
163
|
|
|
126
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
|
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('\
|
|
206
|
+
if (!countAnsiEscapeCodes && (string.includes('\u{1B}') || string.includes('\u{9B}'))) {
|
|
164
207
|
string = stripAnsi(string);
|
|
165
208
|
}
|
|
166
209
|
|
|
167
|
-
|
|
168
|
-
|
|
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": "
|
|
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
|
+
"node": ">=22"
|
|
25
21
|
},
|
|
26
22
|
"scripts": {
|
|
27
|
-
"test": "xo &&
|
|
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.
|
|
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
|
-
"
|
|
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": "
|
|
67
|
+
"sourceVersion": "9.0.0",
|
|
74
68
|
"cjserVersion": 2,
|
|
75
69
|
"original": {
|
|
76
70
|
"name": "string-width",
|
|
77
|
-
"version": "
|
|
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.
|
|
85
|
-
"strip-ansi": "^7.
|
|
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 &&
|
|
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
|
-
> -
|
|
52
|
+
> - https://www.unicode.org/reports/tr11/
|
|
53
53
|
|
|
54
54
|
##### countAnsiEscapeCodes
|
|
55
55
|
|