Skip to content
Closed
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
5 changes: 4 additions & 1 deletion bin/update-tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -798,7 +798,10 @@ def parse_indic_conjunct_breaks(fname: str) -> dict[str, TableDef]:
}


ISC_VALUES = ('Consonant',)
# ISC_CONSONANT is generated for the public API but not used by the runtime.
# Virama and Invisible_Stacker are treated as zero-width combining marks (Mn),
# matching Ghostty's uucode; the runtime no longer consults the ISC categories.
ISC_VALUES = ('Consonant', 'Virama', 'Invisible_Stacker')


def parse_indic_syllabic_category(fname: str) -> dict[str, TableDef]:
Expand Down
16 changes: 12 additions & 4 deletions docs/intro.rst
Original file line number Diff line number Diff line change
Expand Up @@ -506,10 +506,15 @@ This library is used in:
Other Languages
===============

There are similar implementations of the `wcwidth()`_ and `wcswidth()`_ functions in other
languages.
The following libraries provide grapheme and emoji support and closely align with our
specification_:

- `jacobsandlund/uucode`_ Zig
- `contour-terminal/libunicode`_ C++20

There are similar implementations of at least the `wcwidth()`_ and `wcswidth()`_ functions in other
languages:

- `contour-terminal/libunicode`_: C++20
- `ridiculousfish/widecharwidth`_: Python
- `termux/wcwidth`_: C
- `powerman/wcwidth-icons`_: C
Expand All @@ -534,8 +539,10 @@ History
=======

0.8.0 *(unreleased)*
* **Improved** memory resource usage and import time for Python 3.15 using lazy imports `PR #221`_.
* **Improved** memory usage and import time for Python 3.15 using lazy imports `PR #221`_.
* **Improved** performance on Python 3.15 using standard library iter_graphemes() `PR #206`_.
* **Bugfix** Invisible_Stacker viramas now form conjuncts (Burmese, Khmer, etc.) and
change some Virama width calculations to match `jacobsandlund/uucode`_ (ghostty) `PR #XXX`_.

0.7.0 *2026-05-02*
* **New** support for `kitty text sizing protocol`_ (OSC 66) in `width()`_ and `clip()`_.
Expand Down Expand Up @@ -799,6 +806,7 @@ https://www.cl.cam.ac.uk/~mgk25/ucs/wcwidth.c::
.. _`joshuarubin/wcwidth9`: https://github.com/joshuarubin/wcwidth9
.. _`spectreconsole/wcwidth`: https://github.com/spectreconsole/wcwidth
.. _`contour-terminal/libunicode`: https://github.com/contour-terminal/libunicode
.. _`jacobsandlund/uucode`: https://github.com/jacobsandlund/uucode
.. _`ridiculousfish/widecharwidth`: https://github.com/ridiculousfish/widecharwidth
.. _`termux/wcwidth`: https://github.com/termux/wcwidth
.. _`powerman/wcwidth-icons`: https://github.com/powerman/wcwidth-icons
Expand Down
15 changes: 10 additions & 5 deletions docs/specs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -114,15 +114,19 @@ by a Nukta (``Mn``) and then a vowel sign (``Mc``) is measured as base + 1.
Virama Conjunct Formation
-------------------------

In `Brahmic scripts`_, a `Virama`_ (``Indic_Syllabic_Category=Virama`` in
`IndicSyllabicCategory.txt`_) between two consonants triggers `conjunct`_
formation: the consonants are merged into a single ligature glyph.
In `Brahmic scripts`_, `IndicSyllabicCategory.txt`_ defines two categories
that trigger `conjunct`_ formation between consonants: `Virama`_ ("may act
as a Pure_Killer or Invisible_Stacker depending on context") and
``Invisible_Stacker``_ ("not visible by itself; causes conjunct formation
or consonant stacking", the "only as consonant stackers" category
described in the Virama section header).

- A ``Consonant`` immediately following a ``Virama`` contributes 0 width.
- The conjunct still occupies cells and the next visible advance settles it:

- A following ``Mc`` (`Spacing Combining Mark`_, e.g. a vowel sign) counts as
1 cell and closes the conjunct.
- A following ``Mc`` (`Spacing Combining Mark`_, e.g. a vowel sign) closes
the conjunct and accounts for its 1-cell contribution. The ``Mc`` itself
adds no independent width when following a conjunct.
- A following character with positive width (or end of string) adds 1 cell
for the conjunct before counting its own width.

Expand Down Expand Up @@ -176,6 +180,7 @@ See also: `L2/2023/23107`_ "Proper Complex Script Support in Text Terminals".
.. _`Nonspacing Mark`: https://www.unicode.org/versions/latest/core-spec/chapter-4/#G134153
.. _`IndicSyllabicCategory.txt`: https://www.unicode.org/Public/UCD/latest/ucd/IndicSyllabicCategory.txt
.. _`Indic_Syllabic_Category`: https://www.unicode.org/reports/tr44/#Indic_Syllabic_Category
.. _`Invisible_Stacker`: https://www.unicode.org/Public/UCD/latest/ucd/IndicSyllabicCategory.txt
.. _`Brahmic scripts`: https://en.wikipedia.org/wiki/Brahmic_scripts
.. _`Virama`: https://www.unicode.org/glossary/#virama
.. _`conjunct`: https://www.unicode.org/glossary/#consonant_conjunct
Expand Down
32 changes: 28 additions & 4 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ def test_devanagari_script():
"\u093F") # MatraL, Category 'Mc', East Asian Width property 'N' -- DEVANAGARI VOWEL SIGN I
# 23107-terminal-suppt.pdf suggests wcwidth.wcwidth should return (2, 0, 0, 1)
expect_length_each = (1, 0, 1, 0)
# virama conjunct collapses KA+virama+SSA into one cell, Mc adds +1
# virama conjunct collapses KA+virama+SSA into one cell, Mc vowel sign is part of the conjunct
expect_length_phrase = 2

# exercise,
Expand All @@ -330,7 +330,7 @@ def test_tamil_script():
# 23107-terminal-suppt.pdf suggests wcwidth.wcwidth should return (3, 0, 0, 4)
expect_length_each = (1, 0, 1, 0)

# virama conjunct collapses KA+virama+SSA into one cell, Mc adds +1
# virama conjunct collapses KA+virama+SSA into one cell, Mc vowel sign is part of the conjunct
expect_length_phrase = 2

# exercise,
Expand All @@ -353,7 +353,7 @@ def test_kannada_script():
"\u0cc8") # MatraUR, Category 'Mc', East Asian Width property 'N' -- KANNADA VOWEL SIGN AI
# 23107-terminal-suppt.pdf suggests should be (2, 0, 3, 1)
expect_length_each = (1, 0, 1, 0)
# virama conjunct collapses RA+virama+JHA into one cell, Mc adds +1
# virama conjunct collapses RA+virama+JHA into one cell, Mc vowel sign is part of the conjunct
expect_length_phrase = 2

# exercise,
Expand Down Expand Up @@ -441,7 +441,7 @@ def test_mc_width_consistency(repeat):
("\u0915\u094D\u0924\u093F", 2),
("\u0915\u094D\u0930\u093F", 2),
("\u0A95\u0ACD\u0A95\u0ACB", 2),
("\u0938\u094D\u0924\u094D\u0930", 2),
("\u0938\u094D\u0924\u094D\u0930", 3),
("\u0938\u094D\u0924", 2),
("\u0915\u094D\u0020", 2),
("\u09A4\u09CD\u200D\u09AA", 2),
Expand All @@ -454,6 +454,30 @@ def test_virama_conjunct(phrase, expected):
assert wcwidth.width(phrase) == expected


@pytest.mark.parametrize("phrase,expected", [
("\u0995\u09CD\u09A4\u09BF", 2), # Bengali C+V+C+Mc: ক্তি
("\u0915\u094D\u0924\u093F", 2), # Devanagari C+V+C+Mc: क्ति
("\u0995\u09CD\u09A4", 2), # C+V+C (no Mc), unchanged
("\u0995\u09BF", 2), # C+Mc (no virama), unchanged
])
def test_virama_conjunct_mc_vowel(phrase, expected):
"""Vowel sign (Mc) following a virama conjunct does not add independent width."""
assert wcwidth.wcswidth(phrase) == expected
assert wcwidth.width(phrase) == expected


@pytest.mark.parametrize("phrase,expected", [
("\u1000\u1039\u1000", 2), # Burmese KA+VIRAMA+KA
("\u1000\u1039\u1000\u1039\u1002", 3), # Burmese KA+V+KA+V+GA
("\u1000\u1039\u200D\u1000", 2), # Burmese KA+V+ZWJ+KA
("\u1782\u17D2\u1782\u17C1", 2), # Khmer KO+COENG+KO+VOWEL_E (Mc)
("\u1780\u17D2\u1780", 2), # Khmer KA+COENG+KA
])
def test_virama_conjunct_invisible_stacker(phrase, expected):
assert wcwidth.wcswidth(phrase) == expected
assert wcwidth.width(phrase) == expected


def test_zwj_at_end_of_string():
"""ZWJ at end of string (not after virama) is consumed with zero width."""
assert wcwidth.wcswidth('a\u200D') == 1
Expand Down
2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ warn_unused_ignores = true
[testenv:codespell]
basepython = python3.14
deps = codespell
commands = codespell --skip="*.pyc,htmlcov,_build,build,*.egg-info,.tox,data,./tests/*.txt,*.csv,*.ods,table_*.py,docs/specs.rst,*.isorted" \
commands = codespell --skip="*.pyc,htmlcov,_build,build,*.egg-info,.tox,data,./tests/*.txt,*.csv,*.ods,table_*.py,docs/specs.rst,*.isorted,ucs-detect" \
--ignore-words-list="thirdparty,claus,oclock,womens,aprox" \
--uri-ignore-words-list '*' \
--summary --count
Expand Down
38 changes: 8 additions & 30 deletions wcwidth/_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
from .table_mc import CATEGORY_MC
from .table_wide import WIDE_EASTASIAN
from .table_zero import ZERO_WIDTH
from .table_grapheme import EXTENDED_PICTOGRAPHIC, GRAPHEME_REGIONAL_INDICATOR
from .table_grapheme import (ISC_VIRAMA,
EXTENDED_PICTOGRAPHIC,
ISC_INVISIBLE_STACKER,
GRAPHEME_REGIONAL_INDICATOR)
from .table_ambiguous import AMBIGUOUS_EASTASIAN
from .unicode_versions import list_versions

Expand All @@ -23,35 +26,10 @@
_REGIONAL_INDICATOR_SET = frozenset(
range(GRAPHEME_REGIONAL_INDICATOR[0][0], GRAPHEME_REGIONAL_INDICATOR[0][1] + 1)
)
_ISC_VIRAMA_SET = frozenset((
0x094D, # DEVANAGARI SIGN VIRAMA
0x09CD, # BENGALI SIGN VIRAMA
0x0A4D, # GURMUKHI SIGN VIRAMA
0x0ACD, # GUJARATI SIGN VIRAMA
0x0B4D, # ORIYA SIGN VIRAMA
0x0BCD, # TAMIL SIGN VIRAMA
0x0C4D, # TELUGU SIGN VIRAMA
0x0CCD, # KANNADA SIGN VIRAMA
0x0D4D, # MALAYALAM SIGN VIRAMA
0x0DCA, # SINHALA SIGN AL-LAKUNA
0x1B44, # BALINESE ADEG ADEG
0xA806, # SYLOTI NAGRI SIGN HASANTA
0xA8C4, # SAURASHTRA SIGN VIRAMA
0xA9C0, # JAVANESE PANGKON
0x11046, # BRAHMI VIRAMA
0x110B9, # KAITHI SIGN VIRAMA
0x111C0, # SHARADA SIGN VIRAMA
0x11235, # KHOJKI SIGN VIRAMA
0x1134D, # GRANTHA SIGN VIRAMA
0x11442, # NEWA SIGN VIRAMA
0x114C2, # TIRHUTA SIGN VIRAMA
0x115BF, # SIDDHAM SIGN VIRAMA
0x1163F, # MODI SIGN VIRAMA
0x116B6, # TAKRI SIGN VIRAMA
0x11839, # DOGRA SIGN VIRAMA
0x119E0, # NANDINAGARI SIGN VIRAMA
0x11C3F, # BHAIKSUKI SIGN VIRAMA
))
_ISC_VIRAMA_SET = frozenset(
cp for lo, hi in (*ISC_VIRAMA, *ISC_INVISIBLE_STACKER)
for cp in range(lo, hi + 1)
)
# pylint: disable=invalid-name
_LATEST_VERSION = list_versions()[-1]
_CATEGORY_MC_TABLE = CATEGORY_MC[_LATEST_VERSION]
Expand Down
44 changes: 26 additions & 18 deletions wcwidth/_wcswidth.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
"wcwidth._constants",
"wcwidth._wcwidth",
"wcwidth.bisearch",
"wcwidth.table_grapheme",
"wcwidth.table_vs16",
]
# local
Expand Down Expand Up @@ -74,26 +73,30 @@ def wcswidth(
# grapheme-clustering state
last_measured_idx = -2
last_measured_ucs = -1
last_was_virama = False
prev_was_virama = False
conjunct_pending = False

while idx < end:
char = pwcs[idx]
ucs = ord(char)

# ZWJ (U+200D)
#
# Much of the logic below matches the logic in width(), but is repeated for improved
# performance, they are given matching index reference numbers (starting at #6).
#
# 5. ZWJ (U+200D): consumed without contributing width.
# Virama codepoints are treated as zero-width combining marks (Mn), matching
# Ghostty's uucode library. Whether a virama+consonant sequence should
# collapse to 1 or 2 cells is undefined by Unicode; we follow uucode's (1).
if ucs == 0x200D:
if last_was_virama:
if prev_was_virama:
idx += 1
elif idx + 1 < end:
last_was_virama = False
idx += 2
else:
last_was_virama = False
idx += 1
continue

# VS16 (U+FE0F): converts preceding narrow character to wide.
# 6. VS16 (U+FE0F): converts preceding narrow character to wide.
if ucs == 0xFE0F and last_measured_idx >= 0:
total_width += bisearch(
ord(pwcs[last_measured_idx]),
Expand All @@ -103,7 +106,7 @@ def wcswidth(
idx += 1
continue

# Regional Indicator & Fitzpatrick (both above BMP)
# 7. Regional Indicator & Fitzpatrick (both above BMP)
if ucs > 0xFFFF:
if ucs in _REGIONAL_INDICATOR_SET:
ri_before = 0
Expand All @@ -121,15 +124,17 @@ def wcswidth(
continue

# Virama conjunct formation
if last_was_virama and bisearch(ucs, ISC_CONSONANT):
if prev_was_virama and bisearch(ucs, ISC_CONSONANT):
if conjunct_pending:
total_width += 1
last_measured_idx = idx
last_measured_ucs = ucs
last_was_virama = False
prev_was_virama = False
conjunct_pending = True
idx += 1
continue

# Normal character: measure with wcwidth
# 8. Normal character: measure with wcwidth
w = _wcwidth(char)
if w < 0:
# C0/C1 control character
Expand All @@ -141,15 +146,18 @@ def wcswidth(
total_width += w
last_measured_idx = idx
last_measured_ucs = ucs
last_was_virama = False
prev_was_virama = False
elif last_measured_idx >= 0 and bisearch(ucs, _CATEGORY_MC_TABLE):
# Spacing Combining Mark (Mc) following a base character adds 1
total_width += 1
# Spacing Combining Mark (Mc) following a base character
if conjunct_pending:
total_width += 1
conjunct_pending = False
else:
total_width += 1
last_measured_idx = -2
last_was_virama = False
conjunct_pending = False
prev_was_virama = False
else:
last_was_virama = ucs in _ISC_VIRAMA_SET
prev_was_virama = ucs in _ISC_VIRAMA_SET
idx += 1

if conjunct_pending:
Expand Down
Loading