diff --git a/tests/test_emojis.py b/tests/test_emojis.py index f49c9ef6..0627a77c 100644 --- a/tests/test_emojis.py +++ b/tests/test_emojis.py @@ -257,3 +257,55 @@ def test_vs16_effect(): # verify. assert length_each == expect_length_each assert length_phrase == expect_length_phrase + + +def test_control_after_zwj_returns_negative_one(): + """A C0/C1 control following a ZWJ must not be swallowed; result is -1. + + The BEL (U+0007) is a C0 control. Previously the character following a ZWJ was + skipped unconditionally, silently swallowing the control and yielding a positive + width. Per the docstring, C0/C1 controls must yield -1. + """ + # 'a' ZWJ BEL 'b': the BEL is a control and must surface as -1. + assert wcwidth.wcswidth('a‍\x07b') == -1 + assert wcwidth.wcstwidth('a‍\x07b', term_program=False) == -1 + # A control after a *valid* emoji ZWJ join must still yield -1. + assert wcwidth.wcswidth('\U0001F468‍\U0001F469‍\x07') == -1 + assert wcwidth.wcstwidth('\U0001F468‍\U0001F469‍\x07', term_program=False) == -1 + + +@pytest.mark.skipif(NARROW_ONLY, reason="Test cannot verify on python 'narrow' builds") +def test_wide_char_after_leading_zwj_is_measured(): + """A leading ZWJ has no base to join, so the following wide char is measured. + + Previously the character after the ZWJ was skipped unconditionally, swallowing the + CJK ideograph and yielding 0 instead of its true width of 2. + """ + # Leading ZWJ then CJK '一' (U+4E00, wide == 2). + assert wcwidth.wcwidth('一') == 2 + assert wcwidth.wcswidth('‍一') == 2 + assert wcwidth.wcstwidth('‍一', term_program=False) == 2 + + +@pytest.mark.skipif(NARROW_ONLY, reason="Test cannot verify on python 'narrow' builds") +def test_zwj_swallow_regression_sample(): + """Lock a sample of ZWJ-adjacent widths that must not regress with the fix. + + Legitimate emoji ZWJ-sequences still combine to a single cluster, and ZWJ following + any measured base still consumes the joined character. Only leading ZWJ and + following controls are treated differently. + """ + # (text, expected width) -- unchanged by the fix, must stay correct. + samples = ( + ('', 0), + ('hello', 5), + ('一二三', 6), # CJK, three wide chars + ('\U0001F468‍\U0001F469', 2), # man ZWJ woman -> combined + ('\U0001F468‍\U0001F469‍\U0001F467', 2), # man ZWJ woman ZWJ girl + ('\U0001F469\U0001F3FB', 2), # woman + fitzpatrick + ('\U0001F468‍a', 2), # ExtPict base ZWJ 'a' -> joined + ('世‍\U0001F600', 2), # CJK base ZWJ emoji -> joined + ) + for text, expected in samples: + assert wcwidth.wcswidth(text) == expected, text + assert wcwidth.wcstwidth(text, term_program=False) == expected, text diff --git a/tests/test_wcswidth_nonstr.py b/tests/test_wcswidth_nonstr.py new file mode 100644 index 00000000..24a4637e --- /dev/null +++ b/tests/test_wcswidth_nonstr.py @@ -0,0 +1,14 @@ +"""Tests for clear TypeError on non-string wcswidth() input.""" + +# 3rd party +import pytest + +# local +from wcwidth import wcswidth + + +def test_wcswidth_nonstr_raises_clear(): + with pytest.raises(TypeError, match='wcswidth\\(\\) expects a string'): + wcswidth(123) + assert wcswidth('hello') == 5 + assert wcswidth('') == 0 diff --git a/tests/test_wcwidth_multichar.py b/tests/test_wcwidth_multichar.py new file mode 100644 index 00000000..96903893 --- /dev/null +++ b/tests/test_wcwidth_multichar.py @@ -0,0 +1,15 @@ +"""Tests for clear error on multi-character wcwidth() input.""" + +# 3rd party +import pytest + +# local +from wcwidth import wcwidth + + +def test_wcwidth_multichar_raises_clear(): + with pytest.raises(TypeError, match='single character'): + wcwidth('ab') + assert wcwidth('a') == 1 + assert wcwidth(b'a') == 1 # backward-compat preserved + assert wcwidth(None) == 0 diff --git a/wcwidth/_wcswidth.py b/wcwidth/_wcswidth.py index 32488d5b..c1f54287 100644 --- a/wcwidth/_wcswidth.py +++ b/wcwidth/_wcswidth.py @@ -92,6 +92,8 @@ def wcswidth( # pylint: disable=unused-argument,too-many-locals,too-many-statements,redefined-variable-type # pylint: disable=too-complex,too-many-branches,duplicate-code,too-many-nested-blocks + if not isinstance(pwcs, str): + raise TypeError(f'wcswidth() expects a string, got {type(pwcs).__name__}') # Fast path: pure ASCII printable strings are always width == length if n is None and pwcs.isascii() and pwcs.isprintable(): return len(pwcs) @@ -107,6 +109,7 @@ def wcswidth( last_measured_w = 0 prev_was_virama = False cluster_width = 0 + seen_base = False # a base character has been measured (for ZWJ join gating) vs16_nw_table = VS16_NARROW_TO_WIDE['9.0.0'] vs15_wn_table = VS15_WIDE_TO_NARROW['9.0.0'] _bisearch = bisearch @@ -123,11 +126,22 @@ def wcswidth( if ucs == 0x200D: if prev_was_virama: idx += 1 - elif idx + 1 < end: + elif (idx + 1 < end + and seen_base + and _wcwidth(pwcs[idx + 1]) >= 0): + # ZWJ following a measured base continues the grapheme cluster, so the + # joined character is consumed without contributing additional width. + # A following C0/C1 control is excluded (its wcwidth is negative) so it + # is not swallowed and still yields -1 when measured by the loop. last_measured_w = 0 prev_was_virama = False idx += 2 else: + # No measured base to join to (e.g. a leading ZWJ), or a C0/C1 control + # follows: consume only the ZWJ (zero width) and let the loop measure + # the next character normally. This preserves -1 for a following + # control and counts a following wide/narrow character. + last_measured_w = 0 prev_was_virama = False idx += 1 continue @@ -181,6 +195,7 @@ def wcswidth( last_measured_ucs = ucs last_measured_w = w prev_was_virama = False + seen_base = True elif ucs in _ISC_VIRAMA_SET: prev_was_virama = True elif last_measured_idx >= 0 and _bisearch(ucs, _CATEGORY_MC_TABLE): @@ -274,6 +289,7 @@ def wcstwidth( last_measured_ucs = -1 last_measured_w = 0 prev_was_virama = False + seen_base = False # a base character has been measured (for ZWJ join gating) cluster_start = -1 total_before_cluster = 0 cluster_width = 0 @@ -317,9 +333,22 @@ def wcstwidth( continue # No override; ZWJ breaks VS adjacency. # VS16 already set last_measured_idx = -2, blocking further VS16. - last_measured_w = 0 - prev_was_virama = False - idx += 2 + if seen_base and _wcwidth(pwcs[idx + 1]) >= 0: + # ZWJ following a measured base continues the grapheme cluster, so + # the joined character is consumed without contributing width. A + # following C0/C1 control is excluded (its wcwidth is negative) so + # it is not swallowed and still yields -1 when measured by the loop. + last_measured_w = 0 + prev_was_virama = False + idx += 2 + else: + # No measured base to join to (e.g. a leading ZWJ), or a C0/C1 + # control follows: consume only the ZWJ and let the loop measure + # the next character normally. This preserves -1 for a following + # control and counts a following wide/narrow character. + last_measured_w = 0 + prev_was_virama = False + idx += 1 else: prev_was_virama = False idx += 1 @@ -415,6 +444,7 @@ def wcstwidth( last_measured_ucs = ucs last_measured_w = w prev_was_virama = False + seen_base = True elif ucs in _ISC_VIRAMA_SET: prev_was_virama = True elif last_measured_idx >= 0 and _bisearch(ucs, _CATEGORY_MC_TABLE): diff --git a/wcwidth/_wcwidth.py b/wcwidth/_wcwidth.py index 58877bcb..6ccdd6c6 100644 --- a/wcwidth/_wcwidth.py +++ b/wcwidth/_wcwidth.py @@ -135,6 +135,8 @@ def wcwidth(wc: str, unicode_version: str = 'auto', ambiguous_width: int = 1) -> See :ref:`Specification` for details of cell measurement. """ + if isinstance(wc, str) and len(wc) > 1: + raise TypeError(f'wcwidth() expects a single character, got a string of length {len(wc)}') ucs = ord(wc) if wc else 0 # small optimization: early return of 1 for printable ASCII, this provides