TextNormalizer / rules /currency.py
frznkshn's picture
Upload 13 files
ed76d6e verified
Raw History Blame Contribute Delete
5.5 kB
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<lead>[^\w{SYMBOLS}]*)
(?:
(?P<prefix_symbol>[{SYMBOLS}])(?P<prefixed>{AMOUNT})
|
(?P<suffixed>{AMOUNT})(?P<suffix_symbol>[{SYMBOLS}])
)
(?P<trail>[^\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("٫", ".")