Source code for ctnx.misc

# -*- coding: utf-8 -*-
from __future__ import annotations

from unicodedata import name as unicode_name, lookup as unicode_lookup, normalize as unicode_normalize
from functools import lru_cache
from re import compile as re_compile, escape as re_escape, sub as re_sub, IGNORECASE as re_IGNORECASE, \
    Match as re_Match
from typing import Literal, Optional, Iterable, Dict
from itertools import product

from .constants import TONES, TONE_NAMES, NO_TONE_CHAR_TRANS, \
    BASE_TONE_PLACEMENT_REPLACE_PAIRS, NON_WORD_CHARS_REGEX, \
    VOWEL_TONE_TO_CHAR, CHAR_TO_TONE_AND_VOWEL


[docs]def nfc_normalize(text: str) -> str: """Converts combining Unicode characters to their equivalent precomposed characters.""" return unicode_normalize('NFC', text)
[docs]def normalize_confusables(text: str) -> str: """Converts a confusable text to a normal text. Replaces similar-looking characters and homoglyphs with their equivalent Vietnamese characters. Small cap letters are converted to lowercase. """ from .constants.confusables import CONFUSABLE_CHAR_TRANS return nfc_normalize(text.translate(CONFUSABLE_CHAR_TRANS))
[docs]def remove_tones(text: str) -> str: """Removes tone marks from text. Replaces characters with tone marks with their equivalent non-toned characters. Other diacritics are kept. """ return text.translate(NO_TONE_CHAR_TRANS)
[docs]def remove_diacritics(text: str) -> str: """Removes all diacritics from text. Replaces characters with diacritics with their equivalent ASCII characters. """ SPECIAL_TRANS = str.maketrans('đĐ', 'dD') return unicode_normalize('NFKD', text.translate(SPECIAL_TRANS)).encode('ascii', 'ignore').decode()
[docs]def sep_tone_from_char(char: str): """Extracts the tone mark from a character. The returned tone is denoted as one of the following: '': unmarked (ngang) '/': acute accent (sắc) '\\': grave accent (huyền) '?': hook above (hỏi) '~': tilde (ngã) '.': dot below (nặng) Parameters ---------- char : str The character from which the tone will be extracted Returns ------- tuple A tuple of the same character without the tone mark, and the tone mark itself """ try: return CHAR_TO_TONE_AND_VOWEL[char] except KeyError: return _sep_tone_from_char_unicode(char)
@lru_cache(maxsize=160) def _sep_tone_from_char_unicode(char: str): try: name = unicode_name(char) # print(name) except ValueError: return ('', char) nname = '' tone = '' for ti, tname in enumerate(TONE_NAMES): if tname in name: tone = TONES[ti] nname = name.replace(tname, '') break else: return ('', char) if nname.endswith('WITH '): nname = nname[:-5] elif nname.endswith('AND '): nname = nname[:-4] nname = nname.strip() try: new_char = unicode_lookup(nname) return (tone, new_char) except KeyError: raise
[docs]def place_tone_to_char(char, tone) -> str: try: return VOWEL_TONE_TO_CHAR[char][tone] except KeyError: return _place_tone_to_char_unicode(char, tone)
@lru_cache(maxsize=160) def _place_tone_to_char_unicode(char, tone): name = unicode_name(char) if (tone != '') and (tone in TONES): if 'WITH' in name: name += ' AND ' else: name += ' WITH ' name += TONE_NAMES[TONES.index(tone)] return unicode_lookup(name)
[docs]def separate_tone(text: str, all=False): """Extracts the tone mark from text. The returned tone is denoted as one of the following: '': unmarked (ngang) '/': acute accent (sắc) '\\': grave accent (huyền) '?': hook above (hỏi) '~': tilde (ngã) '.': dot below (nặng) Parameters ---------- text : str The text from which the tone will be extracted all : bool, default : False If set to True, extracts the last tone instead of the first one Returns ------- tuple A tuple of the text without tone marks, and the extracted tone mark """ text = nfc_normalize(text) tone = '' for i, lett in enumerate(text): tone, new_char = sep_tone_from_char(lett) if tone == '': continue else: text = text[:i] + new_char + text[i+1:] if not all: break return (text, tone)
[docs]def is_even_tone(tone: str) -> bool: """Checks whether the given tone mark represents an even tone (ngang/unmarked or huyền/grave accent).""" return tone in {'', '\\'}
[docs]class DictBasedOnePassStrReplacer: """A helper class that compiles a dictionary of substring replacements into a single trie-based regular expression for efficient, one-pass string replacements. """ def __init__(self, dictionary: dict, use_atomic_group=False, case_sensitive=True, word_boundary='') -> None: self.use_atomic_group = use_atomic_group self.case_sensitive = case_sensitive self.dictionary = dictionary regex_string = self._build_trie_regex_str(dictionary) regex_string = self._wrap_word_boundaries(regex_string, word_boundary) if case_sensitive: self.regex = re_compile(regex_string) else: self.regex = re_compile(regex_string, re_IGNORECASE) def _build_trie_regex_str(self, dictionary: dict): return self._trie_to_regex_str(self._make_trie(list(dictionary.keys()))) def _wrap_word_boundaries(self, regex_string: str, word_boundary: str): if word_boundary == r'\b': regex_string = word_boundary + regex_string + word_boundary elif word_boundary: regex_string = rf"(?<!{word_boundary})" + \ regex_string + rf"(?!{word_boundary})" return regex_string @classmethod def _make_trie(cls, strings: list) -> dict: full_trie = {} for string in strings: trie = full_trie for ch in string: if not (ch in trie): trie[ch] = {} trie = trie[ch] trie['\0'] = None return full_trie @classmethod def _escape(cls, s: str) -> str: return re_escape(s.replace('/', r"\/")).replace(r'\ ', ' ') def _trie_to_regex_str(self, trie: dict, depth: int = 0) -> str: output = "" if not trie: return output if len(trie) == 2 and '\0' in trie: return "(?:" + next(self._escape(ch) + self._trie_to_regex_str(trie[ch], depth+1) for ch in trie if ch != '\0') + ")?" if len(trie) > 1: output += "(?>" if self.use_atomic_group else "(?:" first = True for ch, sub_trie in sorted(trie.items(), reverse=True): if first: first = False else: output += "|" if ch != '\0': output += self._escape(ch) + \ self._trie_to_regex_str(sub_trie, depth+1) if len(trie) > 1: output += ")" return output def _get_replacement(self, match: str): if self.case_sensitive: return self.dictionary[match] replacement: str = self.dictionary[match.lower()] if match == match.capitalize(): if (match == match.title()) and any(c.isupper() for c in replacement): return replacement.title() else: return replacement[0].upper() + replacement[1:] elif match == match.upper(): return replacement.upper() else: return replacement
[docs] def replace(self, text: str) -> str: return self.regex.sub(lambda match: self._get_replacement(match.group()), text)
def __call__(self, text: str) -> str: return self.replace(text)
[docs]def make_regex_str_from_tokens(tokens: list, use_atomic_group=False, case_sensitive=True, word_boundary=''): """Generates a trie-based regular expression string from a list of tokens.""" replacer = DictBasedOnePassStrReplacer({}, use_atomic_group=use_atomic_group, case_sensitive=case_sensitive, word_boundary=word_boundary) regex_str = replacer._trie_to_regex_str(replacer._make_trie(tokens)) return replacer._wrap_word_boundaries(regex_str, word_boundary)
[docs]def generate_tone_placement_replace_mapping(old_to_new=True, includes_rare_casing=False) -> dict: """Generates a mapping dictionary for replacing Vietnamese tone placements between old and new styles (or vice versa). """ def reverse_sent_case(text): return text[0].lower() + text[1:].upper() mapping = {} for from_chars, to_chars in BASE_TONE_PLACEMENT_REPLACE_PAIRS: if not old_to_new: from_chars, to_chars = to_chars, from_chars mapping[from_chars] = to_chars mapping[from_chars.upper()] = to_chars.upper() mapping[from_chars.capitalize()] = to_chars.capitalize() if includes_rare_casing: mapping[reverse_sent_case( from_chars)] = reverse_sent_case(to_chars) return mapping
normalize_tone_placement_new_style = DictBasedOnePassStrReplacer( generate_tone_placement_replace_mapping()) normalize_tone_placement_old_style = DictBasedOnePassStrReplacer( generate_tone_placement_replace_mapping(old_to_new=False))
[docs]class IYNormalizer(DictBasedOnePassStrReplacer): """String replacer for normalizing the placement of 'i' and 'y' in Vietnamese syllables, following configurable preset styles and exception lists. """ ONSETS = ['qu', 'h', 'k', 'l', 'm', 's', 't', 'v',] LOWER_I_VARIANTS = 'iìíỉĩị' LOWER_Y_VARIANTS = 'yỳýỷỹỵ' I_VARIANTS = LOWER_I_VARIANTS + LOWER_I_VARIANTS.upper() Y_VARIANTS = LOWER_Y_VARIANTS + LOWER_Y_VARIANTS.upper() I_TO_Y_TRANS = str.maketrans(I_VARIANTS, Y_VARIANTS) Y_TO_I_TRANS = str.maketrans(Y_VARIANTS, I_VARIANTS) TRANS_TABLE_ROUTER = { 'i': Y_TO_I_TRANS, 'y': I_TO_Y_TRANS, } SYLLABLE_PATTERN = f"([{''.join(ONSETS[1:])}]|{ONSETS[0]})?[{LOWER_I_VARIANTS}{LOWER_Y_VARIANTS}]" POSSIBLE_PRESET_STYLES = ( "i", "unified_i", "sinoviet_hklmqstv_y", "hklmqstv_y", "sinoviet_hklmqst_y", "hklmqst_y", "sinoviet_hklmqt_y", "hklmqt_y" ) DEFAULT_I_OVERRIDE_LIST = [ "hi hi", "hì hì", "hí hí", "hị hị", "hì hục", "hì hụi", "hỉ hả", "hỉ mũi", "hí hoáy", "hí húi", "hí hửng", "hí hởn", "hủ hỉ", "hậu hĩ", "ki bo", "ki cóp", "ki-lô-gam", "ki-ốt", "kì cạch", "kì cọ", "kì kèo", "kì cùng", "kì đà", "kì giông", "kí ninh", "kĩ tính", "kĩ càng", "cũ kĩ", "cụ kị", "ô li", "li bì", "li ti", "li-ti", "chi li", "cu li", "mi li", "lâm li", "va li", "phẳng lì", "nhẵn lì", "lì loà", "lì lợm", "lì xì", "lí nhí", "lũ lĩ", "kiết lị", "mi-ca", "mi-crô", "mi mắt", "cù mì", "lúa mì", "khoai mì", "bột mì", "mì sợi", "mì chính", "rễ mí", "tỉ mỉ", "mụ mị", "cây si", "nốt si", "si-lic", "đen sì", "hôi sì", "hàn sì", "sì sụp", "mua sỉ", "ti hí", "ti gôn", "ti-tan", "ti toe", "đinh ti", "ti trôn", "ti ti", "ti tỉ", "ti tiện", "tì tì", "tì vết", "tì tay", "tù tì", "tí toáy", "tí tách", "tí teo", "tí hon", "tỉ tê", "bạc tỉ", "tị nạnh", "tí ti", "ki ốt", "si đa", # Sino-Vietnamese words "ti tiện", "tự ti", "tị nạn", "ghen tị", "hồi tị", "tị nạnh", ] def __init__(self, use_atomic_group=False, ignore_likely_proper_nouns=True, h: Literal['i', 'y'] = 'y', k='y', l='y', m='y', qu='y', s='i', t='y', v='i', i='y', use_sinoviet_heuristic=True, i_override_list: Optional[Iterable[str]] = None, max_repl_cache_size: Optional[int] = 0, ) -> None: i_override_list = i_override_list actual_i_override_list = self.DEFAULT_I_OVERRIDE_LIST if i_override_list is not None: actual_i_override_list = i_override_list self.case_sensitive = False self.use_atomic_group = use_atomic_group self.ignore_likely_proper_nouns = ignore_likely_proper_nouns self.i_override_set = set(actual_i_override_list) self.use_sinoviet_heuristic = use_sinoviet_heuristic self.max_repl_cache_size = max_repl_cache_size self.dictionary = self._generate_exception_phrases_mapping( actual_i_override_list) regex_string = self._build_trie_regex_str( self.dictionary) + '|' + self.SYLLABLE_PATTERN regex_string = f"(?:{regex_string})" # prevents lời from becoming lờy regex_string = self._wrap_word_boundaries(regex_string, r'\b') self.regex = re_compile(regex_string, re_IGNORECASE) trans_tables_router = {} trans_tables_router['h'] = self.TRANS_TABLE_ROUTER.get(h) trans_tables_router['k'] = self.TRANS_TABLE_ROUTER.get(k) trans_tables_router['l'] = self.TRANS_TABLE_ROUTER.get(l) trans_tables_router['m'] = self.TRANS_TABLE_ROUTER.get(m) trans_tables_router['qu'] = self.TRANS_TABLE_ROUTER.get(qu) trans_tables_router['s'] = self.TRANS_TABLE_ROUTER.get(s) trans_tables_router['t'] = self.TRANS_TABLE_ROUTER.get(t) trans_tables_router['v'] = self.TRANS_TABLE_ROUTER.get(v) trans_tables_router['i'] = self.TRANS_TABLE_ROUTER.get(i) for k, v in trans_tables_router.items(): if v is None: if k == 'i': raise ValueError( f"Value for case of the standalone 'i' must be either 'i' or 'y'") else: raise ValueError( f"Value for the case of '{k}' onset consonant must be either 'i' or 'y'") self.trans_tables_router = trans_tables_router @classmethod def _generate_exception_phrases_mapping(cls, phrases: Iterable[str]) -> Dict: actual_syllable_pattern = fr"^{cls.SYLLABLE_PATTERN}[ -]?$" syllable_regex = re_compile(actual_syllable_pattern, re_IGNORECASE) tokenize_regex = re_compile(r"\w+[ -]?", re_IGNORECASE) results = {} for phrase in phrases: syllables = tokenize_regex.findall(phrase) combination_choices = [] for syllable in syllables: match = syllable_regex.match(syllable) if match: combination_choices.append( (syllable.translate(cls.Y_TO_I_TRANS), syllable.translate(cls.I_TO_Y_TRANS))) else: combination_choices.append((syllable, syllable)) for combination in product(*combination_choices): results[''.join(combination)] = phrase return results
[docs] @classmethod def from_preset_style(cls, style: Literal["i", "unified_i", "sinoviet_hklmqstv_y", "hklmqstv_y", "sinoviet_hklmqst_y", "hklmqst_y", "sinoviet_hklmqt_y", "hklmqt_y"] = "sinoviet_hklmqt_y", use_atomic_group=False, ignore_likely_proper_nouns=True, i_override_list=None, max_repl_cache_size: Optional[int] = 0, ) -> IYNormalizer: if style == "i": return IYNormalizer( use_atomic_group, ignore_likely_proper_nouns, h='i', k='i', l='i', m='i', qu='i', s='i', t='i', v='i', i='i', use_sinoviet_heuristic=False, i_override_list=[], max_repl_cache_size=max_repl_cache_size) elif style == "unified_i": return IYNormalizer( use_atomic_group, ignore_likely_proper_nouns, h='i', k='i', l='i', m='i', qu='y', s='i', t='i', v='i', i='y', use_sinoviet_heuristic=False, i_override_list=i_override_list, max_repl_cache_size=max_repl_cache_size) elif style == "sinoviet_hklmqstv_y": return IYNormalizer( use_atomic_group, ignore_likely_proper_nouns, h='y', k='y', l='y', m='y', qu='y', s='y', t='y', v='y', i='y', use_sinoviet_heuristic=True, i_override_list=i_override_list, max_repl_cache_size=max_repl_cache_size) elif style == "hklmqstv_y": return IYNormalizer( use_atomic_group, ignore_likely_proper_nouns, h='y', k='y', l='y', m='y', qu='y', s='y', t='y', v='y', i='y', use_sinoviet_heuristic=False, i_override_list=i_override_list, max_repl_cache_size=max_repl_cache_size) elif style == "sinoviet_hklmqst_y": return IYNormalizer( use_atomic_group, ignore_likely_proper_nouns, h='y', k='y', l='y', m='y', qu='y', s='y', t='y', v='i', i='y', use_sinoviet_heuristic=True, i_override_list=i_override_list, max_repl_cache_size=max_repl_cache_size) elif style == "hklmqst_y": return IYNormalizer( use_atomic_group, ignore_likely_proper_nouns, h='y', k='y', l='y', m='y', qu='y', s='y', t='y', v='i', i='y', use_sinoviet_heuristic=False, i_override_list=i_override_list, max_repl_cache_size=max_repl_cache_size) elif style == "sinoviet_hklmqt_y": return IYNormalizer( use_atomic_group, ignore_likely_proper_nouns, h='y', k='y', l='y', m='y', qu='y', s='i', t='y', v='i', i='y', use_sinoviet_heuristic=True, i_override_list=i_override_list, max_repl_cache_size=max_repl_cache_size) elif style == "hklmqt_y": return IYNormalizer( use_atomic_group, ignore_likely_proper_nouns, h='y', k='y', l='y', m='y', qu='y', s='i', t='y', v='i', i='y', use_sinoviet_heuristic=False, i_override_list=i_override_list, max_repl_cache_size=max_repl_cache_size) else: return IYNormalizer()
@property def max_repl_cache_size(self): return self._max_repl_cache_size @max_repl_cache_size.setter def max_repl_cache_size(self, value: Optional[int]): if value == 0: self._get_non_exceptional_replacement = self.__get_non_exceptional_replacement self._get_replacement_maybe_cached = self._get_replacement else: self._get_non_exceptional_replacement = lru_cache( maxsize=value)(self.__get_non_exceptional_replacement) self._get_replacement_maybe_cached = lru_cache( maxsize=value)(self._get_replacement) self._max_repl_cache_size = value def _get_normalized_form(self, match: re_Match): match_str: str = match.group() if self.ignore_likely_proper_nouns: if match_str == match_str.capitalize(): match_pos = match.pos if match_pos > 0: # not at the start of the string return match_str if not match.group(1) and (len(match_str) > 1): # exception phrase found return self._get_replacement_maybe_cached(match_str) else: onset = (match.group(1) or '').lower() return self._get_non_exceptional_replacement(match_str, onset) def __get_non_exceptional_replacement(self, match_str: str, onset: str) -> str: if self.use_sinoviet_heuristic: match_lower_str = match_str.lower() # non-existent Sino-Vietnamese tokens # Source: https://ling.ussh.vnu.edu.vn/vi/nghien-cuu-khoa-hoc/chuong-trinh-de-tai-du-an/ban-tiep-ve-chuyen-i-ngan-y-dai-605.html if match_lower_str in {'hỳ', 'lỳ', 'lỷ', 'lỹ', 'mỷ', 'mý', 'sỳ', 'sý', 'sỵ', 'tỹ'}: return match_str.translate(self.Y_TO_I_TRANS) # non-existent non-Sino-Vietnamese tokens elif match_lower_str in {'kỉ', 'lỉ', 'mĩ', 'sí'}: return match_str.translate(self.I_TO_Y_TRANS) elif match_lower_str in {'hì', 'lì', 'lỉ', 'lĩ', 'mỉ', 'mí', 'sì', 'sí', 'sị', 'tĩ', 'kỷ', 'lỷ', 'mỹ', 'sý', }: return match_str if onset: return match_str.translate(self.trans_tables_router[onset]) else: return match_str.translate(self.trans_tables_router['i'])
[docs] def replace(self, text: str) -> str: return self.regex.sub(lambda match: self._get_normalized_form(match), text)
def __call__(self, text: str) -> str: return self.replace(text)
_TEXT_NORMALIZE_REPLACE_TRANSLATIONS = str.maketrans( ':“”‘’ー  \u200b\ufeff「」【】«»『』《》〖〗〔〕', ':""\'\'- """"""""""""""') _TEXT_NORMALIZE_REMOVE_TRANSLATIONS = str.maketrans('', '', '└*♪*★♥') _TEXT_NORMALIZE_REMOVE_PUNCT_TRANS = str.maketrans( '', '', r"!\"#$%&'()*+,-./:;<=>?@[\]^_`{|}~")
[docs]def normalize_text(text: str, clean_redudant_spaces=True, strip_punctuation=False, do_normalize_confusables=False, normalize_tone_placement=True): """Cleans and normalizes Vietnamese text. Supports NFC normalization, removing redundant spaces, stripping punctuation, converting confusable characters, and normalizing tone placement. """ text = nfc_normalize(text.strip()).translate( _TEXT_NORMALIZE_REPLACE_TRANSLATIONS).translate(_TEXT_NORMALIZE_REMOVE_TRANSLATIONS) if do_normalize_confusables: text = normalize_confusables(text) if strip_punctuation: text = text.translate(_TEXT_NORMALIZE_REMOVE_PUNCT_TRANS) if clean_redudant_spaces: text = re_sub(r" +", " ", text) text = re_sub(r"([!?])\1{1,}", r"\1", text) text = re_sub('-{2,}', '', text) text = re_sub(r'\.{4,}|…', '...', text) if normalize_tone_placement: text = normalize_tone_placement_new_style(text) return text.strip()
_CLEAN_SLUG_SPACES_REGEX = re_compile(r'[ -]+')
[docs]def clean_slug(text: str, sep='_'): """Generates an ASCII-only slug (URL-friendly string) from a Vietnamese text, replacing spaces and non-word characters with a separator. """ slug = remove_diacritics(text.lower()) slug = NON_WORD_CHARS_REGEX.sub('', slug) return _CLEAN_SLUG_SPACES_REGEX.sub(sep, slug)