cellstr 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cellstr/_props.py ADDED
@@ -0,0 +1,61 @@
1
+ """Lookup of the embedded Unicode properties: width class, grapheme and emoji data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from bisect import bisect_right
6
+
7
+ from ._ucd_data import (
8
+ EMOJI_BIT,
9
+ EXT_PICT_BIT,
10
+ GCB_NAMES,
11
+ INCB_SHIFT,
12
+ MODIFIER_BIT,
13
+ RUNS,
14
+ UNICODE_VERSION,
15
+ WIDTH_SHIFT,
16
+ )
17
+
18
+ __all__ = [
19
+ "AMBIGUOUS",
20
+ "EMOJI_BIT",
21
+ "EXT_PICT_BIT",
22
+ "GCB",
23
+ "INCB_SHIFT",
24
+ "MODIFIER_BIT",
25
+ "UNICODE_VERSION",
26
+ "WIDTH_SHIFT",
27
+ "prop",
28
+ ]
29
+
30
+ GCB = {name: index for index, name in enumerate(GCB_NAMES)}
31
+ AMBIGUOUS = 3
32
+
33
+
34
+ def _decode(runs: str) -> tuple[list[int], list[int]]:
35
+ """Expand the run tokens into parallel lists of run starts and property codes."""
36
+ starts: list[int] = []
37
+ codes: list[int] = []
38
+ position = 0
39
+ for token in runs.split():
40
+ starts.append(position)
41
+ codes.append(int(token[:3], 36))
42
+ position += int(token[3:], 36)
43
+ if position != 0x110000:
44
+ raise RuntimeError("embedded Unicode tables do not cover the code space")
45
+ return starts, codes
46
+
47
+
48
+ _STARTS, _CODES = _decode(RUNS)
49
+ _LATIN = tuple(_CODES[bisect_right(_STARTS, cp) - 1] for cp in range(0x300))
50
+
51
+
52
+ def prop(cp: int) -> int:
53
+ """Return the packed property code of code point ``cp``.
54
+
55
+ Bits 0-3 hold Grapheme_Cluster_Break (an index into ``GCB_NAMES``), bit 4 is
56
+ Extended_Pictographic, bits 5-6 Indic_Conjunct_Break, bit 7 Emoji, bit 8
57
+ Emoji_Modifier and bits 9-10 the width class (0 zero, 1 narrow, 2 wide, 3 ambiguous).
58
+ """
59
+ if cp < 0x300:
60
+ return _LATIN[cp]
61
+ return _CODES[bisect_right(_STARTS, cp) - 1]
cellstr/_truncate.py ADDED
@@ -0,0 +1,130 @@
1
+ """Width-aware truncation with a suffix at the end, start or middle, and exact fitting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal, NamedTuple
6
+
7
+ from ._cells import check_tabsize, expand_tabs
8
+ from ._ops import cell_slice, center, check_fill, ljust, rjust, splice
9
+ from ._width import check_ambiguous, text_width
10
+
11
+ Position = Literal["end", "start", "middle"]
12
+ Align = Literal["left", "right", "center"]
13
+ _POSITIONS = ("end", "start", "middle")
14
+ _ALIGNS = ("left", "right", "center")
15
+
16
+
17
+ class Truncation(NamedTuple):
18
+ """The outcome of ``truncate_detailed``.
19
+
20
+ ``text`` is the result, ``width`` the cells it occupies, ``original_width`` the cells
21
+ the input occupied and ``truncated`` whether anything was cut.
22
+ """
23
+
24
+ text: str
25
+ width: int
26
+ original_width: int
27
+ truncated: bool
28
+
29
+
30
+ def truncate_detailed(
31
+ text: str,
32
+ width: int,
33
+ suffix: str = "...",
34
+ *,
35
+ position: Position = "end",
36
+ ambiguous: int = 1,
37
+ tabsize: int | None = None,
38
+ ) -> Truncation:
39
+ """Shorten ``text`` to at most ``width`` cells and report what happened.
40
+
41
+ ``position`` says where the cut goes: ``"end"`` keeps the start, ``"start"`` keeps
42
+ the end (log tails, right-aligned numbers) and ``"middle"`` keeps both ends (paths,
43
+ identifiers), giving the odd cell to the left part. The suffix marks the cut. When
44
+ the suffix alone would not fit, the text is cut to ``width`` with no suffix. Grapheme
45
+ clusters are never split, so a wide character at the cut may leave the result one
46
+ cell short. Every escape sequence in the text is kept once, in place; the suffix is
47
+ inserted as given and must not contain tabs or line breaks.
48
+
49
+ >>> truncate_detailed("hello world", 8)
50
+ Truncation(text='hello...', width=8, original_width=11, truncated=True)
51
+ """
52
+ if width < 0:
53
+ raise ValueError(f"width must be non-negative, not {width}")
54
+ if position not in _POSITIONS:
55
+ raise ValueError(f"position must be one of {', '.join(_POSITIONS)}, not {position!r}")
56
+ if any(ch in suffix for ch in "\t\n\r"):
57
+ raise ValueError("suffix must not contain tabs or line breaks")
58
+ check_ambiguous(ambiguous)
59
+ tabsize = check_tabsize(tabsize)
60
+ if tabsize is not None:
61
+ text = expand_tabs(text, tabsize, ambiguous=ambiguous)
62
+ total = text_width(text, ambiguous)
63
+ if total <= width:
64
+ return Truncation(text, total, total, False)
65
+ room = width - text_width(suffix, ambiguous)
66
+ if room < 0: # no room for the suffix: hard cut, and a middle cut becomes an end cut
67
+ suffix, room = "", width
68
+ position = "start" if position == "start" else "end"
69
+ if position == "end":
70
+ cut = cell_slice(text, 0, room, ambiguous=ambiguous) + suffix
71
+ elif position == "start":
72
+ cut = suffix + cell_slice(text, total - room, ambiguous=ambiguous)
73
+ else:
74
+ head = (room + 1) // 2
75
+ cut = splice(text, head, total - (room - head), suffix, ambiguous)
76
+ return Truncation(cut, text_width(cut, ambiguous), total, True)
77
+
78
+
79
+ def truncate(
80
+ text: str,
81
+ width: int,
82
+ suffix: str = "...",
83
+ *,
84
+ position: Position = "end",
85
+ ambiguous: int = 1,
86
+ tabsize: int | None = None,
87
+ ) -> str:
88
+ """Shorten ``text`` to at most ``width`` cells, marking the cut with ``suffix``.
89
+
90
+ ``text`` is returned unchanged when it already fits; with ``tabsize`` set its tabs
91
+ are expanded first. See ``truncate_detailed`` for the cut positions and the rules.
92
+
93
+ >>> truncate("hello world", 8), truncate("/usr/local/lib/python", 12, position="middle")
94
+ ('hello...', '/usr/...thon')
95
+ """
96
+ return truncate_detailed(
97
+ text, width, suffix, position=position, ambiguous=ambiguous, tabsize=tabsize
98
+ ).text
99
+
100
+
101
+ def fit(
102
+ text: str,
103
+ width: int,
104
+ *,
105
+ align: Align = "left",
106
+ suffix: str = "...",
107
+ position: Position = "end",
108
+ fillchar: str = " ",
109
+ ambiguous: int = 1,
110
+ tabsize: int | None = None,
111
+ ) -> str:
112
+ """Return ``text`` truncated or padded to exactly ``width`` cells: one table cell.
113
+
114
+ Long text is cut as ``truncate`` does, short text is padded as ``ljust``, ``rjust``
115
+ or ``center`` do, depending on ``align``. When a wide character at the cut leaves the
116
+ result one cell short, padding fills it, so columns always line up.
117
+
118
+ >>> fit("\\u4f60\\u597d\\u4e16\\u754c", 5, suffix="") == "\\u4f60\\u597d "
119
+ True
120
+ >>> fit("42", 5, align="right")
121
+ ' 42'
122
+ """
123
+ if align not in _ALIGNS:
124
+ raise ValueError(f"align must be one of {', '.join(_ALIGNS)}, not {align!r}")
125
+ check_fill(fillchar, check_ambiguous(ambiguous))
126
+ cut = truncate_detailed(
127
+ text, width, suffix, position=position, ambiguous=ambiguous, tabsize=tabsize
128
+ ).text
129
+ pad = {"left": ljust, "right": rjust, "center": center}[align]
130
+ return pad(cut, width, fillchar, ambiguous=ambiguous)
cellstr/_ucd_data.py ADDED
@@ -0,0 +1,31 @@
1
+ """Index of the generated Unicode property data."""
2
+
3
+ # Generated by scripts/gen_ucd_tables.py from data/ucd/. Do not edit.
4
+
5
+ from __future__ import annotations
6
+
7
+ from ._ucd_part1 import RUNS as _R0
8
+
9
+ UNICODE_VERSION = "18.0.0"
10
+ GCB_NAMES = (
11
+ "Other",
12
+ "CR",
13
+ "LF",
14
+ "Control",
15
+ "Extend",
16
+ "ZWJ",
17
+ "Regional_Indicator",
18
+ "Prepend",
19
+ "SpacingMark",
20
+ "L",
21
+ "V",
22
+ "T",
23
+ "LV",
24
+ "LVT",
25
+ )
26
+ EXT_PICT_BIT = 16
27
+ INCB_SHIFT = 5
28
+ EMOJI_BIT = 128
29
+ MODIFIER_BIT = 256
30
+ WIDTH_SHIFT = 9
31
+ RUNS = _R0
cellstr/_ucd_part1.py ADDED
@@ -0,0 +1,166 @@
1
+ """Run-length property data, part 1."""
2
+
3
+ # Generated by scripts/gen_ucd_tables.py from data/ucd/. Do not edit.
4
+
5
+ RUNS = (
6
+ "003a 0021 0032 0011 003i 0e83 0hs1 0e86 0hs1 0e85 0hsa 0e81x 003x 0e81 16o1 0e82 16o1 "
7
+ "0e82 16o2 0i81 16o1 0e82 16r1 1ao1 0e81 16o5 0e81 16o5 0e81 16o4 0e86 16o1 0e89 16o1 "
8
+ "0e86 16o2 0e85 16o4 0e84 16o1 0e81 16o3 0e81 16o2 0e82 16o1 0e81 16o2 0e83 16o4 0e81 "
9
+ "16o1 0e81 16o1 0e82 16o1 0e8f 16o1 0e81 16o1 0e87 16o1 0e8a 16o2 0e83 16o1 0e85 16o3 "
10
+ "0e84 16o1 0e86 16o4 0e81 16o1 0e83 16o4 0e81 16o1 0e84 16o2 0e8i 16o2 0e83 16o1 0e82q "
11
+ "16o1 0e81 16o1 0e81 16o1 0e81 16o1 0e81 16o1 0e81 16o1 0e81 16o1 0e81 16o1 0e838 16o1 "
12
+ "0e8f 16o1 0e82q 16o1 0e82 16o1 0e81 16o3 0e81 16o1 0e82 16o1 0e87 16o4 0e81 16o1 0e81 "
13
+ "16o1 0e8w 02s34 0e8x 16oh 0e81 16o7 0e87 16oh 0e81 16o7 0e81j 16o1 0e8e 16o1s 0e81 16o1 "
14
+ "0e81d 02s7 0e87b 02s19 0e81 02s1 0e81 02s2 0e81 02s2 0e81 02s3 0e81i 0076 0e8a 02sb "
15
+ "0e81 0031 0e81a 02sl 0e8g 02s1 0e82t 02s7 0071 0e81 02s6 0e82 02s2 0e81 02s4 0e8x 0071 "
16
+ "0e81 02s1 0e8u 02sr 0e82j 02sb 0e81m 02s9 0e89 02s1 0e8o 02s4 0e81 02s9 0e81 02s3 0e81 "
17
+ "02s5 0e817 02s3 0e81g 0072 0e85 02s9 0e816 02so 0071 02sw 0eg1 0e8h 0g011 02s1 0eg1 "
18
+ "02s1 0e81 0eg3 02s8 0eg4 0101 0eg2 0e81 02s7 0g08 0e82 02s2 0e8k 0g08 0e81 02s1 0eg2 "
19
+ "0e8h 0g0k 0e81 0g07 0e81 0g01 0e83 0g04 0e82 02s1 0e81 0h01 0eg2 02s4 0e82 0eg2 0e82 "
20
+ "0eg2 0101 0e89 0h01 0e84 0g02 0e81 0g01 0e82 02s2 0e8c 0g02 0e8c 02s1 0e82 02s2 0eg1 "
21
+ "0e81k 02s1 0e81 0eg3 02s2 0e84 02s2 0e82 02s3 0e83 02s1 0e8u 02s2 0e83 02s1 0e8b 02s2 "
22
+ "0eg1 0e8h 0g0k 0e81 0g07 0e81 0g02 0e81 0g05 0e82 02s1 0e81 0eg3 02s5 0e81 02s2 0eg1 "
23
+ "0e81 0eg2 0101 0e8k 02s2 0e8l 0g01 02s6 0e81 02s1 0eg2 0e8h 0g0k 0e81 0g07 0e81 0g02 "
24
+ "0e81 0g05 0e82 02s1 0e81 0h01 02s1 0eg1 02s4 0e82 0eg2 0e82 0eg2 0101 0e85 02s4 0h01 "
25
+ "0e84 0g02 0e81 0g01 0e82 02s2 0e8d 0g01 0e8g 02s1 0e81n 0h01 0eg1 02s1 0eg2 0e83 0eg3 "
26
+ "0e81 0eg3 02s1 0e89 0h01 0e814 02s1 0eg3 02s1 0e8g 0g0k 0e81 0g0g 0e82 02s1 0e81 02s3 "
27
+ "0eg4 0e81 02s3 0e81 02s3 0101 0e87 02s2 0e81 0g03 0e87 02s2 0e8t 02s1 0eg2 0e81k 02s1 "
28
+ "0e81 0eg1 02s1 0h01 0eg1 0h01 0eg2 0e81 02s1 0h02 0e81 0h02 02s2 0e87 0h02 0e8b 02s2 "
29
+ "0e8f 0eg1 0e8c 02s2 0eg2 0e8h 0g012 02s2 0e81 0h01 0eg2 02s4 0e81 0eg3 0e81 0eg3 0101 "
30
+ "0ef1 0e88 0h01 0e8a 02s2 0e8t 02s1 0eg2 0e81y 02s1 0e84 0h01 0eg2 02s3 0e81 02s1 0e81 "
31
+ "0eg7 0h01 0e8i 0eg2 0e81p 02s1 0e81 0eg1 02s7 0e8c 02s8 0e82q 02s1 0e81 0eg1 02s9 0e8b "
32
+ "02s7 0e821 02s2 0e8r 02s1 0e81 02s1 0e81 02s1 0e84 0eg2 0e81d 02se 0eg1 02s5 0e81 02s2 "
33
+ "0e85 02sb 0e81 02s10 0e89 02s1 0e81l 0g017 0e82 02s4 0eg1 02s6 0e81 0101 02s1 0eg2 02s2 "
34
+ "0g01 0e8g 0g06 0eg2 02s2 0g04 02s3 0g01 0e83 0g02 0e87 0g03 02s4 0g0d 02s1 0e81 0eg1 "
35
+ "02s2 0e86 02s1 0g01 0e8e 02s1 0e82q 0sp2o 00a20 00b2g 0e89p 02s3 0e8qa 02s3 0h01 0e8s "
36
+ "02s2 0h01 0e8t 02s2 0e8u 02s2 0e8c 0g01g 02s2 0eg1 02s7 0eg8 02s1 0eg2 02s9 0101 02s1 "
37
+ "0e89 02s1 0e819 02s3 0031 02s1 0e839 02s2 0e8y 02s1 0e83a 02s3 0eg4 02s2 0eg3 0e84 0eg2 "
38
+ "02s1 0eg6 02s3 0e863 02s2 0eg2 02s1 0e84 0g01h 0eg1 02s1 0eg1 02s7 0e81 0101 0e81 02s1 "
39
+ "0e82 02s8 0eg6 02sa 0e82 02s1 0e81c 02s1t 0e8f 02s4 0eg1 0e86 0g02 0e86 0g0x 02s1 0h01 "
40
+ "02s5 0h01 02s1 0h01 0eg4 02s1 0h01 0f81 0g08 0e8u 02s9 0e8c 02s2 0eg1 0g0u 0eg1 02s4 "
41
+ "0eg2 02s2 0h01 0101 02s2 0g02 0e8b 0g03 0e814 02s1 0eg1 02s2 0eg3 02s1 0eg1 02s3 0h02 "
42
+ "0e81c 0eg8 02s8 0eg2 02s2 0e848 02s3 0e81 02sd 0eg1 02s7 0e84 02s1 0e86 02s1 0f42 0eg1 "
43
+ "02s2 0e85i 02s1s 0e8ej 0031 0041 02t1 0032 16o1 0e82 16o4 0e81 16o2 0e82 16o2 0e82 16o3 "
44
+ "0e81 16o4 0eb2 0035 0e81 16o1 0e81 16o2 0e81 16o1 0e85 16o1 0i81 0e81 16o1 0e8a 0i81 "
45
+ "0e8m 0035 0eb1 003a 0e84 16o1 0e8a 16o1 0e81 16o4 0e813 16o1 0e8z 02sx 0e8i 16o1 0e81 "
46
+ "16o1 0e83 16o1 0e89 16o1 0e82 16o1 0e8a 16o1 1ao1 0e83 16o1 0e84 16o1 0e8d 0i81 0e8p "
47
+ "16o2 0e86 16o4 0e81 16oc 0e84 16oa 0e8f 16o1 0e86 16o4 1ao6 0e8f 0i82 0e8d 16o2 0e8o "
48
+ "16o1 0e81 16o1 0e8i 16o1 0e8o 16o1 0e81 16o2 0e83 16o2 0e82 16o1 0e83 16o1 0e81 16o1 "
49
+ "0e83 16o1 0e84 16o1 0e82 16o4 0e82 16o1 0e81 16o1 0e81 16o6 0e81 16o1 0e85 16o4 0e84 "
50
+ "16o2 0e8a 16o1 0e83 16o1 0e85 16o1 0e8d 16o2 0e82 16o4 0e82 16o2 0e82 16o2 0e8i 16o2 "
51
+ "0e82 16o2 0e8d 16o1 0e83 16o1 0e8b 16o1 0e8p 16o1 0e82a 16o1 0e87 0wg2 0e8c 0i81 0sg2 "
52
+ "0e84k 0i81 0e8p 0wg4 0i83 0wg1 0i82 0wg1 0e84 0i83 0e82t 16o2q 1ao1 16o13 0e81 16o2p "
53
+ "0e84 16o10 0e8c 16og 0e82 16o4 0e8a 16o2 0e81 16o7 0i82 0e86 16o2 0e82 1ao1 16o1 0e84 "
54
+ "16o2 0e82 1ao1 16o1 0e84 16o3 0e82 16o1 0e82 16o4 0e8g 16o4 0e89 16o1 0e8b 0i82 0wg2 "
55
+ "0e81 0i85 16o2 0e82 16o1 0e84 1ao1 16o1 0e81 0i81 0e82 0wg2 0e82 0i81 0e83 16o1 0i81 "
56
+ "16o1 0e81 0i81 0e81 0i82 0e82 0i81 0e83 0i81 0e83 0i82 0sg8 0i83 0e85 1ao1 0e81 1ao1 "
57
+ "0e85 0wgc 0e8b 0i81 1ao1 16o1 0e81 1ao1 16o1 1ao1 0i81 16o1 1ao1 16o2 0e81 16o2 0e81 "
58
+ "16o1 0e8b 0i81 0e82 0i81 0wg1 0e8a 0sg6 0e82 0i81 0wg1 0i84 0e81 0i81 0e81 0i82 0e81 "
59
+ "16o2 0i81 0wg1 0e85 0i81 0e82 0wg2 0e84 0i82 0e8b 0wg2 16o1 0e84 0wg2 16o2 1ao1 16o5 "
60
+ "0wg1 1ao1 16o1 1ao1 16o1 1ao1 0wg1 16od 0e81 16o1 0e84 16o1 1ao1 0wg1 16o5 1ao2 0wg2 "
61
+ "1ao1 0wg1 16o1 1ao3 0wg1 16o2 0wg1 16o2 0e82 0i81 0e82 0wg1 0e82 0i82 0wg2 0i82 0e81 "
62
+ "0i81 0e82 0i81 0e81 0i81 0e81 0i81 0e86 0i81 0e83 0i81 0e86 0wg1 0e8a 0i82 0e88 16o1 "
63
+ "0e86 0i81 0e82 0i81 0e84 0wg1 0e81 0wg1 0e84 0wg3 0e81 0wg1 0e8b 0i82 0e8h 16oa 0e8l "
64
+ "0wg3 0e89 0i81 0e8e 0wg1 0e8e 0wg1 0e8ac 0i82 0e8cv 0i83 0e8j 0wg2 0e81f 0wg1 0e84 0wg1 "
65
+ "16o4 0e8b9 02s3 0e83x 02s1 0e82o 02sw 0e83k 0sgq 0e81 0sg2h 0e8c 0sg5y 0e8q 0sg1m 02s4 "
66
+ "0v82 0wg1 0sgc 0wg1 0sg1 0e82 0sg2e 0e82 02s2 0sg2t 0e85 0sg17 0e81 0sg2m 0e81 0sg2e "
67
+ "0e89 0sg1c 0e81 0sg14 16o8 0sg1z 0wg1 0sg1 0wg1 0sgmib 0e83 0sg1j 0e8bs 02s4 0e81 02sa "
68
+ "0e8w 02s2 0e828 02s2 0e87k 02s1 0e83 02s1 0e84 02s1 0e8n 0eg2 02s2 0eg1 0e84 02s1 0e82b "
69
+ "0eg2 0e81e 0egg 02s2 0e8q 02si 0e8d 02s1 0e812 02s8 0e8p 02sb 0eg1 0h01 0e8c 0spt 0e83 "
70
+ "02s3 0eg1 0e85 0g03 0e83 0g010 02s1 0eg2 02s4 0eg2 02s2 0eg2 0f81 0e8v 0g05 02s1 0e81 "
71
+ "0g09 0e8a 0g05 0e816 02s6 0eg2 02s2 0eg2 02s2 0e8c 02s1 0e88 02s1 0eg1 0e8i 0g0g 0e81 "
72
+ "0g03 0e86 0g01 0e81 02s1 0e81 0g02 0e81c 02s1 0e81 02s3 0e82 02s2 0e85 02s2 0e81 02s1 "
73
+ "0e8u 0g0b 0eg1 02s2 0eg2 0e85 0eg1 0101 0e85l 0g0r 0e88 0eg2 02s1 0eg2 02s1 0eg2 0e81 "
74
+ "0eg1 02s1 0e8i 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
75
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
76
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
77
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
78
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
79
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
80
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
81
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
82
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
83
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
84
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
85
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
86
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
87
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
88
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
89
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
90
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
91
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
92
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
93
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
94
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
95
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
96
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
97
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
98
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
99
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
100
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
101
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
102
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
103
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
104
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
105
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
106
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
107
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
108
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
109
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
110
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
111
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
112
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
113
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
114
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
115
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
116
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
117
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
118
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
119
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
120
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
121
+ "0ss1 0str 0e8c 00an 0004 00b1d 0004 0e81kw 16o4xs 0sge8 0e8u 02s1 0e8kh 02sg 0sga 0e86 "
122
+ "02sg 0sgz 0e81 0sgj 0e81 0sg4 0e843 0031 0e81 0sg2o 0e81p 0h02 0e81s 0sg7 0e89 0eb9 "
123
+ "0033 0e81 16o1 0e8e7 02s1 0e86a 02s1 0e845 02s5 0e81ad 0g01 02s3 0e81 02s2 0e85 02s4 "
124
+ "0g04 0e81 0g03 0e81 0g0t 0e82 02s3 0e84 0101 0e84l 02s2 0e8fx 02s4 0e81t 02s5 0e88t "
125
+ "02s2 0e8u 02s5 0e8w 02sg 0e81y 02sb 0e81d 02s4 0e83e 0eg1 02s1 0eg1 0e81h 02sf 0e815 "
126
+ "02s1 0e82 02s2 0e8a 02s3 0eg1 0e819 0eg3 02s4 0eg2 02s2 0e82 0071 0e84 02s1 0e8a 0071 "
127
+ "0e81e 02s3 0g010 02s5 0eg1 02s6 0101 02s1 0e8f 0g01 0eg2 0g01 0e817 02s1 0e8c 02s2 0eg1 "
128
+ "0e81c 0eg3 02s9 0eg1 0h01 0e81 0ef2 0e85 02s4 0e81 0eg1 02s1 0e82k 0eg3 02s3 0eg2 02s1 "
129
+ "0h01 02s2 0e86 02s1 0e82 02s1 0e84d 02s1 0eg3 02s8 0e8l 02s2 0eg2 0e81j 02s2 0e81 0h01 "
130
+ "0eg1 02s1 0eg4 0e82 0eg2 0e82 0eg2 0h01 0e89 0h01 0e8a 0eg2 0e82 02s7 0e83 02s5 0e8b "
131
+ "0g0a 0e81 0g01 0e82 0g01 0e81 0g012 0e82 0h01 0eg2 02s6 0e81 0h01 0e82 0h01 0e81 0h03 "
132
+ "0eg1 0e81 0eg2 02s1 0h01 0101 0ef1 02s1 0e8e 02s2 0e82a 0eg3 02s8 0eg2 02s3 0eg1 02s1 "
133
+ "0e8n 02s1 0e829 0h01 0eg2 02s6 0eg1 02s1 0eg2 0h01 0eg1 02s2 0eg1 02s2 0e86j 0h01 0eg2 "
134
+ "02s4 0e82 0eg4 02s2 0eg1 02s2 0e8r 02s2 0e82a 0eg3 02s8 0eg2 02s1 0eg1 02s2 0e82y 02s1 "
135
+ "0eg1 02s1 0eg2 02s6 0h01 02s1 0e82t 02s1 0eg1 02s1 0e82 02s4 0eg1 02s5 0e874 0eg3 02s9 "
136
+ "0eg1 02s2 0e85h 0g07 0e82 0g01 0e82 0g08 0e81 0g02 0e81 0g0o 0h01 0eg5 0e81 0eg2 0e82 "
137
+ "02s2 0h01 0101 0ef1 0eg1 0ef1 0eg1 02s1 0e83x 0eg3 02s4 0e82 02s2 0eg4 02s1 0e83 0eg1 "
138
+ "0e8r 0g01 02sa 0g014 02s6 0eg1 0f41 02s4 0e88 0101 0e88 0g01 02s6 0eg2 02s3 0g014 0ef6 "
139
+ "02sd 0eg1 02s1 0101 0e834 0g01 0e82d 02s1 0eg1 02s3 0eg1 02s1 0eg1 0e85j 0eg1 02s7 0e81 "
140
+ "02s6 0eg1 02s1 0e82a 02sm 0e81 0eg1 02s7 0eg1 02s2 0eg1 02s2 0e83e 02s6 0e83 02s1 0e81 "
141
+ "02s2 0e81 02s7 0ef1 02s1 0e81u 0eg5 0e81 02s2 0e81 0eg2 02s1 0eg1 02s1 0e82g 02s1 0g01 "
142
+ "0e875 02s2 0eg2 0e89 02s2 0ef1 0eg1 0g0d 0e81 0g0y 0eg2 02s5 0e83 0eg2 02s1 0h01 0101 "
143
+ "0e8n 02s1 0e8445 003g 02s1 0e86 02sf 0e88ug 02sc 0eg3 02s3 0e81xc 02s5 0e81n 02s7 0e8fg "
144
+ "0ei1 0e83 0ei4 0e8dg 02s1 0e81 0eg1j 0e87 02s4 0e825 0sg4 02s1 0e8b 0v82 0sg5 0e89 "
145
+ "0sg5p7 0e810 0sgy 0e82n 0sg37 0e8d 0sgpe 0e8e 0sg1f 0e85y5 0sg4 0e81 0sg7 0e81 0sg2 "
146
+ "0e81 0sg89 0e89 0sg1 0e8t 0sg3 0e82 0sg1 0e8e 0sg5 0e87 0sgb0 0e81wh 02s2 0e81 0034 "
147
+ "0e83mk 02s1a 0e82 02sn 0e8dc 02s2 0e81o 0h02 02s3 0e83 0h06 0038 02s8 0e82 02s7 0e8u "
148
+ "02s4 0e844 02s3 0e8b 0h03 0e88 02s2 0e82 0h01 0e8w 0h02 0e83i 0sg2f 0e89 0sgn 0e81ah "
149
+ "02s1j 0e84 02s1e 0e88 02s1 0e8e 02s1 0e8m 02s5 0e81 02sf 0e811s 02s7 0e81 02sh 0e82 "
150
+ "02s7 0e81 02s2 0e81 02s5 0e82s 02s1 0e84g 02s7 0e8af 02s1 0e81p 02s4 0e8e4 02s4 0e872 "
151
+ "02s2 0e86r 02s1 0e82 02s1 0e87 02s2 0e85 02s1 0e8d6 02s7 0e831 02s7 0e81bt 0wg1 0e813 "
152
+ "0eo4 0e82s 0eoc 0e8f 0eo2 0e8f 0eo1 0e8e 0wg1 0eo1 0e811 0eoa 16ob 0e85 16ou 0e82 16o1m "
153
+ "0e86 1ao2 16oc 1ao2 16oe 0wg1 16o2 0wga 16oi 0e81 0sg1 0eo1j 0hyq 0sg1 0wg2 0eod 0sga "
154
+ "0wg1 0sgk 0wg1 0sg2 0wg9 0sg1 0eo4 0sg9 0eo7 0wg2 0eoe 0sg6 0eo4a 0wgx 0i81 0e82 0i89 "
155
+ "0wg9 0i81 0wg1y 0i81 0wgm 0e82 0i82 0e81 0i83 0e82 0i82 0wg17 0i84 0wg5 0i8c 0wgh 0e82 "
156
+ "0i81 0wg1 0i81 0e81 0i81 0wg3 15w5 0wg1r 0i81 0wg1 0i81 0wg57 0i81 0e81 0wg1r 0e8b 0i82 "
157
+ "0wg4 0e81 0wgo 0e87 0i82 0e82 0i87 0wg1 0e8c 0i81 0e82 0i84 0e82 0i81 0e84 0wg2 0e8d "
158
+ "0wg1 0i81 0e82 0i81 0e88 0i82 0e89 0i81 0e85 0i83 0e8c 0i83 0e88 0i83 0e82 0i81 0e81 "
159
+ "0i81 0e84 0i81 0e86 0i81 0e83 0i81 0e86 0i81 0wg2d 0e81c 0wg1y 0e85 0i81 0wg1 0i83 0wg3 "
160
+ "0e82 0wg5 0eo2 0wg4 0i86 0e83 0i81 0e81 0wg2 0eo3 0i81 0e82 0i81 0wg9 0eo3 0e862 0sg1 "
161
+ "0e81 0eo4 0wgc 0eo4 0wg1 0e8r 0eo4 0e81k 0eo8 0e8a 0eo6 0e814 0eo8 0e8u 0eo2 0e8c 0eo4 "
162
+ "0e82 0eoe 0e89 0eo13 0e8c 0wg1b 0e81 0wga 0e81 0wg55 0e82g 0eo8 0e8e 0eo2 0wgd 0eo3 "
163
+ "0wg1z 0eo1 0wg1 0eo3 0wgi 0eo1 0wgd 0eo3 0wgc 0eo5 0e874 0eose 0e82 0sg1eke 0e82 "
164
+ "0sg1eke 0e8e1oi 0eb1 0031 0ebu 02s2o 0eb3k 02s6o 0eb2s0 0e81beo 16o1eke 0e82 16o1eke "
165
+ "0e82 "
166
+ )
cellstr/_width.py ADDED
@@ -0,0 +1,111 @@
1
+ """Display width of code points, grapheme clusters and whole strings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._ansi import segments
6
+ from ._graphemes import grapheme_ends
7
+ from ._props import AMBIGUOUS, EMOJI_BIT, EXT_PICT_BIT, GCB, MODIFIER_BIT, WIDTH_SHIFT, prop
8
+
9
+ _RI = GCB["Regional_Indicator"]
10
+ _VS15 = "\ufe0e"
11
+ _VS16 = "\ufe0f"
12
+ _ZWJ = "\u200d"
13
+ _TAGS = range(0xE0020, 0xE0080)
14
+
15
+
16
+ def check_ambiguous(ambiguous: int) -> int:
17
+ """Return ``ambiguous`` after checking it is 1 or 2."""
18
+ if type(ambiguous) is not int or ambiguous not in (1, 2):
19
+ raise ValueError(f"ambiguous must be 1 or 2, not {ambiguous!r}")
20
+ return ambiguous
21
+
22
+
23
+ def code_width(code: int, ambiguous: int) -> int:
24
+ """Width of a code point from its packed property code."""
25
+ width = code >> WIDTH_SHIFT
26
+ return ambiguous if width == AMBIGUOUS else width
27
+
28
+
29
+ def char_width(char: str, *, ambiguous: int = 1) -> int:
30
+ """Display cells a single code point occupies on its own: 0, 1 or 2.
31
+
32
+ U+0000, the C0 and C1 controls, DEL, nonspacing and enclosing marks (Mn, Me), format
33
+ characters (Cf, except SOFT HYPHEN) and Hangul medial vowels and final consonants are
34
+ zero. East Asian Wide and Fullwidth characters are two. East Asian Ambiguous
35
+ characters take ``ambiguous`` cells (1 by default, 2 for CJK terminals). Everything
36
+ else is one. This is Markus Kuhn's wcwidth, except that controls are 0 instead of -1.
37
+
38
+ >>> char_width("a"), char_width("\\u4f60"), char_width("\\u0301"), char_width("\\u2500")
39
+ (1, 2, 0, 1)
40
+ >>> char_width("\\u2500", ambiguous=2)
41
+ 2
42
+ """
43
+ if len(char) != 1:
44
+ raise ValueError(f"char_width expects a single character, got {len(char)}")
45
+ return code_width(prop(ord(char)), check_ambiguous(ambiguous))
46
+
47
+
48
+ def _emoji_sequence(rest: str) -> bool:
49
+ """True when ``rest`` (after an emoji base) holds a modifier, a tag or a ZWJ + pictograph."""
50
+ joined = False
51
+ for ch in rest:
52
+ code = prop(ord(ch))
53
+ if code & MODIFIER_BIT or ord(ch) in _TAGS or (joined and code & EXT_PICT_BIT):
54
+ return True
55
+ joined = joined or ch == _ZWJ
56
+ return False
57
+
58
+
59
+ def cluster_width(cluster: str, ambiguous: int) -> int:
60
+ """Width of one grapheme cluster, without validating the input."""
61
+ code = prop(ord(cluster[0]))
62
+ if len(cluster) == 1:
63
+ return code_width(code, ambiguous)
64
+ rest = cluster[1:]
65
+ if code & (EMOJI_BIT | EXT_PICT_BIT) and not code & MODIFIER_BIT:
66
+ # A variation selector only counts right after the base.
67
+ if code & EMOJI_BIT and rest[0] in (_VS15, _VS16):
68
+ return 1 if rest[0] == _VS15 else 2
69
+ if _emoji_sequence(rest):
70
+ return 2
71
+ if code & 0x0F == _RI and prop(ord(rest[0])) & 0x0F == _RI:
72
+ return 2
73
+ return sum(code_width(prop(ord(ch)), ambiguous) for ch in cluster)
74
+
75
+
76
+ def grapheme_width(cluster: str, *, ambiguous: int = 1) -> int:
77
+ """Display cells one extended grapheme cluster occupies.
78
+
79
+ ``cluster`` must be exactly one cluster, such as an item from ``iter_graphemes``.
80
+ Emoji sequences draw as a single glyph: an emoji followed by VS16 (U+FE0F), a skin
81
+ tone modifier, a ZWJ sequence, a tag sequence or a pair of regional indicators (a
82
+ flag) is two cells, and an emoji followed by VS15 (U+FE0E) is one. Any other cluster
83
+ is the sum of its code points, which keeps combining marks at zero and conjoining
84
+ Hangul jamo at the width of the syllable.
85
+
86
+ >>> grapheme_width("\\U0001f468\\u200d\\U0001f469\\u200d\\U0001f467")
87
+ 2
88
+ >>> grapheme_width("\\u2764\\ufe0f"), grapheme_width("\\u2764"), grapheme_width("e\\u0301")
89
+ (2, 1, 1)
90
+ """
91
+ if not cluster or next(grapheme_ends(cluster)) != len(cluster):
92
+ raise ValueError("grapheme_width expects exactly one grapheme cluster")
93
+ return cluster_width(cluster, check_ambiguous(ambiguous))
94
+
95
+
96
+ def _plain_width(text: str, ambiguous: int) -> int:
97
+ if text.isascii():
98
+ return sum(1 for ch in text if " " <= ch <= "~")
99
+ total = 0
100
+ start = 0
101
+ for end in grapheme_ends(text):
102
+ total += cluster_width(text[start:end], ambiguous)
103
+ start = end
104
+ return total
105
+
106
+
107
+ def text_width(text: str, ambiguous: int) -> int:
108
+ """Width of ``text`` with no tab stops to honour; an escape sequence ends a cluster."""
109
+ if "\x1b" not in text:
110
+ return _plain_width(text, ambiguous)
111
+ return sum(_plain_width(s.text, ambiguous) for s in segments(text) if not s.is_escape)
cellstr/py.typed ADDED
File without changes
@@ -0,0 +1,144 @@
1
+ Metadata-Version: 2.5
2
+ Name: cellstr
3
+ Version: 0.2.1
4
+ Summary: Display-width aware len, slice, pad and truncate for terminal text: wide CJK, emoji sequences, combining marks and ANSI codes
5
+ Project-URL: Homepage, https://lianes8studio.com
6
+ Project-URL: Repository, https://github.com/JaviLianes8/cellstr
7
+ Project-URL: Issues, https://github.com/JaviLianes8/cellstr/issues
8
+ Project-URL: Changelog, https://github.com/JaviLianes8/cellstr/blob/main/CHANGELOG.md
9
+ Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
10
+ Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
11
+ Author-email: Javier Lianes <javier@lianes8studio.com>
12
+ License-Expression: MIT
13
+ License-File: LICENSE
14
+ License-File: LICENSE-UNICODE
15
+ Keywords: ansi,cli,east-asian-width,emoji,grapheme,terminal,truncate,unicode,wcwidth,width
16
+ Classifier: Development Status :: 4 - Beta
17
+ Classifier: Environment :: Console
18
+ Classifier: Intended Audience :: Developers
19
+ Classifier: License :: OSI Approved :: MIT License
20
+ Classifier: Operating System :: OS Independent
21
+ Classifier: Programming Language :: Python :: 3
22
+ Classifier: Programming Language :: Python :: 3 :: Only
23
+ Classifier: Topic :: Software Development :: Libraries
24
+ Classifier: Topic :: Terminals
25
+ Classifier: Topic :: Text Processing
26
+ Classifier: Typing :: Typed
27
+ Requires-Python: >=3.10
28
+ Provides-Extra: dev
29
+ Requires-Dist: build; extra == 'dev'
30
+ Requires-Dist: mypy; extra == 'dev'
31
+ Requires-Dist: pytest; extra == 'dev'
32
+ Requires-Dist: ruff; extra == 'dev'
33
+ Requires-Dist: twine; extra == 'dev'
34
+ Requires-Dist: wcwidth==0.2.13; extra == 'dev'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # cellstr
38
+
39
+ Display-width aware `len`, slicing, padding and truncation for terminal text: East Asian
40
+ wide characters, emoji sequences, combining marks, zero-width characters and ANSI escape
41
+ sequences, measured the way a terminal draws them. Typed, zero dependencies, Unicode 18.0.0.
42
+
43
+ ## Install
44
+
45
+ ```
46
+ pip install cellstr
47
+ ```
48
+
49
+ ## Usage
50
+
51
+ `len`, slicing and `str.ljust` count code points, so they misalign any text that carries
52
+ colour codes, CJK characters, emoji or accents. `cellstr` counts display cells.
53
+
54
+ ```python
55
+ from cellstr import cell_len, cell_slice, fit, ljust, truncate
56
+
57
+ warn = "\x1b[31mWARN\x1b[0m" # red "WARN"
58
+ cell_len(warn) # -> 4
59
+ cell_len("你好") # -> 4
60
+ cell_len("👨‍👩‍👧") # -> 2
61
+ cell_len("🇯🇵") # -> 2
62
+ cell_len("é") # -> 1
63
+
64
+ cell_slice(warn, 0, 2) # -> "\x1b[31mWA\x1b[0m"
65
+ cell_slice("a你b", 0, 2) # -> "a"
66
+ cell_slice("x👨‍👩‍👧y", 0, 2) # -> "x"
67
+
68
+ ljust("你", 4) # -> "你 "
69
+ truncate("hello world", 8) # -> "hello..."
70
+ truncate("/usr/local/lib/python", 12, position="middle") # -> "/usr/...thon"
71
+ fit("张伟张伟", 5, suffix="") # -> "张伟 "
72
+ ```
73
+
74
+ What it gets right:
75
+
76
+ - **Grapheme clusters.** A ZWJ family, a skin tone sequence, a flag, a keycap or a letter
77
+ with accents is one unit: measured once and never split by slicing or truncation.
78
+ - **Escape sequences.** SGR colours, OSC 8 hyperlinks, cursor movement and the other
79
+ ECMA-48 families count as nothing and are kept in place, so styled text stays styled.
80
+ - **Ambiguous width.** Greek, Cyrillic, box drawing and circled digits are one cell in
81
+ Western terminals and two in CJK ones. Pass `ambiguous=2` to any function.
82
+ - **Tabs.** Pass `tabsize=8` to measure tab stops; slicing and padding turn tabs into the
83
+ spaces they cover. `expand_tabs()` does it on its own.
84
+ - **Real Unicode data.** Tables are generated by a committed script from a committed
85
+ Unicode Character Database snapshot, and the full grapheme break test suite runs.
86
+
87
+ ```python
88
+ from cellstr import cell_len, iter_cells, truncate_detailed
89
+
90
+ cell_len("αβ─", ambiguous=2) # -> 6
91
+ cell_len("a\tb", tabsize=8) # -> 9
92
+ [c.width for c in iter_cells("a你🇯🇵")] # -> [1, 2, 2]
93
+ truncate_detailed("hello world", 8).truncated # -> True
94
+ ```
95
+
96
+ Public API: `cell_len`, `char_width`, `grapheme_width`, `iter_graphemes`, `iter_cells`
97
+ (`Cell`), `cell_slice`, `ljust`, `rjust`, `center`, `fit`, `truncate`,
98
+ `truncate_detailed` (`Truncation`), `expand_tabs`, `strip_ansi`, `UNICODE_VERSION`, and
99
+ the `Align` and `Position` literal types. Every function is fully typed and documented.
100
+
101
+ ## Command line
102
+
103
+ ```
104
+ $ printf 'abc\n你好\n' | cellstr measure
105
+ 3
106
+ 4
107
+ $ printf '/usr/local/lib/python3/site-packages\n' | cellstr truncate 16 --position middle
108
+ /usr/lo...ckages
109
+ $ printf 'id\n12345\n' | cellstr pad 8 --align right
110
+ id
111
+ 12345
112
+ ```
113
+
114
+ Commands: `measure` (`--max`), `truncate N`, `pad N`, `fit N` and `slice START [STOP]`,
115
+ all with `--ambiguous` and `--tabsize`. `cellstr --help` lists runnable examples.
116
+
117
+ ## Terminals disagree
118
+
119
+ There is no standard for how many cells a character takes, and terminals differ on
120
+ ambiguous-width characters, emoji ZWJ sequences and variation selectors. `cellstr`
121
+ follows Markus Kuhn's reference `wcwidth` for single code points and
122
+ measures emoji sequences as one glyph, as terminals with grapheme support draw them.
123
+ [GUIDE.md](GUIDE.md) has the exact rules, concrete examples of where terminals differ, a
124
+ recipe for code point by code point measuring, and every difference from the `wcwidth`
125
+ package.
126
+
127
+ ## Why
128
+
129
+ `wcwidth` measures plain-text width well, but it does not give you a typed, cohesive set
130
+ of string operations: slicing by column that keeps escapes and graphemes intact, padding,
131
+ fitting a table cell and width-aware truncation with a suffix at either end or in the
132
+ middle. `strip-ansi` style tools remove escapes but know nothing about width. `cellstr`
133
+ does all of it in one small, fully typed, zero-dependency package.
134
+
135
+ ## License
136
+
137
+ MIT. See [LICENSE](LICENSE). The property tables in `src/cellstr/_ucd_*.py` are generated
138
+ from the Unicode Character Database and are covered by the Unicode License v3, see [LICENSE-UNICODE](LICENSE-UNICODE).
139
+
140
+ ## Links
141
+
142
+ - Website: https://lianes8studio.com
143
+ - GitHub: https://github.com/JaviLianes8
144
+ - LinkedIn: https://www.linkedin.com/in/jlianes/