Repository navigation
Expand file tree
/
Copy pathindex.js
More file actions
203 lines (161 loc) · 6.3 KB
/
Copy pathindex.js
File metadata and controls
203 lines (161 loc) · 6.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
import stripAnsi from 'strip-ansi';
import {eastAsianWidth} from 'get-east-asian-width';
/**
Logic:
- Segment graphemes to match how terminals render clusters.
- Width rules:
1. Skip non-printing clusters (Default_Ignorable, Control, pure nonspacing/enclosing Mark, lone Surrogates). Tabs are ignored by design.
2. RGI emoji clusters (\p{RGI_Emoji}) are double-width.
3. Minimally-qualified/unqualified emoji clusters (ZWJ sequences with 2+ Extended_Pictographic, or keycap sequences) are double-width.
4. Hangul jamo collapse each standard modern Hangul L+V or L+V+T syllable piece to width 2.
Unmatched repeated leading/vowel/trailing jamo stay additive because that matches how the terminals we target render them.
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).
*/
const segmenter = new Intl.Segmenter();
// 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.
// 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.
const visibleCharacterRegex = /[^\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Nonspacing_Mark}\p{Enclosing_Mark}\p{Surrogate}]/v;
const spacingMarkRegex = /\p{Spacing_Mark}/v;
// RGI emoji sequences
const rgiEmojiRegex = /^\p{RGI_Emoji}$/v;
// Detect minimally-qualified/unqualified emoji sequences (missing VS16 but still render as double-width)
const unqualifiedKeycapRegex = /^[\d#*]\u20E3$/;
const extendedPictographicRegex = /\p{Extended_Pictographic}/gu;
function isDoubleWidthNonRgiEmojiSequence(segment) {
// Real emoji clusters are < 30 chars; guard against pathological input
if (segment.length > 50) {
return false;
}
if (unqualifiedKeycapRegex.test(segment)) {
return true;
}
// ZWJ sequences with 2+ Extended_Pictographic
if (segment.includes('\u200D')) {
const pictographics = segment.match(extendedPictographicRegex);
return pictographics !== null && pictographics.length >= 2;
}
return false;
}
// Returns the cluster starting at its first visible character, or `undefined` if the whole cluster is zero-width.
function baseVisible(segment) {
const index = segment.search(visibleCharacterRegex);
return index === -1 ? undefined : segment.slice(index);
}
function isHangulLeadingJamo(codePoint) {
return (codePoint >= 0x11_00 && codePoint <= 0x11_5F)
|| (codePoint >= 0xA9_60 && codePoint <= 0xA9_7C);
}
function isHangulVowelJamo(codePoint) {
return (codePoint >= 0x11_60 && codePoint <= 0x11_A7)
|| (codePoint >= 0xD7_B0 && codePoint <= 0xD7_C6);
}
function isHangulTrailingJamo(codePoint) {
return (codePoint >= 0x11_A8 && codePoint <= 0x11_FF)
|| (codePoint >= 0xD7_CB && codePoint <= 0xD7_FB);
}
function isHangulJamo(codePoint) {
return isHangulLeadingJamo(codePoint)
|| isHangulVowelJamo(codePoint)
|| isHangulTrailingJamo(codePoint);
}
function hangulClusterWidth(visibleSegment, eastAsianWidthOptions) {
const codePoints = [];
for (const character of visibleSegment) {
if (!visibleCharacterRegex.test(character)) {
continue;
}
codePoints.push(character.codePointAt(0));
}
if (codePoints.length === 0) {
return undefined;
}
let width = 0;
for (let index = 0; index < codePoints.length; index++) {
const codePoint = codePoints[index];
if (!isHangulJamo(codePoint)) {
if (width === 0) {
return undefined;
}
// Mixed cluster (e.g., L + precomposed syllable): use EAW for non-jamo remainder
for (let remaining = index; remaining < codePoints.length; remaining++) {
width += eastAsianWidth(codePoints[remaining], eastAsianWidthOptions);
}
return width;
}
// Modern Hangul L+V(+T) shapes as one syllable block. Unmatched jamo stay additive:
// U+1100 U+1100 U+1161 => U+1100 + (U+1100 U+1161) => 2 + 2.
if (
isHangulLeadingJamo(codePoint)
&& isHangulVowelJamo(codePoints[index + 1])
) {
width += 2;
index += isHangulTrailingJamo(codePoints[index + 2]) ? 2 : 1;
continue;
}
width += eastAsianWidth(codePoint, eastAsianWidthOptions);
}
return width;
}
function trailingWidth(visibleSegment, eastAsianWidthOptions) {
let extra = 0;
let first = true;
for (const character of visibleSegment) {
if (first) {
first = false;
continue;
}
if (
spacingMarkRegex.test(character)
|| (character >= '\uFF00' && character <= '\uFFEF')
) {
extra += eastAsianWidth(character.codePointAt(0), eastAsianWidthOptions);
}
}
return extra;
}
export default function stringWidth(input, options = {}) {
if (typeof input !== 'string' || input.length === 0) {
return 0;
}
const {
ambiguousIsNarrow = true,
countAnsiEscapeCodes = false,
} = options;
let string = input;
// Avoid calling stripAnsi when there are no ANSI escape sequences (ESC = 0x1B, CSI = 0x9B)
if (!countAnsiEscapeCodes && (string.includes('\u001B') || string.includes('\u009B'))) {
string = stripAnsi(string);
}
if (string.length === 0) {
return 0;
}
// Fast path: printable ASCII (0x20–0x7E) needs no segmenter, regex, or EAW lookup — width equals length.
if (/^[\u0020-\u007E]*$/.test(string)) {
return string.length;
}
let width = 0;
const eastAsianWidthOptions = {ambiguousAsWide: !ambiguousIsNarrow};
for (const {segment} of segmenter.segment(string)) {
// Zero-width / non-printing clusters
const visibleSegment = baseVisible(segment);
if (visibleSegment === undefined) {
continue;
}
// Emoji width logic
if (rgiEmojiRegex.test(segment) || isDoubleWidthNonRgiEmojiSequence(segment)) {
width += 2;
continue;
}
const hangulWidth = hangulClusterWidth(visibleSegment, eastAsianWidthOptions);
if (hangulWidth !== undefined) {
width += hangulWidth;
continue;
}
// Everything else: EAW of the cluster’s first visible scalar
const codePoint = visibleSegment.codePointAt(0);
width += eastAsianWidth(codePoint, eastAsianWidthOptions);
// Add width for trailing spacing marks and Halfwidth/Fullwidth Forms (e.g., ゙, ゚, ー)
width += trailingWidth(visibleSegment, eastAsianWidthOptions);
}
return width;
}