import random import re from num2fawords import ordinal_words, words from num2words import num2words from persiantools import digits from rules.base import BaseProcessor from utils import NO_LETTERS, detect_digit_language VULGAR_FRACTIONS = { "½": (1, 2), "⅓": (1, 3), "⅔": (2, 3), "¼": (1, 4), "¾": (3, 4), "⅕": (1, 5), "⅖": (2, 5), "⅗": (3, 5), "⅘": (4, 5), "⅙": (1, 6), "⅚": (5, 6), "⅛": (1, 8), "⅜": (3, 8), "⅝": (5, 8), "⅞": (7, 8), } VULGAR = "".join(VULGAR_FRACTIONS) # A whole number, optionally with thousands separators: `12`, `1,000`, `۱٬۰۰۰`. NUMBER = r"(?:\d{1,3}(?:[,٬]\d{3})+|\d+)" # The fraction itself: `1/3`, `۱/۳`, `1,000/2`, `2 1/2` (mixed number), `½`, `1½`. # Shared with `UnitProcessor`, which reads `1/2 kg`. FRACTION_BODY = rf""" (?: (?:(?P{NUMBER})[ ])?(?P{NUMBER})/(?P{NUMBER}) | (?:(?P{NUMBER})[ ]?)?(?P[{VULGAR}]) ) """ # Does not match parts of dates (`1402/05/10`), decimals (`3.1/2`), or fractions # glued to a word or currency/percent sign (`and/or`, `1/2$`). FRACTION_PATTERN = re.compile( rf""" (? `یک سوم` `۳/۴` -> `سه چهارم` `۲ ۱/۲` -> `دو و یک دوم` English: `1/3` -> `one third` `3/4` -> `three quarters` `2 1/2` -> `two and one half` `½` -> `one half` `1,000/2` -> `one thousand halves` Works on the whole text (not word by word) because a mixed number such as `2 1/2` spans two words. If the text has no letters at all, the digits pick the language: Persian digits -> Persian, ASCII digits -> English. A fraction followed by a unit (`1/2 kg`) is handled by `UnitProcessor`. """ def __init__(self, epsilon: float = 1.0): super(FractionProcessor, self).__init__(epsilon=epsilon) def process(self, text: str, language: str = None, *args, **kwargs) -> str: if random.random() > self.epsilon: return text if language in (None, NO_LETTERS): language = detect_digit_language(text) if language not in ("fa", "en"): return text return FRACTION_PATTERN.sub(lambda match: self._convert(match, language), text) @staticmethod def _convert(match: re.Match, language: str) -> str: parsed = FractionProcessor.parse(match) if parsed is None: return match.group(0) return FractionProcessor.to_text(*parsed, language=language) @staticmethod def parse(match: re.Match): """Returns (whole, numerator, denominator) from a match of `FRACTION_BODY`. `whole` is None when there is no mixed-number part. Returns None for `1/0` (not a fraction) and `3/1` (just a ratio), which are left alone. """ if match.group("vulgar"): numerator, denominator = VULGAR_FRACTIONS[match.group("vulgar")] whole = match.group("vulgar_whole") else: numerator = FractionProcessor._to_int(match.group("numerator")) denominator = FractionProcessor._to_int(match.group("denominator")) whole = match.group("whole") if denominator in (0, 1): return None return ( None if whole is None else FractionProcessor._to_int(whole), numerator, denominator, ) @staticmethod def to_text(whole, numerator: int, denominator: int, language: str) -> str: if language == "fa": fraction = FractionProcessor._persian_fraction(numerator, denominator) joiner, spell = " و ", words else: fraction = FractionProcessor._english_fraction(numerator, denominator) joiner, spell = " and ", num2words if whole is None: return fraction return f"{spell(whole)}{joiner}{fraction}" @staticmethod def _to_int(number: str) -> int: clean = digits.fa_to_en(number).replace(",", "").replace("٬", "") return int(clean) @staticmethod def _persian_fraction(numerator: int, denominator: int) -> str: ordinal = ordinal_words(denominator) # The library writes 30 as `سیم`; the correct form is `سی‌ام`. if ordinal.endswith("سیم"): ordinal = ordinal[: -len("سیم")] + "سی‌ام" # `یک صدم` / `یک هزارم`, not `یک یکصدم` / `یک یک هزارم`. if ordinal == "یکصدم": ordinal = "صدم" elif ordinal.startswith("یک "): ordinal = ordinal[len("یک "):] return f"{words(numerator)} {ordinal}" @staticmethod def _english_fraction(numerator: int, denominator: int) -> str: singular = numerator == 1 if denominator == 2: unit = "half" if singular else "halves" elif denominator == 4: unit = "quarter" if singular else "quarters" else: ordinal = num2words(denominator, to="ordinal") # `one hundredth`, not `one one hundredth`. if ordinal.startswith("one "): ordinal = ordinal[len("one "):] unit = ordinal if singular else f"{ordinal}s" return f"{num2words(numerator)} {unit}"