Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/wcwidth/grapheme.py: 24%
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 when finding grapheme cluster boundaries.
44# Covers all known Unicode grapheme clusters with margin; longer sequences are pathological.
45MAX_GRAPHEME_SCAN = 32
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, ri_count=0)
152 # GB5: ÷ (Control|CR|LF)
153 if curr_gcb in (GCB.CONTROL, GCB.CR, GCB.LF):
154 return BreakResult(should_break=True, ri_count=0)
156 # GB6: L x (L|V|LV|LVT)
157 if prev_gcb == GCB.L and curr_gcb in (GCB.L, GCB.V, GCB.LV, GCB.LVT):
158 return BreakResult(should_break=False, ri_count=0)
160 # GB7: (LV|V) x (V|T)
161 if prev_gcb in (GCB.LV, GCB.V) and curr_gcb in (GCB.V, GCB.T):
162 return BreakResult(should_break=False, ri_count=0)
164 # GB8: (LVT|T) x T
165 if prev_gcb in (GCB.LVT, GCB.T) and curr_gcb == GCB.T:
166 return BreakResult(should_break=False, ri_count=0)
168 # GB9: x (Extend|ZWJ) - but ZWJ needs GB11 check, so only handle Extend here
169 if curr_gcb == GCB.EXTEND:
170 return BreakResult(should_break=False, ri_count=0)
172 # GB9a: x SpacingMark
173 if curr_gcb == GCB.SPACING_MARK:
174 return BreakResult(should_break=False, ri_count=0)
176 # GB9b: Prepend x
177 if prev_gcb == GCB.PREPEND:
178 return BreakResult(should_break=False, ri_count=0)
180 # GB9c and GB11 need lookback - return None to signal complex check needed
181 # GB12/13 (RI pairs) need ri_count state - also handled in main function
182 return None
185def _should_break(
186 prev_gcb: GCB,
187 curr_gcb: GCB,
188 text: str,
189 curr_idx: int,
190 ri_count: int,
191) -> BreakResult:
192 # pylint: disable=too-many-branches,too-complex
193 """
194 Determine if there should be a grapheme cluster break between prev and curr.
196 Implements UAX #29 grapheme cluster boundary rules.
197 """
198 # Try cached simple rules first
199 result = _simple_break_check(prev_gcb, curr_gcb)
200 if result is not None:
201 return result
203 # GB9: x ZWJ (not cached because GB11 needs lookback when prev is ZWJ)
204 if curr_gcb == GCB.ZWJ:
205 return BreakResult(should_break=False, ri_count=0)
207 # GB9c: Indic conjunct cluster
208 # \p{InCB=Linker} \p{InCB=Extend}* x \p{InCB=Consonant}
209 curr_ucs = ord(text[curr_idx])
210 if _is_incb_consonant(curr_ucs):
211 i = curr_idx - 1
212 while i >= 0 and _is_incb_extend(ord(text[i])):
213 i -= 1
214 if i >= 0 and _is_incb_linker(ord(text[i])):
215 return BreakResult(should_break=False, ri_count=0)
217 # GB11: ExtPict Extend* ZWJ x ExtPict
218 if prev_gcb == GCB.ZWJ and _is_extended_pictographic(curr_ucs):
219 i = curr_idx - 2 # Skip the ZWJ at curr_idx - 1
220 while i >= 0:
221 prev_ucs = ord(text[i])
222 prev_prop = _grapheme_cluster_break(prev_ucs)
223 if prev_prop == GCB.EXTEND:
224 i -= 1
225 elif _is_extended_pictographic(prev_ucs):
226 return BreakResult(should_break=False, ri_count=0)
227 else:
228 break
230 # GB12/GB13: RI x RI (pair matching)
231 if prev_gcb == GCB.REGIONAL_INDICATOR and curr_gcb == GCB.REGIONAL_INDICATOR:
232 if ri_count % 2 == 1:
233 return BreakResult(should_break=False, ri_count=ri_count + 1)
234 return BreakResult(should_break=True, ri_count=1)
236 # GB999: Any ÷ Any
237 ri_count = 1 if curr_gcb == GCB.REGIONAL_INDICATOR else 0
238 return BreakResult(should_break=True, ri_count=ri_count)
241def _iter_graphemes_python(
242 unistr: str,
243 start: int = 0,
244 end: int | None = None,
245) -> Iterator[str]:
246 r"""
247 Iterate over grapheme clusters by UAX #29 extended grapheme cluster rules.
249 Grapheme clusters are "user-perceived characters" - what a user would
250 consider a single character, which may consist of multiple Unicode
251 codepoints (e.g., a base character with combining marks, emoji sequences).
253 :param unistr: The Unicode string to segment.
254 :param start: Starting index (default 0).
255 :param end: Ending index (default len(unistr)).
256 :yields: Grapheme cluster substrings.
258 Example::
260 >>> list(iter_graphemes('cafe\u0301'))
261 ['c', 'a', 'f', 'é']
262 >>> list(iter_graphemes('ok\U0001F468\u200D\U0001F469\u200D\U0001F467'))
263 ['o', 'k', '👨\u200d👩\u200d👧']
264 >>> list(iter_graphemes('ok\U0001F1FA\U0001F1F8'))
265 ['o', 'k', '🇺🇸']
267 .. versionadded:: 0.3.0
268 """
269 if not unistr:
270 return
272 length = len(unistr)
274 if end is None:
275 end = length
277 if start >= end or start >= length:
278 return
280 end = min(end, length)
282 # Track state for grapheme cluster boundaries
283 cluster_start = start
284 ri_count = 0
286 # Get GCB for first character
287 prev_gcb = _grapheme_cluster_break(ord(unistr[start]))
289 # Handle Regional Indicator count initialization
290 if prev_gcb == GCB.REGIONAL_INDICATOR:
291 ri_count = 1
293 for idx in range(start + 1, end):
294 curr_gcb = _grapheme_cluster_break(ord(unistr[idx]))
296 result = _should_break(prev_gcb, curr_gcb, unistr, idx, ri_count)
297 ri_count = result.ri_count
299 if result.should_break:
300 yield unistr[cluster_start:idx]
301 cluster_start = idx
303 prev_gcb = curr_gcb
305 # Yield the final cluster
306 yield unistr[cluster_start:end]
309def _find_cluster_start(text: str, pos: int) -> int:
310 """
311 Find the start of the grapheme cluster containing the character before pos.
313 Scans backwards from pos to find a safe starting point, then iterates forward using standard
314 break rules to find the actual cluster boundary.
316 :param text: The Unicode string.
317 :param pos: Position to search before (exclusive).
318 :returns: Start position of the grapheme cluster.
319 """
320 target_cp = ord(text[pos - 1])
322 # GB3: CR x LF - LF after CR is part of same cluster
323 if target_cp == 0x0A and pos >= 2 and text[pos - 2] == '\r':
324 return pos - 2
326 # Fast path: ASCII (except LF) starts its own cluster
327 if target_cp < 0x80:
328 # GB9b: Check for preceding PREPEND (rare: Arabic/Brahmic)
329 if pos >= 2 and target_cp >= 0x20:
330 prev_cp = ord(text[pos - 2])
331 if prev_cp >= 0x80 and _grapheme_cluster_break(prev_cp) == GCB.PREPEND:
332 return _find_cluster_start(text, pos - 1)
333 return pos - 1
335 # Scan backward to find a safe starting point
336 safe_start = pos - 1
337 while safe_start > 0 and (pos - safe_start) < MAX_GRAPHEME_SCAN:
338 cp = ord(text[safe_start])
339 if 0x20 <= cp < 0x80: # ASCII always starts a cluster
340 break
341 if _grapheme_cluster_break(cp) == GCB.CONTROL: # GB4
342 break
343 safe_start -= 1
345 # Verify forward to find the actual cluster boundary
346 cluster_start = safe_start
347 left_gcb = _grapheme_cluster_break(ord(text[safe_start]))
348 ri_count = 1 if left_gcb == GCB.REGIONAL_INDICATOR else 0
350 for i in range(safe_start + 1, pos):
351 right_gcb = _grapheme_cluster_break(ord(text[i]))
352 result = _should_break(left_gcb, right_gcb, text, i, ri_count)
353 ri_count = result.ri_count
354 if result.should_break:
355 cluster_start = i
356 left_gcb = right_gcb
358 return cluster_start
361def grapheme_boundary_before(unistr: str, pos: int) -> int:
362 r"""
363 Find the grapheme cluster boundary immediately before a position.
365 :param unistr: The Unicode string to search.
366 :param pos: Position in the string (0 < pos <= len(unistr)).
367 :returns: Start index of the grapheme cluster containing the character at pos-1.
369 Example::
371 >>> grapheme_boundary_before('Hello \U0001F44B\U0001F3FB', 8)
372 6
373 >>> grapheme_boundary_before('a\r\nb', 3)
374 1
376 .. versionadded:: 0.3.6
377 """
378 if pos <= 0:
379 return 0
380 return _find_cluster_start(unistr, min(pos, len(unistr)))
383def iter_graphemes_reverse(
384 unistr: str,
385 start: int = 0,
386 end: Optional[int] = None,
387) -> Iterator[str]:
388 r"""
389 Iterate over grapheme clusters in reverse order (last to first).
391 :param unistr: The Unicode string to segment.
392 :param start: Starting index (default 0).
393 :param end: Ending index (default len(unistr)).
394 :yields: Grapheme cluster substrings in reverse order.
396 Example::
398 >>> list(iter_graphemes_reverse('cafe\u0301'))
399 ['e\u0301', 'f', 'a', 'c']
401 .. versionadded:: 0.3.6
402 """
403 if not unistr:
404 return
406 length = len(unistr)
408 end = length if end is None else min(end, length)
409 start = max(start, 0)
411 if start >= end or start >= length:
412 return
414 pos = end
415 while pos > start:
416 cluster_start = _find_cluster_start(unistr, pos)
417 # Don't yield partial graphemes that extend before start
418 if cluster_start < start:
419 break
420 yield unistr[cluster_start:pos]
421 pos = cluster_start
424# Bind iter_graphemes at module level to avoid per-call dispatch overhead.
425iter_graphemes = _iter_graphemes_python