Skip to content

Commit 1ace54f

Browse files
committed
Fix width calculation for minimally-qualified emoji sequences
Fixes #67
1 parent 42e7b69 commit 1ace54f

File tree

2 files changed

+47
-2
lines changed

2 files changed

+47
-2
lines changed

index.js

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ Logic:
77
- Width rules:
88
1. Skip non-printing clusters (Default_Ignorable, Control, pure Mark, lone Surrogates). Tabs are ignored by design.
99
2. RGI emoji clusters (\p{RGI_Emoji}) are double-width.
10-
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).
10+
3. Minimally-qualified/unqualified emoji clusters (ZWJ sequences with 2+ Extended_Pictographic, or keycap sequences) are double-width.
11+
4. 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).
1112
*/
1213

1314
const segmenter = new Intl.Segmenter();
@@ -21,6 +22,30 @@ const leadingNonPrintingRegex = /^[\p{Default_Ignorable_Code_Point}\p{Control}\p
2122
// RGI emoji sequences
2223
const rgiEmojiRegex = /^\p{RGI_Emoji}$/v;
2324

25+
// Detect minimally-qualified/unqualified emoji clusters (missing VS16)
26+
// - ZWJ sequences with 2+ Extended_Pictographic (e.g., ❤‍🔥, 🏳‍🌈, ⛹‍♂)
27+
// - Keycap sequences (e.g., #⃣, 0⃣)
28+
const zwjRegex = /\u200D/;
29+
const validKeycapRegex = /^[\d#*].*\u20E3/;
30+
const extendedPictographicRegex = /\p{Extended_Pictographic}/gu;
31+
32+
function isDoubleWidthEmojiCluster(segment) {
33+
// Keycap sequences with valid base (0-9, #, *)
34+
if (validKeycapRegex.test(segment)) {
35+
return true;
36+
}
37+
38+
// ZWJ sequences with 2+ Extended_Pictographic
39+
if (zwjRegex.test(segment)) {
40+
const matches = segment.match(extendedPictographicRegex);
41+
if (matches && matches.length >= 2) {
42+
return true;
43+
}
44+
}
45+
46+
return false;
47+
}
48+
2449
function baseVisible(segment) {
2550
return segment.replace(leadingNonPrintingRegex, '');
2651
}
@@ -72,7 +97,7 @@ export default function stringWidth(input, options = {}) {
7297
}
7398

7499
// Emoji width logic
75-
if (rgiEmojiRegex.test(segment)) {
100+
if (rgiEmojiRegex.test(segment) || isDoubleWidthEmojiCluster(segment)) {
76101
width += 2;
77102
continue;
78103
}

test.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,3 +249,23 @@ test('digit zero as plain text (not emoji)', macro, '0', 1);
249249
test('digit one as plain text', macro, '1', 1);
250250
test('asterisk as plain text', macro, '*', 1);
251251
test('hash as plain text', macro, '#', 1);
252+
253+
// Minimally-qualified/unqualified emoji sequences
254+
// These are emoji sequences missing VS16 but should still be width 2
255+
test('heart on fire (MQ)', macro, '\u2764\u200D\u{1F525}', 2); // ❤‍🔥
256+
test('rainbow flag (MQ)', macro, '\u{1F3F3}\u200D\u{1F308}', 2); // 🏳‍🌈
257+
test('transgender flag (MQ)', macro, '\u{1F3F3}\u200D\u26A7', 2); // 🏳‍⚧
258+
test('broken chain (MQ)', macro, '\u26D3\u200D\u{1F4A5}', 2); // ⛓‍💥
259+
test('eye in speech bubble (MQ)', macro, '\u{1F441}\u200D\u{1F5E8}', 2); // 👁‍🗨
260+
test('man bouncing ball (MQ)', macro, '\u26F9\u200D\u2642', 2); // ⛹‍♂
261+
test('woman bouncing ball (MQ)', macro, '\u26F9\u200D\u2640', 2); // ⛹‍♀
262+
test('man detective (MQ)', macro, '\u{1F575}\u200D\u2642', 2); // 🕵‍♂
263+
test('woman detective (MQ)', macro, '\u{1F575}\u200D\u2640', 2); // 🕵‍♀
264+
265+
// Unqualified keycap sequences (missing VS16)
266+
test('keycap # (UQ)', macro, '#\u20E3', 2); // #⃣
267+
test('keycap 0 (UQ)', macro, '0\u20E3', 2); // 0⃣
268+
test('keycap * (UQ)', macro, '*\u20E3', 2); // *⃣
269+
270+
// Ensure invalid keycap sequences don't match
271+
test('phone + keycap (invalid)', macro, '\u260E\uFE0F\u20E3', 1); // Not a valid keycap base

0 commit comments

Comments
 (0)