Team Ai
Datasetpublic

codekingpro/portable-devtools

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes14kdownloads
grapheme.py429 linesDownload Raw Back to wcwidth
1"""2Grapheme cluster segmentation following Unicode Standard Annex #29.3 4This module provides pure-Python implementation of the grapheme cluster boundary algorithm as5defined in UAX #29: Unicode Text Segmentation.6 7https://www.unicode.org/reports/tr29/8"""9 10from __future__ import annotations11 12# std imports13from enum import IntEnum14from functools import lru_cache15 16from typing import TYPE_CHECKING, Optional, NamedTuple17 18# local19from .bisearch import bisearch as _bisearch20from .table_grapheme import (GRAPHEME_L,21                             GRAPHEME_T,22                             GRAPHEME_V,23                             GRAPHEME_LV,24                             INCB_EXTEND,25                             INCB_LINKER,26                             GRAPHEME_LVT,27                             INCB_CONSONANT,28                             GRAPHEME_EXTEND,29                             GRAPHEME_CONTROL,30                             GRAPHEME_PREPEND,31                             GRAPHEME_SPACINGMARK,32                             EXTENDED_PICTOGRAPHIC,33                             GRAPHEME_REGIONAL_INDICATOR)34 35if TYPE_CHECKING:  # pragma: no cover36    # std imports37    from collections.abc import Iterator38 39# Maximum backward scan distance when finding grapheme cluster boundaries.40# Covers all known Unicode grapheme clusters with margin; longer sequences are pathological.41MAX_GRAPHEME_SCAN = 3242 43 44class GCB(IntEnum):45    """Grapheme Cluster Break property values."""46 47    OTHER = 048    CR = 149    LF = 250    CONTROL = 351    EXTEND = 452    ZWJ = 553    REGIONAL_INDICATOR = 654    PREPEND = 755    SPACING_MARK = 856    L = 957    V = 1058    T = 1159    LV = 1260    LVT = 1361 62 63# All lru_cache sizes in this file use maxsize=1024, chosen by benchmarking UDHR data (500+64# languages) and considering typical process-long sessions: western scripts need ~64 unique65# codepoints, but CJK could reach ~2000 -- but likely not.66@lru_cache(maxsize=1024)67def _grapheme_cluster_break(ucs: int) -> GCB:68    # pylint: disable=too-many-branches,too-complex69    """Return the Grapheme_Cluster_Break property for a codepoint."""70    # Single codepoint matches71    if ucs == 0x000d:72        return GCB.CR73    if ucs == 0x000a:74        return GCB.LF75    if ucs == 0x200d:76        return GCB.ZWJ77    # Matching by codepoint ranges, requiring binary search78    if _bisearch(ucs, GRAPHEME_CONTROL):79        return GCB.CONTROL80    if _bisearch(ucs, GRAPHEME_EXTEND):81        return GCB.EXTEND82    if _bisearch(ucs, GRAPHEME_REGIONAL_INDICATOR):83        return GCB.REGIONAL_INDICATOR84    if _bisearch(ucs, GRAPHEME_PREPEND):85        return GCB.PREPEND86    if _bisearch(ucs, GRAPHEME_SPACINGMARK):87        return GCB.SPACING_MARK88    if _bisearch(ucs, GRAPHEME_L):89        return GCB.L90    if _bisearch(ucs, GRAPHEME_V):91        return GCB.V92    if _bisearch(ucs, GRAPHEME_T):93        return GCB.T94    if _bisearch(ucs, GRAPHEME_LV):95        return GCB.LV96    if _bisearch(ucs, GRAPHEME_LVT):97        return GCB.LVT98    return GCB.OTHER99 100 101@lru_cache(maxsize=1024)102def _is_extended_pictographic(ucs: int) -> bool:103    """Check if codepoint has Extended_Pictographic property."""104    return bool(_bisearch(ucs, EXTENDED_PICTOGRAPHIC))105 106 107@lru_cache(maxsize=1024)108def _is_incb_linker(ucs: int) -> bool:109    """Check if codepoint has InCB=Linker property."""110    return bool(_bisearch(ucs, INCB_LINKER))111 112 113@lru_cache(maxsize=1024)114def _is_incb_consonant(ucs: int) -> bool:115    """Check if codepoint has InCB=Consonant property."""116    return bool(_bisearch(ucs, INCB_CONSONANT))117 118 119@lru_cache(maxsize=1024)120def _is_incb_extend(ucs: int) -> bool:121    """Check if codepoint has InCB=Extend property."""122    return bool(_bisearch(ucs, INCB_EXTEND))123 124 125class BreakResult(NamedTuple):126    """Result of grapheme cluster break decision."""127 128    should_break: bool129    ri_count: int130 131 132@lru_cache(maxsize=1024)133def _simple_break_check(prev_gcb: GCB, curr_gcb: GCB) -> Optional[BreakResult]:134    """135    Check simple GCB-pair-based break rules (cacheable).136 137    Returns BreakResult for rules that can be determined from GCB properties alone, or None if138    complex lookback rules (GB9c, GB11) need to be checked.139    """140    # GB3: CR x LF141    if prev_gcb == GCB.CR and curr_gcb == GCB.LF:142        return BreakResult(should_break=False, ri_count=0)143 144    # GB4: (Control|CR|LF) ÷145    if prev_gcb in (GCB.CONTROL, GCB.CR, GCB.LF):146        return BreakResult(should_break=True, ri_count=0)147 148    # GB5: ÷ (Control|CR|LF)149    if curr_gcb in (GCB.CONTROL, GCB.CR, GCB.LF):150        return BreakResult(should_break=True, ri_count=0)151 152    # GB6: L x (L|V|LV|LVT)153    if prev_gcb == GCB.L and curr_gcb in (GCB.L, GCB.V, GCB.LV, GCB.LVT):154        return BreakResult(should_break=False, ri_count=0)155 156    # GB7: (LV|V) x (V|T)157    if prev_gcb in (GCB.LV, GCB.V) and curr_gcb in (GCB.V, GCB.T):158        return BreakResult(should_break=False, ri_count=0)159 160    # GB8: (LVT|T) x T161    if prev_gcb in (GCB.LVT, GCB.T) and curr_gcb == GCB.T:162        return BreakResult(should_break=False, ri_count=0)163 164    # GB9: x (Extend|ZWJ) - but ZWJ needs GB11 check, so only handle Extend here165    if curr_gcb == GCB.EXTEND:166        return BreakResult(should_break=False, ri_count=0)167 168    # GB9a: x SpacingMark169    if curr_gcb == GCB.SPACING_MARK:170        return BreakResult(should_break=False, ri_count=0)171 172    # GB9b: Prepend x173    if prev_gcb == GCB.PREPEND:174        return BreakResult(should_break=False, ri_count=0)175 176    # GB9c and GB11 need lookback - return None to signal complex check needed177    # GB12/13 (RI pairs) need ri_count state - also handled in main function178    return None179 180 181def _should_break(182    prev_gcb: GCB,183    curr_gcb: GCB,184    text: str,185    curr_idx: int,186    ri_count: int,187) -> BreakResult:188    # pylint: disable=too-many-branches,too-complex189    """190    Determine if there should be a grapheme cluster break between prev and curr.191 192    Implements UAX #29 grapheme cluster boundary rules.193    """194    # Try cached simple rules first195    result = _simple_break_check(prev_gcb, curr_gcb)196    if result is not None:197        return result198 199    # GB9: x ZWJ (not cached because GB11 needs lookback when prev is ZWJ)200    if curr_gcb == GCB.ZWJ:201        return BreakResult(should_break=False, ri_count=0)202 203    # GB9c: Indic conjunct cluster204    # \p{InCB=Consonant} [\p{InCB=Extend}\p{InCB=Linker}]* \p{InCB=Linker}205    #     [\p{InCB=Extend}\p{InCB=Linker}]* x \p{InCB=Consonant}206    curr_ucs = ord(text[curr_idx])207    if _is_incb_consonant(curr_ucs):208        has_linker = False209        i = curr_idx - 1210        while i >= 0:211            prev_ucs = ord(text[i])212            if _is_incb_linker(prev_ucs):213                has_linker = True214                i -= 1215            elif _is_incb_extend(prev_ucs):216                i -= 1217            elif _is_incb_consonant(prev_ucs):218                if has_linker:219                    return BreakResult(should_break=False, ri_count=0)220                break221            else:222                break223 224    # GB11: ExtPict Extend* ZWJ x ExtPict225    if prev_gcb == GCB.ZWJ and _is_extended_pictographic(curr_ucs):226        i = curr_idx - 2  # Skip the ZWJ at curr_idx - 1227        while i >= 0:228            prev_ucs = ord(text[i])229            prev_prop = _grapheme_cluster_break(prev_ucs)230            if prev_prop == GCB.EXTEND:231                i -= 1232            elif _is_extended_pictographic(prev_ucs):233                return BreakResult(should_break=False, ri_count=0)234            else:235                break236 237    # GB12/GB13: RI x RI (pair matching)238    if prev_gcb == GCB.REGIONAL_INDICATOR and curr_gcb == GCB.REGIONAL_INDICATOR:239        if ri_count % 2 == 1:240            return BreakResult(should_break=False, ri_count=ri_count + 1)241        return BreakResult(should_break=True, ri_count=1)242 243    # GB999: Any ÷ Any244    ri_count = 1 if curr_gcb == GCB.REGIONAL_INDICATOR else 0245    return BreakResult(should_break=True, ri_count=ri_count)246 247 248def iter_graphemes(249    unistr: str,250    start: int = 0,251    end: Optional[int] = None,252) -> Iterator[str]:253    r"""254    Iterate over grapheme clusters in a Unicode string.255 256    Grapheme clusters are "user-perceived characters" - what a user would257    consider a single character, which may consist of multiple Unicode258    codepoints (e.g., a base character with combining marks, emoji sequences).259 260    :param unistr: The Unicode string to segment.261    :param start: Starting index (default 0).262    :param end: Ending index (default len(unistr)).263    :yields: Grapheme cluster substrings.264 265    Example::266 267        >>> list(iter_graphemes('cafe\u0301'))268        ['c', 'a', 'f', 'e\u0301']269        >>> list(iter_graphemes('ok\U0001F468\u200D\U0001F469\u200D\U0001F467'))270        ['o', 'k', '\U0001F468\u200D\U0001F469\u200D\U0001F467']271        >>> list(iter_graphemes('ok\U0001F1FA\U0001F1F8'))272        ['o', 'k', '\U0001F1FA\U0001F1F8']273 274    .. versionadded:: 0.3.0275    """276    if not unistr:277        return278 279    length = len(unistr)280 281    if end is None:282        end = length283 284    if start >= end or start >= length:285        return286 287    end = min(end, length)288 289    # Track state for grapheme cluster boundaries290    cluster_start = start291    ri_count = 0292 293    # Get GCB for first character294    prev_gcb = _grapheme_cluster_break(ord(unistr[start]))295 296    # Handle Regional Indicator count initialization297    if prev_gcb == GCB.REGIONAL_INDICATOR:298        ri_count = 1299 300    for idx in range(start + 1, end):301        curr_gcb = _grapheme_cluster_break(ord(unistr[idx]))302 303        result = _should_break(prev_gcb, curr_gcb, unistr, idx, ri_count)304        ri_count = result.ri_count305 306        if result.should_break:307            yield unistr[cluster_start:idx]308            cluster_start = idx309 310        prev_gcb = curr_gcb311 312    # Yield the final cluster313    yield unistr[cluster_start:end]314 315 316def _find_cluster_start(text: str, pos: int) -> int:317    """318    Find the start of the grapheme cluster containing the character before pos.319 320    Scans backwards from pos to find a safe starting point, then iterates forward using standard321    break rules to find the actual cluster boundary.322 323    :param text: The Unicode string.324    :param pos: Position to search before (exclusive).325    :returns: Start position of the grapheme cluster.326    """327    target_cp = ord(text[pos - 1])328 329    # GB3: CR x LF - LF after CR is part of same cluster330    if target_cp == 0x0A and pos >= 2 and text[pos - 2] == '\r':331        return pos - 2332 333    # Fast path: ASCII (except LF) starts its own cluster334    if target_cp < 0x80:335        # GB9b: Check for preceding PREPEND (rare: Arabic/Brahmic)336        if pos >= 2 and target_cp >= 0x20:337            prev_cp = ord(text[pos - 2])338            if prev_cp >= 0x80 and _grapheme_cluster_break(prev_cp) == GCB.PREPEND:339                return _find_cluster_start(text, pos - 1)340        return pos - 1341 342    # Scan backward to find a safe starting point343    safe_start = pos - 1344    while safe_start > 0 and (pos - safe_start) < MAX_GRAPHEME_SCAN:345        cp = ord(text[safe_start])346        if 0x20 <= cp < 0x80:  # ASCII always starts a cluster347            break348        if _grapheme_cluster_break(cp) == GCB.CONTROL:  # GB4349            break350        safe_start -= 1351 352    # Verify forward to find the actual cluster boundary353    cluster_start = safe_start354    left_gcb = _grapheme_cluster_break(ord(text[safe_start]))355    ri_count = 1 if left_gcb == GCB.REGIONAL_INDICATOR else 0356 357    for i in range(safe_start + 1, pos):358        right_gcb = _grapheme_cluster_break(ord(text[i]))359        result = _should_break(left_gcb, right_gcb, text, i, ri_count)360        ri_count = result.ri_count361        if result.should_break:362            cluster_start = i363        left_gcb = right_gcb364 365    return cluster_start366 367 368def grapheme_boundary_before(unistr: str, pos: int) -> int:369    r"""370    Find the grapheme cluster boundary immediately before a position.371 372    :param unistr: The Unicode string to search.373    :param pos: Position in the string (0 < pos <= len(unistr)).374    :returns: Start index of the grapheme cluster containing the character at pos-1.375 376    Example::377 378        >>> grapheme_boundary_before('Hello \U0001F44B\U0001F3FB', 8)379        6380        >>> grapheme_boundary_before('a\r\nb', 3)381        1382 383    .. versionadded:: 0.3.6384    """385    if pos <= 0:386        return 0387    return _find_cluster_start(unistr, min(pos, len(unistr)))388 389 390def iter_graphemes_reverse(391    unistr: str,392    start: int = 0,393    end: Optional[int] = None,394) -> Iterator[str]:395    r"""396    Iterate over grapheme clusters in reverse order (last to first).397 398    :param unistr: The Unicode string to segment.399    :param start: Starting index (default 0).400    :param end: Ending index (default len(unistr)).401    :yields: Grapheme cluster substrings in reverse order.402 403    Example::404 405        >>> list(iter_graphemes_reverse('cafe\u0301'))406        ['e\u0301', 'f', 'a', 'c']407 408    .. versionadded:: 0.3.6409    """410    if not unistr:411        return412 413    length = len(unistr)414 415    end = length if end is None else min(end, length)416    start = max(start, 0)417 418    if start >= end or start >= length:419        return420 421    pos = end422    while pos > start:423        cluster_start = _find_cluster_start(unistr, pos)424        # Don't yield partial graphemes that extend before start425        if cluster_start < start:426            break427        yield unistr[cluster_start:pos]428        pos = cluster_start429 
codekingpro/portable-devtools · Team Ai