Skip to content

Commit 6668301

Browse files
authored
New: ljust(), rjust(), center() justify text (#168)
Justify text with control-code and sequence-awareness, requires #166 Example, >>> wcwidth.center('hi', 6) ' hi ' >>> wcwidth.center('\\x1b[31mhi\\x1b[0m', 6) ' \\x1b[31mhi\\x1b[0m ' >>> wcwidth.center('\\U0001F468\\u200D\\U0001F469\\u200D\\U0001F467', 6) ' 👨‍👩‍👧 '
1 parent bbb0e47 commit 6668301

5 files changed

Lines changed: 281 additions & 28 deletions

File tree

docs/api.rst

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,16 @@ requirements.txt or equivalent. Their signatures will never change.
1212

1313
.. autofunction:: wcwidth.width
1414

15-
.. autofunction:: wcwidth.iter_sequences
15+
.. autofunction:: wcwidth.ljust
16+
17+
.. autofunction:: wcwidth.rjust
18+
19+
.. autofunction:: wcwidth.center
1620

1721
.. autofunction:: wcwidth.iter_graphemes
1822

23+
.. autofunction:: wcwidth.iter_sequences
24+
1925
.. autofunction:: wcwidth.list_versions
2026

2127
.. _SEMVER: https://semver.org

docs/intro.rst

Lines changed: 109 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,18 @@ Some examples of **incorrect results**:
3232
>>> 'café'.center(6, 'X')
3333
'caféX'
3434
35+
>>> # result consumes 4 total cells, 2 expected
36+
>>> '🇿🇼'.ljust(2, 'X')
37+
'🇿🇼 '
38+
3539
>>> # result consumes 2 total cells, 4 expected.
3640
>>> print('👨‍👩‍👧'.center(4, 'X'))
3741
👨‍👩‍👧
3842
3943
Solution
4044
--------
4145

46+
4247
The base functions of this library are the POSIX.1-2001 and POSIX.1-2008 `wcwidth(3)`_ and
4348
`wcswidth(3)`_, which this library precisely copies by interface as `wcwidth()`_ and `wcswidth()`_.
4449
These functions return -1 when C0 and C1 control codes are present.
@@ -48,7 +53,8 @@ the displayed width of most C0 and C1 control codes, and measuring many kinds of
4853
like colors, bold, tabstops, and horizontal cursor movement. This is aided by the
4954
`iter_sequences()`_ function that provides an iterator over terminal sequences.
5055

51-
A ``iter_graphemes()`` iterator function is provided to help format strings for a terminal.
56+
To solve the justification problem, this library provides `ljust()`_, `rjust()`_, and `center()`_
57+
functions that properly handle Unicode character widths and terminal escape sequences.
5258

5359
Discrepancies
5460
-------------
@@ -78,23 +84,8 @@ Measures width of a single codepoint,
7884
>>> wcwidth.wcwidth('\u2640')
7985
1
8086
81-
Use function ``wcwidth()`` to determine the length of a *single unicode character*.
82-
83-
See `Specification <Specification_from_pypi_>`_ of character measurements. More
84-
briefly, return values of function ``wcwidth()`` are:
85-
86-
``-1``
87-
Indeterminate (not printable) control codes (C0 and C1).
88-
89-
``0``
90-
Does not advance the cursor, such as NULL or Combining.
91-
92-
``2``
93-
Characters of category East Asian Wide (W) or East Asian
94-
Full-width (F) which are displayed using two terminal cells.
95-
96-
``1``
97-
All others.
87+
Use function `wcwidth()`_ to determine the length of a *single unicode character*.
88+
See `Specification <Specification_from_pypi_>`_ of character measurements.
9889

9990
wcswidth()
10091
----------
@@ -107,11 +98,97 @@ Measures width of a string, returns -1 for control codes.
10798
>>> wcwidth.wcswidth('♀️')
10899
2
109100
110-
Use function ``wcswidth()`` to determine the length of many, a *string of unicode characters*
101+
Use function `wcswidth()`_ to determine the length of many, a *string of unicode characters*
102+
103+
See `Specification <Specification_from_pypi_>`_ of character measurements. Note that
104+
``-1`` is returned if control codes occurs anywhere in the string.
105+
106+
width()
107+
-------
108+
109+
Measures width of a string with improved handling of ``control_codes``
110+
111+
.. code-block:: python
112+
113+
>>> # same support as wcswidth(), eg. regional indicator flag:
114+
>>> wcwidth.width('\U0001F1FF\U0001F1FC')
115+
2
116+
>>> # SGR colored text, 'WARN', followed by SGR reset
117+
>>> wcwidth.width('\x1b[38;2;255;150;100mWARN\x1b[0m')
118+
4
119+
>>> # customized tabsize and location
120+
>>> wcwidth.width('\t', tabsize=4, column=1)
121+
3
122+
>>> # tab and all other control characters ignored
123+
>>> wcwidth.width('\t', control_codes='ignore')
124+
0
125+
>>> # "vertical" movement and control characters are always ignored
126+
>>> wcwidth.width('\n')
127+
0
128+
>>> # as well as sequences with "indeterminate" effects like Home + Clear
129+
>>> wcwidth.width('\x1b[H\x1b[2J')
130+
0
131+
>>> # ValueError may raise when control_codes='strict'
132+
>>> wcwidth.width('\n', control_codes='strict')
133+
Traceback (most recent call last):
134+
...
135+
ValueError: Vertical movement character 0xa at position 0
136+
>>> wcwidth.width('\x1b[H\x1b[2J', control_codes='strict')
137+
Traceback (most recent call last):
138+
...
139+
ValueError: Indeterminate cursor sequence at position 0
140+
141+
iter_sequences()
142+
----------------
143+
144+
Iterates through text, yielding segments with escape sequence identification.
145+
146+
.. code-block:: python
147+
148+
>>> list(wcwidth.iter_sequences('hello'))
149+
[('hello', False)]
150+
>>> list(wcwidth.iter_sequences('\x1b[31mred\x1b[0m'))
151+
[('\x1b[31m', True), ('red', False), ('\x1b[0m', True)]
152+
153+
Use `iter_sequences()`_ to split text into segments of plain text and escape sequences. Each tuple
154+
contains the segment string and a boolean indicating whether it is an escape sequence (``True``) or
155+
plain text (``False``).
156+
157+
ljust()
158+
-------
159+
160+
Use `ljust()`_ as replacement of `str.ljust()`_:
161+
162+
.. code-block:: python
163+
164+
>>> 'コンニチハ'.ljust(11, '*') # don't do this
165+
'コンニチハ******'
166+
>>> wcwidth.ljust('コンニチハ', 11, '*') # do this!
167+
'コンニチハ*'
168+
169+
rjust()
170+
-------
171+
172+
Use `rjust()`_ as replacement of `str.rjust()`_:
173+
174+
.. code-block:: python
175+
176+
>>> 'コンニチハ'.rjust(11, '*') # don't do this
177+
'******コンニチハ'
178+
>>> wcwidth.rjust('コンニチハ', 11, '*') # do this!
179+
'*コンニチハ'
180+
181+
center()
182+
--------
183+
184+
Use `center()`_ as replacement of `str.center()`_:
185+
186+
.. code-block:: python
111187
112-
See `Specification <Specification_from_pypi_>`_ of character measurements. More briefly, return
113-
values of function ``wcswidth()`` is the sum of ``wcwidth()`` with some additional account for some
114-
kinds of sequences. Similarly, ``-1`` is returned if control codes occurs anywhere in the string.
188+
>>> 'cafe\u0301'.center(6, '*') # don't do this
189+
'café*'
190+
>>> wcwidth.center('cafe\u0301', 6, '*')
191+
'*café*' # do this!
115192
116193
width()
117194
-------
@@ -293,6 +370,8 @@ History
293370

294371
0.2.15 **next version**
295372
* **New** Function `iter_graphemes()`_. `PR #165`_.
373+
* **New** Functions `width()`_ and `iter_sequences()`_. `PR #166`_.
374+
* **New** Functions `ljust()`_, `rjust()`_, `center()`_. `PR #168`_.
296375

297376
0.2.14 *2025-09-22*
298377
* **Drop Support** for Python 2.7 and 3.5. `PR #117`_.
@@ -425,6 +504,8 @@ https://www.cl.cam.ac.uk/~mgk25/ucs/wcwidth.c::
425504
.. _`PR #146`: https://github.com/jquast/wcwidth/pull/146
426505
.. _`PR #149`: https://github.com/jquast/wcwidth/pull/149
427506
.. _`PR #165`: https://github.com/jquast/wcwidth/pull/165
507+
.. _`PR #166`: https://github.com/jquast/wcwidth/pull/166
508+
.. _`PR #168`: https://github.com/jquast/wcwidth/pull/168
428509
.. _`Issue #101`: https://github.com/jquast/wcwidth/issues/101
429510
.. _`jquast/blessed`: https://github.com/jquast/blessed
430511
.. _`selectel/pyte`: https://github.com/selectel/pyte
@@ -461,7 +542,12 @@ https://www.cl.cam.ac.uk/~mgk25/ucs/wcwidth.c::
461542
.. _`str.ljust()`: https://docs.python.org/3/library/stdtypes.html#str.ljust
462543
.. _`str.rjust()`: https://docs.python.org/3/library/stdtypes.html#str.rjust
463544
.. _`str.center()`: https://docs.python.org/3/library/stdtypes.html#str.center
464-
.. _`Annex #29`: https://www.unicode.org/reports/tr29/
545+
.. _`wcwidth()`: https://wcwidth.readthedocs.io/en/latest/api.html#wcwidth.wcwidth
546+
.. _`wcswidth()`: https://wcwidth.readthedocs.io/en/latest/api.html#wcwidth.wcswidth
547+
.. _`iter_sequences()`: https://wcwidth.readthedocs.io/en/latest/api.html#wcwidth.iter_sequences
548+
.. _`ljust()`: https://wcwidth.readthedocs.io/en/latest/api.html#wcwidth.ljust
549+
.. _`rjust()`: https://wcwidth.readthedocs.io/en/latest/api.html#wcwidth.rjust
550+
.. _`center()`: https://wcwidth.readthedocs.io/en/latest/api.html#wcwidth.center
465551
.. _`General Tabulated Summary`: https://ucs-detect.readthedocs.io/results.html
466552
.. |pypi_downloads| image:: https://img.shields.io/pypi/dm/wcwidth.svg?logo=pypi
467553
:alt: Downloads

tests/test_justify.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Tests for text justification functions."""
2+
from wcwidth import ljust, rjust, center, width
3+
4+
SGR_RED = '\x1b[31m'
5+
SGR_RESET = '\x1b[0m'
6+
CJK_WORD = '\u4e2d\u6587'
7+
CAFE_COMBINING = 'cafe\u0301'
8+
EMOJI_FAMILY = '\U0001F468\u200D\U0001F469\u200D\U0001F467'
9+
10+
11+
def test_ljust():
12+
assert ljust('hi', 5) == 'hi '
13+
assert ljust('', 5) == ' '
14+
assert ljust('hello', 3) == 'hello'
15+
assert ljust('hello', 5) == 'hello'
16+
assert ljust('\x1b[31mhi\x1b[0m', 5) == '\x1b[31mhi\x1b[0m '
17+
assert ljust('\u4e2d', 4) == '\u4e2d '
18+
assert ljust('hi', 5, fillchar='-') == 'hi---'
19+
assert ljust('hi', 5, fillchar='\u00b7') == 'hi\u00b7\u00b7\u00b7'
20+
assert ljust(CJK_WORD, 8) == CJK_WORD + ' '
21+
assert width(ljust(CJK_WORD, 8)) == 8
22+
assert width(ljust(CAFE_COMBINING, 8)) == 8
23+
assert width(ljust(EMOJI_FAMILY, 6)) == 6
24+
text = f'{SGR_RED}hi{SGR_RESET}'
25+
assert len(ljust(text, 6, control_codes='ignore')) - len(SGR_RED) - len(SGR_RESET) == 6
26+
27+
28+
def test_rjust():
29+
assert rjust('hi', 5) == ' hi'
30+
assert rjust('', 5) == ' '
31+
assert rjust('hello', 3) == 'hello'
32+
assert rjust('hello', 5) == 'hello'
33+
assert rjust('\x1b[31mhi\x1b[0m', 5) == ' \x1b[31mhi\x1b[0m'
34+
assert rjust('\u4e2d', 4) == ' \u4e2d'
35+
assert rjust('hi', 5, fillchar='-') == '---hi'
36+
assert rjust('hi', 5, fillchar='\u00b7') == '\u00b7\u00b7\u00b7hi'
37+
assert rjust(CJK_WORD, 8) == ' ' + CJK_WORD
38+
assert width(rjust(CAFE_COMBINING, 8)) == 8
39+
assert width(rjust(EMOJI_FAMILY, 6)) == 6
40+
41+
42+
def test_center():
43+
assert center('hi', 6) == ' hi '
44+
assert center('hi', 5) == ' hi '
45+
assert center('', 4) == ' '
46+
assert center('hello', 3) == 'hello'
47+
assert center('hello', 5) == 'hello'
48+
assert center('\x1b[31mhi\x1b[0m', 6) == ' \x1b[31mhi\x1b[0m '
49+
assert center('\u4e2d', 6) == ' \u4e2d '
50+
assert center('hi', 6, fillchar='-') == '--hi--'
51+
assert center('hi', 6, fillchar='\u00b7') == '\u00b7\u00b7hi\u00b7\u00b7'
52+
assert center('x', 4) == ' x '
53+
assert width(center(CJK_WORD, 8)) == 8
54+
assert width(center(CAFE_COMBINING, 8)) == 8
55+
assert width(center(EMOJI_FAMILY, 6)) == 6

wcwidth/__init__.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,24 @@
1010

1111
# local
1212
from .wcwidth import ZERO_WIDTH # noqa
13+
from .grapheme import iter_graphemes # noqa
1314
from .wcwidth import (WIDE_EASTASIAN,
1415
VS16_NARROW_TO_WIDE,
1516
width,
1617
wcwidth,
1718
wcswidth,
1819
list_versions,
1920
iter_sequences,
21+
ljust,
22+
rjust,
23+
center,
2024
_wcmatch_version,
2125
_wcversion_value)
22-
from .bisearch import bisearch as _bisearch
23-
24-
from .grapheme import iter_graphemes
2526

2627
# The __all__ attribute defines the items exported from statement,
2728
# 'from wcwidth import *', but also to say, "This is the public API".
28-
__all__ = ('wcwidth', 'wcswidth', 'width', 'iter_sequences', 'list_versions', 'iter_graphemes')
29+
__all__ = ('wcwidth', 'wcswidth', 'width', 'iter_sequences', 'iter_graphemes'
30+
'ljust', 'rjust', 'center', 'list_versions')
2931

3032
# We also used pkg_resources to load unicode version tables from version.json,
3133
# generated by bin/update-tables.py, but some environments are unable to

wcwidth/wcwidth.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,3 +539,107 @@ def width(text, control_codes='parse', tabsize=8):
539539
idx += 1
540540

541541
return max_extent
542+
543+
544+
def ljust(text, dest_width, fillchar=' ', control_codes='parse'):
545+
"""
546+
Return text left-justified in a string of given display width.
547+
548+
:param str text: String to justify, may contain terminal sequences.
549+
:param int dest_width: Total display width of result in terminal cells.
550+
:param str fillchar: Single character for padding (default space). Must have
551+
display width of 1 (not wide, not zero-width, not combining). Unicode
552+
characters like ``'·'`` are acceptable. The width is not validated.
553+
:param str control_codes: How to handle control sequences when measuring.
554+
Passed to :func:`width` for measurement.
555+
:returns: Text padded on the right to reach ``dest_width``.
556+
:rtype: str
557+
558+
.. versionadded:: 0.2.15
559+
560+
Example::
561+
562+
>>> wcwidth.ljust('hi', 5)
563+
'hi '
564+
>>> wcwidth.ljust('\\x1b[31mhi\\x1b[0m', 5)
565+
'\\x1b[31mhi\\x1b[0m '
566+
>>> wcwidth.ljust('\\U0001F468\\u200D\\U0001F469\\u200D\\U0001F467', 6)
567+
'👨‍👩‍👧 '
568+
"""
569+
if text.isascii() and text.isprintable():
570+
text_width = len(text)
571+
else:
572+
text_width = width(text, control_codes=control_codes)
573+
padding_cells = max(0, dest_width - text_width)
574+
return text + fillchar * padding_cells
575+
576+
577+
def rjust(text, dest_width, fillchar=' ', control_codes='parse'):
578+
"""
579+
Return text right-justified in a string of given display width.
580+
581+
:param str text: String to justify, may contain terminal sequences.
582+
:param int dest_width: Total display width of result in terminal cells.
583+
:param str fillchar: Single character for padding (default space). Must have
584+
display width of 1 (not wide, not zero-width, not combining). Unicode
585+
characters like ``'·'`` are acceptable. The width is not validated.
586+
:param str control_codes: How to handle control sequences when measuring.
587+
Passed to :func:`width` for measurement.
588+
:returns: Text padded on the left to reach ``dest_width``.
589+
:rtype: str
590+
591+
.. versionadded:: 0.2.15
592+
593+
Example::
594+
595+
>>> wcwidth.rjust('hi', 5)
596+
' hi'
597+
>>> wcwidth.rjust('\\x1b[31mhi\\x1b[0m', 5)
598+
' \\x1b[31mhi\\x1b[0m'
599+
>>> wcwidth.rjust('\\U0001F468\\u200D\\U0001F469\\u200D\\U0001F467', 6)
600+
' 👨‍👩‍👧'
601+
"""
602+
if text.isascii() and text.isprintable():
603+
text_width = len(text)
604+
else:
605+
text_width = width(text, control_codes=control_codes)
606+
padding_cells = max(0, dest_width - text_width)
607+
return fillchar * padding_cells + text
608+
609+
610+
def center(text, dest_width, fillchar=' ', control_codes='parse'):
611+
"""
612+
Return text centered in a string of given display width.
613+
614+
:param str text: String to center, may contain terminal sequences.
615+
:param int dest_width: Total display width of result in terminal cells.
616+
:param str fillchar: Single character for padding (default space). Must have
617+
display width of 1 (not wide, not zero-width, not combining). Unicode
618+
characters like ``'·'`` are acceptable. The width is not validated.
619+
:param str control_codes: How to handle control sequences when measuring.
620+
Passed to :func:`width` for measurement.
621+
:returns: Text padded on both sides to reach ``dest_width``.
622+
:rtype: str
623+
624+
For odd-width padding, the extra cell goes on the right (matching
625+
Python's :meth:`str.center` behavior).
626+
627+
.. versionadded:: 0.2.15
628+
629+
Example::
630+
631+
>>> wcwidth.center('hi', 6)
632+
' hi '
633+
>>> wcwidth.center('\\x1b[31mhi\\x1b[0m', 6)
634+
' \\x1b[31mhi\\x1b[0m '
635+
>>> wcwidth.center('\\U0001F468\\u200D\\U0001F469\\u200D\\U0001F467', 6)
636+
' 👨‍👩‍👧 '
637+
"""
638+
if text.isascii() and text.isprintable():
639+
text_width = len(text)
640+
else:
641+
text_width = width(text, control_codes=control_codes)
642+
total_padding = max(0, dest_width - text_width)
643+
left_pad = total_padding // 2
644+
right_pad = total_padding - left_pad
645+
return fillchar * left_pad + text + fillchar * right_pad

0 commit comments

Comments
 (0)