From 8857e5ea065f51b35d62bf03cd28fb90d7951b3c Mon Sep 17 00:00:00 2001 From: Alexander Lichter Date: Wed, 8 Jul 2026 18:16:59 +0200 Subject: [PATCH] Fix spacing mark width handling --- index.js | 20 ++++++++++++-------- test.js | 4 ++++ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/index.js b/index.js index cfc161a..42ec3a1 100644 --- a/index.js +++ b/index.js @@ -5,21 +5,22 @@ 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 Mark, lone Surrogates). Tabs are ignored by design. + 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 Halfwidth/Fullwidth Forms within the same cluster (e.g., dakuten/handakuten/prolonged sound mark). + 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(); // Whole-cluster zero-width -const zeroWidthClusterRegex = /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Format}|\p{Mark}|\p{Surrogate})+$/v; +const zeroWidthClusterRegex = /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Format}|\p{Nonspacing_Mark}|\p{Enclosing_Mark}|\p{Surrogate})+$/v; // Pick the base scalar if the cluster starts with Prepend/Format/Marks -const leadingNonPrintingRegex = /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v; +const leadingNonPrintingRegex = /^[\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; @@ -125,7 +126,7 @@ function hangulClusterWidth(visibleSegment, eastAsianWidthOptions) { return width; } -function trailingHalfwidthWidth(visibleSegment, eastAsianWidthOptions) { +function trailingWidth(visibleSegment, eastAsianWidthOptions) { let extra = 0; let first = true; @@ -135,7 +136,10 @@ function trailingHalfwidthWidth(visibleSegment, eastAsianWidthOptions) { continue; } - if (character >= '\uFF00' && character <= '\uFFEF') { + if ( + spacingMarkRegex.test(character) + || (character >= '\uFF00' && character <= '\uFFEF') + ) { extra += eastAsianWidth(character.codePointAt(0), eastAsianWidthOptions); } } @@ -195,8 +199,8 @@ export default function stringWidth(input, options = {}) { const codePoint = visibleSegment.codePointAt(0); width += eastAsianWidth(codePoint, eastAsianWidthOptions); - // Add width for trailing Halfwidth and Fullwidth Forms (e.g., ゙, ゚, ー) - width += trailingHalfwidthWidth(visibleSegment, eastAsianWidthOptions); + // Add width for trailing spacing marks and Halfwidth/Fullwidth Forms (e.g., ゙, ゚, ー) + width += trailingWidth(visibleSegment, eastAsianWidthOptions); } return width; diff --git a/test.js b/test.js index 0ec22bd..d5e496a 100644 --- a/test.js +++ b/test.js @@ -101,6 +101,10 @@ test('Indic conjunct via ZWJ', macro, 'क्\u200Dष', 1); test('combining diacritical mark', macro, 'e\u0301', 1); test('multiple combining marks', macro, 'e\u0301\u0302', 1); test('combining marks only', macro, '\u0301\u0302', 0); +test('Tibetan combining mark', macro, 'ཟླ', 1); +test('enclosing mark', macro, 'a\u20DD', 1); +test('spacing mark alone', macro, '\u093E', 1); +test('spacing mark after base character', macro, '\u0915\u093E', 2); // Surrogate pairs and high code points test('emoji surrogate pair', macro, '😀', 2);