Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions tests/test_emojis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 14 additions & 0 deletions tests/test_wcswidth_nonstr.py
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions tests/test_wcwidth_multichar.py
Original file line number Diff line number Diff line change
@@ -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
38 changes: 34 additions & 4 deletions wcwidth/_wcswidth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__}')

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This library uses typing and the argument is typed for str. This extra isinstance check is not wanted or necessary

# Fast path: pure ASCII printable strings are always width == length
if n is None and pwcs.isascii() and pwcs.isprintable():
return len(pwcs)
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions wcwidth/_wcwidth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading