Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/wcwidth/grapheme.py: 23%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1"""
2Grapheme cluster segmentation following Unicode Standard Annex #29.
4This module provides pure-Python implementation of the grapheme cluster boundary algorithm as
5defined in UAX #29: Unicode Text Segmentation.
7https://www.unicode.org/reports/tr29/
8"""
10from __future__ import annotations
12# std imports
13from enum import IntEnum
14from functools import lru_cache
16from typing import TYPE_CHECKING, Optional, NamedTuple
18__lazy_modules__ = [
19 "wcwidth.bisearch",
20 "wcwidth.table_grapheme",
21]
22# local
23from .bisearch import bisearch as _bisearch
24from .table_grapheme import (GRAPHEME_L,
25 GRAPHEME_T,
26 GRAPHEME_V,
27 GRAPHEME_LV,
28 INCB_EXTEND,
29 INCB_LINKER,
30 GRAPHEME_LVT,
31 INCB_CONSONANT,
32 GRAPHEME_EXTEND,
33 GRAPHEME_CONTROL,
34 GRAPHEME_PREPEND,
35 GRAPHEME_SPACINGMARK,
36 EXTENDED_PICTOGRAPHIC,
37 GRAPHEME_REGIONAL_INDICATOR)
39if TYPE_CHECKING: # pragma: no cover
40 # std imports
41 from collections.abc import Iterator
43# Maximum backward scan distance before falling back to scanning to the start.
44# Covers all known Unicode grapheme clusters with margin; longer sequences are pathological.
45MAX_GRAPHEME_SCAN = 16
48class GCB(IntEnum):
49 """Grapheme Cluster Break property values."""
51 OTHER = 0
52 CR = 1
53 LF = 2
54 CONTROL = 3
55 EXTEND = 4
56 ZWJ = 5
57 REGIONAL_INDICATOR = 6
58 PREPEND = 7
59 SPACING_MARK = 8
60 L = 9
61 V = 10
62 T = 11
63 LV = 12
64 LVT = 13
67# All lru_cache sizes in this file use maxsize=1024, chosen by benchmarking UDHR data (500+
68# languages) and considering typical process-long sessions: western scripts need ~64 unique
69# codepoints, and CJK can reach ~2000.
70@lru_cache(maxsize=1024)
71def _grapheme_cluster_break(ucs: int) -> GCB:
72 # pylint: disable=too-many-branches,too-complex
73 """Return the Grapheme_Cluster_Break property for a codepoint."""
74 # Single codepoint matches
75 if ucs == 0x000d:
76 return GCB.CR
77 if ucs == 0x000a:
78 return GCB.LF
79 if ucs == 0x200d:
80 return GCB.ZWJ
81 # Matching by codepoint ranges, requiring binary search
82 if _bisearch(ucs, GRAPHEME_CONTROL):
83 return GCB.CONTROL
84 if _bisearch(ucs, GRAPHEME_EXTEND):
85 return GCB.EXTEND
86 if _bisearch(ucs, GRAPHEME_REGIONAL_INDICATOR):
87 return GCB.REGIONAL_INDICATOR
88 if _bisearch(ucs, GRAPHEME_PREPEND):
89 return GCB.PREPEND
90 if _bisearch(ucs, GRAPHEME_SPACINGMARK):
91 return GCB.SPACING_MARK
92 if _bisearch(ucs, GRAPHEME_L):
93 return GCB.L
94 if _bisearch(ucs, GRAPHEME_V):
95 return GCB.V
96 if _bisearch(ucs, GRAPHEME_T):
97 return GCB.T
98 if _bisearch(ucs, GRAPHEME_LV):
99 return GCB.LV
100 if _bisearch(ucs, GRAPHEME_LVT):
101 return GCB.LVT
102 return GCB.OTHER
105@lru_cache(maxsize=1024)
106def _is_extended_pictographic(ucs: int) -> bool:
107 """Check if codepoint has Extended_Pictographic property."""
108 return bool(_bisearch(ucs, EXTENDED_PICTOGRAPHIC))
111@lru_cache(maxsize=1024)
112def _is_incb_linker(ucs: int) -> bool:
113 """Check if codepoint has InCB=Linker property."""
114 return bool(_bisearch(ucs, INCB_LINKER))
117@lru_cache(maxsize=1024)
118def _is_incb_consonant(ucs: int) -> bool:
119 """Check if codepoint has InCB=Consonant property."""
120 return bool(_bisearch(ucs, INCB_CONSONANT))
123@lru_cache(maxsize=1024)
124def _is_incb_extend(ucs: int) -> bool:
125 """Check if codepoint has InCB=Extend property."""
126 return bool(_bisearch(ucs, INCB_EXTEND))
129class BreakResult(NamedTuple):
130 """Result of grapheme cluster break decision."""
132 should_break: bool
133 ri_count: int
136@lru_cache(maxsize=1024)
137def _simple_break_check(prev_gcb: GCB, curr_gcb: GCB) -> Optional[BreakResult]:
138 """
139 Check simple GCB-pair-based break rules (cacheable).
141 Returns BreakResult for rules that can be determined from GCB properties alone, or None if
142 complex lookback rules (GB9c, GB11) need to be checked.
143 """
144 # GB3: CR x LF
145 if prev_gcb == GCB.CR and curr_gcb == GCB.LF:
146 return BreakResult(should_break=False, ri_count=0)
148 # GB4: (Control|CR|LF) ÷
149 if prev_gcb in (GCB.CONTROL, GCB.CR, GCB.LF):
150 return BreakResult(should_break=True,
151 ri_count=1 if curr_gcb == GCB.REGIONAL_INDICATOR else 0)
153 # GB5: ÷ (Control|CR|LF)
154 if curr_gcb in (GCB.CONTROL, GCB.CR, GCB.LF):
155 return BreakResult(should_break=True, ri_count=0)
157 # GB6: L x (L|V|LV|LVT)
158 if prev_gcb == GCB.L and curr_gcb in (GCB.L, GCB.V, GCB.LV, GCB.LVT):
159 return BreakResult(should_break=False, ri_count=0)
161 # GB7: (LV|V) x (V|T)
162 if prev_gcb in (GCB.LV, GCB.V) and curr_gcb in (GCB.V, GCB.T):
163 return BreakResult(should_break=False, ri_count=0)
165 # GB8: (LVT|T) x T
166 if prev_gcb in (GCB.LVT, GCB.T) and curr_gcb == GCB.T:
167 return BreakResult(should_break=False, ri_count=0)
169 # GB9: x (Extend|ZWJ) - but ZWJ needs GB11 check, so only handle Extend here
170 if curr_gcb == GCB.EXTEND:
171 return BreakResult(should_break=False, ri_count=0)
173 # GB9a: x SpacingMark
174 if curr_gcb == GCB.SPACING_MARK:
175 return BreakResult(should_break=False, ri_count=0)
177 # GB9b: Prepend x
178 if prev_gcb == GCB.PREPEND:
179 return BreakResult(should_break=False,
180 ri_count=1 if curr_gcb == GCB.REGIONAL_INDICATOR else 0)
182 # GB9c and GB11 need lookback - return None to signal complex check needed
183 # GB12/13 (RI pairs) need ri_count state - also handled in main function
184 return None
187def _should_break(
188 prev_gcb: GCB,
189 curr_gcb: GCB,
190 text: str,
191 curr_idx: int,
192 ri_count: int,
193 *,
194 start: int = 0,
195) -> BreakResult:
196 # pylint: disable=too-many-branches,too-complex
197 """
198 Determine if there should be a grapheme cluster break between prev and curr.
200 Implements UAX #29 grapheme cluster boundary rules.
201 """
202 # Try cached simple rules first
203 result = _simple_break_check(prev_gcb, curr_gcb)
204 if result is not None:
205 return result
207 # GB9: x ZWJ (not cached because GB11 needs lookback when prev is ZWJ)
208 if curr_gcb == GCB.ZWJ:
209 return BreakResult(should_break=False, ri_count=0)
211 # GB9c: Indic conjunct cluster
212 # \p{InCB=Linker} \p{InCB=Extend}* x \p{InCB=Consonant}
213 curr_ucs = ord(text[curr_idx])
214 if _is_incb_consonant(curr_ucs):
215 i = curr_idx - 1
216 while i >= start and _is_incb_extend(ord(text[i])):
217 i -= 1
218 if i >= start and _is_incb_linker(ord(text[i])):
219 return BreakResult(should_break=False, ri_count=0)
221 # GB11: ExtPict Extend* ZWJ x ExtPict
222 if prev_gcb == GCB.ZWJ and _is_extended_pictographic(curr_ucs):
223 i = curr_idx - 2 # Skip the ZWJ at curr_idx - 1
224 while i >= start:
225 prev_ucs = ord(text[i])
226 prev_prop = _grapheme_cluster_break(prev_ucs)
227 if prev_prop == GCB.EXTEND:
228 i -= 1
229 elif _is_extended_pictographic(prev_ucs):
230 return BreakResult(should_break=False, ri_count=0)
231 else:
232 break
234 # GB12/GB13: RI x RI (pair matching)
235 if prev_gcb == GCB.REGIONAL_INDICATOR and curr_gcb == GCB.REGIONAL_INDICATOR:
236 if ri_count % 2 == 1:
237 return BreakResult(should_break=False, ri_count=ri_count + 1)
238 return BreakResult(should_break=True, ri_count=1)
240 # GB999: Any ÷ Any
241 ri_count = 1 if curr_gcb == GCB.REGIONAL_INDICATOR else 0
242 return BreakResult(should_break=True, ri_count=ri_count)
245def _iter_graphemes_python(
246 unistr: str,
247 start: int = 0,
248 end: int | None = None,
249) -> Iterator[str]:
250 r"""
251 Iterate over grapheme clusters by UAX #29 extended grapheme cluster rules.
253 Grapheme clusters are "user-perceived characters" - what a user would
254 consider a single character, which may consist of multiple Unicode
255 codepoints (e.g., a base character with combining marks, emoji sequences).
257 :param unistr: The Unicode string to segment.
258 :param start: Starting index (default 0).
259 :param end: Ending index (default len(unistr)).
260 :yields: Grapheme cluster substrings.
262 Example::
264 >>> list(iter_graphemes('cafe\u0301'))
265 ['c', 'a', 'f', 'é']
266 >>> list(iter_graphemes('ok\U0001F468\u200D\U0001F469\u200D\U0001F467'))
267 ['o', 'k', '👨\u200d👩\u200d👧']
268 >>> list(iter_graphemes('ok\U0001F1FA\U0001F1F8'))
269 ['o', 'k', '🇺🇸']
271 .. versionadded:: 0.3.0
272 """
273 if not unistr:
274 return
276 length = len(unistr)
278 start = max(start, 0)
280 if end is None:
281 end = length
283 if start >= end or start >= length:
284 return
286 end = min(end, length)
288 # Track state for grapheme cluster boundaries
289 cluster_start = start
290 ri_count = 0
292 # Get GCB for first character
293 prev_gcb = _grapheme_cluster_break(ord(unistr[start]))
295 # Handle Regional Indicator count initialization
296 if prev_gcb == GCB.REGIONAL_INDICATOR:
297 ri_count = 1
299 for idx in range(start + 1, end):
300 curr_gcb = _grapheme_cluster_break(ord(unistr[idx]))
302 result = _should_break(prev_gcb, curr_gcb, unistr, idx, ri_count, start=start)
303 ri_count = result.ri_count
305 if result.should_break:
306 yield unistr[cluster_start:idx]
307 cluster_start = idx
309 prev_gcb = curr_gcb
311 # Yield the final cluster
312 yield unistr[cluster_start:end]
315def _find_cluster_start(text: str, pos: int) -> int:
316 """
317 Find the start of the grapheme cluster containing the character before pos.
319 Scans backwards from pos to find a safe starting point, then iterates forward using standard
320 break rules to find the actual cluster boundary.
322 :param text: The Unicode string.
323 :param pos: Position to search before (exclusive).
324 :returns: Start position of the grapheme cluster.
325 """
326 target_cp = ord(text[pos - 1])
328 # GB3: CR x LF - LF after CR is part of same cluster
329 if target_cp == 0x0A and pos >= 2 and text[pos - 2] == '\r':
330 return pos - 2
332 # Fast path: ASCII (except LF) starts its own cluster
333 if target_cp < 0x80:
334 # GB9b: Check for preceding PREPEND (rare: Arabic/Brahmic)
335 if pos >= 2 and target_cp >= 0x20:
336 prev_cp = ord(text[pos - 2])
337 if prev_cp >= 0x80 and _grapheme_cluster_break(prev_cp) == GCB.PREPEND:
338 return _find_cluster_start(text, pos - 1)
339 return pos - 1
341 # Scan backward to find a safe starting point
342 safe_start = pos - 1
343 while safe_start > 0:
344 cp = ord(text[safe_start])
345 if 0x20 <= cp < 0x80: # ASCII always starts a cluster
346 break
347 if _grapheme_cluster_break(cp) == GCB.CONTROL: # GB4
348 break
349 if pos - safe_start >= MAX_GRAPHEME_SCAN:
350 safe_start = 0
351 break
352 safe_start -= 1
354 # Verify forward to find the actual cluster boundary
355 cluster_start = safe_start
356 left_gcb = _grapheme_cluster_break(ord(text[safe_start]))
357 ri_count = 1 if left_gcb == GCB.REGIONAL_INDICATOR else 0
359 for i in range(safe_start + 1, pos):
360 right_gcb = _grapheme_cluster_break(ord(text[i]))
361 result = _should_break(left_gcb, right_gcb, text, i, ri_count)
362 ri_count = result.ri_count
363 if result.should_break:
364 cluster_start = i
365 left_gcb = right_gcb
367 return cluster_start
370def grapheme_boundary_before(unistr: str, pos: int) -> int:
371 r"""
372 Find the grapheme cluster boundary immediately before a position.
374 :param unistr: The Unicode string to search.
375 :param pos: Position in the string (0 < pos <= len(unistr)).
376 :returns: Start index of the grapheme cluster containing the character at pos-1.
378 Example::
380 >>> grapheme_boundary_before('Hello \U0001F44B\U0001F3FB', 8)
381 6
382 >>> grapheme_boundary_before('a\r\nb', 3)
383 1
385 .. versionadded:: 0.3.6
386 """
387 if pos <= 0 or not unistr:
388 return 0
389 return _find_cluster_start(unistr, min(pos, len(unistr)))
392def iter_graphemes_reverse(
393 unistr: str,
394 start: int = 0,
395 end: Optional[int] = None,
396) -> Iterator[str]:
397 r"""
398 Iterate over grapheme clusters in reverse order (last to first).
400 :param unistr: The Unicode string to segment.
401 :param start: Starting index (default 0).
402 :param end: Ending index (default len(unistr)).
403 :yields: Grapheme cluster substrings in reverse order.
405 Example::
407 >>> list(iter_graphemes_reverse('cafe\u0301'))
408 ['e\u0301', 'f', 'a', 'c']
410 .. versionadded:: 0.3.6
411 """
412 if not unistr:
413 return
415 length = len(unistr)
417 end = length if end is None else min(end, length)
418 start = max(start, 0)
420 if start >= end or start >= length:
421 return
423 pos = end
424 while pos > start:
425 cluster_start = _find_cluster_start(unistr, pos)
426 # Don't yield partial graphemes that extend before start
427 if cluster_start < start:
428 break
429 yield unistr[cluster_start:pos]
430 pos = cluster_start
433# Bind iter_graphemes at module level to avoid per-call dispatch overhead.
434iter_graphemes = _iter_graphemes_python