"""Dependency-free, reviewable TTS normalization. Indian English and ten Indic language tables share one corrected parser. The selected locale controls numbers, currency, dates, units and identifiers; it does not translate prose or choose/change the model conditioning tag. Hindi speech output folds nukta and ends standalone है with a danda outside explicit question sentences (ending in ?). These are intentional pronunciation/pause workarounds, not spelling corrections. Invalid/ambiguous tokens fall back to digit and separator words. FR3_TN_STRICT=1 retains the strict audit contract. See docs/NORMALIZATION_REVIEW.md for the contract and remaining listening gates. """ # -*- coding: utf-8 -*- # The former generator/indic_norm package is absent from this repository. # This tracked file is the source of truth; protect edits with tests/test_normalize.py. import re import os import json import sys import argparse from datetime import date import unicodedata from functools import lru_cache # Keep CLI execution and the imported table module on the same LangBase class. if __name__ == "__main__": sys.modules.setdefault("normalize", sys.modules[__name__]) LANGUAGE_NAMES = {"en-IN": "Indian English", "hi": "Hindi", "te": "Telugu", "ta": "Tamil", "bn": "Bengali", "as": "Assamese", "gu": "Gujarati", "kn": "Kannada", "ml": "Malayalam", "mr": "Marathi", "or": "Odia", "pa": "Punjabi"} _LANGUAGE_ALIASES = {alias.lower(): code for code, name in LANGUAGE_NAMES.items() for alias in (code, name, code if code == "en-IN" else code + "-IN")} _LANGUAGE_ALIASES.update({"en": "en-IN", "english": "en-IN", "en-us": "en-IN", "en-gb": "en-IN", "indian-english": "en-IN", "oriya": "or", "od": "or"}) # Input aliases, not translations of arbitrary prose. Keep identifiers digitwise # even when callers write OTP in native script (including Indian English mode). _OTP_INPUT_WORDS = {"ओटीपी", "ఓటీపీ", "ఓటిపి", "ஓடிபி", "ஓடீபி", "ওটিপি", "ઓટીપી", "ಒಟಿಪಿ", "ಓಟಿಪಿ", "ഒടിപി", "ഓടിപി", "ଓଟିପି", "ਓਟੀਪੀ"} _PAISA_INPUT_WORDS = {"paise", "paisa", "पैसा", "पैसे", "పైసా", "పైసలు", "பைசா", "পয়সা", "પૈસા", "ಪೈಸೆ", "പൈസ", "ପଇସା", "ਪੈਸਾ", "ਪੈਸੇ"} def resolve_language(language): if not isinstance(language, str) or language.lower() not in _LANGUAGE_ALIASES: raise NormalizationError("unsupported_normalization_language", "Choose one of the documented normalization languages.") return _LANGUAGE_ALIASES[language.lower()] @lru_cache(maxsize=12) def language_table(language): code = resolve_language(language) if code == "en-IN": return LANG if __package__: from .indic_languages import LANGUAGES else: from indic_languages import LANGUAGES return LANGUAGES[code] class NormalizationError(ValueError): """A safe-to-return error code. Never includes the caller's financial text.""" def __init__(self, code, message, span=None, source=None): self.span, self.source = span, source self.code = code super().__init__(message) _TAMIL_MONTHS = ["ஜனவரி", "பிப்ரவரி", "மார்ச்", "ஏப்ரல்", "மே", "ஜூன்", "ஜூலை", "ஆகஸ்ட்", "செப்டம்பர்", "அக்டோபர்", "நவம்பர்", "டிசம்பர்"] # Calendar days only. Native-speaker/audio review is required before rollout. _TAMIL_ORDINALS = ["", "முதலாம்", "இரண்டாம்", "மூன்றாம்", "நான்காம்", "ஐந்தாம்", "ஆறாம்", "ஏழாம்", "எட்டாம்", "ஒன்பதாம்", "பத்தாம்", "பதினொன்றாம்", "பன்னிரண்டாம்", "பதின்மூன்றாம்", "பதினான்காம்", "பதினைந்தாம்", "பதினாறாம்", "பதினேழாம்", "பதினெட்டாம்", "பத்தொன்பதாம்", "இருபதாம்", "இருபத்தொன்றாம்", "இருபத்திரண்டாம்", "இருபத்துமூன்றாம்", "இருபத்துநான்காம்", "இருபத்தைந்தாம்", "இருபத்தாறாம்", "இருபத்தேழாம்", "இருபத்தெட்டாம்", "இருபத்தொன்பதாம்", "முப்பதாம்", "முப்பத்தொன்றாம்"] def _ascii_digits(text): # str.isdigit also accepts superscripts. Only Unicode DECIMAL digits belong # here; keep math superscripts for the math matcher. return "".join(str(unicodedata.decimal(c)) if c.isdecimal() else c for c in text) # Hindi speech compatibility, not a general Unicode diacritic remover. Apply to # FINAL output because number/date/letter/URL expansions can introduce nukta. # Keep every other script, accent, vowel sign and nasal mark untouched. _HINDI_NUKTA_FOLD = str.maketrans({ "\u093c": None, "\u0929": "\u0928", "\u0931": "\u0930", "\u0934": "\u0933", "\u0958": "\u0915", "\u0959": "\u0916", "\u095a": "\u0917", "\u095b": "\u091c", "\u095c": "\u0921", "\u095d": "\u0922", "\u095e": "\u092b", "\u095f": "\u092f", }) def _calendar_check(day, month, year=2000, span=None): # A missing year can include Feb 29; never invent a year in the output. try: date(year, month, day) except ValueError: raise NormalizationError("invalid_date", "Invalid calendar date; correct the input.", span=span) from None def _sentence_before(text, start): return re.split(r"[.!?;।॥\n]", text[max(0, start - 100):start])[-1] def _sentence_after(text, end): return re.split(r"[.!?;।॥\n]", text[end:end + 100])[0] def _valid_grouping(number): integer = number.lstrip("+-−").split(".")[0] return "," not in integer or bool(re.fullmatch( r"[0-9]{1,3}(?:,[0-9]{3})+|[0-9]{1,2}(?:,[0-9]{2})*,[0-9]{3}", integer)) def _month_lookup(locale): return {**_ENGLISH_MONTHS, **{name.lower(): i + 1 for i, name in enumerate(locale.MONTHS)}, **{name.lower(): i for name, i in locale.MONTH_ALIASES.items()}} def validate_text(text, *, date_order="DMY", two_digit_year_base=None, language="en-IN"): """Validate before destructive cleanup. Dates default to explicit DMY policy. Two-digit years require a caller-supplied century (1900 or 2000). Short dates in date context must be spelled out; ordinary fractions remain fractions. Plain text only: SSML/templates are rejected, not stripped and spoken. """ if not isinstance(text, str): raise NormalizationError("invalid_text", "text must be a string.") if len(text) > 20000: raise NormalizationError("text_too_long", "text exceeds the 20000 character limit.") if date_order not in ("DMY", "MDY"): raise NormalizationError("invalid_date_order", "date_order must be DMY or MDY.") if two_digit_year_base is not None and (type(two_digit_year_base) is not int or two_digit_year_base not in (1900, 2000)): raise NormalizationError("invalid_century", "two_digit_year_base must be 1900 or 2000.") locale = language_table(language) t = _ascii_digits(text) if (bad := re.search(r"<\s*(?:/?[A-Za-z_]|[!?])|[{}]|\$\{", t)): raise NormalizationError("markup_or_placeholder", "Resolve templates and send plain text, not markup/SSML.", span=bad.span()) for i, c in enumerate(t): if (unicodedata.category(c) == "Cc" and c not in "\n\r\t") or unicodedata.category(c) == "Co": raise NormalizationError("control_character", "Unsupported control character in text.", span=(i, i + 1)) # URI numeric components are identifiers, not dates, amounts or clocks. t = re.sub(r"https?://\S+|www\.\S+|\b[\w.%+\-]+@[\w.\-]+\.[A-Za-z]{2,}", lambda m: " " * len(m[0]), t) if (bad := re.search(r"[0-9]{31,}", t)): raise NormalizationError("number_too_long", "Numeric spans are limited to 30 digits.", span=bad.span()) if (bad := re.search(r"\d{4}-\d{2}-\d{2}T\d", t, re.I)): raise NormalizationError("unsupported_timestamp", "Spell out the date, time and timezone separately.", span=bad.span()) for m in re.finditer(r"(? 30: raise NormalizationError("number_too_long", "Numeric spans are limited to 30 digits.", span=m.span()) if not _valid_grouping(m[0].rstrip(",")): raise NormalizationError("invalid_grouping", "Invalid number grouping; send ungrouped digits or standard grouping.", span=m.span()) for m in re.finditer(r"(? 59 or int(m[3]) > 59: raise NormalizationError("invalid_duration", "Duration minutes and seconds must be below 60.", span=m.span()) no_durations = re.sub(r"[0-9]+:[0-9]{2}:[0-9]{2}", lambda m: " " * len(m[0]), t) for m in re.finditer(r"(? month 1..12 # ---- symbols / unit words ------------------------------------------- # Values are native-script spoken words. Keys are fixed; the engine # relies on every key existing. SYM = { "rupee": "", "dollar": "", "euro": "", "pound": "", "cent": "", "percent": "", # % symbol "point": "", # decimal point word (Hindi: दशमलव) "minus": "", "plus": "", "equals": "", "times": "", "divide": "", "squared": "", # ² (Hindi: का वर्ग — appended after operand) "sqrt": "", # √ (spoken before operand) "lt": "", "gt": "", # < > "plus_minus": "", # ± "at": "", # @ (Hindi: एट द रेट) "underscore": "", "dot": "", # . in emails/URLs "slash": "", # / in URLs "hash": "", # # "amp": "", # & "half": "", "quarter": "", "three_quarters": "", "degree": "", "celsius": "", "fahrenheit": "", "minute": "", # time minutes word } # ---- templates (use {placeholders}) ---------------------------------- TIME_ON_HOUR = "" # "{h} बजे" TIME_HM = "" # "{h} बजकर {m} मिनट" RANGE = "" # "{a} से {b}" (10–20) RANGE_TO = "" # "{a} से {b} तक" (time ranges) RATIO = "" # "{a} अनुपात {b}" POWER = "" # "{base} की घात {exp}" FRACTION = "" # "{num} बटा {den}" (2/7) DECADE_SHORT = "" # "{tens} के" (90s) DECADE_FULL = "" # "{tens} का दशक" (1980s) # Period words emitted before the time (order: period first, then time). PERIODS = {"morning": "", "afternoon": "", "evening": "", "night": ""} # ---- source-text detection vocab (native words the engine must # recognise in the INPUT). Include all common variants/spellings. TIME_MARKERS = [] # e.g. ["बजे", "बजकर"] # markers that carry a case suffix which must be re-attached to the # rendered time ("10 గంటలకు" -> "పది గంటలకు", not "పది గంటలు") MARKER_SUFFIXES = {} # e.g. {"గంటలకు": "కు"} PERIOD_DETECT = {} # native word -> "morning"/"afternoon"/"evening"/"night" YEAR_WORDS = [] # e.g. ["साल", "वर्ष", "सन्"] ID_KEYWORDS = [] # native keywords marking identifiers: नंबर, खाता ... ID_AFTER_WORDS = [] # words AFTER a digit-run marking it as an # identifier ("7104 తో ఎండ్ అయ్యే number") LABEL_WORDS = [] # native label words read as cardinal: कमरा, अनुच्छेद ... MINUTE_WORDS = [] # native "minute" words in input, e.g. ["मिनट"] SCALE_WORDS = {} # native scale word in input -> "crore"/"lakh"/"thousand" CURRENCY_WORDS = {} # native currency words in input -> canonical key # e.g. {"रुपये": "rupee", "रु": "rupee"} # ---- ordinals --------------------------------------------------------- ORDINAL_EXCEPTIONS = {} # n -> word (Hindi: {1: "पहला", 2: "दूसरा", ...}) ORDINAL_SUFFIX = "" # default suffix appended to cardinal (Hindi: वाँ) # Suffixes seen in input that should be preserved on output when possible ORDINAL_INPUT_SUFFIXES = [] # e.g. ["वीं", "वाँ", "वें", "वां"] # ====================================================================== # Composition methods — Indo-Aryan defaults; Dravidian modules override. # ====================================================================== def digit(self, d: int) -> str: return self.ONES[d] def two_digit(self, n: int) -> str: return self.ONES[n] def hundreds(self, h: int, rest: int) -> str: """h in 1..9, rest 0..99 -> 'X sau [rest]'.""" out = f"{self.ONES[h]} {self.HUNDRED}" if rest: out += f" {self.two_digit(rest)}" return out def under_thousand(self, n: int) -> str: if n < 100: return self.two_digit(n) return self.hundreds(n // 100, n % 100) def cardinal(self, n: int) -> str: """Indian-system cardinal. Override in Dravidian modules.""" if n < 0: return f"{self.SYM['minus']} {self.cardinal(-n)}" if n < 1000: return self.under_thousand(n) parts = [] crore, n = divmod(n, 10**7) lakh, n = divmod(n, 10**5) thousand, n = divmod(n, 1000) if crore: parts.append(f"{self.cardinal(crore)} {self.CRORE}") if lakh: parts.append(f"{self.two_digit(lakh)} {self.LAKH}") if thousand: parts.append(f"{self.two_digit(thousand)} {self.THOUSAND}") if n: parts.append(self.under_thousand(n)) return " ".join(parts) def year(self, y: int) -> str: """Year-style reading. Indo-Aryan: 1947 -> 'unnis sau saintalis'. Dravidian modules usually override to full cardinal.""" if 1100 <= y <= 1999: hi, lo = divmod(y, 100) out = f"{self.two_digit(hi)} {self.HUNDRED}" if lo: out += f" {self.two_digit(lo)}" return out return self.cardinal(y) def letter(self, c: str) -> str: return self.LETTERS.get(c.upper(), c) def month(self, i: int) -> str: """i is 1..12""" return self.MONTHS[i - 1] def time_phrase(self, h: int, m: int, period_key=None) -> str: # _resolve_time may deliberately hand back a 24-hour hour (languages # that transcribe am/pm keep "14:30" as "fourteen thirty"); only fold # into 12-hour when it has not already been decided upstream. if self.AMPM_WORDS and h > 12: h12 = h else: h12 = h % 12 if h12 == 0: h12 = 12 if m == 0: core = self.TIME_ON_HOUR.format(h=self.two_digit(h12)) else: core = self.TIME_HM.format(h=self.two_digit(h12), m=self.two_digit(m)) if period_key: return f"{self.PERIODS[period_key]} {core}" return core def ordinal(self, n: int, src_suffix: str = "") -> str: if n in self.ORDINAL_EXCEPTIONS and not src_suffix: return self.ORDINAL_EXCEPTIONS[n] return self._ordinal_compose(n, src_suffix) def _ordinal_compose(self, n: int, src_suffix: str) -> str: """Default: cardinal + suffix. Languages with stem changes override.""" base = self.ORDINAL_EXCEPTIONS.get(n) if base and not src_suffix: return base suffix = src_suffix if src_suffix in self.ORDINAL_INPUT_SUFFIXES else self.ORDINAL_SUFFIX return f"{self.cardinal(n)}{suffix}" def decimal(self, int_part: int, frac_digits: str) -> str: frac = " ".join(self.digit(int(d)) for d in frac_digits) return f"{self.cardinal(int_part)} {self.SYM['point']} {frac}" def decade(self, tens: int, full: bool) -> str: """tens: e.g. 90 for '90s', 80 for '1980s'.""" tpl = self.DECADE_FULL if full else self.DECADE_SHORT return tpl.format(tens=self.two_digit(tens)) # ------------------------------------------------------------------ # Grammar hooks — no-ops by default; languages with case/sandhi # morphology (Telugu, ...) override these. # ------------------------------------------------------------------ # unit initialisms that read as full native words instead of letters # (e.g. Telugu KM -> కిలోమీటర్లు) UNIT_WORDS = {} # written abbreviations that are not speakable words — expanded to native # words wherever they appear (mins -> నిమిషాలు). Keys uppercased. ABBREV_UNITS = {} # spoken word for the "No." / "no." abbreviation before digits NUMBER_WORD = "" # When non-empty the language transcribes the am/pm token literally and the # engine stops INFERRING a period from the hour. Indo-Aryan languages say # the period naturally ("shaam paanch baje"), so they leave this empty; # English must not have "in the evening" inserted into text that only said # "pm". Keys are lowercase, dots stripped. AMPM_WORDS = {} COUNT_ONE = None SINGULAR_NOUNS = {} def join_amount(self, words: str, noun: str) -> str: """Join number words with a following noun we insert (currency, scale, unit). Languages may apply oblique/singular morphology.""" if words == self.ONES[1]: return f"{self.COUNT_ONE or words} {self.SINGULAR_NOUNS.get(noun, noun)}" return f"{words} {noun}" def scale_noun(self, canon: str, numstr: str) -> str: """Scale word used in '₹3 crore'-type phrases; languages may pluralize when the number is not 1.""" return {"crore": self.CRORE, "lakh": self.LAKH, "thousand": self.THOUSAND}.get(canon, canon) def attach_postposition(self, rep: str, tok: str): """Called when a native token `tok` immediately follows a rendered span `rep`. Return the merged string (consuming the token), or None to leave both untouched.""" return None # ---------------------------------------------------------------------------- # Static tables # ---------------------------------------------------------------------------- # All Indic decimal digit blocks -> ASCII _INDIC_DIGIT_BLOCKS = [ 0x0966, # Devanagari 0x09E6, # Bengali 0x0A66, # Gurmukhi 0x0AE6, # Gujarati 0x0B66, # Odia 0x0BE6, # Tamil 0x0C66, # Telugu 0x0CE6, # Kannada 0x0D66, # Malayalam ] _DIGIT_TRANS = {} for _base in _INDIC_DIGIT_BLOCKS: for _i in range(10): _DIGIT_TRANS[_base + _i] = ord("0") + _i # Emoji / decorative ranges to strip entirely _EMOJI_RE = re.compile( "[" "\U0001F300-\U0001FAFF" # symbols & pictographs, emoticons, transport... "\U00002700-\U000027BF" # dingbats "\U00002600-\U000026FF" # misc symbols "\U0001F1E6-\U0001F1FF" # flags "\U0000FE00-\U0000FE0F" # variation selectors "\U0001F000-\U0001F0FF" "\U00002B00-\U00002BFF" # arrows/stars (⭐) "\U00002300-\U000023FF" # misc technical (⌛ ⏳ ⏬) "\U00002190-\U000021FF" # arrows (→) "\U000025A0-\U000025FF" # geometric (● ▶) "\U00002022" # bullet • "\U00002605\U00002606" # ★ ☆ "\U00002122\U000000A9\U000000AE" # ™ © ® # NOTE: ZWNJ/ZWJ (200C/200D) are deliberately NOT stripped — they are # meaningful orthography in Telugu/Malayalam (ఆన్‌లైన్). Only the truly # invisible ZWSP/BOM are removed, without inserting a space. "`~|\\\\{}\\[\\]«»‹›„‚•·¤§†‡" "]+" ) _ZW_STRIP = {0x200B: None, 0x200E: None, 0x200F: None, 0xFEFF: None} _ENGLISH_MONTHS = { "jan": 1, "january": 1, "feb": 2, "february": 2, "mar": 3, "march": 3, "apr": 4, "april": 4, "may": 5, "jun": 6, "june": 6, "jul": 7, "july": 7, "aug": 8, "august": 8, "sep": 9, "sept": 9, "september": 9, "oct": 10, "october": 10, "nov": 11, "november": 11, "dec": 12, "december": 12, } # Established acronyms that stay in Latin script (never spelled out unless # they are part of an alphanumeric identifier). _ACRONYM_WHITELIST = { "UPI", "EMI", "API", "MRI", "GST", "SEBI", "MCX", "IGI", "HDFC", "ISRO", "IMD", "SBI", "RBI", "ICICI", "IRCTC", "ATM", "SIM", "OTP", "PIN", "CEO", "CFO", "CTO", "IPL", "BCCI", "DNA", "RNA", "WHO", "NASA", "PDF", "GPS", "LED", "LCD", "USB", "WIFI", "AI", "IT", "TV", "FM", "AC", "EV", "CBI", "NIA", "ED", "GDP", "CPI", "FII", "DII", "IPO", "NSE", "BSE", "KYC", "NEFT", "RTGS", "IMPS", "IFSC", "CVV", "PAN", "UID", "NRI", "MLA", "MP", "PM", "CM", "DM", "SP", "IAS", "IPS", "NEET", "JEE", "UPSC", "CBSE", "ICSE", "BA", "MA", "MBA", "MBBS", "AIIMS", "ISKCON", "DLF", "TCS", "LTIMINDTREE", "ONGC", "NTPC", "BHEL", "SAIL", "GAIL", "LIC", "RAM", "ROM", "CPU", "GPU", "SSD", "HDD", "GPA", "SUV", "SLA", "GDP", "UIDAI", "FASTAG", "GSTIN", "OK", "US", "UK", "UAE", "USA", "EU", "UN", "PSU", "FIR", "RTI", "PIL", "GPRS", "HD", "UHD", "CCTV", "AQI", "UV", } # Units read letter-by-letter (initialisms). Keys uppercased for lookup. _UNIT_INITIALISMS = { "GB", "MB", "KB", "TB", "PB", "MAH", "MPH", "KMPH", "MHZ", "GHZ", "KHZ", "HZ", "KWH", "RPM", "HP", "BHP", "CC", "DPI", "PPI", "FPS", "MBPS", "GBPS", "KBPS", "PSI", "BPM", "V", "KV", "W", "KW", "MW", "MA", "AH", "WH", "DB", "PPM", "ML", "MG", "KG", "KM", "CM", "MM", "GM", "MCG", "IU", } # English "big number" scale words that stay in Latin (5 million -> पाँच million) _EN_SCALES = {"million", "billion", "trillion", "mn", "bn", "millions", "billions"} _ID_KEYWORDS_EN = { "phone", "mobile", "card", "account", "otp", "pin", "code", "order", "ref", "reference", "train", "flight", "ticket", "tracking", "license", "licence", "booking", "pnr", "upi", "imei", "registration", "number", "no", "ending", "khasra", "plot", "docket", "complaint", "case", "loan", "policy", "invoice", "receipt", "transaction", "txn", "utr", "id", "extension", "helpline", "hotline", "whatsapp", "aadhaar", "aadhar", "passport", "cvv", "gstin", } _LABEL_WORDS_EN = { "hall", "window", "counter", "room", "ward", "terminal", "zone", "gate", "section", "tower", "platform", "floor", "seat", "berth", "coach", "season", "episode", "chapter", "class", "grade", "level", "article", "position", "page", "question", "step", "stage", "round", "phase", "wing", "block", "lane", "screen", "stand", "row", "table", "lesson", "unit", "part", "volume", "rank", "standard", "semester", "waitlist", } _STREET_WORDS = { "मार्ग", "रोड", "नगर", "सड़क", "गली", "चौक", "road", "street", "lane", "avenue", "marg", "nagar", "chowk", "colony", } _CURRENCY_SYMBOLS = {"₹": "rupee", "$": "dollar", "€": "euro", "£": "pound", "Rs.": "rupee", "Rs": "rupee"} _MATH_WORDS_EN = {"squared", "cubed", "plus", "minus", "times", "equals", "power", "root", "over", "by", "divided", "into"} def _fmt(template, **kw): out = template for k, v in kw.items(): out = out.replace("{" + k + "}", str(v)) return out class Normalizer: def __init__(self, lang, *, date_order="DMY", two_digit_year_base=None): self.lang = lang self.date_order = date_order self.two_digit_year_base = two_digit_year_base self._build_regexes() # ------------------------------------------------------------------ # Regex construction (depends on language vocab) # ------------------------------------------------------------------ def _build_regexes(self): L = self.lang def alt(words): return "|".join(re.escape(w) for w in sorted(set(words), key=len, reverse=True)) or r"(?!x)x" self._period_re = alt(list(L.PERIOD_DETECT.keys())) self._marker_re = alt(L.TIME_MARKERS) self._month_lookup = _month_lookup(L) self._month_re = alt(self._month_lookup) self._scale_re = alt(list(L.SCALE_WORDS.keys()) + ["crore", "crores", "Crore", "Crores", "Cr", "cr", "lakh", "lakhs", "Lakh", "Lakhs", "thousand"] + sorted(_EN_SCALES)) self._ord_suffix_re = alt(list(L.ORDINAL_INPUT_SUFFIXES) + ["st", "nd", "rd", "th"]) native_currency = [word for word, key in L.CURRENCY_WORDS.items() if not word.isascii() and key in {"rupee", "dollar", "euro", "pound"}] self._currency_re = r"₹|\$|€|£|Rs\.?|INR|USD|EUR|GBP" if native_currency: self._currency_re += "|(?:" + alt(native_currency) + r")\.?" self._id_keywords = _ID_KEYWORDS_EN | _OTP_INPUT_WORDS | {w.lower() for w in L.ID_KEYWORDS} self._label_words = _LABEL_WORDS_EN | {w.lower() for w in L.LABEL_WORDS} self._minute_re = alt(L.MINUTE_WORDS + ["minute", "minutes", "min"]) # ratio detection keywords: english words + the language's own ratio # word extracted from its RATIO template ("{a} अनुपात {b}" -> अनुपात), # so "விகிதம் 9:15" reads as a ratio, not a time tpl_words = re.sub(r"\{[ab]\}", " ", L.RATIO or "").split() self._ratio_kws = {"ratio", "odds", "proportion", "अनुपात"} | \ {w.lower() for w in tpl_words} # ------------------------------------------------------------------ # Small helpers # ------------------------------------------------------------------ def _month_index(self, token): return self._month_lookup.get(token.strip().lower()) def _digits_spelled(self, digits, comma=True): if os.environ.get("FR3_TN_LONG_DIGITS", "0") == "1" and len(digits) >= 8: if __package__: from .identifier_rules import group_digits else: from identifier_rules import group_digits return group_digits(digits, self.lang) words = [self.lang.digit(int(d)) for d in digits] return ", ".join(words) if comma else " ".join(words) def _letters_spelled(self, letters, comma=False): words = [self.lang.letter(c) for c in letters] return ", ".join(words) if comma else " ".join(words) def _spell_alnum(self, token, comma): """Spell a code segment: letters by sound, digits one by one.""" parts = [] for ch in token: if ch.isdigit(): parts.append(self.lang.digit(int(ch))) elif ch.isalpha() and ch.isascii(): parts.append(self.lang.letter(ch)) # other chars (hyphens) become separators, dropped return (", " if comma else " ").join(parts) def _spell_model_segment(self, seg): """WH-1000XM5 style segment: letter runs by sound, digit runs as cardinal when a clean round block, else digit-by-digit; spaces.""" out = [] for run in re.findall(r"[A-Za-z]+|\d+", seg): if run.isdigit(): n = int(run) if len(run) == 1 or (n % 100 == 0 and n >= 100): out.append(self.lang.cardinal(n)) else: out.append(self._digits_spelled(run, comma=False)) else: out.append(self._letters_spelled(run)) return " ".join(out) def _number_words(self, numstr): """Plain number string (commas allowed) -> cardinal / decimal words.""" if numstr.startswith(("-", "+")): sign = self.lang.SYM["minus" if numstr[0] == "-" else "plus"] return f"{sign} {self._number_words(numstr[1:])}" numstr = numstr.replace(",", "") if "." in numstr: ip, fp = numstr.split(".", 1) return self.lang.decimal(int(ip or 0), fp) return self.lang.cardinal(int(numstr)) def _keyword_before(self, text, start, keywords, window=3): before = _sentence_before(text, start).replace("\u200c", "") toks = re.findall(r"[\w#ऀ-෿']+", before.lower()) for tok in toks[-window:]: tok = tok.strip("#'") if tok in keywords: return True return False def _period_for_hour24(self, h): if 4 <= h < 12: return "morning" if 12 <= h < 16: return "afternoon" if 16 <= h < 20: return "evening" return "night" def _time_words(self, h, m, period_key): if self.lang.code != "en" and (h == 0 or h > 12): # Keep the written 24-hour hour. The inherited language time methods # otherwise fold zero into twelve or fourteen into two. template = self.lang.TIME_HM if m else self.lang.TIME_ON_HOUR return template.format(h=self.lang.cardinal(h), m=self.lang.cardinal(m)) return self.lang.time_phrase(h, m, period_key) # ------------------------------------------------------------------ # Pre-clean # ------------------------------------------------------------------ def _preclean(self, text): text = _ascii_digits(text) text = text.replace("−", "-") text = text.translate(_ZW_STRIP) # double quotation marks (incl. CSV-style "" and """ runs) carry no # sound — a voice model cannot speak them. text = re.sub(r'["“”„]+', "", text) # single quotes wrapping phrases ('Premium Home Decor') go too. # Kept: apostrophes inside words (D'Souza, John's) and quoted single # letters ('Y' -> letter name, handled by its own matcher). text = re.sub(r"(?<=[\s(।,—–-])['‘’](?![A-Za-z]['‘’])(?=\S)", "", text) text = re.sub(r"^['‘’](?![A-Za-z]['‘’])(?=\S)", "", text) text = re.sub(r"(?<=\S)(? read as a comma text = re.sub(r"\s*(?:…|‥|\.{2,})\s*", ", ", text) # parentheses are silent; the aside becomes a comma pause text = re.sub(r"\s*[()]\s*", ", ", text) # //comment// markers are markup junk ((? native words (mins -> నిమిషాలు); # guards keep code segments like MIN-22 intact for ab, word in self.lang.ABBREV_UNITS.items(): # consume the abbreviation's own full stop when the sentence # continues ("Dr. Rao" -> "doctor Rao"), but keep it when the # abbreviation ends the sentence ("... approx." -> "approximately.") text = re.sub(rf"(? canonical range dash; others deleted text = re.sub(r"(?<=[\d])\s*[–—]\s*(?=[\d])", "–", text) # time-range dash: "11 AM – 3 PM" (dash not directly digit-adjacent) text = re.sub(r"\s+[–—]\s+(?=\d)", " – ", text) text = re.sub(r"[—]", " ", text) text = re.sub(r"(?]+|www\.[^\s<>]+" # bare domains: maya.ai, irctc.co.in/booking r"|\b[A-Za-z][\w\-]*\.(?:com|org|net|ai|io|co|dev|app|edu|gov|in)" r"(?:\.(?:in|uk|us))?(?:[/?#][^\s<>]*)?(?![\w.])") for m in re.finditer(pat, text): token = m.group(0).rstrip(".,!?;)") body = re.sub(r"^https?://", "", token) yield m.start(), m.start() + len(token), self._spell_uri_part(body) def _m_handle(self, text): # trailing \w so a sentence-final period after the handle is not # swallowed and spoken as "dot" ("@test_user." -> handle + full stop) pat = r"(? 12 or h == 0: # 24-hour clock if infer: period = period or self._period_for_hour24(h) if L.AMPM_WORDS and not ampm and (h > 12 or h == 0): return h, m, period # "14:30" -> "fourteen thirty", not "two thirty" h12 = h % 12 if h12 == 0: h12 = 12 return h12, m, period def _m_duration(self, text): # H:MM:SS is a duration, not a 12-hour clock. Zero hours must not become 12. L = self.lang sec_word = L.SYM.get("second", "seconds") for m in re.finditer(r"\b(\d{1,3}):(\d{2}):(\d{2})\b", text): h, mm, ss = int(m.group(1)), int(m.group(2)), int(m.group(3)) if mm > 59 or ss > 59: continue hours = L.ABBREV_UNITS.get("HRS", "hours") minutes = L.SYM["minute"] parts = [L.join_amount(L.cardinal(n), unit if n != 1 or L.code != 'en' else unit.rstrip('s')) for n, unit in ((h, hours), (mm, minutes), (ss, sec_word)) if n] yield m.start(), m.end(), " ".join(parts) or L.join_amount(L.cardinal(0), sec_word) def _m_time_verbalized(self, text): # "9 बजकर 15 मिनट" — already half-verbalized with digits: rebuild the # canonical phrase instead of reading the pieces separately L = self.lang MIN = self._minute_re pat = re.compile( rf"\b(\d{{1,2}})\s*(?:{self._marker_re})\s+(\d{{1,2}})\s*({MIN})(?!\w)") for m in pat.finditer(text): h, mm = int(m.group(1)), int(m.group(2)) if h > 23 or mm > 59: continue rep = self._time_words(h, mm, None) sfx = L.MARKER_SUFFIXES.get(m.group(3) or "") if sfx: rep = L.attach_postposition(rep, sfx) or rep yield m.start(), m.end(), rep def _m_time(self, text): pat = self._time_pattern() for m in pat.finditer(text): lead_p, h, mm, ampm, marker, trail_p = m.groups() if h is None: continue # must look like a time: needs marker, am/pm, period word, or a # valid HH:MM hh = int(h) is_hhmm = mm is not None and int(mm) <= 59 and hh < 24 if not (ampm or marker or ((lead_p or trail_p) and (is_hhmm or mm is None))): if not (is_hhmm and mm is not None and hh > 12): # bare h:mm with hh<=12: still a time if mm is 2-digit <=59 if not (is_hhmm and not self._keyword_before( text, m.start(), self._ratio_kws, window=4)): continue if mm is None and not (ampm or marker): continue # bare number is not a time if mm is not None and int(mm) > 59: continue if hh > 23: continue h12, mnt, period = self._resolve_time(lead_p, h, mm, ampm, marker, trail_p) src_period = lead_p or trail_p if src_period: rep = f"{src_period} {self.lang.time_phrase(h12, mnt, None)}" else: rep = self._time_words(h12, mnt, period) if ampm and self.lang.AMPM_WORDS: w = self.lang.AMPM_WORDS.get(ampm.lower().replace(".", "")) if w: # a bare hour with am/pm reads "eight p m", never # "eight o'clock p m" -- o'clock was not in the input if mnt == 0: rep = self.lang.two_digit(h12) rep = f"{rep} {w}" sfx = self.lang.MARKER_SUFFIXES.get(marker or "") if sfx: rep = self.lang.attach_postposition(rep, sfx) or rep yield m.start(), m.end(), rep def _m_time_range(self, text): # 11 AM – 3 PM / 9:00 – 17:00 / 11 AM to 3 PM stays "to" pat = self._time_pattern() rng = re.compile( rf"({pat.pattern})\s*[–\-]\s*({pat.pattern})", pat.flags) for m in rng.finditer(text): g = m.groups() left, right = g[0], g[7] gl, gr = g[1:7], g[8:14] if gl[1] is None or gr[1] is None: continue # each side must independently look like a time if not ((gl[3] or gl[4] or gl[2]) and (gr[3] or gr[4] or gr[2])): continue h1, m1, p1 = self._resolve_time(*gl) h2, m2, p2 = self._resolve_time(*gr) a = self._time_words(h1, m1, p1) b = self._time_words(h2, m2, p2) if self.lang.AMPM_WORDS: if gl[3]: a = (self.lang.two_digit(h1) if m1 == 0 else a) + " " + self.lang.AMPM_WORDS[gl[3].lower().replace('.', '')] if gr[3]: b = (self.lang.two_digit(h2) if m2 == 0 else b) + " " + self.lang.AMPM_WORDS[gr[3].lower().replace('.', '')] rep = _fmt(self.lang.RANGE_TO, a=a, b=b) tail = self.lang.RANGE_TO.split("{b}")[-1].strip() if tail and re.match(rf"\s*{re.escape(tail)}(?!\w)", text[m.end():]): rep = rep[: -len(tail)].rstrip() # source already says it yield m.start(), m.end(), rep # --- dates ----------------------------------------------------------- def _m_tamil_date(self, text): if self.lang.code not in ("en", "ta"): return months = "|".join(_TAMIL_MONTHS) # Only calendar-day suffixes. Do not pretend all Tamil number morphology # can be repaired by concatenating an English cardinal with a suffix. pat = rf"(? "five hundred to seven hundred rupees" L = self.lang pat = re.compile( rf"(? main + sub # unit ($84.20 -> 84 dollar 20 cent; ₹95.41 -> 95 rupees 41 paise) if "." in num and not scale and len(num.split(".", 1)[1]) == 2: ip, fp = num.split(".", 1) main = L.cardinal(int(ip or 0)) if key == "rupee" and L.SYM.get("paisa"): sub_word = L.SYM["paisa"] else: sub_word = S["cent"] if key != "rupee" else None if fp.strip("0") == "": out = L.join_amount(main, cur_word) elif sub_word: out = (f"{L.join_amount(main, cur_word)} " f"{L.join_amount(L.cardinal(int(fp)), sub_word)}") else: out = L.join_amount(L.decimal(int(ip or 0), fp), cur_word) else: words = self._number_words(num) if scale: sl = scale.lower() canon = L.SCALE_WORDS.get(scale) if canon is None and sl in _EN_SCALES: scale_word = scale # 1.5 million -> ... million dollar else: if canon is None: canon = ("crore" if sl in ("cr",) or "crore" in sl else "lakh" if "lakh" in sl else "thousand") scale_word = L.scale_noun(canon, num) out = L.join_amount(L.join_amount(words, scale_word), cur_word) case = m[6] if case: out = L.attach_postposition(out, case) or out else: out = L.join_amount(words, cur_word) sign = m[1] or m[3] if sign: out = f"{S['minus'] if sign == '-' else S['plus']} {out}" # Keep whitespace outside the matched amount. space = m[0][:1].isspace() or (m.start() > 0 and text[m.start() - 1].isalnum()) yield m.start(), m.end(), (" " if space else "") + out def _m_amount_context(self, text): # Explicit currency suffix/amount label wins over broad identifier hints. units = (r"rupees?|rs\.?|inr|dollars?|usd|euros?|pounds?|paise|paisa|cents?|" r"ரூபாய்|பைசா|रुपये|रुपया|रुपए|రూ\.?|రూపాయలు|രൂപ") units += "|" + "|".join(map(re.escape, self.lang.CURRENCY_WORDS)) for m in re.finditer(rf"(? "40 to 70 percent" L = self.lang for m in re.finditer(r"\b(\d+(?:\.\d+)?)\s*[–\-]\s*(\d+(?:\.\d+)?)\s*%", text): a = self._number_words(m.group(1)) b = self._number_words(m.group(2)) yield m.start(), m.end(), \ f"{_fmt(L.RANGE, a=a, b=b)} {L.SYM['percent']}" def _m_percent(self, text): L = self.lang for m in re.finditer(r"(-?)(\d+(?:,\d{2,3})*(?:\.\d+)?)\s*%", text): words = self._number_words(m.group(2)) if m.group(1): words = f"{L.SYM['minus']} {words}" yield m.start(), m.end(), f"{words} {L.SYM['percent']}" # --- designations (Sony WH-1000XM5, Kepler-186f, Soyuz T-11) ----------- def _m_designation(self, text): # TitleCase brand fused: Kepler-186f, Salyut-7 for m in re.finditer(r"\b([A-Z][a-z]{2,})-([A-Za-z0-9]+)\b", text): seg = m.group(2) if not re.search(r"\d", seg): continue if self._month_index(seg) or self._month_index(m.group(1)): continue yield m.start(), m.end(), \ f"{m.group(1)} {self._spell_model_segment(seg)}" # Brand word + short designation: Sony WH-1000XM5, Soyuz T-11 pat = re.compile( r"\b([A-Z][a-z]{2,})\s+([A-Z]{1,4}-[A-Za-z0-9]+)\b") for m in pat.finditer(text): brand = m.group(1) if brand.lower() in self._id_keywords or brand.lower() in self._label_words: continue seg = m.group(2) if not re.search(r"\d", seg): continue yield (m.start(2), m.end(2), self._spell_model_segment(seg)) # --- alphanumeric codes ------------------------------------------------- def _m_code(self, text): # hyphenated tokens containing at least one letter and one digit pat = re.compile(r"#?\b(?=[A-Za-z0-9/-]*\d)(?=[A-Za-z0-9/-]*[A-Za-z])" r"[A-Za-z0-9]+(?:[-/][A-Za-z0-9]+)+(?![A-Za-z0-9])") for m in pat.finditer(text): tok = m.group(0).lstrip("#") # date like 12-Aug-2025 -> handled by date matcher (higher prio) parts = re.split(r"[-/]", tok) if (any(self._month_index(p) for p in parts) and all(p.isdigit() for p in parts if not self._month_index(p))): continue # date-like 12-Aug-2025; MAY-17Y7 is still a code # a lowercase word segment means this is prose, not a code # (900-point, 80-watt) -> handled by num-hyphen-word matcher if any(re.fullmatch(r"[a-z][a-z]+|[A-Z][a-z]{2,}", p) for p in parts): continue # prose word segment (900-point, 7-East) if (len(parts) == 2 and parts[0].isdigit() and parts[1].isalpha() and len(parts[1]) >= 3): continue # 30-DAY, 24-HOUR -> number + word # 15-GB -> unit matcher if (len(parts) == 2 and parts[0].isdigit() and parts[1].upper() in _UNIT_INITIALISMS): continue # Hall 3-A / Window 5-B -> label matcher (but CR-2A stays a code) if (re.fullmatch(r"\d{1,3}-?[A-Za-z]", tok) and self._keyword_before(text, m.start(), self._label_words, window=2)): continue if sum(c.isalnum() for c in tok) <= 2: continue yield m.start(), m.end(), self._spell_alnum(tok, comma=True) # solid mixed tokens (length>=5, >=2 digits): MH12AB8821, 77K2L9 pat2 = re.compile(r"\b(?=(?:[A-Za-z]*\d){2})(?=[A-Za-z0-9]*[A-Za-z])" r"[A-Za-z0-9]{5,}\b") for m in pat2.finditer(text): tok = m.group(0) if re.fullmatch(r"\d+s", tok): continue # 1980s -> decade matcher if re.fullmatch(r"\d+[x×]\d+", tok): continue # 1920x1080 -> dimensions matcher m2 = re.fullmatch(r"\d+([A-Za-z]{1,4})", tok) if m2 and m2.group(1).upper() in _UNIT_INITIALISMS: continue # 250ml -> unit matcher if re.fullmatch(r"(?:[A-Z][a-z]?\d*)+", tok) and self._chem_ok(tok): continue # chemistry wins yield m.start(), m.end(), self._spell_alnum(tok, comma=True) def _m_vehicle_plate(self, text): # spaced Indian plates: AP 09 CL 4421 / TS 09 EA 1234 L = self.lang pat = re.compile(r"\b([A-Z]{2})[ -](\d{1,2})[ -]([A-Z]{1,3})[ -](\d{3,4})\b") for m in pat.finditer(text): out = [] for grp in m.groups(): for ch in grp: out.append(L.digit(int(ch)) if ch.isdigit() else L.letter(ch)) yield m.start(), m.end(), ", ".join(out) # --- identifier digit runs ---------------------------------------------- def _m_long_digit_runs(self, text): if os.environ.get("FR3_TN_LONG_DIGITS", "0") != "1": return if __package__: from .identifier_rules import long_runs else: from identifier_rules import long_runs reserved = [(s, e) for matcher in (self._m_percent_range, self._m_percent, self._m_negative) for s, e, _ in matcher(text)] for s, e, spoken in long_runs(text, self.lang): if not any(s < end and e > start for start, end in reserved): yield s, e, spoken def _m_id_digits(self, text): L = self.lang # hyphen/space separated digit groups, or long runs, or # prefixed pat = re.compile(r"(\+?)(?:#?\b\d+(?:[-\s]\d+)+\b|#\s?\d{3,}\b" r"|(?= 8: is_id = True # +91 ... country-code phone elif m.group(0).startswith("#"): is_id = True elif len(digits) >= 7 and " " not in tok: is_id = True elif any(len(g) >= 4 for g in groups) and len(groups) >= 2 and "-" in tok: is_id = True elif len(groups) >= 3 and "-" in tok: is_id = True elif self._keyword_before(text, m.start(), self._id_keywords): is_id = True else: a_toks = re.findall(r"[\w#ऀ-෿']+", _sentence_after(text, m.end()).lower())[:4] if any(t in self._id_keywords for t in a_toks): is_id = True if not is_id: continue # ranges like 10-20 without keyword: ascending small pairs -> skip a_toks2 = re.findall(r"[\w#ऀ-෿']+", _sentence_after(text, m.end()).lower())[:4] if ("-" in tok and len(groups) == 2 and all(len(g) <= 3 for g in groups) and int(groups[0]) < int(groups[1]) and not self._keyword_before(text, m.start(), self._id_keywords) and not any(t in self._id_keywords for t in a_toks2)): continue rep = self._digits_spelled(digits, comma=True) if has_plus: rep = f"{L.SYM['plus']} {rep}" yield m.start(), m.end(), rep # keyword-driven short runs: OTP 4471, code 22377, train 207, # "ending in 7104" for m in re.finditer(r"(?= 2 or (bool(digits) and tok in self._CHEM_WHITELIST) def _m_chem(self, text): L = self.lang for m in re.finditer(r"\b(?:[A-Z][a-z]?\d*){1,8}\b", text): tok = m.group(0) if len(tok) < 2 or tok.upper() in _ACRONYM_WHITELIST: continue if not self._chem_ok(tok): continue out = [] for piece in re.findall(r"[A-Z][a-z]?|\d+", tok): if piece.isdigit(): out.append(L.cardinal(int(piece))) else: out.append(self._letters_spelled(piece.upper())) yield m.start(), m.end(), " ".join(out) # --- math ------------------------------------------------------------- def _m_comparison(self, text): # Indic comparative phrases are postpositions: '<500' -> '500 से कम', # not 'से कम 500'. Treat the numeric bound as one semantic span. L = self.lang number = r"[+-]?\d+(?:,\d{2,3})*(?:\.\d+)?" operand = rf"(?:₹\s*)?{number}(?:\s*%)?" pattern = rf"(?{operand})\s*)?(?P<=|>=|≤|≥|<|>)\s*(?P{operand})" def words(token): s = token.strip() if s.startswith("₹"): return L.join_amount(self._number_words(s[1:].strip()), L.SYM["rupee"]) if s.endswith("%"): return f"{self._number_words(s[:-1].strip())} {L.SYM['percent']}" return self._number_words(s) ors = {"hi": "या", "te": "లేదా", "ta": "அல்லது", "bn": "বা", "gu": "અથવા", "kn": "ಅಥವಾ", "ml": "അല്ലെങ്കിൽ", "mr": "किंवा", "or": "କିମ୍ବା", "pa": "ਜਾਂ"} for m in re.finditer(pattern, text): op = m['op'] comparison = L.SYM["lt" if op.startswith("<") or op == "≤" else "gt"] if len(op) > 1 or op in ("≤", "≥"): comparison += " or equal to" if L.code == "en" else f" {ors[L.code]} {L.SYM['equals']}" left = words(m['left']) + " " if m['left'] else "" right = words(m['right']) yield m.start(), m.end(), left + (f"{comparison} {right}" if L.code == "en" else f"{right} {comparison}") def _m_math(self, text): L = self.lang S = L.SYM # power: 10^23 / x^2 for m in re.finditer(r"(\d+(?:\.\d+)?|[A-Za-z])\s*\^\s*(\d+|[A-Za-z])", text): base, exp = m.group(1), m.group(2) bw = self._number_words(base) if base[0].isdigit() else L.letter(base) ew = self._number_words(exp) if exp[0].isdigit() else L.letter(exp) yield m.start(), m.end(), _fmt(L.POWER, base=bw, exp=ew) # squared ² for m in re.finditer(r"(\d+(?:\.\d+)?|[A-Za-z])²", text): v = m.group(1) vw = self._number_words(v) if v[0].isdigit() else L.letter(v) yield m.start(), m.end(), f"{vw} {S['squared']}" # √ for m in re.finditer(r"√\s*(\d+(?:\.\d+)?)", text): yield m.start(), m.end(), f"{S['sqrt']} {self._number_words(m.group(1))}" # operator chains: x + 5 = 12, E = mc^2, 17 × 3 = 51 — rendered only # when a strong math operator (= × ÷ ^ √) is present, so hyphen # ranges/ids and prose like "e-mail" never match eq = re.compile( r"(? "fifty fifty" yield m.start(), m.end(), f"{L.cardinal(a)} {L.cardinal(b)}" continue if a > b: if re.match(r"\s+(?:days?|months?|years?|hours?|minutes?)\b", text[m.end():], re.I): yield m.start(), m.end(), _fmt(L.RANGE, a=L.cardinal(a), b=L.cardinal(b)) continue # score-style pair: "3-2 से जीते" -> "तीन दो" yield m.start(), m.end(), f"{L.cardinal(a)} {L.cardinal(b)}" continue yield m.start(), m.end(), \ _fmt(L.RANGE, a=L.cardinal(a), b=L.cardinal(b)) # --- dimensions / "x" as times: 24x7, 1920x1080, 6 x 4 ------------------- def _m_dimensions(self, text): L = self.lang for m in re.finditer( r"\b(\d+(?:\.\d+)?)\s*[x×]\s*(\d+(?:\.\d+)?)\b", text): if m.group(1) == "0": continue # hex literals like 0x1F yield m.start(), m.end(), \ f"{self._number_words(m.group(1))} {L.SYM['times']} " \ f"{self._number_words(m.group(2))}" # lone x between something and a number (higher-priority matcher may # have consumed the left operand: "₹4,583.33 x 24") for m in re.finditer(r"(?<=\s)[x×](?=\s\d)", text): yield m.start(), m.end(), L.SYM["times"] # --- Roman numerals after label words: Class XII, World War II ---------- _ROMAN_VALS = {"I": 1, "V": 5, "X": 10, "L": 50, "C": 100, "D": 500, "M": 1000} def _roman_value(self, s): total = 0 for i, ch in enumerate(s): v = self._ROMAN_VALS[ch] if i + 1 < len(s) and self._ROMAN_VALS[s[i + 1]] > v: total -= v else: total += v return total def _m_roman(self, text): L = self.lang # {2,7}: single letters stay letters (Counter 2-C reads "C", not 100) pat = re.compile( r"\b(?=[IVXLCDM]{2,7}\b)M{0,3}(?:CM|CD|D?C{0,3})" r"(?:XC|XL|L?X{0,3})(?:IX|IV|V?I{0,3})\b") ctx = self._label_words | {"war", "vol", "part", "act", "book", "youdh", "युद्ध", "अध्याय", "कक्षा", "भाग", "खंड"} for m in pat.finditer(text): tok = m.group(0) if not tok: continue # only with a label-ish word right before — avoids XL sizes, # initials, and words like "MIX" if not self._keyword_before(text, m.start(), ctx, window=2): continue yield m.start(), m.end(), L.cardinal(self._roman_value(tok)) # --- K/M/B suffixes glued to numbers: 3.4K, 1.2M ------------------------- def _m_kmb_suffix(self, text): L = self.lang for m in re.finditer(r"(? time matcher takes it if len(mm) == 2 and int(mm) <= 59 and not self._keyword_before( text, m.start(), self._ratio_kws, window=4): continue after = text[m.end():m.end() + 20].strip() if any(after.startswith(w) for w in self._ratio_kws if not w.isascii()): # source already says the ratio word ("1:3 నిష్పత్తిలో") yield m.start(), m.end(), \ f"{L.cardinal(int(m.group(1)))} {L.cardinal(int(mm))}" continue yield m.start(), m.end(), \ _fmt(L.RATIO, a=L.cardinal(int(m.group(1))), b=L.cardinal(int(mm))) # --- labels (Room 314, Hall 3-A, Seat 23A, coach B4) -------------------- def _m_label(self, text): L = self.lang lbl = "|".join(re.escape(w) for w in sorted(self._label_words, key=len, reverse=True)) pat = re.compile( rf"\b({lbl})\s+(?:(\d+)(?![\d.]|,\d)(?!-[A-Za-z]{{2,}})(?:[-\s]?([A-Za-z])\b)?|([A-Za-z])\b(?:[-\s]?(\d+))?)", re.IGNORECASE) for m in pat.finditer(text): word = m.group(1) out = [word] if m.group(2): out.append(L.cardinal(int(m.group(2)))) if m.group(3): out.append(L.letter(m.group(3))) elif m.group(4): if len(m.group(4)) == 1: out.append(L.letter(m.group(4))) else: out.append(m.group(4)) if m.group(5): out.append(L.cardinal(int(m.group(5)))) yield m.start(), m.end(), " ".join(out) # --- ALLCAPS model prefix + number: "XPS 15" ---------------------------- def _m_acronym_model(self, text): L = self.lang for m in re.finditer(r"\b([A-Z]{2,4})\s+(\d{1,3})(?![\d.])", text): tok = m.group(1) if tok in _ACRONYM_WHITELIST or tok.upper() in _UNIT_INITIALISMS: continue if tok.lower() in self._id_keywords or tok.lower() in self._label_words: continue yield m.start(), m.end(), \ f"{self._letters_spelled(tok)} {L.cardinal(int(m.group(2)))}" # --- unit initialisms after numbers: 1.5 GB, 5,000 mAh ------------------ def _m_unit_initialism(self, text): L = self.lang units = "|".join(sorted(_UNIT_INITIALISMS, key=len, reverse=True)) pat = re.compile( rf"(? "eighty's decade" (spec) yield m.start(), m.end(), L.decade(int(m.group(2)) * 10, full=True) else: # 2020s -> full year words + decade template y = int(m.group(0)[:4]) yield m.start(), m.end(), _fmt(L.DECADE_FULL, tens=L.year(y)) for m in re.finditer(r"(? चौदह सौ बाईस) after = text[m.end():m.end() + 30].strip().lower() first_words = after.split()[:2] if (len(digits) == 4 and digits[0] != "0" and any(w.strip(".,") in _STREET_WORDS for w in first_words)): yield m.start(), m.end(), L.year(int(digits)) continue if digits.startswith("0") and len(digits) > 1: yield m.start(), m.end(), self._digits_spelled(digits, comma=True) continue yield m.start(), m.end(), L.cardinal(int(digits)) # ------------------------------------------------------------------ # Final sweep: leftover meaningful symbols -> words; decorative junk out # ------------------------------------------------------------------ def _final_sweep(self, text): S = self.lang.SYM text = re.sub(r"<=|≤", " less than or equal to ", text) text = re.sub(r">=|≥", " greater than or equal to ", text) text = re.sub(r"°\s*C\b", f" {S['celsius']} ", text) text = re.sub(r"°\s*F\b", f" {S['fahrenheit']} ", text) text = text.replace("°", f" {S['degree']} ") rep = {"%": S["percent"], "@": S["at"], "&": S["amp"], "=": S["equals"], "+": S["plus"], "×": S["times"], "÷": S["divide"], "±": S["plus_minus"], "√": S["sqrt"], "_": S["underscore"], "#": S["hash"]} for sym, word in rep.items(): text = text.replace(sym, f" {word} ") text = text.replace("<", f" {S['lt']} ").replace(">", f" {S['gt']} ") text = re.sub(r"[\^*]", " ", text) # slashes not consumed by URL/fraction/date/code matchers are junk text = re.sub(r"\s*/\s*", " ", text) # clause colons read as a pause ("అడ్రస్: Door" -> "అడ్రస్, Door") text = re.sub(r"\s*:\s*", ", ", text) text = re.sub(r"[₹$€£](?!\s?\d)", " ", text) # bare currency signs text = re.sub(r"[–—]", " ", text) return text # ------------------------------------------------------------------ # Fallback: absolutely no digits may remain # ------------------------------------------------------------------ def _digit_fallback(self, text): def repl(m): d = m.group(0) if len(d) >= 7 or d.startswith("0"): return self._digits_spelled(d, comma=True) return self.lang.cardinal(int(d)) return re.sub(r"\d+", repl, text) # ------------------------------------------------------------------ def normalize(self, text): validate_text(text, date_order=self.date_order, two_digit_year_base=self.two_digit_year_base, language=self.lang.code) # URLs/emails are opaque identifiers. Protect them before prose cleanup # can expand Dr/No inside a path or turn // and parentheses into pauses. raw = _ascii_digits(text) protected, originals, spans = {}, {}, [] identifiers = (self._m_email, self._m_url) if os.environ.get("FR3_TN_URL_SPELL", "0") == "1": identifiers += (self._m_handle,) for matcher in identifiers: for s, e, spoken in matcher(raw): if not any(s < b and e > a for a, b, _ in spans): key = chr(0xF0000 + len(protected)) protected[key] = spoken originals[key] = raw[s:e] spans.append((s, e, key)) for s, e, key in sorted(spans, reverse=True): raw = raw[:s] + key + raw[e:] text = raw text = self._preclean(text) try: matches = self._gather(text) except NormalizationError as exc: if exc.span is not None: start, end = exc.span pieces = [text[:start], text[start:end], text[end:]] # Parser coordinates refer to precleaned text containing opaque # URI placeholders. Restore their source text before retrying. for key, original in originals.items(): pieces = [piece.replace(key, original) for piece in pieces] exc.source = "".join(pieces) exc.span = (len(pieces[0]), len(pieces[0]) + len(pieces[1])) raise # priority asc, then leftmost, then longest matches.sort(key=lambda t: (t[0], t[1], -(t[2] - t[1]))) taken = [] accepted = [] for prio, s, e, rep in matches: if any(not (e <= ts or s >= te) for ts, te in taken): continue taken.append((s, e)) accepted.append((s, e, rep)) accepted.sort(key=lambda t: t[0]) out = [] pos = 0 for s, e, rep in accepted: out.append(text[pos:s]) pos = e # native postposition right after the span (spaced OR attached: # "గంటలు కి" / "6:30కి") -> language attaches it with sandhi while True: nxt = re.match(r"[ \t]*-?([^\s\d,।.!?;:()\-—–]{1,14})", text[pos:]) if not nxt: break if text[pos + nxt.end(): pos + nxt.end() + 1] == ".": break # abbreviation like కి.మీ — not a postposition merged = self.lang.attach_postposition(rep, nxt.group(1)) if merged is None: break rep = merged pos += nxt.end() out.append(rep) out.append(text[pos:]) result = "".join(out) result = self._final_sweep(result) result = self._digit_fallback(result) for key, spoken in protected.items(): result = result.replace(key, spoken) # A foreign cardinal must never be glued into an Indic suffix (fiveన). result = re.sub(r"(?<=[A-Za-z])(?=[\u0900-\u0DFF])", " ", result) # tidy whitespace / punctuation spacing result = re.sub(r"\s+([,।.!?;])", r"\1", result) result = re.sub(r",\s*([।.!?;,])", r"\1", result) result = re.sub(r",(?=\S)", ", ", result) # space after every comma result = re.sub(r"\s{2,}", " ", result) result = re.sub(r"\s+’", "’", result) result = re.sub(r"[,\s]+$", ".", result) if result.rstrip().endswith(",") else result if self.lang.code == "hi": result = result.translate(_HINDI_NUKTA_FOLD) return result.strip() # --------------------------------------------------------------------------- # English table (the only language table) # --------------------------------------------------------------------------- _ONES_0_19 = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen", "nineteen"] _TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"] def _two_digit(n): """0..99 -> 'zero' .. 'ninety nine'. No hyphen: the model reads words.""" if n < 20: return _ONES_0_19[n] t, o = divmod(n, 10) return _TENS[t] if not o else f"{_TENS[t]} {_ONES_0_19[o]}" _ORDINAL_IRREGULAR = {1: "first", 2: "second", 3: "third", 5: "fifth", 8: "eighth", 9: "ninth", 12: "twelfth"} def _ordinal(n): if n in _ORDINAL_IRREGULAR: return _ORDINAL_IRREGULAR[n] w = _two_digit(n) if n < 100 else None if w is None: return None # twenty-first, thirty-second ... take the irregular form on the last digit tail = n % 10 if n >= 20 and tail in _ORDINAL_IRREGULAR: return f"{_TENS[n // 10]} {_ORDINAL_IRREGULAR[tail]}" if w.endswith("y"): # twenty -> twentieth return w[:-1] + "ieth" return w + "th" class English(LangBase): code = "en" name = "English" native_digits = "0123456789" # English uses ASCII digits ONES = [_two_digit(i) for i in range(100)] HUNDRED = "hundred" THOUSAND = "thousand" LAKH = "lakh" CRORE = "crore" # Letter names are already English, so each maps to itself. LETTERS = {chr(65 + i): chr(65 + i) for i in range(26)} MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] SYM = { "rupee": "rupees", "dollar": "dollars", "euro": "euros", "pound": "pounds", "cent": "cents", "percent": "percent", "point": "point", "minus": "minus", "plus": "plus", "equals": "equals", "times": "times", "divide": "divided by", "squared": "squared", "sqrt": "square root of", "lt": "less than", "gt": "greater than", "plus_minus": "plus or minus", "at": "at", "underscore": "underscore", "dot": "dot", "slash": "slash", "hash": "hash", "amp": "and", "half": "half", "quarter": "quarter", "three_quarters": "three quarters", "degree": "degrees", "celsius": "celsius", "fahrenheit": "fahrenheit", "minute": "minutes", } TIME_ON_HOUR = "{h} o'clock" TIME_HM = "{h} {m}" # "ten thirty" — how a clock is read aloud RANGE = "{a} to {b}" RANGE_TO = "{a} to {b}" RATIO = "{a} to {b}" POWER = "{base} to the power {exp}" FRACTION = "{num} over {den}" DECADE_SHORT = "{tens}s" DECADE_FULL = "{tens}s" PERIODS = {"morning": "in the morning", "afternoon": "in the afternoon", "evening": "in the evening", "night": "at night"} # ---- vocabulary the engine must recognise in the INPUT ---------------- TIME_MARKERS = ["o'clock", "oclock"] MARKER_SUFFIXES = {} PERIOD_DETECT = {"morning": "morning", "afternoon": "afternoon", "evening": "evening", "night": "night", "am": "morning", "a.m.": "morning", "pm": "afternoon", "p.m.": "afternoon"} YEAR_WORDS = ["year", "years"] # Deliberately NOT "order"/"invoice"/"ticket" on their own: "your order is 250 # rupees" is a price, and listing them made it read "two, five, zero". They only # mark an identifier in the explicit "order number ..." form, which "number" catches. # One normalizer serves every language, so an identifier must be recognised # whatever script the sentence around it is written in. Without the native # words, "ऑर्डर नंबर 47291" reads as a cardinal instead of digit-by-digit. ID_KEYWORDS = ["number", "pin", "otp", "pnr", "imei", "ifsc", "account", "नंबर", "नम्बर", "खाता", "खाते", "कोड", "ऑर्डर", "फ़ोन", "फोन", "मोबाइल", "నంబర్", "నంబరు", "నెంబర్", "నెంబరు", "ఖాతా", "ఫోన్", "మొబైల్", "కోడ్", "ఆర్డర్", "ఎక్స్ప్రెస్", "express", "అకౌంట్", "టికెట్", "రిజిస్ట్రేషన్", "యూజర్", "వాయిదా", "అంకె", "లావాదేవీ", "పిన్", "எண்", "நம்பர்", "கணக்கு", "தொலைபேசி", "போன்", "மொபைல்", "ஆர்டர்", "ஓடிபி", "பின்", "ஐடி", "கடவுச்சொல்", "ரயில்", "எக்ஸ்பிரஸ்", "பிஎன்ஆர்", "கார்டு", "குறியீடு", "டிக்கெட்", "கணக்கில்", "யூசர்", "தவணை", "இலக்கம்", "பரிவர்த்தனை", "ನಂಬರ್", "ಸಂಖ್ಯೆ", "ಖಾತೆ", "ಕೋಡ್", "ಆರ್ಡರ್", "ಫೋನ್", "ಮೊಬೈಲ್", "ಐಡಿ", "ಪಿನ್", "ಪಾಸ್‌ವರ್ಡ್", "ರೈಲು", "ಎಕ್ಸ್‌ಪ್ರೆಸ್", "ಪಿಎನ್‌ಆರ್", "ಕಾರ್ಡ್", "ರೆಫರೆನ್ಸ್", "ಟ್ರಾನ್ಸಾಕ್ಷನ್", "ಟಿಕೆಟ್", "ಓಟಿಪಿ", "ಅಕೌಂಟ್", "ಅಂಕಿ", "ಬಳಕೆದಾರ", "ಕಂತು", "ವಹಿವಾಟು", "നമ്പർ", "നമ്പര്", "നമ്പറിൽ", "അക്കൗണ്ട്", "കോഡ്", "ഓർഡർ", "ഫോൺ", "മൊബൈൽ", "ഒടിപി", "പിൻ", "ഐഡി", "പാസ്‌വേഡ്", "ട്രെയിൻ", "എക്സ്പ്രസ്", "പിഎൻആർ", "കാർഡ്", "റഫറൻസ്", "ട്രാൻസാക്ഷൻ", "ടിക്കറ്റ്", "അക്കൗണ്ടിൽ", "നമ്പറിലേക്ക്", "യൂസർ", "തവണ", "അക്കം", "ഇടപാട്", "क्रमांक", "खात्याचा", "खात्यावर", "मोबाईल", "अकाउंट", "अकाउंटसाठी", "अकाउंटमधून", "खात्यातून", "खात्यात", "खात्याची", "पिन", "आयडी", "पासवर्ड", "ट्रेन", "एक्सप्रेस", "पीएनआर", "कार्ड", "रेफरन्स", "ट्रांझॅक्शन", "तिकीट", "ओटीपी", "गाडी", "युजर", "यूजर", "हप्ता", "हप्त्याचा", "अंक", "व्यवहार", "ट्रान्झॅक्शन", "क्र", "क्रं", "चेक", "धनादेश", "নম্বর", "নাম্বার", "অ্যাকাউন্ট", "একাউন্ট", "কোড", "অর্ডার", "ফোন", "মোবাইল", "ওটিপি", "পিন", "আইডি", "পাসওয়ার্ড", "ট্রেন", "এক্সপ্রেস", "পিএনআর", "কার্ড", "রেফারেন্স", "ট্রানজেকশন", "টিকিট", "অ্যাকাউন্টের", "নম্বরে", "আইডিতে", "ইউজার", "ইনস্টলমেন্ট", "কিস্তি", "ডিজিট", "অঙ্ক", "লেনদেন", "નંબર", "નમ્બર", "ખાતા", "ખાતું", "ખાતાં", "કોડ", "ઓર્ડર", "ફોન", "મોબાઇલ", "મોબાઈલ", "એકાઉન્ટ", "એકાઉન્ટમાં", "એકાઉન્ટનો", "પિન", "આઈડી", "પાસવર્ડ", "ટ્રેન", "એક્સપ્રેસ", "પીએનઆર", "કાર્ડ", "રેફરન્સ", "ટ્રાન્ઝેક્શન", "ટિકિટ", "ઓટીપી", "નંબરમાં", "યુઝર", "હપ્તો", "અંક", "ડિજિટ", "ਨੰਬਰ", "ਖਾਤਾ", "ਖਾਤੇ", "ਕੋਡ", "ਆਰਡਰ", "ਫ਼ੋਨ", "ਫੋਨ", "ਮੋਬਾਈਲ", "ਮੋਬਾਇਲ", "ਅਕਾਊਂਟ", "ਅਕਾਊਂਟਵਿੱਚ", "ਪਿੰਨ", "ਆਈਡੀ", "ਪਾਸਵਰਡ", "ਟ੍ਰੇਨ", "ਐਕਸਪ੍ਰੈਸ", "ਪੀਐਨਆਰ", "ਕਾਰਡ", "ਰੈਫਰੈਂਸ", "ਟਿਕਟ", "ਓਟੀਪੀ", "ਯੂਜ਼ਰ", "ਕਿਸ਼ਤ", "ਅੰਕ", "ਲੈਣ-ਦੇਣ", "ନମ୍ବର", "ନମ୍ବର୍", "ଖାତା", "ଆକାଉଣ୍ଟ", "କୋଡ୍", "ଅର୍ଡର", "ଫୋନ୍", "ଫୋନ", "ମୋବାଇଲ୍", "ମୋବାଇଲ", "ଓଟିପି", "ପିନ୍", "ଆଇଡି", "ପାସୱାର୍ଡ", "ଏକ୍ସପ୍ରେସ", "ପିଏନ୍‌ଆର୍", "କାର୍ଡ", "ରେଫରେନ୍ସ", "ଟିକେଟ", "ଆକାଉଣ୍ଟରୁ", "ଟ୍ରେନ୍", "ୟୁଜର", "କିସ୍ତି", "ଅଙ୍କ", "ଲାଣଦେଣ"] ID_AFTER_WORDS = ["number", "code", "खत्म", "डायल", "कॉल", "संपर्क", "ఎండ్", "ముగిసే", "ముగుస్తుంది", "చివర", "చివరి", "అంకెలతో", "డయల్", "పిన్", "పిన్‌కోడ్", "pin", "pincode", "కాల్", "సంప్రదించండి", "டயல்", "அழை", "தொடர்பு", "ಡಯಲ್", "ಕರೆ", "ಸಂಪರ್ಕ", "ডায়াল", "কল", "যোগাযোগ", "ડાયલ", "કૉલ", "સંપર્ક", "ਡਾਇਲ", "ਕਾਲ", "ਸੰਪਰਕ", "ଡାଏଲ", "କଲ", "ଯୋଗାଯୋଗ"] LABEL_WORDS = ["room", "flat", "floor", "gate", "platform", "seat", "section", "chapter", "page", "block", "phase"] MINUTE_WORDS = ["minute", "minutes", "min", "mins"] SCALE_WORDS = {"crore": "crore", "crores": "crore", "cr": "crore", "lakh": "lakh", "lakhs": "lakh", "lac": "lakh", "thousand": "thousand", "thousands": "thousand", "k": "thousand"} CURRENCY_WORDS = {"rupee": "rupee", "rupees": "rupee", "rs": "rupee", "inr": "rupee", "dollar": "dollar", "dollars": "dollar", "usd": "dollar", "euro": "euro", "euros": "euro", "pound": "pound", "pounds": "pound", "paisa": "cent", "paise": "cent", "cent": "cent", "cents": "cent"} # Unit initialisms spoken as full words instead of letters. The engine # falls back to letter-spelling ("K G") when a key is absent, which is # right for GB/MB but wrong for the metric units customers actually say. UNIT_WORDS = { "KG": "kilograms", "KM": "kilometers", "CM": "centimeters", "MM": "millimeters", "GM": "grams", "MG": "milligrams", "ML": "millilitres", "MCG": "micrograms", "KMPH": "kilometers per hour", "MPH": "miles per hour", "KWH": "kilowatt hours", "RPM": "R P M", } # Written-only abbreviations expanded wherever they appear. Case-insensitive, # guarded by word boundaries, so "Drew" and "MIN-22" are untouched. ABBREV_UNITS = { "MINS": "minutes", "MIN": "minutes", "HRS": "hours", "SECS": "seconds", "DR": "doctor", "MR": "mister", "MRS": "missus", "MS": "miss", "PROF": "professor", "LTD": "limited", "PVT": "private", "A/C": "account", "APPROX": "approximately", "ETA": "E T A", } # "No. 12" -> "number twelve" NUMBER_WORD = "number" # English transcribes the am/pm that was written; it never invents a period. # "6.45 pm" -> "six forty five p m", NOT "six forty five in the evening". AMPM_WORDS = {"am": "a m", "pm": "p m"} ORDINAL_EXCEPTIONS = {n: _ordinal(n) for n in range(1, 100) if _ordinal(n)} ORDINAL_SUFFIX = "th" ORDINAL_INPUT_SUFFIXES = ["st", "nd", "rd", "th"] def time_phrase(self, h, m, period_key=None): """English puts the period AFTER the clock time — "five in the evening", not the base class's Indo-Aryan "in the evening five" (शाम पाँच बजे).""" if h == 0: core = "zero " + ("hundred" if m == 0 else self.two_digit(m)) else: core = super().time_phrase(h, m, None) return f"{core} {self.PERIODS[period_key]}" if period_key else core def _ordinal_compose(self, n, src_suffix=""): if n < 100: return "zeroth" if n == 0 else _ordinal(n) for scale, word in ((10**7, self.CRORE), (10**5, self.LAKH), (1000, self.THOUSAND), (100, self.HUNDRED)): if n >= scale: count, rest = divmod(n, scale) head = f"{self.cardinal(count)} {word}" return f"{head} {self.ordinal(rest)}" if rest else head + "th" LANG = English() _NORMALIZER = Normalizer(LANG) @lru_cache(maxsize=66) def _engine(language, date_order, two_digit_year_base): return Normalizer(language_table(language), date_order=date_order, two_digit_year_base=two_digit_year_base) def normalize(text, language="en-IN", *, date_order="DMY", two_digit_year_base=None): """Locale-specific numbers and symbols; prose is preserved, never translated. Omitted language retains Indian English for compatibility. Use an explicit language/locale for Indic speech. language="auto" selects majority-script inference with a guard against introducing foreign-script expansions. """ auto = isinstance(language, str) and language == "auto" original = text if auto: if __package__: from .script_guard import detect_language, guard_output else: from script_guard import detect_language, guard_output language = detect_language(text) if isinstance(text, str) else "en-IN" code = resolve_language(language) # Validate before accessing the cache, including unhashable option values. warning = None while True: try: validate_text(text, date_order=date_order, two_digit_year_base=two_digit_year_base, language=code) result = _engine(code, date_order, two_digit_year_base).normalize(text) break except NormalizationError as exc: # Request type/options are not text tokens and cannot be rendered. if os.environ.get("FR3_TN_STRICT", "0") == "1" or exc.span is None: raise warning = warning or exc.code source = exc.source if exc.source is not None else text start, end = exc.span token = source[start:end] locale = language_table(code) symbols = {"/": "slash", ".": "dot", "-": "minus", "+": "plus", "@": "at", "_": "underscore", "₹": "rupee", "$": "dollar", "%": "percent"} words = [] for char in token: if char.isdecimal(): words.append(" " + locale.ONES[unicodedata.decimal(char)] + " ") elif char in symbols: words.append(" " + locale.SYM[symbols[char]] + " ") elif char in ",:;<>{}" or unicodedata.category(char) in ("Cc", "Co"): words.append(" ") else: words.append(char) replacement = "".join(words) if replacement == token: raise # no progress: do not loop on a new, unhandled error text = source[:start] + " " + replacement + " " + source[end:] if warning: # One privacy-safe observation per normalization call, never caller text. try: import obs obs.event("normalization_fallback", level="warn", code=warning) except ImportError: print("[obs] " + json.dumps({"event": "normalization_fallback", "level": "warn", "code": warning}), flush=True) return guard_output(original, result, language_table(code)) if auto else result if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("text", nargs="?", help="Plain text; otherwise read stdin.") parser.add_argument("--date-order", choices=("DMY", "MDY"), default="DMY") parser.add_argument("--language", default="en-IN", help="Language code/name, including hi, te and en-IN.") parser.add_argument("--two-digit-year-base", type=int, choices=(1900, 2000)) args = parser.parse_args() try: print(normalize(args.text if args.text is not None else sys.stdin.read(), language=args.language, date_order=args.date_order, two_digit_year_base=args.two_digit_year_base)) except NormalizationError as exc: print(f"{exc.code}: {exc}", file=sys.stderr) sys.exit(2)