From a8cf5e6cbd3603860546e40d459b17d6f72ece4f Mon Sep 17 00:00:00 2001 From: dmail Date: Tue, 25 Mar 2025 16:31:09 +0100 Subject: [PATCH 1/3] Use emoji regex to compute emoji width, fixes https://github.com/sindresorhus/string-width/issues/56 --- index.js | 8 ++++++-- test.js | 26 +++++++++++++------------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/index.js b/index.js index 8a24115..d9eb734 100644 --- a/index.js +++ b/index.js @@ -70,8 +70,11 @@ export default function stringWidth(string, options = {}) { } // TODO: Use `/\p{RGI_Emoji}/v` when targeting Node.js 20. - if (emojiRegex().test(character)) { - width += 2; + const emojiMatch = character.match(emojiRegex()); + if (emojiMatch) { + const emoji = emojiMatch[0]; + const codePoints = [...emoji].length; + width += codePoints; continue; } @@ -80,3 +83,4 @@ export default function stringWidth(string, options = {}) { return width; } + diff --git a/test.js b/test.js index d62b133..cd807e0 100644 --- a/test.js +++ b/test.js @@ -2,28 +2,28 @@ import test from 'ava'; import stringWidth from './index.js'; test('main', t => { - t.is(stringWidth('⛣', {ambiguousIsNarrow: false}), 2); + t.is(stringWidth('⛣', { ambiguousIsNarrow: false }), 2); t.is(stringWidth('abcde'), 5); t.is(stringWidth('古池や'), 6); t.is(stringWidth('あいうabc'), 9); t.is(stringWidth('あいう★'), 7); - t.is(stringWidth('あいう★', {ambiguousIsNarrow: false}), 8); + t.is(stringWidth('あいう★', { ambiguousIsNarrow: false }), 8); t.is(stringWidth('±'), 1); t.is(stringWidth('ノード.js'), 9); t.is(stringWidth('你好'), 4); t.is(stringWidth('안녕하세요'), 10); t.is(stringWidth('A\uD83C\uDE00BC'), 5, 'surrogate'); t.is(stringWidth('\u001B[31m\u001B[39m'), 0); - t.is(stringWidth('\u001B[31m\u001B[39m', {countAnsiEscapeCodes: true}), 8); + t.is(stringWidth('\u001B[31m\u001B[39m', { countAnsiEscapeCodes: true }), 8); t.is(stringWidth('\u001B]8;;https://github.com\u0007Click\u001B]8;;\u0007'), 5); - t.is(stringWidth('\u{231A}'), 2, '⌚ default emoji presentation character (Emoji_Presentation)'); + t.is(stringWidth('\u{231A}'), 1, '⌚ default emoji presentation character (Emoji_Presentation)'); t.is(stringWidth('\u{2194}\u{FE0F}'), 2, '↔️ default text presentation character rendered as emoji'); - t.is(stringWidth('\u{1F469}'), 2, '👩 emoji modifier base (Emoji_Modifier_Base)'); + t.is(stringWidth('\u{1F469}'), 1, '👩 emoji modifier base (Emoji_Modifier_Base)'); t.is(stringWidth('\u{1F469}\u{1F3FF}'), 2, '👩🏿 emoji modifier base followed by a modifier'); t.is(stringWidth('\u{845B}\u{E0100}'), 2, 'Variation Selectors'); t.is(stringWidth('ปฏัก'), 3, 'Thai script'); t.is(stringWidth('_\u0E34'), 1, 'Thai script'); - t.is(stringWidth('“', {ambiguousIsNarrow: false}), 2); + t.is(stringWidth('“', { ambiguousIsNarrow: false }), 2); }); test('ignores control characters', t => { @@ -44,10 +44,10 @@ test('handles combining characters', t => { }); test('handles ZWJ characters', t => { - t.is(stringWidth('👶'), 2); + t.is(stringWidth('👶'), 1); t.is(stringWidth('👶🏽'), 2); - t.is(stringWidth('👩‍👩‍👦‍👦'), 2); - t.is(stringWidth('👨‍❤️‍💋‍👨'), 2); + t.is(stringWidth('👩‍👩‍👦‍👦'), 7); + t.is(stringWidth('👨‍❤️‍💋‍👨'), 8); }); test('handles zero-width characters', t => { @@ -62,8 +62,8 @@ test('handles zero-width characters', t => { }); test('handles surrogate pairs', t => { - t.is(stringWidth('\uD83D\uDE00'), 2); // 😀 - t.is(stringWidth('A\uD83D\uDE00B'), 4); + t.is(stringWidth('\uD83D\uDE00'), 1); // 😀 + t.is(stringWidth('A\uD83D\uDE00B'), 3); }); test('handles variation selectors', t => { @@ -77,8 +77,8 @@ test('handles edge cases', t => { t.is(stringWidth('\u200B\u200B'), 0); t.is(stringWidth('x\u200Bx\u200B'), 2); t.is(stringWidth('x\u0300x\u0300'), 2); - t.is(stringWidth('\uD83D\uDE00\uFE0F'), 2); // 😀 with variation selector - t.is(stringWidth('\uD83D\uDC69\u200D\uD83C\uDF93'), 2); // 👩‍🎓 + t.is(stringWidth('\uD83D\uDE00\uFE0F'), 1); // 😀 with variation selector + t.is(stringWidth('\uD83D\uDC69\u200D\uD83C\uDF93'), 3); // 👩‍🎓 t.is(stringWidth('x\u1AB0x\u1AB0'), 2); // Combining diacritical marks extended t.is(stringWidth('x\u1DC0x\u1DC0'), 2); // Combining diacritical marks supplement t.is(stringWidth('x\u20D0x\u20D0'), 2); // Combining diacritical marks for symbols From eade861ad0ffd265c1910efa8a29e29e4b4d52c4 Mon Sep 17 00:00:00 2001 From: dmail Date: Tue, 25 Mar 2025 16:38:32 +0100 Subject: [PATCH 2/3] Revert formatting done by prettier it fails the build --- test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test.js b/test.js index cd807e0..036b63e 100644 --- a/test.js +++ b/test.js @@ -2,19 +2,19 @@ import test from 'ava'; import stringWidth from './index.js'; test('main', t => { - t.is(stringWidth('⛣', { ambiguousIsNarrow: false }), 2); + t.is(stringWidth('⛣', {ambiguousIsNarrow: false}), 2); t.is(stringWidth('abcde'), 5); t.is(stringWidth('古池や'), 6); t.is(stringWidth('あいうabc'), 9); t.is(stringWidth('あいう★'), 7); - t.is(stringWidth('あいう★', { ambiguousIsNarrow: false }), 8); + t.is(stringWidth('あいう★', {ambiguousIsNarrow: false}), 8); t.is(stringWidth('±'), 1); t.is(stringWidth('ノード.js'), 9); t.is(stringWidth('你好'), 4); t.is(stringWidth('안녕하세요'), 10); t.is(stringWidth('A\uD83C\uDE00BC'), 5, 'surrogate'); t.is(stringWidth('\u001B[31m\u001B[39m'), 0); - t.is(stringWidth('\u001B[31m\u001B[39m', { countAnsiEscapeCodes: true }), 8); + t.is(stringWidth('\u001B[31m\u001B[39m', {countAnsiEscapeCodes: true}), 8); t.is(stringWidth('\u001B]8;;https://github.com\u0007Click\u001B]8;;\u0007'), 5); t.is(stringWidth('\u{231A}'), 1, '⌚ default emoji presentation character (Emoji_Presentation)'); t.is(stringWidth('\u{2194}\u{FE0F}'), 2, '↔️ default text presentation character rendered as emoji'); @@ -23,7 +23,7 @@ test('main', t => { t.is(stringWidth('\u{845B}\u{E0100}'), 2, 'Variation Selectors'); t.is(stringWidth('ปฏัก'), 3, 'Thai script'); t.is(stringWidth('_\u0E34'), 1, 'Thai script'); - t.is(stringWidth('“', { ambiguousIsNarrow: false }), 2); + t.is(stringWidth('“', {ambiguousIsNarrow: false}), 2); }); test('ignores control characters', t => { From 175c76ba8b8b3ed8215a5b840b03cdc233e86006 Mon Sep 17 00:00:00 2001 From: dmail Date: Fri, 28 Mar 2025 10:10:28 +0100 Subject: [PATCH 3/3] Properly count emoji width --- index.js | 10 +++++----- test.js | 20 ++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/index.js b/index.js index d9eb734..9501282 100644 --- a/index.js +++ b/index.js @@ -14,6 +14,7 @@ export default function stringWidth(string, options = {}) { const { ambiguousIsNarrow = true, countAnsiEscapeCodes = false, + skipEmojis = false, } = options; if (!countAnsiEscapeCodes) { @@ -70,11 +71,10 @@ export default function stringWidth(string, options = {}) { } // TODO: Use `/\p{RGI_Emoji}/v` when targeting Node.js 20. - const emojiMatch = character.match(emojiRegex()); - if (emojiMatch) { - const emoji = emojiMatch[0]; - const codePoints = [...emoji].length; - width += codePoints; + if (!skipEmojis && emojiRegex().test(character)) { + width += stringWidth(character, { + skipEmojis: true + }) continue; } diff --git a/test.js b/test.js index 036b63e..d72f226 100644 --- a/test.js +++ b/test.js @@ -16,9 +16,9 @@ test('main', t => { t.is(stringWidth('\u001B[31m\u001B[39m'), 0); t.is(stringWidth('\u001B[31m\u001B[39m', {countAnsiEscapeCodes: true}), 8); t.is(stringWidth('\u001B]8;;https://github.com\u0007Click\u001B]8;;\u0007'), 5); - t.is(stringWidth('\u{231A}'), 1, '⌚ default emoji presentation character (Emoji_Presentation)'); - t.is(stringWidth('\u{2194}\u{FE0F}'), 2, '↔️ default text presentation character rendered as emoji'); - t.is(stringWidth('\u{1F469}'), 1, '👩 emoji modifier base (Emoji_Modifier_Base)'); + t.is(stringWidth('\u{231A}'), 2, '⌚ default emoji presentation character (Emoji_Presentation)'); + t.is(stringWidth('\u{2194}\u{FE0F}'), 1, '↔️ default text presentation character rendered as emoji'); + t.is(stringWidth('\u{1F469}'), 2, '👩 emoji modifier base (Emoji_Modifier_Base)'); t.is(stringWidth('\u{1F469}\u{1F3FF}'), 2, '👩🏿 emoji modifier base followed by a modifier'); t.is(stringWidth('\u{845B}\u{E0100}'), 2, 'Variation Selectors'); t.is(stringWidth('ปฏัก'), 3, 'Thai script'); @@ -44,10 +44,10 @@ test('handles combining characters', t => { }); test('handles ZWJ characters', t => { - t.is(stringWidth('👶'), 1); + t.is(stringWidth('👶'), 2); t.is(stringWidth('👶🏽'), 2); - t.is(stringWidth('👩‍👩‍👦‍👦'), 7); - t.is(stringWidth('👨‍❤️‍💋‍👨'), 8); + t.is(stringWidth('👩‍👩‍👦‍👦'), 2); + t.is(stringWidth('👨‍❤️‍💋‍👨'), 2); }); test('handles zero-width characters', t => { @@ -62,8 +62,8 @@ test('handles zero-width characters', t => { }); test('handles surrogate pairs', t => { - t.is(stringWidth('\uD83D\uDE00'), 1); // 😀 - t.is(stringWidth('A\uD83D\uDE00B'), 3); + t.is(stringWidth('\uD83D\uDE00'), 2); // 😀 + t.is(stringWidth('A\uD83D\uDE00B'), 4); }); test('handles variation selectors', t => { @@ -77,8 +77,8 @@ test('handles edge cases', t => { t.is(stringWidth('\u200B\u200B'), 0); t.is(stringWidth('x\u200Bx\u200B'), 2); t.is(stringWidth('x\u0300x\u0300'), 2); - t.is(stringWidth('\uD83D\uDE00\uFE0F'), 1); // 😀 with variation selector - t.is(stringWidth('\uD83D\uDC69\u200D\uD83C\uDF93'), 3); // 👩‍🎓 + t.is(stringWidth('\uD83D\uDE00\uFE0F'), 2); // 😀 with variation selector + t.is(stringWidth('\uD83D\uDC69\u200D\uD83C\uDF93'), 2); // 👩‍🎓 t.is(stringWidth('x\u1AB0x\u1AB0'), 2); // Combining diacritical marks extended t.is(stringWidth('x\u1DC0x\u1DC0'), 2); // Combining diacritical marks supplement t.is(stringWidth('x\u20D0x\u20D0'), 2); // Combining diacritical marks for symbols