Skip to content

Commit bf041e0

Browse files
committed
Add more tests and simplify implementation
Fixes #66
1 parent 8691a94 commit bf041e0

File tree

2 files changed

+93
-27
lines changed

2 files changed

+93
-27
lines changed

index.js

Lines changed: 2 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Logic:
66
- Segment graphemes to match how terminals render clusters.
77
- Width rules:
88
1. Skip non-printing clusters (Default_Ignorable, Control, pure Mark, lone Surrogates). Tabs are ignored by design.
9-
2. Emoji clusters are double-width only when VS16 is present, the base has Emoji_Presentation (and not VS15), or the cluster has multiple scalars (flags, ZWJ, keycaps, tags, etc.).
9+
2. RGI emoji clusters (\p{RGI_Emoji}) are double-width.
1010
3. Otherwise use East Asian Width of the cluster’s first visible code point, and add widths for trailing Halfwidth/Fullwidth Forms within the same cluster (e.g., dakuten/handakuten/prolonged sound mark).
1111
*/
1212

@@ -20,8 +20,6 @@ const leadingNonPrintingRegex = /^[\p{Default_Ignorable_Code_Point}\p{Control}\p
2020

2121
// RGI emoji sequences
2222
const rgiEmojiRegex = /^\p{RGI_Emoji}$/v;
23-
// Default emoji presentation (single-scalar emoji without VS16)
24-
const emojiPresentationRegex = /^\p{Emoji_Presentation}$/v;
2523

2624
function baseVisible(segment) {
2725
return segment.replace(leadingNonPrintingRegex, '');
@@ -31,29 +29,6 @@ function isZeroWidthCluster(segment) {
3129
return zeroWidthClusterRegex.test(segment);
3230
}
3331

34-
function isDoubleWidthEmojiCluster(segment) {
35-
const hasVs16 = segment.includes('\uFE0F');
36-
37-
if (hasVs16) {
38-
return true;
39-
}
40-
41-
const visible = baseVisible(segment);
42-
const baseScalar = visible.codePointAt(0);
43-
const baseChar = String.fromCodePoint(baseScalar);
44-
const baseIsEmojiPresentation = emojiPresentationRegex.test(baseChar);
45-
const hasVs15 = segment.includes('\uFE0E');
46-
47-
if (baseIsEmojiPresentation && !hasVs15) {
48-
return true;
49-
}
50-
51-
const codePointCount = [...segment].length;
52-
const multiScalarMeaningful = codePointCount > 1 && !(codePointCount === 2 && hasVs15 && !hasVs16);
53-
54-
return multiScalarMeaningful;
55-
}
56-
5732
function trailingHalfwidthWidth(segment, eastAsianWidthOptions) {
5833
let extra = 0;
5934
if (segment.length > 1) {
@@ -97,7 +72,7 @@ export default function stringWidth(input, options = {}) {
9772
}
9873

9974
// Emoji width logic
100-
if (rgiEmojiRegex.test(segment) && isDoubleWidthEmojiCluster(segment)) {
75+
if (rgiEmojiRegex.test(segment)) {
10176
width += 2;
10277
continue;
10378
}

test.js

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,3 +158,94 @@ test('prepend + emoji', macro, '\u0600😀', 2);
158158

159159
// Sequence consisting only of default ignorables should be zero
160160
test('only default ignorables (tags)', macro, '\u{E0020}\u{E007F}', 0);
161+
162+
// Emoji coverage for RGI and related cases
163+
// Case 1: Emoji with VS16 (should be double-width)
164+
test('digit with VS16 (keycap base)', macro, '0\uFE0F', 1); // Digits/asterisk/pound are not RGI emoji
165+
test('asterisk with VS16', macro, '*\uFE0F', 1);
166+
test('pound with VS16', macro, '#\uFE0F', 1);
167+
test('trademark with VS16', macro, '™\uFE0F', 2);
168+
test('watch with VS16', macro, '⌚\uFE0F', 2);
169+
test('phone with VS16', macro, '☎\uFE0F', 2);
170+
test('keyboard with VS16', macro, '⌨\uFE0F', 2);
171+
test('envelope with VS16', macro, '✉\uFE0F', 2);
172+
173+
// Case 2: Emoji_Presentation characters without VS15 (should be double-width)
174+
test('face emoji (has Emoji_Presentation)', macro, '😀', 2);
175+
test('heart emoji (has Emoji_Presentation)', macro, '❤️', 2);
176+
test('fire emoji (has Emoji_Presentation)', macro, '🔥', 2);
177+
test('rocket emoji (has Emoji_Presentation)', macro, '🚀', 2);
178+
test('star emoji (has Emoji_Presentation)', macro, '⭐', 2);
179+
180+
// Case 3: Emoji_Presentation with VS15 (should be single-width)
181+
test('heart with VS15 (text style)', macro, '❤\uFE0E', 1);
182+
test('star with VS15 (text style)', macro, '⭐\uFE0E', 2); // Star with VS15 still renders as 2 in terminals
183+
184+
// Case 4: Multi-scalar meaningful sequences (should be double-width)
185+
test('keycap sequence 0️⃣', macro, '0️⃣', 2);
186+
test('keycap sequence 1️⃣', macro, '1️⃣', 2);
187+
test('keycap sequence 9️⃣', macro, '9️⃣', 2);
188+
test('keycap sequence *️⃣', macro, '*️⃣', 2);
189+
test('keycap sequence #️⃣', macro, '#️⃣', 2);
190+
test('flag emoji GB', macro, '🇬🇧', 2);
191+
test('flag emoji JP', macro, '🇯🇵', 2);
192+
test('skin tone modifier', macro, '👋🏻', 2);
193+
test('skin tone modifier dark', macro, '👋🏿', 2);
194+
test('couple with heart', macro, '👨‍❤️‍👨', 2);
195+
test('woman technologist', macro, '👩‍💻', 2);
196+
test('man health worker', macro, '👨‍⚕️', 2);
197+
198+
// Case 5: Non-emoji presentation without VS16 (should be single-width via EAW)
199+
test('watch without VS', macro, '⌚', 2); // Watch is actually RGI emoji with Emoji_Presentation
200+
test('phone without VS (not Emoji_Presentation)', macro, '☎', 1);
201+
test('keyboard without VS (not Emoji_Presentation)', macro, '⌨', 1);
202+
test('envelope without VS (not Emoji_Presentation)', macro, '✉', 1);
203+
204+
// Case 6: Edge cases for multi-scalar logic
205+
test('single code point with VS15 (2 scalars but not meaningful)', macro, 'A\uFE0E', 1);
206+
test('emoji with VS15 only (2 scalars, VS15 present)', macro, '⌚\uFE0E', 2); // Watch with VS15 still renders as 2
207+
test('three code points with VS15 at end', macro, 'A👋\uFE0E', 3);
208+
209+
// Case 7: RGI emoji coverage
210+
test('RGI emoji face', macro, '😊', 2);
211+
test('RGI emoji hand', macro, '✋', 2);
212+
test('RGI emoji animal', macro, '🐶', 2);
213+
test('RGI emoji food', macro, '🍕', 2);
214+
test('RGI emoji flag', macro, '🏁', 2);
215+
216+
// Case 8: Complex emoji sequences that test all branches
217+
test('emoji with combining mark', macro, '😀\u0301', 2);
218+
test('emoji with ZWJ and another emoji', macro, '😀\u200D😀', 2);
219+
test('text character made emoji with VS16', macro, '↔\uFE0F', 2);
220+
test('text character kept text with VS15', macro, '↔\uFE0E', 1);
221+
222+
// Case 9: Non-RGI sequences
223+
test('non-RGI with VS16', macro, '〰\uFE0F', 2);
224+
test('non-RGI multi-scalar', macro, '☎\uFE0F\u20E3', 1); // Not RGI emoji, counts as 1
225+
226+
// Case 10: VS15 on Emoji_Presentation remain width 2 in most terminals via EAW
227+
test('hourglass with VS15', macro, '⌛\uFE0E', 2);
228+
test('fast-forward with VS15', macro, '⏩\uFE0E', 2);
229+
test('rewind with VS15', macro, '⏪\uFE0E', 2);
230+
test('arrow double up with VS15', macro, '⏫\uFE0E', 2);
231+
test('arrow double down with VS15', macro, '⏬\uFE0E', 2);
232+
test('alarm clock with VS15', macro, '⏰\uFE0E', 2);
233+
test('hourglass flowing sand with VS15', macro, '⏳\uFE0E', 2);
234+
test('umbrella with rain with VS15', macro, '☔\uFE0E', 2);
235+
test('hot beverage with VS15', macro, '☕\uFE0E', 2);
236+
// Removed redundant duplicate of '⭐\uFE0E' test
237+
238+
// Other emoji that may not have Emoji_Presentation but are affected
239+
test('sun with VS15', macro, '☀\uFE0E', 1);
240+
test('cloud with VS15', macro, '☁\uFE0E', 1);
241+
test('umbrella with VS15', macro, '☂\uFE0E', 1);
242+
test('snowman with VS15', macro, '☃\uFE0E', 1);
243+
test('comet with VS15', macro, '☄\uFE0E', 1);
244+
test('black nib with VS15', macro, '✒\uFE0E', 1);
245+
test('heavy check mark with VS15', macro, '✔\uFE0E', 1);
246+
247+
// More edge cases for single-scalar text characters (not emoji)
248+
test('digit zero as plain text (not emoji)', macro, '0', 1);
249+
test('digit one as plain text', macro, '1', 1);
250+
test('asterisk as plain text', macro, '*', 1);
251+
test('hash as plain text', macro, '#', 1);

0 commit comments

Comments
 (0)