Skip to content

Commit b297eaf

Browse files
authored
New functions: iter_graphemes_reverse(), grapheme_boundary_before() (#192)
As I audit and integrate with dependent libraries, in [urwid](https://github.com/urwid/urwid/) I find the need to help "move the cursor left" and look backwards from a location in a string to the previous boundary, while looking forward using ``iter_graphemes()``. Looking backwards is provided by new function ``grapheme_boundary_before()``. The more obvious function, ``iter_graphemes_reverse()`` is provided (though not useful to urwid). @penguinolog
1 parent b1cdbdf commit b297eaf

8 files changed

Lines changed: 304 additions & 9 deletions

File tree

docs/api.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ requirements.txt or equivalent. Their signatures will never change.
1616

1717
.. autofunction:: wcwidth.iter_graphemes
1818

19+
.. autofunction:: wcwidth.iter_graphemes_reverse
20+
21+
.. autofunction:: wcwidth.grapheme_boundary_before
22+
1923
.. autofunction:: wcwidth.ljust
2024

2125
.. autofunction:: wcwidth.rjust

docs/intro.rst

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,10 @@ Text-justification is solved by the grapheme and sequence-aware functions `ljust
4848
of the same names.
4949

5050
The iterator functions `iter_graphemes()`_ and `iter_sequences()`_ allow for careful navigation of
51-
grapheme and terminal control sequence boundaries. The `clip()`_ function extracts substrings by
52-
display column positions, and `strip_sequences()`_ removes terminal escape sequences from text.
51+
grapheme and terminal control sequence boundaries. `iter_graphemes_reverse()`_, and
52+
`grapheme_boundary_before()`_ are useful for editing and searching of complex unicode. The
53+
`clip()`_ function extracts substrings by display column positions, and `strip_sequences()`_ removes
54+
terminal escape sequences from text altogether.
5355

5456
Discrepancies
5557
-------------
@@ -442,7 +444,8 @@ languages.
442444
History
443445
=======
444446

445-
*next release*
447+
0.4.0 *2026-01-25*
448+
* **New** Functions `iter_graphemes_reverse()`_, `grapheme_boundary_before()`_.
446449
* **Bugfix** OSC Hyperlinks should not be broken by ``wrap()``
447450

448451
0.3.5 *2026-01-24*
@@ -663,6 +666,8 @@ https://www.cl.cam.ac.uk/~mgk25/ucs/wcwidth.c::
663666
.. _`wcswidth()`: https://wcwidth.readthedocs.io/en/latest/api.html#wcwidth.wcswidth
664667
.. _`width()`: https://wcwidth.readthedocs.io/en/latest/api.html#wcwidth.width
665668
.. _`iter_graphemes()`: https://wcwidth.readthedocs.io/en/latest/api.html#wcwidth.iter_graphemes
669+
.. _`iter_graphemes_reverse()`: https://wcwidth.readthedocs.io/en/latest/api.html#wcwidth.iter_graphemes_reverse
670+
.. _`grapheme_boundary_before()`: https://wcwidth.readthedocs.io/en/latest/api.html#wcwidth.grapheme_boundary_before
666671
.. _`ljust()`: https://wcwidth.readthedocs.io/en/latest/api.html#wcwidth.ljust
667672
.. _`rjust()`: https://wcwidth.readthedocs.io/en/latest/api.html#wcwidth.rjust
668673
.. _`center()`: https://wcwidth.readthedocs.io/en/latest/api.html#wcwidth.center

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ requires = [ "hatchling" ]
44

55
[project]
66
name = "wcwidth"
7-
version = "0.3.5"
7+
version = "0.4.0"
88
description = "Measures the displayed width of unicode strings in a terminal"
99
readme = "README.rst"
1010
keywords = [

tests/test_benchmarks.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Performance benchmarks for wcwidth module."""
22
# std imports
33
import os
4+
import unicodedata
45

56
# local
67
import wcwidth
@@ -54,6 +55,31 @@ def test_wcswidth_emoji_sequence(benchmark):
5455
benchmark(wcwidth.wcswidth, text)
5556

5657

58+
# NFC vs NFD comparison - text with combining marks
59+
DIACRITICS_COMPOSED = 'café résumé naïve ' * 100
60+
DIACRITICS_DECOMPOSED = unicodedata.normalize('NFD', DIACRITICS_COMPOSED)
61+
62+
63+
def test_wcswidth_composed(benchmark):
64+
"""Benchmark wcswidth() with NFC-composed text."""
65+
benchmark(wcwidth.wcswidth, DIACRITICS_COMPOSED)
66+
67+
68+
def test_wcswidth_decomposed(benchmark):
69+
"""Benchmark wcswidth() with NFD-decomposed text."""
70+
benchmark(wcwidth.wcswidth, DIACRITICS_DECOMPOSED)
71+
72+
73+
def test_width_composed(benchmark):
74+
"""Benchmark width() with NFC-composed text."""
75+
benchmark(wcwidth.width, DIACRITICS_COMPOSED)
76+
77+
78+
def test_width_decomposed(benchmark):
79+
"""Benchmark width() with NFD-decomposed text."""
80+
benchmark(wcwidth.width, DIACRITICS_DECOMPOSED)
81+
82+
5783
def test_width_ascii(benchmark):
5884
"""Benchmark width() with ASCII string."""
5985
benchmark(wcwidth.width, 'hello world')
@@ -88,6 +114,36 @@ def test_iter_graphemes_combining(benchmark):
88114
benchmark(lambda: list(wcwidth.iter_graphemes(text)))
89115

90116

117+
def test_grapheme_boundary_before_short(benchmark):
118+
"""Benchmark grapheme_boundary_before() near start of short string."""
119+
text = 'Hello 👋🏻!'
120+
benchmark(wcwidth.grapheme_boundary_before, text, 8)
121+
122+
123+
def test_grapheme_boundary_before_long_end(benchmark):
124+
"""Benchmark grapheme_boundary_before() near end of long line."""
125+
text = 'x' * 95 + '👨\u200d👩\u200d👧!'
126+
benchmark(wcwidth.grapheme_boundary_before, text, 100)
127+
128+
129+
def test_grapheme_boundary_before_long_mid(benchmark):
130+
"""Benchmark grapheme_boundary_before() in middle of long line."""
131+
text = 'x' * 50 + '👨\u200d👩\u200d👧' + 'y' * 50
132+
benchmark(wcwidth.grapheme_boundary_before, text, 55)
133+
134+
135+
def test_iter_graphemes_reverse_short(benchmark):
136+
"""Benchmark iter_graphemes_reverse() with short string."""
137+
text = 'café\u0301 🇫🇷!'
138+
benchmark(lambda: list(wcwidth.iter_graphemes_reverse(text)))
139+
140+
141+
def test_iter_graphemes_reverse_long(benchmark):
142+
"""Benchmark iter_graphemes_reverse() with long string."""
143+
text = 'The quick brown 🦊 jumps over the lazy 🐕. ' * 5
144+
benchmark(lambda: list(wcwidth.iter_graphemes_reverse(text)))
145+
146+
91147
def test_ljust_ascii(benchmark):
92148
"""Benchmark ljust() with ASCII string."""
93149
benchmark(wcwidth.ljust, 'hello', 20)

tests/test_grapheme.py

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import pytest
77

88
# local
9-
from wcwidth import iter_graphemes
9+
from wcwidth import iter_graphemes, iter_graphemes_reverse, grapheme_boundary_before
1010

1111
try:
1212
chr(0x2fffe)
@@ -144,3 +144,112 @@ def test_wide_unicode_graphemes(input_str, expected):
144144
def test_unicode_grapheme_break_test(input_str, expected):
145145
"""Validate against official Unicode GraphemeBreakTest.txt."""
146146
assert list(iter_graphemes(input_str)) == expected
147+
148+
149+
# Prepend: Arabic Number Sign
150+
PREPEND_CHAR = '\u0600'
151+
# Multiple combining marks: e + acute + grave
152+
MULTI_COMBINE = 'e\u0301\u0300'
153+
154+
155+
# grapheme_boundary_before(text, pos) returns start of grapheme cluster before pos.
156+
# (text, pos, expected): pos=search from here, expected=where cluster starts
157+
@pytest.mark.parametrize(("text", "pos", "expected"), [
158+
# 'abc': 0=a, 1=b, 2=c
159+
('abc', 3, 2), # from end -> 'c' at 2
160+
('abc', 2, 1), # from 'c' -> 'b' at 1
161+
('abc', 1, 0), # from 'b' -> 'a' at 0
162+
# 'a\r\nb': CRLF is one cluster (GB3)
163+
('a\r\nb', 3, 1), # from 'b' -> '\r\n' at 1
164+
# 'café': e + combining acute is one cluster (GB9)
165+
('cafe\u0301', 5, 3), # from end -> 'é' at 3
166+
('cafe\u0301', 4, 3), # from acute -> still 'é' at 3
167+
# Multiple combining marks: e + acute + grave (GB9)
168+
('a' + MULTI_COMBINE + 'b', 4, 1), # from 'b' -> e+marks at 1
169+
# Prepend + char is one cluster (GB9b)
170+
(PREPEND_CHAR + 'a', 2, 0), # whole cluster
171+
# Prepend + Control: control breaks (GB4)
172+
(PREPEND_CHAR + '\n', 2, 1), # '\n' separate at 1
173+
# C1 control (NEL, 0x85) stops backward scan in _find_cluster_start (GB4)
174+
('X\x85\u0301', 3, 2),
175+
])
176+
def test_grapheme_boundary_before_basic(text, pos, expected):
177+
"""Basic grapheme_boundary_before tests."""
178+
assert grapheme_boundary_before(text, pos) == expected
179+
180+
181+
@pytest.mark.skipif(NARROW_ONLY, reason="requires wide Unicode")
182+
@pytest.mark.parametrize(("text", "pos", "expected"), [
183+
# 'Hi 👋🏻!': 0=H,1=i,2=space,3=wave,4=skin,5=!; wave+skin is one cluster
184+
('Hi \U0001F44B\U0001F3FB!', 6, 5), # from end -> '!' at 5
185+
('Hi \U0001F44B\U0001F3FB!', 5, 3), # from '!' -> wave+skin at 3
186+
('Hi \U0001F44B\U0001F3FB!', 3, 2), # from wave -> space at 2
187+
# 'a🇺🇸b': 0=a,1-2=flag,3=b; flag is one cluster (GB12/13)
188+
('a' + FLAG_US + 'b', 4, 3), # from end -> 'b' at 3
189+
('a' + FLAG_US + 'b', 3, 1), # from 'b' -> flag at 1
190+
# Three RIs (🇺🇸🇦): flag + solo RI
191+
(FLAG_US + RI_A, 3, 2), # from end -> solo RI at 2
192+
(FLAG_US + RI_A, 2, 0), # from solo -> flag at 0
193+
# 'a👨‍👩‍👧b': 0=a,1-5=family,6=b; ZWJ sequence is one cluster (GB11)
194+
('a' + FAMILY + 'b', 7, 6), # from end -> 'b' at 6
195+
('a' + FAMILY + 'b', 6, 1), # from 'b' -> family at 1
196+
])
197+
def test_grapheme_boundary_before_unicode(text, pos, expected):
198+
"""grapheme_boundary_before with emoji and wide Unicode."""
199+
assert grapheme_boundary_before(text, pos) == expected
200+
201+
202+
@pytest.mark.parametrize(("input_str", "expected"), [
203+
('', []),
204+
('abc', ['c', 'b', 'a']),
205+
# café with combining mark mixed with CRLF
206+
('cafe\u0301\r\nok', ['k', 'o', '\r\n', 'e\u0301', 'f', 'a', 'c']),
207+
])
208+
def test_iter_graphemes_reverse_basic(input_str, expected):
209+
"""Basic iter_graphemes_reverse tests."""
210+
assert list(iter_graphemes_reverse(input_str)) == expected
211+
212+
213+
@pytest.mark.skipif(NARROW_ONLY, reason="requires wide Unicode")
214+
@pytest.mark.parametrize(("input_str", "expected"), [
215+
# Multiple emoji types in one string
216+
('cafe\u0301 ' + WAVE_SKIN + ' ' + FLAG_US + '!',
217+
['!', FLAG_US, ' ', WAVE_SKIN, ' ', 'e\u0301', 'f', 'a', 'c']),
218+
# Two families
219+
(FAMILY + FAMILY, [FAMILY, FAMILY]),
220+
# Flag + solo RI + text
221+
('Hi' + FLAG_US + RI_A + '!', ['!', RI_A, FLAG_US, 'i', 'H']),
222+
])
223+
def test_iter_graphemes_reverse_unicode(input_str, expected):
224+
"""iter_graphemes_reverse with wide Unicode."""
225+
assert list(iter_graphemes_reverse(input_str)) == expected
226+
227+
228+
@pytest.mark.skipif(NARROW_ONLY, reason="requires wide Unicode")
229+
@pytest.mark.parametrize(("input_str", "expected"), read_grapheme_break_test())
230+
def test_grapheme_roundtrip_consistency(input_str, expected):
231+
"""Forward and reverse iteration produce identical boundaries."""
232+
forward = list(iter_graphemes(input_str))
233+
reverse = list(iter_graphemes_reverse(input_str))[::-1]
234+
assert forward == reverse
235+
236+
237+
def test_grapheme_boundary_before_edge_cases():
238+
"""Edge cases for grapheme_boundary_before."""
239+
assert grapheme_boundary_before('abc', 0) == 0
240+
assert grapheme_boundary_before('abc', 100) == 2 # pos > len clamps
241+
assert grapheme_boundary_before('', 0) == 0
242+
243+
244+
def test_iter_graphemes_reverse_edge_cases():
245+
"""Edge cases for iter_graphemes_reverse."""
246+
assert list(iter_graphemes_reverse('abcdef', start=2, end=5)) == ['e', 'd', 'c']
247+
assert list(iter_graphemes_reverse('abc', start=0, end=100)) == ['c', 'b', 'a']
248+
assert not list(iter_graphemes_reverse('abc', start=5))
249+
assert not list(iter_graphemes_reverse('abc', start=2, end=2))
250+
# PREPEND + char is one grapheme (GB9b), so start=1 yields nothing (won't split)
251+
assert not list(iter_graphemes_reverse(PREPEND_CHAR + 'a', start=1))
252+
# But start=0 yields the full grapheme
253+
assert list(iter_graphemes_reverse(PREPEND_CHAR + 'a', start=0)) == [PREPEND_CHAR + 'a']
254+
# Negative start is clamped to 0
255+
assert list(iter_graphemes_reverse('abc', start=-5)) == ['c', 'b', 'a']

wcwidth/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,18 @@
2626
_wcmatch_version,
2727
_wcversion_value)
2828
from .bisearch import bisearch as _bisearch
29-
from .grapheme import iter_graphemes # noqa
29+
from .grapheme import grapheme_boundary_before # noqa
30+
from .grapheme import iter_graphemes, iter_graphemes_reverse
3031
from .textwrap import SequenceTextWrapper, wrap
3132

3233
# The __all__ attribute defines the items exported from statement,
3334
# 'from wcwidth import *', but also to say, "This is the public API".
3435
__all__ = ('wcwidth', 'wcswidth', 'width', 'iter_sequences', 'iter_graphemes',
36+
'iter_graphemes_reverse', 'grapheme_boundary_before',
3537
'ljust', 'rjust', 'center', 'wrap', 'clip', 'strip_sequences',
3638
'list_versions')
3739

3840
# We also used pkg_resources to load unicode version tables from version.json,
3941
# generated by bin/update-tables.py, but some environments are unable to
4042
# import pkg_resources for one reason or another, yikes!
41-
__version__ = '0.3.5'
43+
__version__ = '0.4.0'

0 commit comments

Comments
 (0)