Instructions to use zero0303/maya2-mlx-dynamic-8bit with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- MLX
How to use zero0303/maya2-mlx-dynamic-8bit with MLX:
# Download the model from the Hub pip install huggingface_hub[hf_xet] hf download zero0303/maya2-mlx-dynamic-8bit --local-dir maya2-mlx-dynamic-8bit
- Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- LM Studio
- Atomic Chat
Download maya3/text/tn/normalize.py from zero0303/maya2-mlx-dynamic-8bit: direct link, hf CLI and curl.
- Browser
- Download file 114 kB
-
https://huggingface.co/zero0303/maya2-mlx-dynamic-8bit/resolve/main/maya3/text/tn/normalize.py
- Command line
-
hf download hf://zero0303/maya2-mlx-dynamic-8bit/maya3/text/tn/normalize.py
-
curl -L -o normalize.py https://huggingface.co/zero0303/maya2-mlx-dynamic-8bit/resolve/main/maya3/text/tn/normalize.py
114 kB
| """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()] | |
| 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"(?<![\w.])[+-]?[0-9][0-9,]*(?:\.[0-9]+)?", t): | |
| if len(re.sub(r"\D", "", m[0])) > 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"(?<![\w\d])([0-9]{4})-([0-9]{1,2})-([0-9]{1,2})(?!\d)", t): | |
| _calendar_check(int(m[3]), int(m[2]), int(m[1]), span=m.span()) | |
| for m in re.finditer(r"(?<![\w\d-])([0-9]{1,2})([/.-])([0-9]{1,2})\2([0-9]{2,4})(?!\d)", t): | |
| y = int(m[4]) | |
| if len(m[4]) != 4: | |
| if len(m[4]) != 2 or two_digit_year_base is None: | |
| raise NormalizationError("ambiguous_year", "Use a four-digit year or explicitly configure the century.", span=m.span()) | |
| y += two_digit_year_base | |
| a, b = int(m[1]), int(m[3]) | |
| _calendar_check(a if date_order == "DMY" else b, b if date_order == "DMY" else a, y, span=m.span()) | |
| months = {**{name: i + 1 for i, name in enumerate(_TAMIL_MONTHS)}, **_month_lookup(locale)} | |
| month_re = "|".join(sorted(map(re.escape, months), key=len, reverse=True)) | |
| ordinal_re = "|".join(sorted(map(re.escape, set(locale.ORDINAL_INPUT_SUFFIXES) | {"st", "nd", "rd", "th", "ஆம்", "ம்"}), key=len, reverse=True)) | |
| for pattern, reverse in [ | |
| (rf"(?<!\w)([0-9]{{1,2}})(?:-?(?:{ordinal_re}))?[ -]+({month_re})(?!\w)(?:[ ,\-]+([0-9]{{4}}))?", False), | |
| (rf"(?<!\w)({month_re})[ -]+([0-9]{{1,2}})(?:-?(?:{ordinal_re}))?(?!\d)(?:,?\s+([0-9]{{4}}))?", True), | |
| ]: | |
| for m in re.finditer(pattern, t, re.I): | |
| day, month = (m[2], m[1]) if reverse else (m[1], m[2]) | |
| _calendar_check(int(day), months[month.lower()], int(m[3]) if m[3] else 2000, span=m.span()) | |
| tamil_month_re = "|".join(_TAMIL_MONTHS) | |
| for m in re.finditer(rf"({tamil_month_re})\s+(\d{{1,2}})(?:-?(?:ஆம்|ம்))?(?:\s*தேதி)?(?:\s+(\d{{4}}))?", t): | |
| _calendar_check(int(m[2]), months[m[1]], int(m[3]) if m[3] else 2000, span=m.span()) | |
| if (bad := re.search(r"\b(?:on|by|before|after|date|due)\s+[0-9]{1,2}/[0-9]{1,2}(?![/\d])", t, re.I)): | |
| raise NormalizationError("ambiguous_short_date", "Spell out the month and year for a short date.", span=bad.span()) | |
| if (bad := re.search(r"தேதி\s+[0-9]{1,2}\.\s*[0-9]{4}", t)): | |
| raise NormalizationError("conflicting_date", "Conflicting date components; confirm the intended date.", span=bad.span()) | |
| rupee_words = [word for word, key in locale.CURRENCY_WORDS.items() if key == "rupee"] | |
| rupee_aliases = "|".join(sorted(map(re.escape, rupee_words + ["INR", "Rs"]), key=len, reverse=True)) | |
| rupee_prefix = rf"(?:₹|(?<!\w)(?:{rupee_aliases})\.?)" | |
| minor_units = "|".join(sorted(map(re.escape, _PAISA_INPUT_WORDS), key=len, reverse=True)) | |
| if (bad := re.search(rf"{rupee_prefix}\s*[+-]?[0-9][0-9,.]*\s*(?:{minor_units})(?=$|[\s.,!?;।॥-])", t, re.I)): | |
| raise NormalizationError("conflicting_currency_unit", "Rupee prefix conflicts with a paise suffix; confirm the unit.", span=bad.span()) | |
| if (bad := re.search(rf"(?:{rupee_prefix}|[$€£]|\b(?:USD|EUR|GBP))\s*[+-]?[0-9][0-9,.]*\s*%", t, re.I)): | |
| raise NormalizationError("conflicting_currency_unit", "A value cannot have both a currency prefix and percent suffix.", span=bad.span()) | |
| for m in re.finditer(r"(?<![\w:])([0-9]{1,3}):([0-9]{2}):([0-9]{2})(?![\d:])", t): | |
| if int(m[2]) > 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"(?<![\w:])([0-9]{1,2})(?:[:.]([0-9]{2}))?\s*(a\.?m\.?|p\.?m\.?)(?!\w)|(?<![\w:])([0-9]{1,2}):([0-9]{2})(?![\d:])", no_durations, re.I): | |
| if m[1]: | |
| valid = 1 <= int(m[1]) <= 12 and int(m[2] or 0) < 60 | |
| else: | |
| # Explicit ratio context is not a clock. | |
| if re.search(r"\b(?:ratio|odds|proportion)\b", _sentence_before(t, m.start()), re.I): | |
| continue | |
| valid = int(m[4]) < 24 and int(m[5]) < 60 | |
| if not valid: | |
| raise NormalizationError("invalid_time", "Invalid clock time; use 00:00-23:59 or 1-12 with am/pm.", span=m.span()) | |
| class LangBase: | |
| # ---- identity ------------------------------------------------------- | |
| code = "" # ISO 639-1: hi bn ta te mr gu kn ml pa or | |
| name = "" | |
| native_digits = "" # the 10 native digit glyphs "०१२३४५६७८९" etc. | |
| # ---- numbers -------------------------------------------------------- | |
| ONES = [] # words for 0..99 (100 entries, native script) | |
| HUNDRED = "" # word used in "X hundred" composition (Indo-Aryan) | |
| THOUSAND = "" | |
| LAKH = "" | |
| CRORE = "" | |
| # ---- letters (English letter names written in native script) -------- | |
| LETTERS = {} # {"A": "...", ..., "Z": "..."} | |
| # ---- calendar ------------------------------------------------------- | |
| MONTHS = [] # 12 native month names (Jan..Dec, Gregorian) | |
| MONTH_ALIASES = {} # accepted alternate input spelling -> 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)(?<!['‘’][A-Za-z])['‘’](?=[\s,।.!?;:)]|$)", "", text) | |
| text = re.sub(r"#{2,}", " ", text) # markdown header/### junk | |
| # ellipses (… / ... / ..) are a spoken pause -> 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 ((?<!:) keeps https:// intact) | |
| text = re.sub(r"(?<!:)/{2,}", " ", text) | |
| # "No." / "no." before digits is the number abbreviation, not "no" | |
| if self.lang.NUMBER_WORD: | |
| text = re.sub(r"\b[Nn][Oo]s?\.\s*(?=\d)", | |
| self.lang.NUMBER_WORD + " ", text) | |
| # written-only unit abbreviations -> 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"(?<![\w-]){ab}(?:\.(?=\s+\S))?(?![\w-])", word, text, | |
| flags=re.IGNORECASE) | |
| text = re.sub(r"^[,\s]+", "", text) | |
| # Comparison operators are meaningful even without a left numeric operand. | |
| text = _EMOJI_RE.sub(" ", text) | |
| # en/em dash between numbers -> 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"(?<![\d\s])[–](?![\d\s])", " ", text) | |
| text = re.sub(r"[ \t]{2,}", " ", text) | |
| return text | |
| # ------------------------------------------------------------------ | |
| # Matchers — each returns list of (start, end, replacement) | |
| # Priority = order in self._matchers (earlier wins). | |
| # ------------------------------------------------------------------ | |
| def _gather(self, text): | |
| matches = [] # (priority, start, end, replacement) | |
| matchers = [ | |
| self._m_email, | |
| self._m_url, | |
| self._m_handle, | |
| self._m_comparison, | |
| self._m_time_range, | |
| self._m_duration, | |
| self._m_time_verbalized, | |
| self._m_time, | |
| self._m_tamil_date, | |
| self._m_date, | |
| self._m_currency_range, | |
| self._m_currency, | |
| self._m_bare_rupee_suffix, | |
| self._m_long_digit_runs, | |
| self._m_amount_context, | |
| self._m_percent_range, | |
| self._m_percent, | |
| self._m_negative, | |
| self._m_designation, | |
| self._m_vehicle_plate, | |
| self._m_ordinal, | |
| self._m_code, | |
| self._m_id_digits, | |
| self._m_chem, | |
| self._m_math, | |
| self._m_num_range, | |
| self._m_dimensions, | |
| self._m_roman, | |
| self._m_unit_initialism, | |
| self._m_kmb_suffix, | |
| self._m_fraction, | |
| self._m_ratio, | |
| self._m_label, | |
| self._m_acronym_model, | |
| self._m_num_hyphen_word, | |
| self._m_decade, | |
| self._m_year, | |
| self._m_quoted_letter, | |
| self._m_short_mixed_token, | |
| self._m_decimal, | |
| self._m_int, | |
| ] | |
| for prio, fn in enumerate(matchers): | |
| for (s, e, rep) in fn(text): | |
| matches.append((prio, s, e, rep)) | |
| return matches | |
| # --- email / url / handle ----------------------------------------- | |
| def _spell_uri_part(self, part): | |
| """Word chunks stay; digits spelled; symbols to words.""" | |
| if os.environ.get("FR3_TN_URL_SPELL", "0") == "1": | |
| if __package__: | |
| from .identifier_rules import spell_uri | |
| else: | |
| from identifier_rules import spell_uri | |
| return spell_uri(part, self.lang, self._digits_spelled) | |
| S = self.lang.SYM | |
| out = [] | |
| for run in re.findall(r"[^\W\d_]+|\d+|[^\w\s]|_", part): | |
| if run.isdigit(): | |
| out.append(self._digits_spelled(run, comma=False)) | |
| elif run == ".": | |
| out.append(S["dot"]) | |
| elif run == "_": | |
| out.append(S["underscore"]) | |
| elif run == "@": | |
| out.append(S["at"]) | |
| elif run == "/": | |
| out.append(S["slash"]) | |
| elif run == "-": | |
| out.append("dash") | |
| elif run == "+": | |
| out.append(S["plus"]) | |
| elif run in {"?", "=", "&", "#", "%", ":", "~"}: | |
| out.append({"?": "question mark", "=": "equals", "&": "ampersand", | |
| "#": "hash", "%": "percent", ":": "colon", "~": "tilde"}[run]) | |
| elif run.lower() == "www": | |
| out.append(self._letters_spelled("WWW")) | |
| elif len(run) == 1 or run.upper() in self.lang.ABBREV_UNITS: | |
| # Do not expand /Dr/ to /doctor/ on a second normalization pass. | |
| out.append(self._letters_spelled(run.upper())) | |
| else: | |
| out.append(run) | |
| return " ".join(w for w in out if w) | |
| def _m_email(self, text): | |
| for m in re.finditer(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b", text): | |
| yield m.start(), m.end(), self._spell_uri_part(m.group(0)) | |
| def _m_url(self, text): | |
| pat = (r"https?://[^\s<>]+|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"(?<![\w.])@([A-Za-z](?:[\w.\-]*\w)?)" if os.environ.get("FR3_TN_URL_SPELL", "0") == "1" else r"(?<![\w.])@([A-Za-z](?:[\w.]*\w)?)" | |
| for m in re.finditer(pat, text): | |
| yield m.start(), m.end(), \ | |
| f"{self.lang.SYM['at']} {self._spell_uri_part(m.group(1))}" | |
| # --- time ----------------------------------------------------------- | |
| def _parse_time_match(self, m, text): | |
| """Shared logic. Returns (h12, mm, period_key).""" | |
| pass | |
| def _time_pattern(self): | |
| P = self._period_re | |
| MK = self._marker_re | |
| # (?<!\w) / (?!\w) around native-word alternations: "ગુજરાત" ends | |
| # with રાત (night) — without boundaries the period word matches | |
| # inside other words | |
| return re.compile( | |
| rf"(?:(?<!\w)({P})\s+)?" # leading period word | |
| rf"(\d{{1,2}})" # hour | |
| # a real time needs :mm, AM/PM or a time marker right after the | |
| # hour — otherwise bare numbers spuriously swallow period words | |
| rf"(?=:\d{{2}}" | |
| rf"|\.\d{{2}}\s*(?:AM|PM|am|pm|a\.m\.|p\.m\.)(?![A-Za-z])" # 6.45 pm — dot form | |
| rf"|\s*(?:AM|PM|am|pm|a\.m\.|p\.m\.)(?![A-Za-z])|\s*(?:{MK})(?!\w))" | |
| rf"(?:[.:](\d{{2}}))?" # :mm or .mm (gated by the lookahead) | |
| rf"(?:\s*(AM|PM|am|pm|a\.m\.|p\.m\.))?" # am/pm | |
| rf"(?:\s*({MK})(?!\w))?" # बजे / मणि ... | |
| rf"(?:\s+({P})(?!\w))?", re.I # trailing period word | |
| ) | |
| def _resolve_time(self, lead_p, h, mm, ampm, marker, trail_p): | |
| L = self.lang | |
| h = int(h) | |
| m = int(mm) if mm is not None else 0 | |
| period = None | |
| src_period = lead_p or trail_p | |
| if src_period: | |
| period = L.PERIOD_DETECT.get(src_period) | |
| if L.code != "en" and not ampm and not src_period: | |
| return h, m, None | |
| infer = not L.AMPM_WORDS # languages that speak am/pm keep the token instead | |
| if ampm: | |
| a = ampm.lower().replace(".", "") | |
| h24 = h % 12 + (12 if a == "pm" else 0) | |
| if infer: | |
| period = period or self._period_for_hour24(h24) | |
| elif h > 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"(?<!\w)({months})\s+(\d{{1,2}})(?:-?(?:ஆம்|ம்))?(?![\dA-Za-z])(?:\s*தேதி)?" | |
| for m in re.finditer(pat, text): | |
| d = int(m[2]) | |
| if 1 <= d <= 31: | |
| yield m.start(), m.end(), f"{m[1]} {_TAMIL_ORDINALS[d]} தேதி" | |
| for m in re.finditer(r"(?<!\w)(\d+)-?(?:ஆம்|ம்)(?![\w\u0B80-\u0BFF])", text): | |
| d = int(m[1]) | |
| if not 1 <= d <= 31: | |
| if self.lang.code == "en": | |
| raise NormalizationError("unsupported_tamil_ordinal", "Select Tamil normalization for ordinals outside calendar days 1-31.", span=m.span(), source=text) | |
| yield m.start(), m.end(), self.lang.ordinal(d, "ஆம்") | |
| else: | |
| yield m.start(), m.end(), _TAMIL_ORDINALS[d] | |
| def _date_words(self, day, month, year=None, ordinal=False, month_first=False): | |
| L = self.lang | |
| if L.code == "ta": | |
| out = f"{L.month(month)} {_TAMIL_ORDINALS[day]} தேதி" | |
| else: | |
| suffix = ordinal if isinstance(ordinal, str) and ordinal in L.ORDINAL_INPUT_SUFFIXES else "" | |
| day_words = L.ordinal(day, suffix) if ordinal else L.cardinal(day) | |
| out = f"{L.month(month)} {day_words}" if month_first and L.code != "en" else f"{day_words} {L.month(month)}" | |
| return f"{out} {L.year(year)}" if year is not None else out | |
| def _m_date(self, text): | |
| L = self.lang | |
| for m in re.finditer(rf"\b(\d{{1,2}})[-–](\d{{1,2}})\s+({self._month_re})(?!\w)(?:\s+(\d{{4}}))?", text, re.I): | |
| mo = self._month_index(m[3]) | |
| for day in (m[1], m[2]): | |
| _calendar_check(int(day), mo, int(m[4]) if m[4] else 2000) | |
| days = _fmt(L.RANGE, a=L.cardinal(int(m[1])), b=L.cardinal(int(m[2]))) | |
| out = f"{days} {L.month(mo)}" | |
| if m[4]: | |
| out += f" {L.year(int(m[4]))}" | |
| yield m.start(), m.end(), out | |
| for m in re.finditer(r"(?<![\w-])(\d{4})-(\d{1,2})-(\d{1,2})(?![\d-])", text): | |
| yield m.start(), m.end(), self._date_words(int(m[3]), int(m[2]), int(m[1])) | |
| # DD/MM/YYYY or DD-MM-YYYY or DD.MM.YYYY | |
| for m in re.finditer(r"\b(\d{1,2})([/.\-])(\d{1,2})\2(\d{2,4})\b(?![/A-Za-z-])", text): | |
| d, mo, y = int(m.group(1)), int(m.group(3)), int(m.group(4)) | |
| if self.date_order == "MDY": | |
| d, mo = mo, d | |
| if not (1 <= d <= 31 and 1 <= mo <= 12): | |
| continue | |
| if len(m.group(4)) == 2: | |
| y += self.two_digit_year_base | |
| yield m.start(), m.end(), self._date_words(d, mo, y) | |
| # DD-Mon-YYYY / DD Mon YYYY / DD Mon, with native or English months | |
| # (?!\w) after the month: without it "25 ಮೇಲೆ" would parse as | |
| # "25 May" because ಮೇಲೆ starts with ಮೇ (May) | |
| pat = re.compile( | |
| rf"\b(\d{{1,2}})(?:-?({self._ord_suffix_re}))?[\-\s]({self._month_re})(?!\w)" | |
| rf"(?:[\-\s,]+(\d{{4}}))?", re.IGNORECASE) | |
| for m in pat.finditer(text): | |
| d = int(m.group(1)) | |
| mo = self._month_index(m.group(3)) | |
| if mo is None or not (1 <= d <= 31): | |
| continue | |
| out = self._date_words(d, mo, int(m[4]) if m[4] else None, m[2]) | |
| yield m.start(), m.end(), out | |
| # Month DD, YYYY (English order) | |
| pat2 = re.compile( | |
| rf"\b({self._month_re})\s+(\d{{1,2}})(?:-?({self._ord_suffix_re}))?(?:,?\s+(\d{{4}}))?(?!\d)", | |
| re.IGNORECASE) | |
| for m in pat2.finditer(text): | |
| mo = self._month_index(m.group(1)) | |
| d = int(m.group(2)) | |
| if mo is None or not (1 <= d <= 31): | |
| continue | |
| out = self._date_words(d, mo, int(m[4]) if m[4] else None, m[3], month_first=True) | |
| yield m.start(), m.end(), out | |
| # --- currency --------------------------------------------------------- | |
| _CURRENCY_CODE_KEYS = {"₹": "rupee", "$": "dollar", "€": "euro", | |
| "£": "pound", "RS": "rupee", "INR": "rupee", | |
| "USD": "dollar", "EUR": "euro", "GBP": "pound"} | |
| def _cur_key(self, symtok): | |
| t = symtok.strip().rstrip(".").strip().upper() | |
| return self._CURRENCY_CODE_KEYS.get(t) or self.lang.CURRENCY_WORDS.get(t) or self.lang.CURRENCY_WORDS.get(t + ".") or "rupee" | |
| def _m_currency_range(self, text): | |
| # ₹500-700 -> "five hundred to seven hundred rupees" | |
| L = self.lang | |
| pat = re.compile( | |
| rf"(?<!\w)([+-]?)({self._currency_re})" | |
| rf"\s*([+-]?\d+(?:,\d{{2,3}})*(?:\.\d+)?)\s*[–\-]\s*([+-]?\d+(?:,\d{{2,3}})*(?:\.\d+)?)(?!\d|[.,]\d)", re.I) | |
| for m in pat.finditer(text): | |
| if m[1] and m[3].startswith(("-", "+")): | |
| raise NormalizationError("conflicting_sign", "Currency amount has two signs; confirm the value.", span=m.span(), source=text) | |
| a, b = self._number_words(m[1] + m[3]), self._number_words(m[4]) | |
| cw = L.SYM[self._cur_key(m[2])] | |
| rng = _fmt(L.RANGE, a=a, b=L.join_amount(b, cw)) | |
| yield m.start(), m.end(), rng | |
| def _m_bare_rupee_suffix(self, text): | |
| L = self.lang | |
| for m in re.finditer(r"(?<![\w₹.])(\d+(?:,\d{2,3})*(?:\.\d+)?)\s?/-", text): | |
| num = m.group(1).replace(",", "") | |
| words = self._number_words(num) | |
| yield m.start(), m.end(), L.join_amount(words, L.SYM["rupee"]) | |
| def _m_currency(self, text): | |
| L = self.lang | |
| S = L.SYM | |
| pat = re.compile( | |
| rf"(?<!\d)([+-]?)\s*(₹|\$|€|£|(?<!\w)(?:{self._currency_re}))" | |
| rf"\s*([+-]?)(\d+(?:,\d{{2,3}})*(?:\.\d+)?)" | |
| rf"(?:\s*({self._scale_re})(?:((?:ां|ं)?(?:चा|ची|चे|च्या|त|ना|हून|पर्यंत|मध्ये))|(?![ऀ-ःऺ-ॏঀ-ঃা-্ਁ-ਃਾ-੍ઁ-ઃા-્ଁ-ଃା-୍ஂா-்ఀ-ఄా-ౌಁ-ಃಾ-ೌഀ-ഃാ-ൌ]))(?![A-Za-z0-9]))?(?:\s?/-)?", re.I) | |
| for m in pat.finditer(text): | |
| if m[1] and m[3]: | |
| raise NormalizationError("conflicting_sign", "Currency amount has two signs; confirm the value.", span=m.span(), source=text) | |
| key = self._cur_key(m[2]) | |
| num = m[4].replace(",", "") | |
| scale = m[5] | |
| cur_word = S[key] | |
| # money with exactly 2 decimals and no scale word -> 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"(?<![\w.])([+-]?\d[\d,]*(?:\.\d+)?)(?=\s*(?:{units})(?![A-Za-z]))", text, re.I): | |
| yield m.start(), m.end(), self._number_words(m[1]) | |
| labels = {"balance", "amount", "payable", "outstanding", "payment", "price", "fee", "pay", "emi", "தவணை"} | |
| for m in re.finditer(r"(?<![\w.,+/-])\d+(?:,\d{2,3})*(?!\w|[.,/-]\d)", text): | |
| before = _sentence_before(text, m.start()).lower() | |
| toks = re.findall(r"\w+", before)[-3:] | |
| if any(t in labels for t in toks) and not any(t in {"number", "id", "code", "otp", "pin"} for t in toks): | |
| yield m.start(), m.end(), self._number_words(m[0]) | |
| # --- percent ------------------------------------------------------------ | |
| def _m_percent_range(self, text): | |
| # 40-70% / 40–70% -> "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"|(?<![\d.])(?<!\d,)\d{7,}(?![.,]?\d))") | |
| for m in pat.finditer(text): | |
| has_plus = m.group(1) == "+" | |
| tok = m.group(0).lstrip("+# ") | |
| groups = re.split(r"[-\s]", tok) | |
| digits = "".join(groups) | |
| is_id = False | |
| if has_plus and len(digits) >= 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"(?<![\d.])(?<!\d,)\d{3,6}(?![.,]?\d)(?!s\b)", text): | |
| # "139 dial करें" — dial/डायल shortly after marks a hotline number | |
| after_toks = re.findall(r"[\w#ऀ-']+", | |
| _sentence_after(text, m.end()).lower())[:6] | |
| id_after = {"dial", "डायल"} | set(self.lang.ID_AFTER_WORDS) | |
| if any(t in id_after for t in after_toks): | |
| yield m.start(), m.end(), \ | |
| self._digits_spelled(m.group(0), comma=True) | |
| continue | |
| # an identifier keyword within ~4 tokens AFTER also marks it | |
| # ("4321 हा OTP", "998877 वापरा password") — but not label/year | |
| if (any(t in self._id_keywords for t in after_toks[:4]) | |
| and not self._keyword_before(text, m.start(), self._label_words, window=2) | |
| and not self._keyword_before(text, m.start(), | |
| {w.lower() for w in L.YEAR_WORDS} | {"year"}, window=2)): | |
| yield m.start(), m.end(), \ | |
| self._digits_spelled(m.group(0), comma=True) | |
| continue | |
| if self._keyword_before(text, m.start(), self._id_keywords): | |
| # label words override (Room 314 stays cardinal) | |
| if self._keyword_before(text, m.start(), self._label_words, window=2): | |
| continue | |
| # year words override (साल 2026) | |
| if self._keyword_before(text, m.start(), | |
| {w.lower() for w in L.YEAR_WORDS} | {"year"}, | |
| window=2): | |
| continue | |
| yield m.start(), m.end(), \ | |
| self._digits_spelled(m.group(0), comma=True) | |
| # --- chemistry ------------------------------------------------------------ | |
| _ELEMENTS = { | |
| "H", "He", "Li", "Be", "B", "C", "N", "O", "F", "Ne", "Na", "Mg", | |
| "Al", "Si", "P", "S", "Cl", "Ar", "K", "Ca", "Fe", "Cu", "Zn", "Ag", | |
| "Au", "Hg", "Pb", "I", "Mn", "Cr", "Ni", "Sn", "Br", "Mg", | |
| } | |
| _CHEM_WHITELIST = {"HCl", "NaCl", "KCl", "NaOH", "KOH", "HBr", "HF", | |
| "CO", "NO", "NH3", "CH4", "H2O", "CO2", "SO2", "O2", | |
| "N2", "H2", "H2SO4", "HNO3", "CaCO3", "C6H12O6", | |
| "NaHCO3", "MgSO4", "ZnO", "FeO"} | |
| def _chem_ok(self, tok): | |
| if tok in self._CHEM_WHITELIST: | |
| return True | |
| pieces = re.findall(r"[A-Z][a-z]?|\d+", tok) | |
| letters = [p for p in pieces if not p.isdigit()] | |
| digits = [p for p in pieces if p.isdigit()] | |
| if not letters or any(p not in self._ELEMENTS for p in letters): | |
| return False | |
| return bool(digits) and len(letters) >= 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"(?<![\w.])(?:(?P<left>{operand})\s*)?(?P<op><=|>=|≤|≥|<|>)\s*(?P<right>{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"(?<![\w.])((?:[A-Za-z]{1,2}|\d+(?:\.\d+)?)" | |
| r"(?:\s*[=+\-−×*÷/^]\s*(?:[A-Za-z]{1,2}|\d+(?:\.\d+)?))+)") | |
| for m in eq.finditer(text): | |
| span = m.group(1) | |
| if not re.search(r"[=×÷^√]", span): | |
| continue | |
| yield m.start(1), m.end(1), self._render_math_expr(span) | |
| # single letters adjacent to verbal math words: "a squared plus b" | |
| for m in re.finditer(r"\b([A-Za-z])\b(?=\s+(?:squared|cubed|plus|minus|" | |
| r"times|equals|to the power)\b)", text): | |
| yield m.start(), m.end(), L.letter(m.group(1)) | |
| # Operators are expanded in-place in _final_sweep. This also handles | |
| # thresholds without a left operand and preserves x < y word order. | |
| def _render_math_expr(self, span): | |
| L = self.lang | |
| S = L.SYM | |
| opmap = {"=": S["equals"], "+": S["plus"], "-": S["minus"], | |
| "−": S["minus"], "×": S["times"], "*": S["times"], | |
| "÷": S["divide"], "/": S["divide"]} | |
| toks = re.findall(r"\d+(?:\.\d+)?|[A-Za-z]+|[=+\-−×*÷/^]", span) | |
| out = [] | |
| i = 0 | |
| while i < len(toks): | |
| tok = toks[i] | |
| if tok == "^": | |
| # base already emitted; render exponent via the power template | |
| exp = toks[i + 1] if i + 1 < len(toks) else "" | |
| ew = (self._number_words(exp) if exp[:1].isdigit() | |
| else self._letters_spelled(exp.upper())) | |
| base = out.pop() if out else "" | |
| out.append(_fmt(L.POWER, base=base, exp=ew)) | |
| i += 2 | |
| continue | |
| if tok in opmap: | |
| out.append(opmap[tok]) | |
| elif tok[0].isdigit(): | |
| out.append(self._number_words(tok)) | |
| else: | |
| out.append(self._letters_spelled(tok.upper())) | |
| i += 1 | |
| return " ".join(out) | |
| # --- ranges ----------------------------------------------------------- | |
| def _m_num_range(self, text): | |
| L = self.lang | |
| for m in re.finditer(r"(\d+(?:\.\d+)?)\s*[–]\s*(\d+(?:\.\d+)?)", text): | |
| a = self._number_words(m.group(1)) | |
| b = self._number_words(m.group(2)) | |
| yield m.start(), m.end(), _fmt(L.RANGE, a=a, b=b) | |
| # hyphen range: only ascending small pairs, no id keyword | |
| for m in re.finditer(r"\b(\d{1,3})-(\d{1,3})\b", text): | |
| if self._keyword_before(text, m.start(), self._id_keywords): | |
| continue | |
| a, b = int(m.group(1)), int(m.group(2)) | |
| if a == b: | |
| # 50-50 -> "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"(?<![\d.A-Za-z])(\d+(?:\.\d+)?)([KMB])\b", text): | |
| yield m.start(), m.end(), \ | |
| f"{self._number_words(m.group(1))} {L.letter(m.group(2))}" | |
| # --- ordinals ----------------------------------------------------------- | |
| def _m_ordinal(self, text): | |
| L = self.lang | |
| pat = re.compile(rf"\b(\d+)-?({self._ord_suffix_re})(?![A-Za-z0-9])", re.I) | |
| for m in pat.finditer(text): | |
| n = int(m.group(1)) | |
| suf = m.group(2).lower() | |
| if suf in ("st", "nd", "rd", "th"): | |
| yield m.start(), m.end(), L.ordinal(n) | |
| else: | |
| yield m.start(), m.end(), L.ordinal(n, suf) | |
| # --- fractions ----------------------------------------------------------- | |
| def _m_fraction(self, text): | |
| L = self.lang | |
| S = L.SYM | |
| pat = re.compile(r"\b(\d{1,4}(?:\.\d+)?)/(\d{1,4}(?:\.\d+)?)\b") | |
| for m in pat.finditer(text): | |
| a, b = m.group(1), m.group(2) | |
| if (a, b) == ("1", "2"): | |
| rep = S["half"] | |
| elif (a, b) == ("1", "4"): | |
| rep = S["quarter"] | |
| elif (a, b) == ("3", "4"): | |
| rep = S["three_quarters"] | |
| else: | |
| rep = _fmt(L.FRACTION, num=self._number_words(a), | |
| den=self._number_words(b)) | |
| yield m.start(), m.end(), rep | |
| # --- ratios ----------------------------------------------------------- | |
| def _m_ratio(self, text): | |
| L = self.lang | |
| for m in re.finditer(r"\b(\d{1,3}):(\d{1,2})\b", text): | |
| mm = m.group(2) | |
| # 2-digit right side <=59 looks like a time -> 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"(?<![\w.])(-?)\b(\d+(?:,\d{{2,3}})*(?:\.\d+)?)[\s\-]?({units})" | |
| rf"(?:\s?/\s?([A-Za-z]{{1,4}}))?\b", | |
| re.IGNORECASE) | |
| for m in pat.finditer(text): | |
| unit = m.group(3) | |
| if unit.upper() not in _UNIT_INITIALISMS: | |
| continue | |
| # single-letter units (V, W) only when written in caps — | |
| # a lowercase "5 v" is too ambiguous | |
| if len(unit) == 1 and not unit.isupper(): | |
| continue | |
| num = self._number_words(m.group(2)) | |
| if m.group(1): | |
| num = f"{L.SYM['minus']} {num}" | |
| uk = re.sub(r"[^A-Za-z]", "", unit).upper() | |
| word = L.UNIT_WORDS.get(uk) or self._letters_spelled(uk) | |
| rep = L.join_amount(num, word) | |
| if m.group(4): | |
| u2 = m.group(4).upper() | |
| w2 = L.UNIT_WORDS.get(u2) or self._letters_spelled(u2) | |
| rep += f" {L.SYM.get('per', 'per')} {w2}" | |
| yield m.start(), m.end(), rep | |
| # --- number-hyphen-word: 80-watt, 900-point ------------------------------ | |
| def _m_num_hyphen_word(self, text): | |
| for m in re.finditer(r"\b(\d+(?:,\d{2,3})*(?:\.\d+)?)-([A-Za-zऀ-ൿ]{2,})\b", | |
| text): | |
| word = m.group(2) | |
| if self._month_index(word): | |
| continue # 12-Aug | |
| if word.upper() in _UNIT_INITIALISMS: | |
| continue # unit matcher handles | |
| num = self._number_words(m.group(1)) | |
| yield m.start(), m.end(), f"{num} {word}" | |
| # --- decades ------------------------------------------------------------ | |
| def _m_decade(self, text): | |
| L = self.lang | |
| for m in re.finditer(r"\b(19|20)(\d)0s\b", text): | |
| if m.group(1) == "19": | |
| # 1980s -> "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"(?<![\w])'?(\d)0s\b", text): | |
| yield m.start(), m.end(), L.decade(int(m.group(1)) * 10, full=False) | |
| # --- years --------------------------------------------------------------- | |
| def _m_year(self, text): | |
| L = self.lang | |
| year_words = {w.lower() for w in L.YEAR_WORDS} | \ | |
| {"year", "in", "since", "by", "from", "साल", "वर्ष"} | |
| for m in re.finditer(r"\b(1[5-9]\d{2}|20\d{2})\b", text): | |
| y = int(m.group(0)) | |
| ctx = self._keyword_before(text, m.start(), year_words, window=2) | |
| # attached native locative suffix right after: 1947में / 1947-ல் | |
| after = text[m.end():m.end() + 20] | |
| # danda ।/॥ are inside the Devanagari block — they are sentence | |
| # punctuation, not a locative suffix | |
| suffixed = bool(re.match(r"^-?(?![।॥])[ऀ-]", after)) | |
| # following token is/starts with a year word: "1947 সালে", | |
| # "1983 साली", "१९४७ ମସିହାରେ", or Hindi locatives | |
| next_tok = after.strip().split()[0] if after.strip() else "" | |
| follower = next_tok in ("में", "से", "तक", "માં", "मध्ये", | |
| "ਵਿੱਚ", "ਤੋਂ") or any( | |
| next_tok.startswith(w) for w in L.YEAR_WORDS if w) | |
| if ctx or suffixed or follower: | |
| yield m.start(), m.end(), L.year(y) | |
| # --- quoted single letters: reply 'Y' ----------------------------------- | |
| def _m_quoted_letter(self, text): | |
| for m in re.finditer(r"['\"‘“]([A-Za-z])['\"’”]", text): | |
| yield m.start(), m.end(), self.lang.letter(m.group(1)) | |
| # --- short mixed tokens: 5G, A4, B12, Y2K, S24, A380 --------------------- | |
| def _m_short_mixed_token(self, text): | |
| L = self.lang | |
| pat = re.compile(r"(?<![\d.])\b(?=[A-Za-z0-9]{2,5}\b)" | |
| r"(?=[A-Za-z0-9]*\d)(?=[A-Za-z0-9]*[A-Za-z])" | |
| r"[A-Za-z0-9]+\b") | |
| for m in pat.finditer(text): | |
| tok = m.group(0) | |
| if re.fullmatch(r"\d+(?:st|nd|rd|th|s)", tok): | |
| continue # ordinals / decades | |
| out = [] | |
| for run in re.findall(r"[A-Za-z]+|\d+", tok): | |
| if run.isdigit(): | |
| out.append(L.cardinal(int(run))) | |
| else: | |
| out.append(self._letters_spelled(run.upper())) | |
| yield m.start(), m.end(), " ".join(out) | |
| # --- decimals ------------------------------------------------------------- | |
| def _m_decimal(self, text): | |
| # trailing (?!\.?\d) instead of (?![\d.]) so a sentence-final full | |
| # stop after the decimal ("...830.93.") doesn't block the match | |
| for m in re.finditer( | |
| r"(?<![\d.])(\d{1,3}(?:,\d{2,3})+|\d+)\.(\d+)(?!\.?\d)", text): | |
| yield m.start(), m.end(), \ | |
| self.lang.decimal(int(m.group(1).replace(",", "")), m.group(2)) | |
| # --- negative numbers ------------------------------------------------------- | |
| def _m_negative(self, text): | |
| L = self.lang | |
| for m in re.finditer(r"(?<![\w.,-])-(\d+(?:\.\d+)?)", text): | |
| yield m.start(), m.end(), \ | |
| f"{L.SYM['minus']} {self._number_words(m.group(1))}" | |
| # --- plain integers ---------------------------------------------------------- | |
| def _m_int(self, text): | |
| L = self.lang | |
| for m in re.finditer(r"\b\d{1,3}(?:,\d{2,3})+\b|\b\d+\b", text): | |
| digits = m.group(0).replace(",", "") | |
| # street numbers read year-style (1422 गांधी मार्ग -> चौदह सौ बाईस) | |
| 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) | |
| 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) | |