import random import re from dataclasses import dataclass from decimal import Decimal from num2fawords import words from num2words import num2words from persiantools import digits from rules.base import BaseProcessor from utils import NO_LETTERS, detect_digit_language @dataclass(frozen=True) class Currency: symbol: str fa_unit: str fa_subunit: str en_unit: str en_units: str en_subunit: str en_subunits: str CURRENCIES = { "$": Currency("$", "دلار", "سنت", "dollar", "dollars", "cent", "cents"), "€": Currency("€", "یورو", "سنت", "euro", "euros", "cent", "cents"), "£": Currency("£", "پوند", "پنس", "pound", "pounds", "penny", "pence"), } SYMBOLS = "".join(re.escape(symbol) for symbol in CURRENCIES) AMOUNT = r"\d+(?:[.,٫٬]\d+)*" # Matches a single token such as `3$`, `$3`, `۳€`, `£12.50`, `1,000$`, # optionally wrapped in punctuation, e.g. `(3$)` or `£12.50.`. CURRENCY_PATTERN = re.compile( rf""" ^ (?P[^\w{SYMBOLS}]*) (?: (?P[{SYMBOLS}])(?P{AMOUNT}) | (?P{AMOUNT})(?P[{SYMBOLS}]) ) (?P[^\w{SYMBOLS}]*) $ """, re.VERBOSE, ) class CurrencyProcessor(BaseProcessor): """Converts amounts such as `3$`, `€3` or `£12.50` into spoken text. Supported symbols: `$` (dollar), `€` (euro), `£` (pound). Persian: `۳$` -> `سه دلار` `€12.50` -> `دوازده یورو و پنجاه سنت` `£12.50` -> `دوازده پوند و پنجاه پنس` English: `3$` -> `three dollars` `$12.50` -> `twelve dollars and fifty cents` `£12.50` -> `twelve pounds and fifty pence` Amounts with more than two decimal places are read as a plain decimal number (e.g. `3.141$` -> `three point one four one dollars`). If the text has no letters at all, the digits pick the language: `۱۰۰۰$` -> `یک هزار دلار` `1000$` -> `one thousand dollars` """ def __init__(self, epsilon: float = 1.0): super(CurrencyProcessor, self).__init__(epsilon=epsilon) def process(self, token: str, language: str = "en", *args, **kwargs) -> str: if random.random() > self.epsilon: return token match = CURRENCY_PATTERN.fullmatch(token) if match is None: return token # A text with no letters at all (e.g. just `۱۰۰۰$`) has no language, so # the digits decide: Persian digits -> Persian, ASCII digits -> English. if language == NO_LETTERS: language = detect_digit_language(token) amount = digits.fa_to_en(match.group("prefixed") or match.group("suffixed")) symbol = match.group("prefix_symbol") or match.group("suffix_symbol") currency = CURRENCIES[symbol] if language == "fa": text = self._amount_to_persian(amount, currency) elif language == "en": text = self._amount_to_english(amount, currency) else: return token return f"{match.group('lead')}{text}{match.group('trail')}" @staticmethod def is_currency(token: str) -> bool: return CURRENCY_PATTERN.fullmatch(token) is not None @staticmethod def _amount_to_persian(amount: str, currency: Currency) -> str: clean = CurrencyProcessor._clean_amount(amount) parts = CurrencyProcessor._split_major_and_minor(clean) if parts is None: return f"{words(float(clean))} {currency.fa_unit}" major, minor = parts return CurrencyProcessor._compose( major=major, minor=minor, spell=words, major_unit=currency.fa_unit, minor_unit=currency.fa_subunit, joiner=" و ", ) @staticmethod def _amount_to_english(amount: str, currency: Currency) -> str: clean = CurrencyProcessor._clean_amount(amount) parts = CurrencyProcessor._split_major_and_minor(clean) if parts is None: return f"{num2words(Decimal(clean))} {currency.en_units}" major, minor = parts return CurrencyProcessor._compose( major=major, minor=minor, spell=num2words, major_unit=currency.en_unit if major == 1 else currency.en_units, minor_unit=currency.en_subunit if minor == 1 else currency.en_subunits, joiner=" and ", ) @staticmethod def _compose(major, minor, spell, major_unit, minor_unit, joiner) -> str: pieces = [] # Skip "zero dollars" when there are cents (`0.50$` -> "fifty cents"). if major or not minor: pieces.append(f"{spell(major)} {major_unit}") if minor: pieces.append(f"{spell(minor)} {minor_unit}") return joiner.join(pieces) @staticmethod def _split_major_and_minor(clean: str): """Returns (major, minor), or None if there are more than 2 decimals.""" if "." not in clean: return int(clean), 0 whole, fraction = clean.split(".") if len(fraction) > 2: return None return int(whole), int(fraction.ljust(2, "0")) @staticmethod def _clean_amount(amount: str) -> str: # Thousands separators are dropped; ٫ is the Persian decimal mark. return amount.replace(",", "").replace("٬", "").replace("٫", ".")