Skip to content

Commit

Permalink
Let string splitters respect East_Asian_Width property (#3445)
Browse files Browse the repository at this point in the history
This patch changes the preview style so that string splitters respect
Unicode East Asian Width[^1] property.  If you are not familiar to CJK
languages it is not clear immediately.  Let me elaborate with some
examples.

Traditionally, East Asian characters (including punctuation) have
taken up space twice than European letters and stops when they are
rendered in monospace typeset.  Compare the following characters:

```
abcdefg.
글、字。
```

The characters at the first line are half-width, and the second line
are full-width.  (Also note that the last character with a small
circle, the East Asian period, is also full-width.)  Therefore, if we
want to prevent those full-width characters to exceed the maximum
columns per line, we need to count their *width* rather than the number
of characters.  Again, the following characters:

```
글、字。
```

These are just 4 characters, but their total width is 8.

Suppose we want to maintain up to 4 columns per line with the following
text:

```
abcdefg.
글、字。
```

How should it be then?  We want it to look like:

```
abcd
efg.
글、
字。
```

However, Black currently turns it into like this:

```
abcd
efg.
글、字。
```

It's because Black currently counts the number of characters in the line
instead of measuring their width. So, how could we measure the width?
How can we tell if a character is full- or half-width? What if half-width
characters and full-width ones are mixed in a line? That's why Unicode
defined an attribute named `East_Asian_Width`. Unicode grouped every
single character according to their width in fixed-width typeset.

This partially addresses #1197, but only for string splitters. The other
parts need to be fixed as well in future patches.

This was implemented by copying rich's own approach to handling wide
characters: generate a table using wcwidth, check it into source
control, and use in to drive helper functions in Black's logic. This
gets us the best of both worlds: accuracy and performance (and let's us
update as per our stability policy too!).

Co-authored-by: Jelle Zijlstra <jelle.zijlstra@gmail.com>
  • Loading branch information
dahlia and JelleZijlstra committed Mar 19, 2023
1 parent 5c064a9 commit ef6e079
Show file tree
Hide file tree
Showing 7 changed files with 678 additions and 21 deletions.
5 changes: 5 additions & 0 deletions CHANGES.md
Expand Up @@ -23,6 +23,11 @@
compared to their non-async version. (#3609)
- `with` statements that contain two context managers will be consistently wrapped in
parentheses (#3589)
- Let string splitters respect [East Asian Width](https://www.unicode.org/reports/tr11/)
(#3445)
- Now long string literals can be split after East Asian commas and periods (`` U+3001
IDEOGRAPHIC COMMA, `` U+3002 IDEOGRAPHIC FULL STOP, & `` U+FF0C FULLWIDTH COMMA)
besides before spaces (#3445)
- For stubs, enforce one blank line after a nested class with a body other than just
`...` (#3564)

Expand Down
73 changes: 73 additions & 0 deletions scripts/make_width_table.py
@@ -0,0 +1,73 @@
"""Generates a width table for Unicode characters.
This script generates a width table for Unicode characters that are not
narrow (width 1). The table is written to src/black/_width_table.py (note
that although this file is generated, it is checked into Git) and is used
by the char_width() function in src/black/strings.py.
You should run this script when you upgrade wcwidth, which is expected to
happen when a new Unicode version is released. The generated table contains
the version of wcwidth and Unicode that it was generated for.
In order to run this script, you need to install the latest version of wcwidth.
You can do this by running:
pip install -U wcwidth
"""
import sys
from os.path import basename, dirname, join
from typing import Iterable, Tuple

import wcwidth


def make_width_table() -> Iterable[Tuple[int, int, int]]:
start_codepoint = -1
end_codepoint = -1
range_width = -2
for codepoint in range(0, sys.maxunicode + 1):
width = wcwidth.wcwidth(chr(codepoint))
if width <= 1:
# Ignore narrow characters along with zero-width characters so that
# they are treated as single-width. Note that treating zero-width
# characters as single-width is consistent with the heuristics built
# on top of str.isascii() in the str_width() function in strings.py.
continue
if start_codepoint < 0:
start_codepoint = codepoint
range_width = width
elif width != range_width or codepoint != end_codepoint + 1:
yield (start_codepoint, end_codepoint, range_width)
start_codepoint = codepoint
range_width = width
end_codepoint = codepoint
if start_codepoint >= 0:
yield (start_codepoint, end_codepoint, range_width)


def main() -> None:
table_path = join(dirname(__file__), "..", "src", "black", "_width_table.py")
with open(table_path, "w") as f:
f.write(
f"""# Generated by {basename(__file__)}
# wcwidth {wcwidth.__version__}
# Unicode {wcwidth.list_versions()[-1]}
import sys
from typing import List, Tuple
if sys.version_info < (3, 8):
from typing_extensions import Final
else:
from typing import Final
WIDTH_TABLE: Final[List[Tuple[int, int, int]]] = [
"""
)
for triple in make_width_table():
f.write(f" {triple!r},\n")
f.write("]\n")


if __name__ == "__main__":
main()

0 comments on commit ef6e079

Please sign in to comment.