Spaces:
Sleeping
Sleeping
Upload 13 files
Browse files- rules/base.py +9 -0
- rules/cards.py +102 -0
- rules/currency.py +164 -0
- rules/dates.py +154 -0
- rules/fraction.py +164 -0
- rules/numbers.py +161 -0
- rules/phone.py +312 -0
- rules/postal_national_id.py +99 -0
- rules/spaces.py +38 -0
- rules/symbols.py +630 -0
- rules/times.py +91 -0
- rules/units.py +324 -0
- rules/wrappers.py +9 -0
rules/base.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import ABC
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class BaseProcessor(ABC):
|
| 5 |
+
def __init__(self, epsilon:float=1.0):
|
| 6 |
+
self.epsilon = epsilon
|
| 7 |
+
|
| 8 |
+
def process(self, *args, **kwargs):
|
| 9 |
+
raise NotImplementedError
|
rules/cards.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
import re
|
| 3 |
+
|
| 4 |
+
from num2words import num2words
|
| 5 |
+
|
| 6 |
+
from rules.base import BaseProcessor
|
| 7 |
+
from rules.phone import PhoneNumberProcessor
|
| 8 |
+
from utils import NO_LETTERS, detect_digit_language
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
# ASCII, Persian (۰-۹) and Arabic-Indic (٠-٩) digits.
|
| 12 |
+
DIGIT = "0-9۰-۹٠-٩"
|
| 13 |
+
TO_ASCII = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789")
|
| 14 |
+
|
| 15 |
+
GROUP = rf"[{DIGIT}]{{4}}"
|
| 16 |
+
SEPARATOR = r"(?:[ \t]*[-–][ \t]*|[ \t]+)"
|
| 17 |
+
|
| 18 |
+
# Four groups of four digits (`6219 - 8765 - 2345 - 9031`, `6219-8765-2345-9031`,
|
| 19 |
+
# `6219 8765 2345 9031`) or sixteen digits in a row. It must not be glued to a
|
| 20 |
+
# word, a longer number, a decimal or a URL path.
|
| 21 |
+
CARD_PATTERN = re.compile(
|
| 22 |
+
rf"(?<![\w.,/+\-])"
|
| 23 |
+
rf"(?<![{DIGIT}][ \t])(?<![{DIGIT}][-–])(?<![{DIGIT}][-–][ \t])(?<![{DIGIT}][ \t][-–])(?<![{DIGIT}][ \t][-–][ \t])"
|
| 24 |
+
rf"(?:{GROUP}(?:{SEPARATOR}{GROUP}){{3}}|[{DIGIT}]{{16}})"
|
| 25 |
+
rf"(?![\w/]|[-–.,][{DIGIT}]|{SEPARATOR}[{DIGIT}])"
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
# English reads a pair that starts with 0 as "oh five" and `00` as "double zero".
|
| 29 |
+
ENGLISH_ZERO = "oh"
|
| 30 |
+
ENGLISH_DOUBLE_ZERO = "double zero"
|
| 31 |
+
|
| 32 |
+
GROUP_JOINER = " - "
|
| 33 |
+
PAIR_JOINER = {"fa": "، ", "en": ", "}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class CardNumberProcessor(BaseProcessor):
|
| 37 |
+
"""Reads 16-digit bank card numbers in pairs of digits.
|
| 38 |
+
|
| 39 |
+
Persian: `۶۲۱۹ - ۸۷۶۵ - ۲۳۴۵ - ۹۰۳۱`
|
| 40 |
+
-> `شصت و دو، نوزده - هشتاد و هفت، شصت و پنج - بیست و سه، چهل و پنج - نود، سی و یک`
|
| 41 |
+
English: `4111 1111 1111 1111`
|
| 42 |
+
-> `forty-one, eleven - eleven, eleven - eleven, eleven - eleven, eleven`
|
| 43 |
+
`5105-1051-0510-5100`
|
| 44 |
+
-> `fifty-one, oh five - ten, fifty-one - oh five, ten - fifty-one, double zero`
|
| 45 |
+
|
| 46 |
+
Each group of four digits is said as two numbers separated by a pause
|
| 47 |
+
(comma), and the groups are separated by ` - `. A pair that starts with `0`
|
| 48 |
+
keeps its zero: Persian `05` -> `صفر پنج`, `00` -> `دو صفر`; English `05` ->
|
| 49 |
+
`oh five`, `00` -> `double zero`.
|
| 50 |
+
|
| 51 |
+
Recognised: four groups of four digits separated by spaces or hyphens (or
|
| 52 |
+
sixteen digits in a row), written with ASCII, Persian or Arabic-Indic digits,
|
| 53 |
+
with any first digit. Left alone: other lengths (15-digit Amex, 19-digit),
|
| 54 |
+
masked numbers (`6219-****-****-9031`), numbers glued to words, and decimals.
|
| 55 |
+
|
| 56 |
+
With no letters in the text, the digits pick the language: Persian digits ->
|
| 57 |
+
Persian, ASCII digits -> English. This processor must run before
|
| 58 |
+
`PhoneNumberProcessor`, because a word like `شماره` ("number") before a card
|
| 59 |
+
would otherwise make the phone processor read it.
|
| 60 |
+
"""
|
| 61 |
+
|
| 62 |
+
def __init__(self, epsilon: float = 1.0):
|
| 63 |
+
super(CardNumberProcessor, self).__init__(epsilon=epsilon)
|
| 64 |
+
|
| 65 |
+
def process(self, text: str, language: str = None, *args, **kwargs) -> str:
|
| 66 |
+
if random.random() > self.epsilon:
|
| 67 |
+
return text
|
| 68 |
+
if re.search(rf"[{DIGIT}]", text) is None:
|
| 69 |
+
return text
|
| 70 |
+
|
| 71 |
+
if language in (None, NO_LETTERS):
|
| 72 |
+
language = detect_digit_language(text)
|
| 73 |
+
if language not in ("fa", "en"):
|
| 74 |
+
return text
|
| 75 |
+
|
| 76 |
+
return CARD_PATTERN.sub(lambda match: self._convert(match, language), text)
|
| 77 |
+
|
| 78 |
+
def _convert(self, match: re.Match, language: str) -> str:
|
| 79 |
+
digits = re.sub(rf"[^{DIGIT}]", "", match.group(0)).translate(TO_ASCII)
|
| 80 |
+
if len(digits) != 16:
|
| 81 |
+
return match.group(0)
|
| 82 |
+
|
| 83 |
+
groups = [digits[index:index + 4] for index in range(0, 16, 4)]
|
| 84 |
+
return GROUP_JOINER.join(
|
| 85 |
+
PAIR_JOINER[language].join(
|
| 86 |
+
self._pair_to_words(group[start:start + 2], language) for start in (0, 2)
|
| 87 |
+
)
|
| 88 |
+
for group in groups
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
@staticmethod
|
| 92 |
+
def _pair_to_words(pair: str, language: str) -> str:
|
| 93 |
+
if language == "fa":
|
| 94 |
+
# Same reading as inside a phone number: `62` -> `شصت و دو`,
|
| 95 |
+
# `05` -> `صفر پنج`, `00` -> `دو صفر`.
|
| 96 |
+
return PhoneNumberProcessor._persian_number(pair)
|
| 97 |
+
|
| 98 |
+
if pair == "00":
|
| 99 |
+
return ENGLISH_DOUBLE_ZERO
|
| 100 |
+
if pair.startswith("0"):
|
| 101 |
+
return f"{ENGLISH_ZERO} {num2words(int(pair))}"
|
| 102 |
+
return num2words(int(pair))
|
rules/currency.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
import re
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from decimal import Decimal
|
| 5 |
+
|
| 6 |
+
from num2fawords import words
|
| 7 |
+
from num2words import num2words
|
| 8 |
+
from persiantools import digits
|
| 9 |
+
|
| 10 |
+
from rules.base import BaseProcessor
|
| 11 |
+
from utils import NO_LETTERS, detect_digit_language
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass(frozen=True)
|
| 15 |
+
class Currency:
|
| 16 |
+
symbol: str
|
| 17 |
+
fa_unit: str
|
| 18 |
+
fa_subunit: str
|
| 19 |
+
en_unit: str
|
| 20 |
+
en_units: str
|
| 21 |
+
en_subunit: str
|
| 22 |
+
en_subunits: str
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
CURRENCIES = {
|
| 26 |
+
"$": Currency("$", "دلار", "سنت", "dollar", "dollars", "cent", "cents"),
|
| 27 |
+
"€": Currency("€", "یورو", "سنت", "euro", "euros", "cent", "cents"),
|
| 28 |
+
"£": Currency("£", "پوند", "پنس", "pound", "pounds", "penny", "pence"),
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
SYMBOLS = "".join(re.escape(symbol) for symbol in CURRENCIES)
|
| 32 |
+
AMOUNT = r"\d+(?:[.,٫٬]\d+)*"
|
| 33 |
+
|
| 34 |
+
# Matches a single token such as `3$`, `$3`, `۳€`, `£12.50`, `1,000$`,
|
| 35 |
+
# optionally wrapped in punctuation, e.g. `(3$)` or `£12.50.`.
|
| 36 |
+
CURRENCY_PATTERN = re.compile(
|
| 37 |
+
rf"""
|
| 38 |
+
^
|
| 39 |
+
(?P<lead>[^\w{SYMBOLS}]*)
|
| 40 |
+
(?:
|
| 41 |
+
(?P<prefix_symbol>[{SYMBOLS}])(?P<prefixed>{AMOUNT})
|
| 42 |
+
|
|
| 43 |
+
(?P<suffixed>{AMOUNT})(?P<suffix_symbol>[{SYMBOLS}])
|
| 44 |
+
)
|
| 45 |
+
(?P<trail>[^\w{SYMBOLS}]*)
|
| 46 |
+
$
|
| 47 |
+
""",
|
| 48 |
+
re.VERBOSE,
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class CurrencyProcessor(BaseProcessor):
|
| 53 |
+
"""Converts amounts such as `3$`, `€3` or `£12.50` into spoken text.
|
| 54 |
+
|
| 55 |
+
Supported symbols: `$` (dollar), `€` (euro), `£` (pound).
|
| 56 |
+
|
| 57 |
+
Persian: `۳$` -> `سه دلار`
|
| 58 |
+
`€12.50` -> `دوازده یورو و پنجاه سنت`
|
| 59 |
+
`£12.50` -> `دوازده پوند و پنجاه پنس`
|
| 60 |
+
English: `3$` -> `three dollars`
|
| 61 |
+
`$12.50` -> `twelve dollars and fifty cents`
|
| 62 |
+
`£12.50` -> `twelve pounds and fifty pence`
|
| 63 |
+
|
| 64 |
+
Amounts with more than two decimal places are read as a plain decimal
|
| 65 |
+
number (e.g. `3.141$` -> `three point one four one dollars`).
|
| 66 |
+
|
| 67 |
+
If the text has no letters at all, the digits pick the language:
|
| 68 |
+
`۱۰۰۰$` -> `یک هزار دلار`
|
| 69 |
+
`1000$` -> `one thousand dollars`
|
| 70 |
+
"""
|
| 71 |
+
|
| 72 |
+
def __init__(self, epsilon: float = 1.0):
|
| 73 |
+
super(CurrencyProcessor, self).__init__(epsilon=epsilon)
|
| 74 |
+
|
| 75 |
+
def process(self, token: str, language: str = "en", *args, **kwargs) -> str:
|
| 76 |
+
if random.random() > self.epsilon:
|
| 77 |
+
return token
|
| 78 |
+
|
| 79 |
+
match = CURRENCY_PATTERN.fullmatch(token)
|
| 80 |
+
if match is None:
|
| 81 |
+
return token
|
| 82 |
+
|
| 83 |
+
# A text with no letters at all (e.g. just `۱۰۰۰$`) has no language, so
|
| 84 |
+
# the digits decide: Persian digits -> Persian, ASCII digits -> English.
|
| 85 |
+
if language == NO_LETTERS:
|
| 86 |
+
language = detect_digit_language(token)
|
| 87 |
+
|
| 88 |
+
amount = digits.fa_to_en(match.group("prefixed") or match.group("suffixed"))
|
| 89 |
+
symbol = match.group("prefix_symbol") or match.group("suffix_symbol")
|
| 90 |
+
currency = CURRENCIES[symbol]
|
| 91 |
+
|
| 92 |
+
if language == "fa":
|
| 93 |
+
text = self._amount_to_persian(amount, currency)
|
| 94 |
+
elif language == "en":
|
| 95 |
+
text = self._amount_to_english(amount, currency)
|
| 96 |
+
else:
|
| 97 |
+
return token
|
| 98 |
+
|
| 99 |
+
return f"{match.group('lead')}{text}{match.group('trail')}"
|
| 100 |
+
|
| 101 |
+
@staticmethod
|
| 102 |
+
def is_currency(token: str) -> bool:
|
| 103 |
+
return CURRENCY_PATTERN.fullmatch(token) is not None
|
| 104 |
+
|
| 105 |
+
@staticmethod
|
| 106 |
+
def _amount_to_persian(amount: str, currency: Currency) -> str:
|
| 107 |
+
clean = CurrencyProcessor._clean_amount(amount)
|
| 108 |
+
parts = CurrencyProcessor._split_major_and_minor(clean)
|
| 109 |
+
|
| 110 |
+
if parts is None:
|
| 111 |
+
return f"{words(float(clean))} {currency.fa_unit}"
|
| 112 |
+
|
| 113 |
+
major, minor = parts
|
| 114 |
+
return CurrencyProcessor._compose(
|
| 115 |
+
major=major,
|
| 116 |
+
minor=minor,
|
| 117 |
+
spell=words,
|
| 118 |
+
major_unit=currency.fa_unit,
|
| 119 |
+
minor_unit=currency.fa_subunit,
|
| 120 |
+
joiner=" و ",
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
@staticmethod
|
| 124 |
+
def _amount_to_english(amount: str, currency: Currency) -> str:
|
| 125 |
+
clean = CurrencyProcessor._clean_amount(amount)
|
| 126 |
+
parts = CurrencyProcessor._split_major_and_minor(clean)
|
| 127 |
+
|
| 128 |
+
if parts is None:
|
| 129 |
+
return f"{num2words(Decimal(clean))} {currency.en_units}"
|
| 130 |
+
|
| 131 |
+
major, minor = parts
|
| 132 |
+
return CurrencyProcessor._compose(
|
| 133 |
+
major=major,
|
| 134 |
+
minor=minor,
|
| 135 |
+
spell=num2words,
|
| 136 |
+
major_unit=currency.en_unit if major == 1 else currency.en_units,
|
| 137 |
+
minor_unit=currency.en_subunit if minor == 1 else currency.en_subunits,
|
| 138 |
+
joiner=" and ",
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
@staticmethod
|
| 142 |
+
def _compose(major, minor, spell, major_unit, minor_unit, joiner) -> str:
|
| 143 |
+
pieces = []
|
| 144 |
+
# Skip "zero dollars" when there are cents (`0.50$` -> "fifty cents").
|
| 145 |
+
if major or not minor:
|
| 146 |
+
pieces.append(f"{spell(major)} {major_unit}")
|
| 147 |
+
if minor:
|
| 148 |
+
pieces.append(f"{spell(minor)} {minor_unit}")
|
| 149 |
+
return joiner.join(pieces)
|
| 150 |
+
|
| 151 |
+
@staticmethod
|
| 152 |
+
def _split_major_and_minor(clean: str):
|
| 153 |
+
"""Returns (major, minor), or None if there are more than 2 decimals."""
|
| 154 |
+
if "." not in clean:
|
| 155 |
+
return int(clean), 0
|
| 156 |
+
whole, fraction = clean.split(".")
|
| 157 |
+
if len(fraction) > 2:
|
| 158 |
+
return None
|
| 159 |
+
return int(whole), int(fraction.ljust(2, "0"))
|
| 160 |
+
|
| 161 |
+
@staticmethod
|
| 162 |
+
def _clean_amount(amount: str) -> str:
|
| 163 |
+
# Thousands separators are dropped; ٫ is the Persian decimal mark.
|
| 164 |
+
return amount.replace(",", "").replace("٬", "").replace("٫", ".")
|
rules/dates.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from typing import Union
|
| 4 |
+
|
| 5 |
+
from dateutil import parser as gregorian_parser
|
| 6 |
+
from num2fawords import words
|
| 7 |
+
from num2words import num2words
|
| 8 |
+
from persiantools import digits
|
| 9 |
+
from persiantools.jdatetime import JalaliDate
|
| 10 |
+
|
| 11 |
+
from rules.base import BaseProcessor
|
| 12 |
+
import random
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
Date = Union[datetime, JalaliDate]
|
| 16 |
+
|
| 17 |
+
GREGORIAN_MONTHS = (
|
| 18 |
+
"January",
|
| 19 |
+
"February",
|
| 20 |
+
"March",
|
| 21 |
+
"April",
|
| 22 |
+
"May",
|
| 23 |
+
"June",
|
| 24 |
+
"July",
|
| 25 |
+
"August",
|
| 26 |
+
"September",
|
| 27 |
+
"October",
|
| 28 |
+
"November",
|
| 29 |
+
"December",
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
DATE_PATTERN = re.compile(
|
| 33 |
+
r"""
|
| 34 |
+
(?:
|
| 35 |
+
(?P<year>\d{4})[^\d]+(?P<month>\d{1,2})[^\d]+(?P<day>\d{1,2})
|
| 36 |
+
|
|
| 37 |
+
(?P<day_first>\d{1,2})[^\d]+(?P<month_first>\d{1,2})[^\d]+(?P<year_first>\d{4})
|
| 38 |
+
)
|
| 39 |
+
""",
|
| 40 |
+
re.VERBOSE,
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class DateProcessor(BaseProcessor):
|
| 45 |
+
def __init__(self, epsilon:float=1.0):
|
| 46 |
+
super(DateProcessor, self).__init__(epsilon=epsilon)
|
| 47 |
+
|
| 48 |
+
def process(self, date_string: str, language: str, *args, **kwargs) -> str:
|
| 49 |
+
if random.random() > self.epsilon:
|
| 50 |
+
return date_string
|
| 51 |
+
if not self.is_date(date_string):
|
| 52 |
+
return date_string
|
| 53 |
+
|
| 54 |
+
try:
|
| 55 |
+
date = self._parse_date(date_string)
|
| 56 |
+
except ValueError:
|
| 57 |
+
return date_string
|
| 58 |
+
|
| 59 |
+
if date is None:
|
| 60 |
+
raise date_string
|
| 61 |
+
|
| 62 |
+
return self._date_to_text(date, language)
|
| 63 |
+
|
| 64 |
+
@staticmethod
|
| 65 |
+
def is_date(date_string: str) -> bool:
|
| 66 |
+
date_string = digits.fa_to_en(date_string)
|
| 67 |
+
return DATE_PATTERN.fullmatch(date_string) is not None
|
| 68 |
+
|
| 69 |
+
def _parse_date(self, date_string: str) -> Date | None:
|
| 70 |
+
clean_string = digits.fa_to_en(date_string)
|
| 71 |
+
|
| 72 |
+
match = DATE_PATTERN.search(clean_string)
|
| 73 |
+
|
| 74 |
+
if match:
|
| 75 |
+
year, month, day = self._extract_date_parts(match)
|
| 76 |
+
|
| 77 |
+
# Years less than 1600 is considered as Jalali
|
| 78 |
+
if year < 1600:
|
| 79 |
+
try:
|
| 80 |
+
return JalaliDate(year, month, day)
|
| 81 |
+
except ValueError:
|
| 82 |
+
pass
|
| 83 |
+
|
| 84 |
+
# Try Gregorian parsing.
|
| 85 |
+
try:
|
| 86 |
+
date = gregorian_parser.parse(clean_string)
|
| 87 |
+
|
| 88 |
+
if date.year >= 1600:
|
| 89 |
+
return date
|
| 90 |
+
|
| 91 |
+
except (ValueError, OverflowError):
|
| 92 |
+
pass
|
| 93 |
+
|
| 94 |
+
# If regex matched, give Jalali parsing one final attempt so that
|
| 95 |
+
# invalid Jalali dates produce a useful error.
|
| 96 |
+
if match:
|
| 97 |
+
year, month, day = self._extract_date_parts(match)
|
| 98 |
+
|
| 99 |
+
try:
|
| 100 |
+
return JalaliDate(year, month, day)
|
| 101 |
+
except ValueError as exc:
|
| 102 |
+
raise ValueError(
|
| 103 |
+
f"Invalid Jalali date: {date_string!r}"
|
| 104 |
+
) from exc
|
| 105 |
+
|
| 106 |
+
return None
|
| 107 |
+
|
| 108 |
+
@staticmethod
|
| 109 |
+
def _extract_date_parts(
|
| 110 |
+
match: re.Match[str],
|
| 111 |
+
) -> tuple[int, int, int]:
|
| 112 |
+
if match.group("year"):
|
| 113 |
+
return (
|
| 114 |
+
int(match.group("year")),
|
| 115 |
+
int(match.group("month")),
|
| 116 |
+
int(match.group("day")),
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
return (
|
| 120 |
+
int(match.group("year_first")),
|
| 121 |
+
int(match.group("month_first")),
|
| 122 |
+
int(match.group("day_first")),
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
def _date_to_text(self, date: Date, language: str) -> str:
|
| 126 |
+
if language == "fa":
|
| 127 |
+
return self._date_to_persian(date)
|
| 128 |
+
|
| 129 |
+
if language == "en":
|
| 130 |
+
return self._date_to_english(date)
|
| 131 |
+
|
| 132 |
+
raise ValueError(f"Unsupported language: {language!r}")
|
| 133 |
+
|
| 134 |
+
@staticmethod
|
| 135 |
+
def _date_to_persian(date: Date) -> str:
|
| 136 |
+
return f"{words(date.day)} {words(date.month)} {words(date.year)}"
|
| 137 |
+
|
| 138 |
+
@staticmethod
|
| 139 |
+
def _date_to_english(date: Date) -> str:
|
| 140 |
+
year = date.year
|
| 141 |
+
|
| 142 |
+
if "00" in str(year) or len(str(year)) < 4:
|
| 143 |
+
year_text = num2words(year)
|
| 144 |
+
else:
|
| 145 |
+
year_text = (
|
| 146 |
+
f"{num2words(year // 100)} "
|
| 147 |
+
f"{num2words(year % 100)}"
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
return (
|
| 151 |
+
f"{num2words(date.day, ordinal=True)} "
|
| 152 |
+
f"of {GREGORIAN_MONTHS[date.month - 1]}, "
|
| 153 |
+
f"{year_text}"
|
| 154 |
+
)
|
rules/fraction.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
import re
|
| 3 |
+
|
| 4 |
+
from num2fawords import ordinal_words, words
|
| 5 |
+
from num2words import num2words
|
| 6 |
+
from persiantools import digits
|
| 7 |
+
|
| 8 |
+
from rules.base import BaseProcessor
|
| 9 |
+
from utils import NO_LETTERS, detect_digit_language
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
VULGAR_FRACTIONS = {
|
| 13 |
+
"½": (1, 2),
|
| 14 |
+
"⅓": (1, 3),
|
| 15 |
+
"⅔": (2, 3),
|
| 16 |
+
"¼": (1, 4),
|
| 17 |
+
"¾": (3, 4),
|
| 18 |
+
"⅕": (1, 5),
|
| 19 |
+
"⅖": (2, 5),
|
| 20 |
+
"⅗": (3, 5),
|
| 21 |
+
"⅘": (4, 5),
|
| 22 |
+
"⅙": (1, 6),
|
| 23 |
+
"⅚": (5, 6),
|
| 24 |
+
"⅛": (1, 8),
|
| 25 |
+
"⅜": (3, 8),
|
| 26 |
+
"⅝": (5, 8),
|
| 27 |
+
"⅞": (7, 8),
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
VULGAR = "".join(VULGAR_FRACTIONS)
|
| 31 |
+
|
| 32 |
+
# A whole number, optionally with thousands separators: `12`, `1,000`, `۱٬۰۰۰`.
|
| 33 |
+
NUMBER = r"(?:\d{1,3}(?:[,٬]\d{3})+|\d+)"
|
| 34 |
+
|
| 35 |
+
# The fraction itself: `1/3`, `۱/۳`, `1,000/2`, `2 1/2` (mixed number), `½`, `1½`.
|
| 36 |
+
# Shared with `UnitProcessor`, which reads `1/2 kg`.
|
| 37 |
+
FRACTION_BODY = rf"""
|
| 38 |
+
(?:
|
| 39 |
+
(?:(?P<whole>{NUMBER})[ ])?(?P<numerator>{NUMBER})/(?P<denominator>{NUMBER})
|
| 40 |
+
|
|
| 41 |
+
(?:(?P<vulgar_whole>{NUMBER})[ ]?)?(?P<vulgar>[{VULGAR}])
|
| 42 |
+
)
|
| 43 |
+
"""
|
| 44 |
+
|
| 45 |
+
# Does not match parts of dates (`1402/05/10`), decimals (`3.1/2`), or fractions
|
| 46 |
+
# glued to a word or currency/percent sign (`and/or`, `1/2$`).
|
| 47 |
+
FRACTION_PATTERN = re.compile(
|
| 48 |
+
rf"""
|
| 49 |
+
(?<![\w/])(?<!\d[.,٫٬])
|
| 50 |
+
{FRACTION_BODY}
|
| 51 |
+
(?![\w/$€£%°])(?![.,٫٬]\d)
|
| 52 |
+
""",
|
| 53 |
+
re.VERBOSE,
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class FractionProcessor(BaseProcessor):
|
| 58 |
+
"""Converts fractions into spoken text.
|
| 59 |
+
|
| 60 |
+
Persian: `۱/۳` -> `یک سوم`
|
| 61 |
+
`۳/۴` -> `سه چهارم`
|
| 62 |
+
`۲ ۱/۲` -> `دو و یک دوم`
|
| 63 |
+
English: `1/3` -> `one third`
|
| 64 |
+
`3/4` -> `three quarters`
|
| 65 |
+
`2 1/2` -> `two and one half`
|
| 66 |
+
`½` -> `one half`
|
| 67 |
+
`1,000/2` -> `one thousand halves`
|
| 68 |
+
|
| 69 |
+
Works on the whole text (not word by word) because a mixed number such as
|
| 70 |
+
`2 1/2` spans two words. If the text has no letters at all, the digits pick
|
| 71 |
+
the language: Persian digits -> Persian, ASCII digits -> English.
|
| 72 |
+
|
| 73 |
+
A fraction followed by a unit (`1/2 kg`) is handled by `UnitProcessor`.
|
| 74 |
+
"""
|
| 75 |
+
|
| 76 |
+
def __init__(self, epsilon: float = 1.0):
|
| 77 |
+
super(FractionProcessor, self).__init__(epsilon=epsilon)
|
| 78 |
+
|
| 79 |
+
def process(self, text: str, language: str = None, *args, **kwargs) -> str:
|
| 80 |
+
if random.random() > self.epsilon:
|
| 81 |
+
return text
|
| 82 |
+
|
| 83 |
+
if language in (None, NO_LETTERS):
|
| 84 |
+
language = detect_digit_language(text)
|
| 85 |
+
if language not in ("fa", "en"):
|
| 86 |
+
return text
|
| 87 |
+
|
| 88 |
+
return FRACTION_PATTERN.sub(lambda match: self._convert(match, language), text)
|
| 89 |
+
|
| 90 |
+
@staticmethod
|
| 91 |
+
def _convert(match: re.Match, language: str) -> str:
|
| 92 |
+
parsed = FractionProcessor.parse(match)
|
| 93 |
+
if parsed is None:
|
| 94 |
+
return match.group(0)
|
| 95 |
+
return FractionProcessor.to_text(*parsed, language=language)
|
| 96 |
+
|
| 97 |
+
@staticmethod
|
| 98 |
+
def parse(match: re.Match):
|
| 99 |
+
"""Returns (whole, numerator, denominator) from a match of `FRACTION_BODY`.
|
| 100 |
+
|
| 101 |
+
`whole` is None when there is no mixed-number part. Returns None for
|
| 102 |
+
`1/0` (not a fraction) and `3/1` (just a ratio), which are left alone.
|
| 103 |
+
"""
|
| 104 |
+
if match.group("vulgar"):
|
| 105 |
+
numerator, denominator = VULGAR_FRACTIONS[match.group("vulgar")]
|
| 106 |
+
whole = match.group("vulgar_whole")
|
| 107 |
+
else:
|
| 108 |
+
numerator = FractionProcessor._to_int(match.group("numerator"))
|
| 109 |
+
denominator = FractionProcessor._to_int(match.group("denominator"))
|
| 110 |
+
whole = match.group("whole")
|
| 111 |
+
|
| 112 |
+
if denominator in (0, 1):
|
| 113 |
+
return None
|
| 114 |
+
return (
|
| 115 |
+
None if whole is None else FractionProcessor._to_int(whole),
|
| 116 |
+
numerator,
|
| 117 |
+
denominator,
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
@staticmethod
|
| 121 |
+
def to_text(whole, numerator: int, denominator: int, language: str) -> str:
|
| 122 |
+
if language == "fa":
|
| 123 |
+
fraction = FractionProcessor._persian_fraction(numerator, denominator)
|
| 124 |
+
joiner, spell = " و ", words
|
| 125 |
+
else:
|
| 126 |
+
fraction = FractionProcessor._english_fraction(numerator, denominator)
|
| 127 |
+
joiner, spell = " and ", num2words
|
| 128 |
+
|
| 129 |
+
if whole is None:
|
| 130 |
+
return fraction
|
| 131 |
+
return f"{spell(whole)}{joiner}{fraction}"
|
| 132 |
+
|
| 133 |
+
@staticmethod
|
| 134 |
+
def _to_int(number: str) -> int:
|
| 135 |
+
clean = digits.fa_to_en(number).replace(",", "").replace("٬", "")
|
| 136 |
+
return int(clean)
|
| 137 |
+
|
| 138 |
+
@staticmethod
|
| 139 |
+
def _persian_fraction(numerator: int, denominator: int) -> str:
|
| 140 |
+
ordinal = ordinal_words(denominator)
|
| 141 |
+
# The library writes 30 as `سیم`; the correct form is `سیام`.
|
| 142 |
+
if ordinal.endswith("سیم"):
|
| 143 |
+
ordinal = ordinal[: -len("سیم")] + "سیام"
|
| 144 |
+
# `یک صدم` / `یک هزارم`, not `یک یکصدم` / `یک یک هزارم`.
|
| 145 |
+
if ordinal == "یکصدم":
|
| 146 |
+
ordinal = "صدم"
|
| 147 |
+
elif ordinal.startswith("یک "):
|
| 148 |
+
ordinal = ordinal[len("یک "):]
|
| 149 |
+
return f"{words(numerator)} {ordinal}"
|
| 150 |
+
|
| 151 |
+
@staticmethod
|
| 152 |
+
def _english_fraction(numerator: int, denominator: int) -> str:
|
| 153 |
+
singular = numerator == 1
|
| 154 |
+
if denominator == 2:
|
| 155 |
+
unit = "half" if singular else "halves"
|
| 156 |
+
elif denominator == 4:
|
| 157 |
+
unit = "quarter" if singular else "quarters"
|
| 158 |
+
else:
|
| 159 |
+
ordinal = num2words(denominator, to="ordinal")
|
| 160 |
+
# `one hundredth`, not `one one hundredth`.
|
| 161 |
+
if ordinal.startswith("one "):
|
| 162 |
+
ordinal = ordinal[len("one "):]
|
| 163 |
+
unit = ordinal if singular else f"{ordinal}s"
|
| 164 |
+
return f"{num2words(numerator)} {unit}"
|
rules/numbers.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
import re
|
| 3 |
+
|
| 4 |
+
from num2fawords import words
|
| 5 |
+
from num2words import num2words
|
| 6 |
+
|
| 7 |
+
from rules.base import BaseProcessor
|
| 8 |
+
from utils import NO_LETTERS, detect_digit_language
|
| 9 |
+
|
| 10 |
+
# ASCII, Persian (۰-۹) and Arabic-Indic (٠-٩) digits.
|
| 11 |
+
DIGIT = "0-9۰-۹٠-٩"
|
| 12 |
+
TO_ASCII = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789")
|
| 13 |
+
|
| 14 |
+
THOUSANDS_SEPARATORS = ",٬"
|
| 15 |
+
DECIMAL_SEPARATORS = ".٫"
|
| 16 |
+
|
| 17 |
+
# `1,000,000`, `12`, `۱٬۰۰۰`, `3.5`, `۳٫۵`, `1,000.25`.
|
| 18 |
+
NUMBER = (
|
| 19 |
+
rf"(?:[{DIGIT}]{{1,3}}(?:[{THOUSANDS_SEPARATORS}][{DIGIT}]{{3}})+|[{DIGIT}]+)"
|
| 20 |
+
rf"(?:[{DECIMAL_SEPARATORS}][{DIGIT}]+)?"
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
# A number must stand alone. It is left alone when it is glued to a letter, a
|
| 24 |
+
# digit or an underscore on either side (`3D`, `mp3`, `H2O`, `A4`, `5G`,
|
| 25 |
+
# `covid19`), or sits inside a time, date, version, range, URL, e-mail,
|
| 26 |
+
# hashtag or `1,5` style number (`10:30`, `2024/01/05`, `1.2.3`, `10-12`,
|
| 27 |
+
# `#2024`, `a@3`).
|
| 28 |
+
NUMBER_PATTERN = re.compile(
|
| 29 |
+
rf"(?<![\w.,٫٬:/\\\-–+#@$€£])"
|
| 30 |
+
rf"(?P<number>{NUMBER})"
|
| 31 |
+
rf"(?![\w@]|[.,٫٬:/\\][{DIGIT}]|[-–][{DIGIT}]|[-–][A-Za-z]\b)"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
MAX_INTEGER_DIGITS = 15
|
| 35 |
+
MAX_FLOAT_FRACTION_DIGITS = 9
|
| 36 |
+
|
| 37 |
+
ENGLISH_YEAR_WORDS = r"(?:in|since|year|until|till|circa|c\.|AD|BC)"
|
| 38 |
+
ENGLISH_YEAR_PATTERN = re.compile(rf"(?:^|\s|[(\[\"'“‘]){ENGLISH_YEAR_WORDS}\s+$", re.IGNORECASE)
|
| 39 |
+
PERSIAN_YEAR_PATTERN = re.compile(r"(?:^|\s|[(\[\"'«])سال\s+$")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class NumberProcessor(BaseProcessor):
|
| 43 |
+
"""Reads any remaining standalone number as words, in Persian or English.
|
| 44 |
+
|
| 45 |
+
English: `I have 20 apples` -> `I have twenty apples`
|
| 46 |
+
`1,000` -> `one thousand`, `3.5` -> `three point five`
|
| 47 |
+
Persian: `من ۲۰ سیب دارم` -> `من بیست سیب دارم`
|
| 48 |
+
`۱٬۰۰۰` -> `یک هزار`, `۳٫۵` -> `سه و پنج دهم`
|
| 49 |
+
|
| 50 |
+
It is the last step of the pipeline, so every more specific processor
|
| 51 |
+
(units, currency, dates, times, phone numbers, ranges, fractions, ...) gets
|
| 52 |
+
the number first and this only reads what is left.
|
| 53 |
+
|
| 54 |
+
Handled:
|
| 55 |
+
* integers in ASCII, Persian or Arabic-Indic digits, with thousands
|
| 56 |
+
separators (`,` or `٬`);
|
| 57 |
+
* decimals (`.` or `٫`). English reads every digit after the point, so
|
| 58 |
+
`3.50` -> `three point five zero` and `0.05` -> `zero point zero five`.
|
| 59 |
+
Persian reads the decimal as a fraction (`۰٫۰۵` -> `پنج صدم`), and a
|
| 60 |
+
decimal with more than nine digits after the point as `ممیز` followed by
|
| 61 |
+
the digits;
|
| 62 |
+
* negative numbers are read by `NegativeSignProcessor` before this runs.
|
| 63 |
+
|
| 64 |
+
Language rules:
|
| 65 |
+
* English years are read the way English speakers say them, but only after
|
| 66 |
+
a word such as `in`, `since`, `year`, `until`, `till`, `circa`, `AD`,
|
| 67 |
+
`BC`: `in 1999` -> `in nineteen ninety-nine`, `since 1905` ->
|
| 68 |
+
`since nineteen oh five`, `in 2024` -> `in twenty twenty-four`,
|
| 69 |
+
`in 2005` -> `in two thousand and five`.
|
| 70 |
+
* Persian years after `سال` drop the leading `یک`: `سال ۱۴۰۳` ->
|
| 71 |
+
`سال هزار و چهارصد و سه`.
|
| 72 |
+
* An integer with a leading zero (`007`, `۰۰۷`) is read digit by digit,
|
| 73 |
+
as an identifier: `zero zero seven` / `صفر صفر هفت`.
|
| 74 |
+
|
| 75 |
+
Left alone: numbers glued to letters or digits (`3D`, `mp3`, `H2O`, `A4`,
|
| 76 |
+
`5G`), times, dates, versions (`1.2.3`), `1,5` style numbers, numbers in
|
| 77 |
+
URLs, e-mails and hashtags, `+5`, integers longer than 15 digits, and text
|
| 78 |
+
in "mixed" or any other language.
|
| 79 |
+
|
| 80 |
+
With no letters in the text, the digits pick the language: Persian digits ->
|
| 81 |
+
Persian, ASCII digits -> English.
|
| 82 |
+
"""
|
| 83 |
+
|
| 84 |
+
def __init__(self, epsilon: float = 1.0):
|
| 85 |
+
super(NumberProcessor, self).__init__(epsilon=epsilon)
|
| 86 |
+
|
| 87 |
+
def process(self, text: str, language: str = None, *args, **kwargs) -> str:
|
| 88 |
+
if random.random() > self.epsilon:
|
| 89 |
+
return text
|
| 90 |
+
if re.search(rf"[{DIGIT}]", text) is None:
|
| 91 |
+
return text
|
| 92 |
+
|
| 93 |
+
if language in (None, NO_LETTERS):
|
| 94 |
+
language = detect_digit_language(text)
|
| 95 |
+
if language not in ("fa", "en"):
|
| 96 |
+
return text
|
| 97 |
+
|
| 98 |
+
return NUMBER_PATTERN.sub(lambda match: self._convert(match, language), text)
|
| 99 |
+
|
| 100 |
+
def _convert(self, match: re.Match, language: str) -> str:
|
| 101 |
+
raw = match.group("number")
|
| 102 |
+
clean = raw.translate(TO_ASCII)
|
| 103 |
+
has_thousands_separator = any(separator in clean for separator in THOUSANDS_SEPARATORS)
|
| 104 |
+
for separator in THOUSANDS_SEPARATORS:
|
| 105 |
+
clean = clean.replace(separator, "")
|
| 106 |
+
clean = clean.replace("٫", ".")
|
| 107 |
+
|
| 108 |
+
whole, _, fraction = clean.partition(".")
|
| 109 |
+
has_fraction = "." in clean
|
| 110 |
+
|
| 111 |
+
if has_fraction:
|
| 112 |
+
if len(whole) > 1 and whole.startswith("0"):
|
| 113 |
+
return raw
|
| 114 |
+
return self._decimal_to_words(whole, fraction, language)
|
| 115 |
+
|
| 116 |
+
if len(whole) > MAX_INTEGER_DIGITS:
|
| 117 |
+
return raw
|
| 118 |
+
if len(whole) > 1 and whole.startswith("0") and not has_thousands_separator:
|
| 119 |
+
return self._digits_to_words(whole, language)
|
| 120 |
+
|
| 121 |
+
before = match.string[:match.start()]
|
| 122 |
+
return self._integer_to_words(int(whole), language, before)
|
| 123 |
+
|
| 124 |
+
@staticmethod
|
| 125 |
+
def _integer_to_words(value: int, language: str, before: str = "") -> str:
|
| 126 |
+
if language == "fa":
|
| 127 |
+
text = words(value)
|
| 128 |
+
if 1000 <= value <= 1999 and PERSIAN_YEAR_PATTERN.search(before):
|
| 129 |
+
text = text.removeprefix("یک ")
|
| 130 |
+
return text
|
| 131 |
+
|
| 132 |
+
if 1100 <= value <= 2099 and not 2000 <= value <= 2009 \
|
| 133 |
+
and ENGLISH_YEAR_PATTERN.search(before):
|
| 134 |
+
return NumberProcessor._english_year(value)
|
| 135 |
+
return num2words(value)
|
| 136 |
+
|
| 137 |
+
@staticmethod
|
| 138 |
+
def _english_year(value: int) -> str:
|
| 139 |
+
"""1999 -> `nineteen ninety-nine`, 1905 -> `nineteen oh five`, 1900 -> `nineteen hundred`."""
|
| 140 |
+
high, low = divmod(value, 100)
|
| 141 |
+
if low == 0:
|
| 142 |
+
return f"{num2words(high)} hundred"
|
| 143 |
+
if low < 10:
|
| 144 |
+
return f"{num2words(high)} oh {num2words(low)}"
|
| 145 |
+
return f"{num2words(high)} {num2words(low)}"
|
| 146 |
+
|
| 147 |
+
@staticmethod
|
| 148 |
+
def _decimal_to_words(whole: str, fraction: str, language: str) -> str:
|
| 149 |
+
if language == "fa":
|
| 150 |
+
if len(fraction) > MAX_FLOAT_FRACTION_DIGITS or len(whole) > MAX_INTEGER_DIGITS:
|
| 151 |
+
spoken_fraction = " ".join(words(int(digit)) for digit in fraction)
|
| 152 |
+
return f"{words(int(whole))} ممیز {spoken_fraction}"
|
| 153 |
+
return words(float(f"{whole}.{fraction}"))
|
| 154 |
+
|
| 155 |
+
spoken_fraction = " ".join(num2words(int(digit)) for digit in fraction)
|
| 156 |
+
return f"{num2words(int(whole))} point {spoken_fraction}"
|
| 157 |
+
|
| 158 |
+
@staticmethod
|
| 159 |
+
def _digits_to_words(digits: str, language: str) -> str:
|
| 160 |
+
spell = words if language == "fa" else num2words
|
| 161 |
+
return " ".join(spell(int(digit)) for digit in digits)
|
rules/phone.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
import re
|
| 3 |
+
|
| 4 |
+
from num2fawords import words
|
| 5 |
+
|
| 6 |
+
from rules.base import BaseProcessor
|
| 7 |
+
from utils import NO_LETTERS, detect_digit_language
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
DIGIT_WORDS = {
|
| 11 |
+
"en": ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine"],
|
| 12 |
+
"fa": ["صفر", "یک", "دو", "سه", "چهار", "پنج", "شش", "هفت", "هشت", "نه"],
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
PLUS_WORDS = {"en": "plus", "fa": "مثبت"}
|
| 16 |
+
|
| 17 |
+
# ASCII, Persian (۰-۹) and Arabic-Indic (٠-٩) digits.
|
| 18 |
+
DIGIT = "0-9۰-۹٠-٩"
|
| 19 |
+
TO_ASCII = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789")
|
| 20 |
+
|
| 21 |
+
# One group of digits, optionally wrapped: `912`, `(021)`, `(+98)`, `+98`.
|
| 22 |
+
FIRST_GROUP = rf"(?:\([ \t]*)?(?:\+[ \t]*)?[{DIGIT}]+(?:[ \t]*\))?"
|
| 23 |
+
NEXT_GROUP = rf"(?:\([ \t]*)?[{DIGIT}]+(?:[ \t]*\))?"
|
| 24 |
+
# Between groups: spaces, a hyphen or dot, or nothing at all right after a
|
| 25 |
+
# closing parenthesis (`(0915)6486320`).
|
| 26 |
+
SEPARATOR = r"(?:[ \t]*[-.–][ \t]*|[ \t]+|(?<=\)))"
|
| 27 |
+
|
| 28 |
+
# A run of digit groups: `+98-912-4338135`, `021 88069895`, `(021) 88069895`.
|
| 29 |
+
# It must not be glued to a word, a decimal, a URL path or a currency sign.
|
| 30 |
+
RUN_PATTERN = re.compile(
|
| 31 |
+
rf"(?<![\w.,/\-+$€£]){FIRST_GROUP}(?:{SEPARATOR}{NEXT_GROUP})*"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
GROUP_PATTERN = re.compile(
|
| 35 |
+
rf"(?P<open>\()?[ \t]*(?P<plus>\+)?[ \t]*(?P<digits>[{DIGIT}]+)(?P<close>[ \t]*\))?"
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
# Words after which a looser number (7-15 digits, any start) is a phone number:
|
| 39 |
+
# `tel: 88069895`, `تلفن ۸۸۰۶۹۸۹۵`.
|
| 40 |
+
TRIGGER_PATTERN = re.compile(
|
| 41 |
+
r"(?<![^\W\d_])"
|
| 42 |
+
r"(?:phone|tel|telephone|mobile|cell|cellphone|fax|call|whatsapp|contact|"
|
| 43 |
+
r"تلفن|موبایل|همراه|شماره|فکس|تماس|واتس[ ]?اپ|تلگرام)"
|
| 44 |
+
r"(?:[ \t]*(?:number|no\.?|num|#|:|،|-|–))*[ \t]*$",
|
| 45 |
+
re.IGNORECASE,
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class PhoneNumberProcessor(BaseProcessor):
|
| 50 |
+
"""Reads phone numbers digit by digit.
|
| 51 |
+
|
| 52 |
+
English: `09124338135` -> `zero nine one two, four three three, eight one, three five`
|
| 53 |
+
`+98-912-4338135` -> `plus nine eight, nine one two, four three three, eight one, three five`
|
| 54 |
+
`021-88069895` -> `zero two one, eight eight, zero six, nine eight, nine five`
|
| 55 |
+
Persian: `۰۹۱۲۴۳۳۸۱۳۵` -> `صفر نهصد و دوازده، چهارصد و سی و سه، هشتاد و یک، سی و پنج`
|
| 56 |
+
`02188037395` -> `صفر بیست و یک، هشتاد و هشت، صفر سه، هفتاد و سه، نود و پنج`
|
| 57 |
+
`+989124338135` -> `مثبت نود و هشت، نهصد و دوازده، چهارصد و سی و سه، هشتاد و یک، سی و پنج`
|
| 58 |
+
|
| 59 |
+
An Iranian number is said in groups: mobile `0|912|433|81|35`, landline
|
| 60 |
+
`0|21|88|03|73|95`, and an 8-digit local number after a phone word in pairs.
|
| 61 |
+
Persian says each group as a number (a leading zero inside a group is said:
|
| 62 |
+
`03` -> `صفر سه`, and several are counted: `001` -> `دو صفر یک`), English reads each group digit by digit with a pause (comma)
|
| 63 |
+
between groups. Other numbers (`+44 ...`, US style) are read digit by digit:
|
| 64 |
+
English with a comma between the groups as written (`555-123-4567` ->
|
| 65 |
+
`five five five, one two three, four five six seven`), Persian without commas.
|
| 66 |
+
|
| 67 |
+
Recognised (digits may be ASCII, Persian or Arabic-Indic, and may be
|
| 68 |
+
separated by spaces, hyphens or dots, with the area or country code in
|
| 69 |
+
parentheses):
|
| 70 |
+
- international, with `+`: 8-15 digits (`+98 912 433 8135`, `+1 (555) 123-4567`);
|
| 71 |
+
- national, starting with `0`: 10-11 digits (`0912 433 8135`, `021-88069895`,
|
| 72 |
+
`(021) 88069895`); starting with `00`: up to 17 digits (`0098 912 4338135`);
|
| 73 |
+
- US style `555-123-4567` / `(555) 123-4567` / `1-555-123-4567`;
|
| 74 |
+
- any 7-15 digit number right after a word like `tel`, `phone`, `call`,
|
| 75 |
+
`تلفن`, `موبایل`, `شماره` (`tel: 88069895`).
|
| 76 |
+
|
| 77 |
+
Left alone: numbers without a leading `0` or `+` (`4338135`, `2024-05-10`),
|
| 78 |
+
decimals (`0.123456789`), numbers glued to words, URL paths, longer
|
| 79 |
+
numbers (card numbers) and anything that is not 10-11 digits after a `0`.
|
| 80 |
+
|
| 81 |
+
With no letters in the text, the digits pick the language: Persian digits ->
|
| 82 |
+
Persian, ASCII digits -> English. Parentheses around a group are kept:
|
| 83 |
+
`(021) 88069895` -> `(zero two one) eight eight ...`.
|
| 84 |
+
"""
|
| 85 |
+
|
| 86 |
+
def __init__(self, epsilon: float = 1.0):
|
| 87 |
+
super(PhoneNumberProcessor, self).__init__(epsilon=epsilon)
|
| 88 |
+
|
| 89 |
+
def process(self, text: str, language: str = None, *args, **kwargs) -> str:
|
| 90 |
+
if random.random() > self.epsilon:
|
| 91 |
+
return text
|
| 92 |
+
if re.search(rf"[{DIGIT}]", text) is None:
|
| 93 |
+
return text
|
| 94 |
+
|
| 95 |
+
if language in (None, NO_LETTERS):
|
| 96 |
+
language = detect_digit_language(text)
|
| 97 |
+
if language not in ("fa", "en"):
|
| 98 |
+
return text
|
| 99 |
+
|
| 100 |
+
return RUN_PATTERN.sub(lambda match: self._convert_run(match, language), text)
|
| 101 |
+
|
| 102 |
+
def _convert_run(self, match: re.Match, language: str) -> str:
|
| 103 |
+
text = match.string
|
| 104 |
+
groups = list(GROUP_PATTERN.finditer(match.group(0), 0))
|
| 105 |
+
# Absolute positions of every group in the whole text.
|
| 106 |
+
base = match.start()
|
| 107 |
+
spans = [self._group_span(group, base) for group in groups]
|
| 108 |
+
|
| 109 |
+
pieces = []
|
| 110 |
+
cursor = match.start()
|
| 111 |
+
index = 0
|
| 112 |
+
while index < len(groups):
|
| 113 |
+
found = self._find_phone(text, groups, spans, index, match.end())
|
| 114 |
+
if found is None:
|
| 115 |
+
break
|
| 116 |
+
start, end = found
|
| 117 |
+
pieces.append(text[cursor:spans[start][0]])
|
| 118 |
+
pieces.append(self._render(groups[start:end], language))
|
| 119 |
+
cursor = spans[end - 1][1]
|
| 120 |
+
index = end
|
| 121 |
+
|
| 122 |
+
if not pieces:
|
| 123 |
+
return match.group(0)
|
| 124 |
+
pieces.append(text[cursor:match.end()])
|
| 125 |
+
return "".join(pieces)
|
| 126 |
+
|
| 127 |
+
@staticmethod
|
| 128 |
+
def _group_span(group: re.Match, base: int):
|
| 129 |
+
start = min(
|
| 130 |
+
group.start(name) for name in ("open", "plus", "digits") if group.group(name)
|
| 131 |
+
)
|
| 132 |
+
end = group.end("close") if group.group("close") else group.end("digits")
|
| 133 |
+
return base + start, base + end
|
| 134 |
+
|
| 135 |
+
def _find_phone(self, text, groups, spans, index, run_end):
|
| 136 |
+
"""The first (start, end) group range from `index` on that is a phone number."""
|
| 137 |
+
for start in range(index, len(groups)):
|
| 138 |
+
# A number that begins in the middle of a run must follow a plain
|
| 139 |
+
# space, not a hyphen or a dot (`3.0912433813` is a decimal).
|
| 140 |
+
if start > index or start > 0:
|
| 141 |
+
gap = text[spans[start - 1][1]:spans[start][0]]
|
| 142 |
+
if gap.strip(" \t"):
|
| 143 |
+
continue
|
| 144 |
+
for end in range(len(groups), start, -1):
|
| 145 |
+
if not self._is_phone(text, groups, spans, start, end):
|
| 146 |
+
continue
|
| 147 |
+
if end < len(groups):
|
| 148 |
+
gap = text[spans[end - 1][1]:spans[end][0]]
|
| 149 |
+
# `021-88069895-12`, `0912433813.5`: not a clean boundary.
|
| 150 |
+
if gap.strip(" \t"):
|
| 151 |
+
continue
|
| 152 |
+
return start, end
|
| 153 |
+
return None
|
| 154 |
+
|
| 155 |
+
def _is_phone(self, text, groups, spans, start, end) -> bool:
|
| 156 |
+
chosen = groups[start:end]
|
| 157 |
+
digit_groups = [group.group("digits").translate(TO_ASCII) for group in chosen]
|
| 158 |
+
total = sum(len(digits) for digits in digit_groups)
|
| 159 |
+
first = digit_groups[0]
|
| 160 |
+
has_plus = chosen[0].group("plus") is not None
|
| 161 |
+
|
| 162 |
+
after = text[spans[end - 1][1]:spans[end - 1][1] + 1]
|
| 163 |
+
if after and re.match(r"\w", after):
|
| 164 |
+
return False
|
| 165 |
+
|
| 166 |
+
if has_plus:
|
| 167 |
+
return 8 <= total <= 15
|
| 168 |
+
if self._is_us_format(chosen, digit_groups, text, spans, start):
|
| 169 |
+
return True
|
| 170 |
+
if TRIGGER_PATTERN.search(text[:spans[start][0]]):
|
| 171 |
+
return 7 <= total <= 15
|
| 172 |
+
if len(first) < 2 or not first.startswith("0"):
|
| 173 |
+
return False
|
| 174 |
+
# `0912433813.5` is a decimal: every later group needs at least 2 digits.
|
| 175 |
+
if any(len(digits) < 2 for digits in digit_groups[1:]):
|
| 176 |
+
return False
|
| 177 |
+
if first.startswith("00"):
|
| 178 |
+
return 10 <= total <= 17
|
| 179 |
+
return 10 <= total <= 11
|
| 180 |
+
|
| 181 |
+
@staticmethod
|
| 182 |
+
def _is_us_format(chosen, digit_groups, text, spans, start) -> bool:
|
| 183 |
+
"""`555-123-4567`, `(555) 123-4567`, `1-555-123-4567` (not plain spaces)."""
|
| 184 |
+
sizes = tuple(len(digits) for digits in digit_groups)
|
| 185 |
+
if sizes not in ((3, 3, 4), (1, 3, 3, 4)):
|
| 186 |
+
return False
|
| 187 |
+
if sizes[0] == 1 and digit_groups[0] != "1":
|
| 188 |
+
return False
|
| 189 |
+
begin, finish = spans[start][0], spans[start + len(chosen) - 1][1]
|
| 190 |
+
return re.search(r"[-.–(]", text[begin:finish]) is not None
|
| 191 |
+
|
| 192 |
+
def _render(self, chosen, language: str) -> str:
|
| 193 |
+
grouped = self._render_groups(chosen, language)
|
| 194 |
+
if grouped is not None:
|
| 195 |
+
return grouped
|
| 196 |
+
|
| 197 |
+
# Not an Iranian number: read each written group digit by digit. English
|
| 198 |
+
# puts a pause (comma) between the written groups, Persian does not.
|
| 199 |
+
words_for_digits = DIGIT_WORDS[language]
|
| 200 |
+
rendered = []
|
| 201 |
+
for group in chosen:
|
| 202 |
+
spoken = " ".join(
|
| 203 |
+
words_for_digits[int(digit)] for digit in group.group("digits").translate(TO_ASCII)
|
| 204 |
+
)
|
| 205 |
+
if group.group("plus"):
|
| 206 |
+
spoken = f"{PLUS_WORDS[language]} {spoken}"
|
| 207 |
+
rendered.append(
|
| 208 |
+
f"{'(' if group.group('open') else ''}{spoken}{')' if group.group('close') else ''}"
|
| 209 |
+
)
|
| 210 |
+
return (", " if language == "en" else " ").join(rendered)
|
| 211 |
+
|
| 212 |
+
def _render_groups(self, chosen, language: str):
|
| 213 |
+
"""Reads an Iranian number in its spoken groups, or None.
|
| 214 |
+
|
| 215 |
+
Persian: `09124338135` -> `صفر نهصد و دوازده، چهارصد و سی و سه، هشتاد و یک، سی و پنج`
|
| 216 |
+
English: `09124338135` -> `zero nine one two, four three three, eight one, three five`
|
| 217 |
+
"""
|
| 218 |
+
digits = "".join(group.group("digits").translate(TO_ASCII) for group in chosen)
|
| 219 |
+
has_plus = chosen[0].group("plus") is not None
|
| 220 |
+
units = self._iranian_units(digits, has_plus)
|
| 221 |
+
if units is None:
|
| 222 |
+
return None
|
| 223 |
+
|
| 224 |
+
# Parentheses are kept only where they line up with a spoken unit.
|
| 225 |
+
opens, closes, offset = set(), set(), 0
|
| 226 |
+
for group in chosen:
|
| 227 |
+
if group.group("open"):
|
| 228 |
+
opens.add(offset)
|
| 229 |
+
offset += len(group.group("digits"))
|
| 230 |
+
if group.group("close"):
|
| 231 |
+
closes.add(offset)
|
| 232 |
+
|
| 233 |
+
pieces = []
|
| 234 |
+
for index, (start, end) in enumerate(units):
|
| 235 |
+
spoken = self._spoken_number(digits[start:end], language)
|
| 236 |
+
if index == 0 and has_plus:
|
| 237 |
+
spoken = f"{PLUS_WORDS[language]} {spoken}"
|
| 238 |
+
if start in opens:
|
| 239 |
+
spoken = f"({spoken}"
|
| 240 |
+
if end in closes:
|
| 241 |
+
spoken = f"{spoken})"
|
| 242 |
+
pieces.append(spoken)
|
| 243 |
+
return ("، " if language == "fa" else ", ").join(pieces)
|
| 244 |
+
|
| 245 |
+
@classmethod
|
| 246 |
+
def _spoken_number(cls, digits: str, language: str) -> str:
|
| 247 |
+
if language == "fa":
|
| 248 |
+
return cls._persian_number(digits)
|
| 249 |
+
return " ".join(DIGIT_WORDS["en"][int(digit)] for digit in digits)
|
| 250 |
+
|
| 251 |
+
@staticmethod
|
| 252 |
+
def _persian_number(digits: str) -> str:
|
| 253 |
+
"""`912` -> `نهصد و دوازده`; leading zeros are said: `03` -> `صفر سه`.
|
| 254 |
+
|
| 255 |
+
Two or more leading zeros are counted: `001` -> `دو صفر یک`, `00` -> `دو صفر`.
|
| 256 |
+
"""
|
| 257 |
+
stripped = digits.lstrip("0")
|
| 258 |
+
zeros = len(digits) - len(stripped)
|
| 259 |
+
spoken = []
|
| 260 |
+
if zeros == 1:
|
| 261 |
+
spoken.append(DIGIT_WORDS["fa"][0])
|
| 262 |
+
elif zeros > 1:
|
| 263 |
+
spoken.append(f"{words(zeros)} {DIGIT_WORDS['fa'][0]}")
|
| 264 |
+
if stripped:
|
| 265 |
+
spoken.append(words(int(stripped)))
|
| 266 |
+
return " ".join(spoken)
|
| 267 |
+
|
| 268 |
+
@staticmethod
|
| 269 |
+
def _iranian_units(digits: str, has_plus: bool):
|
| 270 |
+
"""Splits an Iranian number into spoken units as (start, end) digit offsets
|
| 271 |
+
(the same units in Persian and English).
|
| 272 |
+
|
| 273 |
+
Mobile `0|912|433|81|35`, landline `0|21|88|03|73|95`; the `0` or the
|
| 274 |
+
country code (`98`, `0098`) is said together with / before the first
|
| 275 |
+
group. Returns None for anything that is not an Iranian number, which
|
| 276 |
+
is then read digit by digit in its written groups.
|
| 277 |
+
"""
|
| 278 |
+
country = False
|
| 279 |
+
if has_plus:
|
| 280 |
+
if len(digits) != 12 or not digits.startswith("98"):
|
| 281 |
+
return None
|
| 282 |
+
base, country = 2, True
|
| 283 |
+
elif digits.startswith("0098"):
|
| 284 |
+
if len(digits) != 14:
|
| 285 |
+
return None
|
| 286 |
+
base, country = 4, True
|
| 287 |
+
elif len(digits) == 11 and digits[0] == "0":
|
| 288 |
+
base = 1
|
| 289 |
+
elif len(digits) == 8:
|
| 290 |
+
# A local number after a phone word: `۸۸۰۶۹۸۹۵` -> 88|06|98|95.
|
| 291 |
+
return [(index, index + 2) for index in range(0, 8, 2)]
|
| 292 |
+
else:
|
| 293 |
+
return None
|
| 294 |
+
|
| 295 |
+
national = digits[base:]
|
| 296 |
+
if national[0] == "9":
|
| 297 |
+
sizes = (3, 3, 2, 2)
|
| 298 |
+
elif national[0] in "012345678":
|
| 299 |
+
sizes = (2, 2, 2, 2, 2)
|
| 300 |
+
else:
|
| 301 |
+
return None
|
| 302 |
+
|
| 303 |
+
units = []
|
| 304 |
+
position = base
|
| 305 |
+
for size in sizes:
|
| 306 |
+
units.append((position, position + size))
|
| 307 |
+
position += size
|
| 308 |
+
if country:
|
| 309 |
+
units.insert(0, (0, base))
|
| 310 |
+
else:
|
| 311 |
+
units[0] = (0, units[0][1])
|
| 312 |
+
return units
|
rules/postal_national_id.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
import re
|
| 3 |
+
|
| 4 |
+
from rules.base import BaseProcessor
|
| 5 |
+
from rules.cards import CardNumberProcessor, DIGIT, TO_ASCII
|
| 6 |
+
from utils import NO_LETTERS, detect_digit_language
|
| 7 |
+
|
| 8 |
+
# Ten digits in a row, split 5 + 5 with a space or hyphen (`15147-17118`), or
|
| 9 |
+
# split 3 + 6 + 1 with hyphens (`001-234567-9`, a common national ID layout).
|
| 10 |
+
# It must not be glued to a word, a longer number, a decimal or a URL path.
|
| 11 |
+
_D = rf"[{DIGIT}]"
|
| 12 |
+
IDENTIFIER_PATTERN = re.compile(
|
| 13 |
+
rf"(?<![\w.,/+\-])(?<![{DIGIT}][ \t])(?<![{DIGIT}][-–])"
|
| 14 |
+
rf"(?:{_D}{{5}}[ \t]?[-–]?[ \t]?{_D}{{5}}|{_D}{{3}}-{_D}{{6}}-{_D})"
|
| 15 |
+
rf"(?![\w/]|[-–.,][{DIGIT}]|[ \t][{DIGIT}])"
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
PAIR_JOINER = {"fa": "، ", "en": ", "}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class PostalCodeAndNationalIdProcessor(BaseProcessor):
|
| 22 |
+
"""Reads 10-digit Iranian postal codes and national ID numbers in pairs of digits.
|
| 23 |
+
|
| 24 |
+
Persian: `۱۵۱۴۷۱۷۱۱۸` -> `پانزده، چهارده، هفتاد و یک، هفتاد و یک، هجده`
|
| 25 |
+
`۰۰۱۲۳۴۵۶۷۹` -> `دو صفر، دوازده، سی و چهار، پنجاه و شش، هفتاد و نه`
|
| 26 |
+
English: `1514717118` -> `fifteen, fourteen, seventy-one, seventy-one, eighteen`
|
| 27 |
+
`0012345679` -> `double zero, twelve, thirty-four, fifty-six, seventy-nine`
|
| 28 |
+
|
| 29 |
+
Both kinds of number are ten digits and are read the same way: five pairs
|
| 30 |
+
separated by a pause (comma). A pair that starts with `0` keeps its zero,
|
| 31 |
+
exactly as in `CardNumberProcessor`: Persian `05` -> `صفر پنج`, `00` ->
|
| 32 |
+
`دو صفر`; English `05` -> `oh five`, `00` -> `double zero`. This matters for
|
| 33 |
+
national IDs, which often start with one or two zeros.
|
| 34 |
+
|
| 35 |
+
A ten-digit number is accepted when it is either
|
| 36 |
+
* a postal code: the first digit is not 0 and the first five digits
|
| 37 |
+
contain no 0 or 2, or
|
| 38 |
+
* a national ID: the check digit is correct (the sum of the first nine
|
| 39 |
+
digits times 10, 9, ..., 2 gives a remainder r; the last digit must be r
|
| 40 |
+
when r < 2, otherwise 11 - r) and the digits are not all the same.
|
| 41 |
+
Written in a row (`1514717118`), split 5 + 5 with a hyphen or space
|
| 42 |
+
(`15147-17118`), or, for national IDs, split 3 + 6 + 1 with hyphens
|
| 43 |
+
(`001-234567-9`). The 3 + 6 + 1 split is accepted only when the check digit
|
| 44 |
+
is correct. ASCII, Persian and Arabic-Indic digits are all accepted.
|
| 45 |
+
|
| 46 |
+
Left alone: other lengths (such as 5-digit ZIP codes), ten-digit numbers
|
| 47 |
+
that pass neither test (for example a phone number without its leading zero,
|
| 48 |
+
`9124338135`), numbers glued to words, and decimals.
|
| 49 |
+
|
| 50 |
+
With no letters in the text, the digits pick the language: Persian digits ->
|
| 51 |
+
Persian, ASCII digits -> English. It runs after `CardNumberProcessor` and
|
| 52 |
+
before `PhoneNumberProcessor`.
|
| 53 |
+
"""
|
| 54 |
+
|
| 55 |
+
def __init__(self, epsilon: float = 1.0):
|
| 56 |
+
super(PostalCodeAndNationalIdProcessor, self).__init__(epsilon=epsilon)
|
| 57 |
+
|
| 58 |
+
def process(self, text: str, language: str = None, *args, **kwargs) -> str:
|
| 59 |
+
if random.random() > self.epsilon:
|
| 60 |
+
return text
|
| 61 |
+
if re.search(rf"[{DIGIT}]", text) is None:
|
| 62 |
+
return text
|
| 63 |
+
|
| 64 |
+
if language in (None, NO_LETTERS):
|
| 65 |
+
language = detect_digit_language(text)
|
| 66 |
+
if language not in ("fa", "en"):
|
| 67 |
+
return text
|
| 68 |
+
|
| 69 |
+
return IDENTIFIER_PATTERN.sub(lambda match: self._convert(match, language), text)
|
| 70 |
+
|
| 71 |
+
def _convert(self, match: re.Match, language: str) -> str:
|
| 72 |
+
raw = match.group(0)
|
| 73 |
+
digits = re.sub(rf"[^{DIGIT}]", "", raw).translate(TO_ASCII)
|
| 74 |
+
if len(digits) != 10:
|
| 75 |
+
return raw
|
| 76 |
+
|
| 77 |
+
is_national_id_layout = raw.count("-") == 2
|
| 78 |
+
if is_national_id_layout:
|
| 79 |
+
if not self._is_national_id(digits):
|
| 80 |
+
return raw
|
| 81 |
+
elif not (self._is_postal_code(digits) or self._is_national_id(digits)):
|
| 82 |
+
return raw
|
| 83 |
+
|
| 84 |
+
pairs = [digits[index:index + 2] for index in range(0, 10, 2)]
|
| 85 |
+
return PAIR_JOINER[language].join(
|
| 86 |
+
CardNumberProcessor._pair_to_words(pair, language) for pair in pairs
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
@staticmethod
|
| 90 |
+
def _is_postal_code(digits: str) -> bool:
|
| 91 |
+
return digits[0] != "0" and not set(digits[:5]) & {"0", "2"}
|
| 92 |
+
|
| 93 |
+
@staticmethod
|
| 94 |
+
def _is_national_id(digits: str) -> bool:
|
| 95 |
+
if len(set(digits)) == 1:
|
| 96 |
+
return False
|
| 97 |
+
remainder = sum(int(digits[i]) * (10 - i) for i in range(9)) % 11
|
| 98 |
+
check = remainder if remainder < 2 else 11 - remainder
|
| 99 |
+
return int(digits[9]) == check
|
rules/spaces.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
import re
|
| 3 |
+
|
| 4 |
+
from rules.base import BaseProcessor
|
| 5 |
+
from rules.currency import SYMBOLS
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
# A number, whitespace, then a standalone currency symbol
|
| 9 |
+
# (e.g. `1 $`, `۳ €`, `2.5 £`). A symbol followed by a digit or letter
|
| 10 |
+
# (e.g. `1 $5`) is not a currency suffix.
|
| 11 |
+
NUMBER_SPACE_SYMBOL_PATTERN = re.compile(
|
| 12 |
+
rf"""
|
| 13 |
+
(?<![\w.,٫٬])
|
| 14 |
+
(?P<amount>\d+(?:[.,٫٬]\d+)*)
|
| 15 |
+
[^\S\n]+
|
| 16 |
+
(?P<symbol>[{SYMBOLS}])
|
| 17 |
+
(?![\w{SYMBOLS}])
|
| 18 |
+
""",
|
| 19 |
+
re.VERBOSE,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class CurrencySpaceProcessor(BaseProcessor):
|
| 24 |
+
"""Glues a currency symbol to the number before it: `1 $` -> `1$`.
|
| 25 |
+
|
| 26 |
+
Supported symbols: `$`, `€`, `£`.
|
| 27 |
+
|
| 28 |
+
This works on the whole text (not word by word) because the number and
|
| 29 |
+
the symbol are separate words. Run it before `CurrencyProcessor`.
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
def __init__(self, epsilon: float = 1.0):
|
| 33 |
+
super(CurrencySpaceProcessor, self).__init__(epsilon=epsilon)
|
| 34 |
+
|
| 35 |
+
def process(self, text: str, language: str = "en", *args, **kwargs) -> str:
|
| 36 |
+
if random.random() > self.epsilon:
|
| 37 |
+
return text
|
| 38 |
+
return NUMBER_SPACE_SYMBOL_PATTERN.sub(r"\g<amount>\g<symbol>", text)
|
rules/symbols.py
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
import re
|
| 3 |
+
from decimal import Decimal
|
| 4 |
+
|
| 5 |
+
from num2fawords import words
|
| 6 |
+
from num2words import num2words
|
| 7 |
+
from persiantools import digits
|
| 8 |
+
|
| 9 |
+
from rules.base import BaseProcessor
|
| 10 |
+
from rules.fraction import FRACTION_PATTERN
|
| 11 |
+
from rules.units import UNIT_PATTERN, UnitProcessor
|
| 12 |
+
from rules.wrappers import CLOSERS, OPENERS, WRAPPERS
|
| 13 |
+
from utils import NO_LETTERS, detect_digit_language, detect_language
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# ---------------------------------------------------------------------------
|
| 17 |
+
# Roman numerals
|
| 18 |
+
# ---------------------------------------------------------------------------
|
| 19 |
+
|
| 20 |
+
ROMAN_VALUES = {"I": 1, "V": 5, "X": 10, "L": 50, "C": 100, "D": 500, "M": 1000}
|
| 21 |
+
|
| 22 |
+
# Strictly valid numerals only (1-3999): `IV` yes, `IIII`, `VX`, `IC` no.
|
| 23 |
+
ROMAN_NUMERAL = re.compile(
|
| 24 |
+
r"(?=[IVXLCDM])M{0,3}(?:CM|CD|D?C{0,3})(?:XC|XL|L?X{0,3})(?:IX|IV|V?I{0,3})"
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
# A standalone run of uppercase Roman letters (lowercase `iv`, `mix` is never read).
|
| 28 |
+
ROMAN_CANDIDATE = re.compile(r"(?<!\w)[IVXLCDM]+(?!\w)")
|
| 29 |
+
|
| 30 |
+
# Valid numerals that are far more often something else: `Washington DC`,
|
| 31 |
+
# `MD`, `CD player`, `XL` shirt, `MIX`. Add to this set to skip more.
|
| 32 |
+
AMBIGUOUS_ABBREVIATIONS = {
|
| 33 |
+
"DC", "MD", "CD", "CV", "MC", "CM", "MM", "CC", "MI", "LI", "XL",
|
| 34 |
+
"MIX", "DIV", "CIV",
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
# `I`, `V` and `X` on their own are only numerals right after one of these
|
| 38 |
+
# words (`Chapter IV`, `World War I`); `Vitamin C`, `Appendix D` or the
|
| 39 |
+
# pronoun `I` are not. `L`, `C`, `D` and `M` on their own are never read.
|
| 40 |
+
SINGLE_LETTER_NUMERALS = "IVX"
|
| 41 |
+
|
| 42 |
+
EN_TRIGGERS = {
|
| 43 |
+
"chapter", "part", "section", "volume", "vol", "book", "act", "scene",
|
| 44 |
+
"phase", "stage", "appendix", "annex", "article", "page", "table",
|
| 45 |
+
"figure", "fig", "war", "episode", "season", "series", "type", "class",
|
| 46 |
+
"level", "grade", "step", "unit", "module", "tier",
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
FA_TRIGGERS = {
|
| 50 |
+
"فصل", "بخش", "قسمت", "جلد", "پرده", "مرحله", "فاز", "پیوست", "ضمیمه",
|
| 51 |
+
"ماده", "جدول", "شکل", "صفحه", "قرن", "جنگ", "جهانی", "سری", "نوع",
|
| 52 |
+
"دوره", "کتاب", "گام", "سطح", "درجه", "کلاس", "رده", "گروه",
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def roman_to_int(token: str):
|
| 57 |
+
"""Returns the value of a strictly valid Roman numeral, else None."""
|
| 58 |
+
if not ROMAN_NUMERAL.fullmatch(token):
|
| 59 |
+
return None
|
| 60 |
+
total = 0
|
| 61 |
+
previous = 0
|
| 62 |
+
for letter in reversed(token):
|
| 63 |
+
value = ROMAN_VALUES[letter]
|
| 64 |
+
total += -value if value < previous else value
|
| 65 |
+
previous = max(previous, value)
|
| 66 |
+
return total
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class RomanNumeralProcessor(BaseProcessor):
|
| 70 |
+
"""Converts Roman numerals into plain numbers in words.
|
| 71 |
+
|
| 72 |
+
English: `Henry VIII` -> `Henry eight`
|
| 73 |
+
`Chapter IV` -> `Chapter four`
|
| 74 |
+
`World War II` -> `World War two`
|
| 75 |
+
Persian: `قرن XX` -> `قرن بیست`
|
| 76 |
+
`فصل IV` -> `فصل چهار`
|
| 77 |
+
|
| 78 |
+
Only uppercase, strictly valid numerals from 1 to 3999 are read. Several
|
| 79 |
+
letters (`XIV`) are read anywhere; a single `I`, `V` or `X` only after a word
|
| 80 |
+
like `Chapter`, `Part` or `فصل`, so the pronoun `I` is safe. Common
|
| 81 |
+
abbreviations that happen to be valid numerals (`DC`, `MD`, `CD`, ...) are
|
| 82 |
+
skipped, see `AMBIGUOUS_ABBREVIATIONS`.
|
| 83 |
+
|
| 84 |
+
The language is detected ignoring the numerals themselves, so a Persian
|
| 85 |
+
sentence stays Persian even with a long numeral like `XVIII`.
|
| 86 |
+
"""
|
| 87 |
+
|
| 88 |
+
def __init__(self, epsilon: float = 1.0):
|
| 89 |
+
super(RomanNumeralProcessor, self).__init__(epsilon=epsilon)
|
| 90 |
+
|
| 91 |
+
def process(self, text: str, language: str = None, *args, **kwargs) -> str:
|
| 92 |
+
if random.random() > self.epsilon:
|
| 93 |
+
return text
|
| 94 |
+
|
| 95 |
+
found = []
|
| 96 |
+
for match in ROMAN_CANDIDATE.finditer(text):
|
| 97 |
+
value = self._value_to_read(text, match)
|
| 98 |
+
if value is not None:
|
| 99 |
+
found.append((match, value))
|
| 100 |
+
if not found:
|
| 101 |
+
return text
|
| 102 |
+
|
| 103 |
+
language = self._resolve_language(text, found, language)
|
| 104 |
+
if language is None:
|
| 105 |
+
return text
|
| 106 |
+
|
| 107 |
+
spell = words if language == "fa" else num2words
|
| 108 |
+
pieces = []
|
| 109 |
+
last = 0
|
| 110 |
+
for match, value in found:
|
| 111 |
+
pieces.append(text[last:match.start()])
|
| 112 |
+
pieces.append(spell(value))
|
| 113 |
+
last = match.end()
|
| 114 |
+
pieces.append(text[last:])
|
| 115 |
+
return "".join(pieces)
|
| 116 |
+
|
| 117 |
+
@staticmethod
|
| 118 |
+
def _value_to_read(text: str, match: re.Match):
|
| 119 |
+
token = match.group(0)
|
| 120 |
+
if token in AMBIGUOUS_ABBREVIATIONS:
|
| 121 |
+
return None
|
| 122 |
+
|
| 123 |
+
value = roman_to_int(token)
|
| 124 |
+
if value is None:
|
| 125 |
+
return None
|
| 126 |
+
|
| 127 |
+
if len(token) > 1:
|
| 128 |
+
return value
|
| 129 |
+
|
| 130 |
+
if token not in SINGLE_LETTER_NUMERALS:
|
| 131 |
+
return None
|
| 132 |
+
previous = re.search(
|
| 133 |
+
rf"([^\W\d_]+)(?:[^\S\n]+|[^\S\n]*{OPENERS}[^\S\n]*)$", text[: match.start()]
|
| 134 |
+
)
|
| 135 |
+
if previous is None:
|
| 136 |
+
return None
|
| 137 |
+
word = previous.group(1)
|
| 138 |
+
if word in FA_TRIGGERS:
|
| 139 |
+
return value
|
| 140 |
+
if word.lower() in EN_TRIGGERS:
|
| 141 |
+
# `Part I` yes, `after the war I went` no.
|
| 142 |
+
if token == "I" and not word[0].isupper():
|
| 143 |
+
return None
|
| 144 |
+
return value
|
| 145 |
+
return None
|
| 146 |
+
|
| 147 |
+
@staticmethod
|
| 148 |
+
def _resolve_language(text: str, found, language: str):
|
| 149 |
+
"""Detects the language ignoring the numerals that will be replaced."""
|
| 150 |
+
pieces = []
|
| 151 |
+
last = 0
|
| 152 |
+
for match, _ in found:
|
| 153 |
+
pieces.append(text[last:match.start()])
|
| 154 |
+
last = match.end()
|
| 155 |
+
pieces.append(text[last:])
|
| 156 |
+
|
| 157 |
+
detected = detect_language(" ".join(pieces))
|
| 158 |
+
if detected in ("fa", "en"):
|
| 159 |
+
return detected
|
| 160 |
+
if detected != NO_LETTERS:
|
| 161 |
+
return None
|
| 162 |
+
return language if language in ("fa", "en") else None
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
# ---------------------------------------------------------------------------
|
| 166 |
+
# Ampersand
|
| 167 |
+
# ---------------------------------------------------------------------------
|
| 168 |
+
|
| 169 |
+
HTML_ENTITY = r"(?:#\d+|#[xX][0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*)"
|
| 170 |
+
|
| 171 |
+
# A single `&`: not `&&`, and not the start of an HTML entity like `&`.
|
| 172 |
+
AMPERSAND_SIGN = rf"(?<!&)&(?!&|{HTML_ENTITY};)"
|
| 173 |
+
AMPERSAND = re.compile(AMPERSAND_SIGN)
|
| 174 |
+
|
| 175 |
+
AMOUNT = r"\d+(?:[.,٫٬]\d+)*"
|
| 176 |
+
|
| 177 |
+
# A number right before an `&` (`3 & 4` -> the 3), possibly wrapped: `(3) & (4)`,
|
| 178 |
+
# `3 (&) 4`. Not part of a date, time, range, URL or currency
|
| 179 |
+
# (`2024-05-10`, `10:30`, `5-6`, `a=1&b`, `$3`).
|
| 180 |
+
NUMBER_BEFORE_AMPERSAND = re.compile(
|
| 181 |
+
rf"(?<![\w./,٫٬=:\-$€£])(?P<number>{AMOUNT})"
|
| 182 |
+
rf"(?=(?:[^\S\n]|{CLOSERS})*(?:{OPENERS}[^\S\n]*)?{AMPERSAND_SIGN})"
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
# A number right after an `&` (`3 & 4` -> the 4).
|
| 186 |
+
NUMBER_AFTER_AMPERSAND = re.compile(
|
| 187 |
+
rf"(?P<sign>{AMPERSAND_SIGN}(?:[^\S\n]|{CLOSERS})*(?:{OPENERS}[^\S\n]*)?)"
|
| 188 |
+
rf"(?P<number>{AMOUNT})(?![\w/:\-$€£%°]|[.,٫٬]\d)"
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
# `(&)`, `[&]`, `"&"`: the symbol alone inside a wrapper.
|
| 192 |
+
WRAPPED_AMPERSAND = re.compile(rf"(?<!&)(?P<open>{OPENERS})[^\S\n]*&[^\S\n]*(?P<close>{CLOSERS})")
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def number_to_words(number: str, language: str) -> str:
|
| 196 |
+
"""`3` -> `three` / `سه`; handles Persian digits, `1,000` and decimals."""
|
| 197 |
+
clean = digits.fa_to_en(number).replace(",", "").replace("٬", "").replace("٫", ".")
|
| 198 |
+
if language == "fa":
|
| 199 |
+
return words(float(clean) if "." in clean else int(clean))
|
| 200 |
+
return num2words(Decimal(clean) if "." in clean else int(clean))
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
class AmpersandProcessor(BaseProcessor):
|
| 204 |
+
"""Converts `&` into `and` (English) or `و` (Persian).
|
| 205 |
+
|
| 206 |
+
English: `Tom & Jerry` -> `Tom and Jerry`
|
| 207 |
+
`R&D` -> `R and D`
|
| 208 |
+
`3 & 4` -> `three and four`
|
| 209 |
+
Persian: `تام & جری` -> `تام و جری`
|
| 210 |
+
`۳ & ۴` -> `سه و چهار`
|
| 211 |
+
|
| 212 |
+
A plain number right next to an `&` is read as words too (`Windows 10 & 11`
|
| 213 |
+
-> `Windows ten and eleven`, `2.5 & 3.5` -> `two point five and three point
|
| 214 |
+
five`). Fractions, units, percents and currency beside an `&` are already
|
| 215 |
+
read by their own processors (`1/2 & 1/3`, `5 km & 3 kg`, `5% & 10%`,
|
| 216 |
+
`$3 & $4`).
|
| 217 |
+
|
| 218 |
+
Left alone: `&&`, HTML entities (`&`) and URLs / query strings
|
| 219 |
+
(`a=1&b=2`). With no letters in the text, the digits pick the language:
|
| 220 |
+
`۱ & ۲` -> `یک و دو`, `1 & 2` -> `one and two`.
|
| 221 |
+
"""
|
| 222 |
+
|
| 223 |
+
def __init__(self, epsilon: float = 1.0):
|
| 224 |
+
super(AmpersandProcessor, self).__init__(epsilon=epsilon)
|
| 225 |
+
|
| 226 |
+
def process(self, text: str, language: str = None, *args, **kwargs) -> str:
|
| 227 |
+
if random.random() > self.epsilon:
|
| 228 |
+
return text
|
| 229 |
+
|
| 230 |
+
if language in (None, NO_LETTERS):
|
| 231 |
+
language = detect_digit_language(text)
|
| 232 |
+
if language not in ("fa", "en"):
|
| 233 |
+
return text
|
| 234 |
+
|
| 235 |
+
protected = [
|
| 236 |
+
match.span()
|
| 237 |
+
for match in re.finditer(r"\S+", text)
|
| 238 |
+
if "://" in match.group(0) or "=" in match.group(0)
|
| 239 |
+
]
|
| 240 |
+
|
| 241 |
+
def is_protected(position: int) -> bool:
|
| 242 |
+
return any(start <= position < end for start, end in protected)
|
| 243 |
+
|
| 244 |
+
def read_number(match: re.Match) -> str:
|
| 245 |
+
if is_protected(match.start()):
|
| 246 |
+
return match.group(0)
|
| 247 |
+
return number_to_words(match.group("number"), language)
|
| 248 |
+
|
| 249 |
+
def read_number_after(match: re.Match) -> str:
|
| 250 |
+
if is_protected(match.start()):
|
| 251 |
+
return match.group(0)
|
| 252 |
+
return match.group("sign") + number_to_words(match.group("number"), language)
|
| 253 |
+
|
| 254 |
+
text = NUMBER_BEFORE_AMPERSAND.sub(read_number, text)
|
| 255 |
+
text = NUMBER_AFTER_AMPERSAND.sub(read_number_after, text)
|
| 256 |
+
|
| 257 |
+
word = "و" if language == "fa" else "and"
|
| 258 |
+
return re.sub(r"\S+", lambda match: self._convert_token(match.group(0), word), text)
|
| 259 |
+
|
| 260 |
+
@staticmethod
|
| 261 |
+
def _convert_token(token: str, word: str) -> str:
|
| 262 |
+
if "&" not in token or "://" in token or "=" in token:
|
| 263 |
+
return token
|
| 264 |
+
token = WRAPPED_AMPERSAND.sub(rf"\g<open>{word}\g<close>", token)
|
| 265 |
+
return AMPERSAND.sub(f" {word} ", token).strip()
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
# ---------------------------------------------------------------------------
|
| 269 |
+
# Negative sign
|
| 270 |
+
# ---------------------------------------------------------------------------
|
| 271 |
+
|
| 272 |
+
# A minus right before a number that is not part of a word, a date or a range:
|
| 273 |
+
# `-2`, `(-2)`, `= -2`, `-$5`. `a-2`, `5-3`, `COVID-19`, `2024-05-10` don't match.
|
| 274 |
+
NEGATIVE_PATTERN = re.compile(
|
| 275 |
+
r"(?<![\w.,٫٬)\]}/\-−])(?P<sign>[-−])(?P<space>[^\S\n]*)"
|
| 276 |
+
r"(?P<currency>[$€£])?(?P<number>" + AMOUNT + r")"
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
NEGATIVE_WORDS = {"en": "negative", "fa": "منفی"}
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
class NegativeSignProcessor(BaseProcessor):
|
| 283 |
+
"""Converts a minus sign before a number into `negative` / `منفی`.
|
| 284 |
+
|
| 285 |
+
English: `-2` -> `negative two`
|
| 286 |
+
`-2 m` -> `negative two metres`
|
| 287 |
+
`-1/2 kg`-> `negative one half kilogram`
|
| 288 |
+
Persian: `-۲ m` -> `منفی دو متر`
|
| 289 |
+
`-۲` -> `منفی دو`
|
| 290 |
+
|
| 291 |
+
A number followed by a unit, fraction, percent or currency is left for
|
| 292 |
+
those processors, so this must run before `UnitProcessor`. A space after the
|
| 293 |
+
minus (`- 2 m`) is accepted only with a unit, or right after an operator
|
| 294 |
+
(`3 + - 2`, `= - 2`), so a list item like `- 2024 report` is not touched.
|
| 295 |
+
"""
|
| 296 |
+
|
| 297 |
+
def __init__(self, epsilon: float = 1.0):
|
| 298 |
+
super(NegativeSignProcessor, self).__init__(epsilon=epsilon)
|
| 299 |
+
|
| 300 |
+
def process(self, text: str, language: str = None, *args, **kwargs) -> str:
|
| 301 |
+
if random.random() > self.epsilon:
|
| 302 |
+
return text
|
| 303 |
+
if NEGATIVE_PATTERN.search(text) is None:
|
| 304 |
+
return text
|
| 305 |
+
|
| 306 |
+
language = UnitProcessor._resolve_language(text, language)
|
| 307 |
+
if language not in ("fa", "en"):
|
| 308 |
+
return text
|
| 309 |
+
|
| 310 |
+
return NEGATIVE_PATTERN.sub(lambda match: self._convert(match, language), text)
|
| 311 |
+
|
| 312 |
+
@staticmethod
|
| 313 |
+
def _convert(match: re.Match, language: str) -> str:
|
| 314 |
+
text = match.string
|
| 315 |
+
start = match.start("number")
|
| 316 |
+
end = match.end("number")
|
| 317 |
+
word = NEGATIVE_WORDS[language]
|
| 318 |
+
currency = match.group("currency") or ""
|
| 319 |
+
|
| 320 |
+
unit_follows = UNIT_PATTERN.match(text, start) is not None
|
| 321 |
+
fraction_follows = FRACTION_PATTERN.match(text, start) is not None
|
| 322 |
+
suffix_follows = text[end:end + 1] in tuple("$€£%٪°")
|
| 323 |
+
leave_digits = bool(currency) or unit_follows or fraction_follows or suffix_follows
|
| 324 |
+
|
| 325 |
+
if match.group("space"):
|
| 326 |
+
before = text[: match.start()].rstrip()
|
| 327 |
+
after_operator = before[-1:] in tuple("+×÷=*")
|
| 328 |
+
# `10 - 20 km` is a range, not a negative number.
|
| 329 |
+
after_operand = bool(before) and (
|
| 330 |
+
bool(re.match(r"\w", before[-1])) or before[-1] in tuple("%٪$€£°)]")
|
| 331 |
+
)
|
| 332 |
+
if not (after_operator or (unit_follows and not after_operand)):
|
| 333 |
+
return match.group(0)
|
| 334 |
+
|
| 335 |
+
if leave_digits:
|
| 336 |
+
return f"{word} {currency}{match.group('number')}"
|
| 337 |
+
|
| 338 |
+
following = text[end:end + 1]
|
| 339 |
+
if following and re.match(r"[\w/:\-]", following):
|
| 340 |
+
return match.group(0)
|
| 341 |
+
return f"{word} {number_to_words(match.group('number'), language)}"
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
# ---------------------------------------------------------------------------
|
| 345 |
+
# Math operators
|
| 346 |
+
# ---------------------------------------------------------------------------
|
| 347 |
+
|
| 348 |
+
OPERATOR_WORDS = {
|
| 349 |
+
"en": {"+": "plus", "×": "times", "*": "times", "÷": "divided by", "=": "equals", "−": "minus", "-": "minus"},
|
| 350 |
+
"fa": {"+": "بعلاوه", "×": "ضربدر", "*": "ضربدر", "÷": "تقسیم بر", "=": "مساوی است با", "−": "منهای", "-": "منهای"},
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
OPERATOR_CHARS = re.compile(r"[+×÷=*−-]")
|
| 354 |
+
|
| 355 |
+
# Wrappers and spaces may sit between an operand and the symbol: `(3) + (4)`, `3 (+) 4`.
|
| 356 |
+
NUMBER_LEFT_OF_OPERATOR = re.compile(
|
| 357 |
+
r"(?<![\w./,٫٬=:\-$€£])(?P<number>" + AMOUNT + rf")(?:[^\S\n]|{WRAPPERS})*$"
|
| 358 |
+
)
|
| 359 |
+
NUMBER_RIGHT_OF_OPERATOR = re.compile(
|
| 360 |
+
rf"(?:[^\S\n]|{WRAPPERS})*(?P<number>" + AMOUNT + r")(?![\w/:\-$€£%°]|[.,٫٬]\d)"
|
| 361 |
+
)
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
class MathOperatorProcessor(BaseProcessor):
|
| 365 |
+
"""Converts `+`, `×`, `÷`, `=` (and `*`, `−`) between operands into words.
|
| 366 |
+
|
| 367 |
+
English: `3 + 4` -> `three plus four`
|
| 368 |
+
`6 × 2` -> `six times two`
|
| 369 |
+
`8 ÷ 2` -> `eight divided by two`
|
| 370 |
+
`3 + 4 = 7` -> `three plus four equals seven`
|
| 371 |
+
Persian: `۳ + ۴` -> `سه بعلاوه چهار`
|
| 372 |
+
`۶ × ۲` -> `شش ضربدر دو`
|
| 373 |
+
`۸ ÷ ۲` -> `هشت تقسیم بر دو`
|
| 374 |
+
`۷ = ۷` -> `هفت مساوی است با هفت`
|
| 375 |
+
|
| 376 |
+
A symbol is read only between two operands, spaced on both sides (`3 + 4`)
|
| 377 |
+
or glued between two digits (`3+4`), so `+98 912 ...`, `C++`, `A+ grade`,
|
| 378 |
+
`x=5`, `a=1&b=2`, `==` and bullet `* item` are left alone. A plain number
|
| 379 |
+
next to the symbol is read as words too; numbers with units, fractions or
|
| 380 |
+
currency are read by their own processors. An ASCII `-` is `minus` only
|
| 381 |
+
with spaces on both sides in a line that also has an `=` (`5 - 3 = 2`);
|
| 382 |
+
elsewhere it is a dash or a range.
|
| 383 |
+
"""
|
| 384 |
+
|
| 385 |
+
def __init__(self, epsilon: float = 1.0):
|
| 386 |
+
super(MathOperatorProcessor, self).__init__(epsilon=epsilon)
|
| 387 |
+
|
| 388 |
+
def process(self, text: str, language: str = None, *args, **kwargs) -> str:
|
| 389 |
+
if random.random() > self.epsilon:
|
| 390 |
+
return text
|
| 391 |
+
if OPERATOR_CHARS.search(text) is None:
|
| 392 |
+
return text
|
| 393 |
+
|
| 394 |
+
if language in (None, NO_LETTERS):
|
| 395 |
+
language = detect_digit_language(text)
|
| 396 |
+
if language not in ("fa", "en"):
|
| 397 |
+
return text
|
| 398 |
+
|
| 399 |
+
protected = [
|
| 400 |
+
match.span()
|
| 401 |
+
for match in re.finditer(r"\S+", text)
|
| 402 |
+
if "://" in match.group(0) or ("?" in match.group(0) and "=" in match.group(0))
|
| 403 |
+
]
|
| 404 |
+
operators = self._find_operators(text, protected)
|
| 405 |
+
if not operators:
|
| 406 |
+
return text
|
| 407 |
+
|
| 408 |
+
replacements = {}
|
| 409 |
+
for position, char in operators:
|
| 410 |
+
end = position + 1
|
| 411 |
+
left = NUMBER_LEFT_OF_OPERATOR.search(text, 0, position)
|
| 412 |
+
if left is not None:
|
| 413 |
+
replacements[left.span("number")] = number_to_words(left.group("number"), language)
|
| 414 |
+
right = NUMBER_RIGHT_OF_OPERATOR.match(text, end)
|
| 415 |
+
if right is not None:
|
| 416 |
+
replacements[right.span("number")] = number_to_words(right.group("number"), language)
|
| 417 |
+
|
| 418 |
+
# No extra space next to a wrapper: `3 (+) 4` -> `3 (plus) 4`.
|
| 419 |
+
before = " " if not re.match(rf"[\s]|{WRAPPERS}", text[position - 1]) else ""
|
| 420 |
+
after = " " if not re.match(rf"[\s]|{WRAPPERS}", text[end]) else ""
|
| 421 |
+
replacements[(position, end)] = f"{before}{OPERATOR_WORDS[language][char]}{after}"
|
| 422 |
+
|
| 423 |
+
pieces = []
|
| 424 |
+
last = 0
|
| 425 |
+
for (start, stop), replacement in sorted(replacements.items()):
|
| 426 |
+
pieces.append(text[last:start])
|
| 427 |
+
pieces.append(replacement)
|
| 428 |
+
last = stop
|
| 429 |
+
pieces.append(text[last:])
|
| 430 |
+
return "".join(pieces)
|
| 431 |
+
|
| 432 |
+
@staticmethod
|
| 433 |
+
def _find_operators(text: str, protected):
|
| 434 |
+
def is_protected(position: int) -> bool:
|
| 435 |
+
return any(start <= position < end for start, end in protected)
|
| 436 |
+
|
| 437 |
+
def is_between_operands(position: int) -> bool:
|
| 438 |
+
line_start = text.rfind("\n", 0, position) + 1
|
| 439 |
+
line_end = text.find("\n", position)
|
| 440 |
+
line_end = len(text) if line_end == -1 else line_end
|
| 441 |
+
if not text[line_start:position].strip() or not text[position + 1:line_end].strip():
|
| 442 |
+
return False
|
| 443 |
+
# Look past spaces and wrappers on both sides: `(3) + (4)`, `3 (+) 4`.
|
| 444 |
+
before = re.search(rf"(?:[^\S\n]|{WRAPPERS})*$", text[line_start:position]).group(0)
|
| 445 |
+
after = re.match(rf"(?:[^\S\n]|{WRAPPERS})*", text[position + 1:line_end]).group(0)
|
| 446 |
+
if before and after:
|
| 447 |
+
return True
|
| 448 |
+
return text[position - 1].isdigit() and text[position + 1].isdigit()
|
| 449 |
+
|
| 450 |
+
found = []
|
| 451 |
+
equation_lines = set()
|
| 452 |
+
for match in OPERATOR_CHARS.finditer(text):
|
| 453 |
+
position, char = match.start(), match.group(0)
|
| 454 |
+
if char == "-" or is_protected(position) or position == 0 or position + 1 >= len(text):
|
| 455 |
+
continue
|
| 456 |
+
if is_between_operands(position):
|
| 457 |
+
found.append((position, char))
|
| 458 |
+
if char == "=":
|
| 459 |
+
equation_lines.add(text.rfind("\n", 0, position))
|
| 460 |
+
|
| 461 |
+
# An ASCII hyphen is a minus only in a spaced equation: `5 - 3 = 2`.
|
| 462 |
+
for match in re.finditer(r"-", text):
|
| 463 |
+
position = match.start()
|
| 464 |
+
if position == 0 or position + 1 >= len(text) or is_protected(position):
|
| 465 |
+
continue
|
| 466 |
+
left, right = text[position - 1], text[position + 1]
|
| 467 |
+
if left in " \t" and right in " \t" and text.rfind("\n", 0, position) in equation_lines \
|
| 468 |
+
and is_between_operands(position):
|
| 469 |
+
found.append((position, "-"))
|
| 470 |
+
|
| 471 |
+
return sorted(found)
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
# ---------------------------------------------------------------------------
|
| 475 |
+
# Ranges
|
| 476 |
+
# ---------------------------------------------------------------------------
|
| 477 |
+
|
| 478 |
+
RANGE_WORDS = {"en": "to", "fa": "تا"}
|
| 479 |
+
|
| 480 |
+
RANGE_DASH = re.compile(r"[-–]")
|
| 481 |
+
|
| 482 |
+
BARE_NUMBER_LEFT = re.compile(r"(?<![\w./,٫٬=:\-–$€£])(?P<number>" + AMOUNT + r")$")
|
| 483 |
+
BARE_NUMBER_RIGHT = re.compile(r"(?P<number>" + AMOUNT + r")(?![\w/:\-–$€£%°]|[.,٫٬]\d)")
|
| 484 |
+
CURRENCY_LEFT = re.compile(r"(?<![\w./,٫٬=:\-–])(?:[$€£]" + AMOUNT + r"|" + AMOUNT + r"[$€£])$")
|
| 485 |
+
CURRENCY_RIGHT = re.compile(r"(?:[$€£]" + AMOUNT + r"|" + AMOUNT + r"[$€£])(?![\w/:\-–])")
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
class RangeProcessor(BaseProcessor):
|
| 489 |
+
"""Converts a dash between two amounts into `to` / `تا`.
|
| 490 |
+
|
| 491 |
+
English: `3 - 5` -> `three to five`
|
| 492 |
+
`pages 3-5` -> `pages three to five`
|
| 493 |
+
`3kg - 5 kg` -> `three kilograms to five kilograms`
|
| 494 |
+
`10 - 20 km` -> `ten to twenty kilometres`
|
| 495 |
+
`$3 - $5` -> `three dollars to five dollars`
|
| 496 |
+
`1/2 - 3/4` -> `one half to three quarters`
|
| 497 |
+
Persian: `۳ - ۵` -> `سه تا پنج`
|
| 498 |
+
`۳kg - ۵ kg` -> `سه کیلوگرم تا پنج کیلوگرم`
|
| 499 |
+
|
| 500 |
+
Both sides must be amounts: plain numbers (read here), or numbers with a
|
| 501 |
+
unit, fraction, percent or currency (read by their own processors, so this
|
| 502 |
+
must run before `UnitProcessor`). Hyphen and en dash (–) are accepted,
|
| 503 |
+
spaced on both sides (`3 - 5`) or on neither (`3-5`).
|
| 504 |
+
|
| 505 |
+
Left alone: dates and phone numbers (`2024-05-10`, `0912-123-4567`), long
|
| 506 |
+
numbers glued to a hyphen (`1990-2000`, `555-1234`; with spaces they are
|
| 507 |
+
read), leading zeros (`2024-05`), times (`10:30 - 11:30`), words (`well - maybe`),
|
| 508 |
+
URLs, and any line with an `=` (there `5 - 3` is a subtraction, which
|
| 509 |
+
`MathOperatorProcessor` reads).
|
| 510 |
+
"""
|
| 511 |
+
|
| 512 |
+
def __init__(self, epsilon: float = 1.0):
|
| 513 |
+
super(RangeProcessor, self).__init__(epsilon=epsilon)
|
| 514 |
+
|
| 515 |
+
def process(self, text: str, language: str = None, *args, **kwargs) -> str:
|
| 516 |
+
if random.random() > self.epsilon:
|
| 517 |
+
return text
|
| 518 |
+
if RANGE_DASH.search(text) is None:
|
| 519 |
+
return text
|
| 520 |
+
|
| 521 |
+
ranges = self._find_ranges(text)
|
| 522 |
+
if not ranges:
|
| 523 |
+
return text
|
| 524 |
+
|
| 525 |
+
language = UnitProcessor._resolve_language(text, language)
|
| 526 |
+
if language not in ("fa", "en"):
|
| 527 |
+
return text
|
| 528 |
+
|
| 529 |
+
replacements = {}
|
| 530 |
+
for dash, attached, bare_spans in ranges:
|
| 531 |
+
for span in bare_spans:
|
| 532 |
+
replacements[span] = number_to_words(text[span[0]:span[1]], language)
|
| 533 |
+
padding = " " if attached else ""
|
| 534 |
+
replacements[(dash, dash + 1)] = f"{padding}{RANGE_WORDS[language]}{padding}"
|
| 535 |
+
|
| 536 |
+
pieces = []
|
| 537 |
+
last = 0
|
| 538 |
+
for (start, stop), replacement in sorted(replacements.items()):
|
| 539 |
+
pieces.append(text[last:start])
|
| 540 |
+
pieces.append(replacement)
|
| 541 |
+
last = stop
|
| 542 |
+
pieces.append(text[last:])
|
| 543 |
+
return "".join(pieces)
|
| 544 |
+
|
| 545 |
+
@staticmethod
|
| 546 |
+
def _find_ranges(text: str):
|
| 547 |
+
protected = [
|
| 548 |
+
match.span() for match in re.finditer(r"\S+", text) if "://" in match.group(0)
|
| 549 |
+
]
|
| 550 |
+
found = []
|
| 551 |
+
for match in RANGE_DASH.finditer(text):
|
| 552 |
+
dash = match.start()
|
| 553 |
+
if dash == 0 or dash + 1 >= len(text):
|
| 554 |
+
continue
|
| 555 |
+
if any(start <= dash < end for start, end in protected):
|
| 556 |
+
continue
|
| 557 |
+
|
| 558 |
+
line_start = text.rfind("\n", 0, dash) + 1
|
| 559 |
+
line_end = text.find("\n", dash)
|
| 560 |
+
line_end = len(text) if line_end == -1 else line_end
|
| 561 |
+
if "=" in text[line_start:line_end]:
|
| 562 |
+
continue
|
| 563 |
+
|
| 564 |
+
left_char, right_char = text[dash - 1], text[dash + 1]
|
| 565 |
+
spaced = left_char in " \t" and right_char in " \t"
|
| 566 |
+
attached = not left_char.isspace() and not right_char.isspace()
|
| 567 |
+
if not (spaced or attached):
|
| 568 |
+
continue
|
| 569 |
+
|
| 570 |
+
# `(3) - (5)`: look past a closing wrapper on the left and an
|
| 571 |
+
# opening one on the right.
|
| 572 |
+
raw_left_end = len(text[:dash].rstrip(" \t"))
|
| 573 |
+
left_end = raw_left_end
|
| 574 |
+
while left_end and re.match(CLOSERS, text[left_end - 1]):
|
| 575 |
+
left_end = len(text[:left_end - 1].rstrip(" \t"))
|
| 576 |
+
raw_right_start = dash + 1
|
| 577 |
+
while raw_right_start < len(text) and text[raw_right_start] in " \t":
|
| 578 |
+
raw_right_start += 1
|
| 579 |
+
right_start = raw_right_start
|
| 580 |
+
while right_start < len(text) and re.match(rf"[ \t]|{OPENERS}", text[right_start]):
|
| 581 |
+
right_start += 1
|
| 582 |
+
# A wrapped unit (`3 (cm)`) ends with its closer, so try that first.
|
| 583 |
+
left = RangeProcessor._left_operand(text, raw_left_end) or RangeProcessor._left_operand(text, left_end)
|
| 584 |
+
right = RangeProcessor._right_operand(text, raw_right_start) or RangeProcessor._right_operand(text, right_start)
|
| 585 |
+
if left is None or right is None:
|
| 586 |
+
continue
|
| 587 |
+
|
| 588 |
+
if attached and match.group(0) == "-" and not RangeProcessor._is_plain_pair(text, left, right):
|
| 589 |
+
continue
|
| 590 |
+
|
| 591 |
+
found.append((dash, attached, [span for span in (left[0], right[0]) if span is not None]))
|
| 592 |
+
return found
|
| 593 |
+
|
| 594 |
+
@staticmethod
|
| 595 |
+
def _left_operand(text: str, end: int):
|
| 596 |
+
"""(bare number span or None, digits) of the amount ending at `end`."""
|
| 597 |
+
for pattern in (UNIT_PATTERN, FRACTION_PATTERN):
|
| 598 |
+
for match in pattern.finditer(text, 0, end):
|
| 599 |
+
if match.end() == end:
|
| 600 |
+
return None, match.group(0)
|
| 601 |
+
match = CURRENCY_LEFT.search(text, 0, end)
|
| 602 |
+
if match is not None:
|
| 603 |
+
return None, match.group(0)
|
| 604 |
+
match = BARE_NUMBER_LEFT.search(text, 0, end)
|
| 605 |
+
if match is not None:
|
| 606 |
+
return match.span("number"), match.group("number")
|
| 607 |
+
return None
|
| 608 |
+
|
| 609 |
+
@staticmethod
|
| 610 |
+
def _right_operand(text: str, start: int):
|
| 611 |
+
for pattern in (UNIT_PATTERN, FRACTION_PATTERN, CURRENCY_RIGHT):
|
| 612 |
+
match = pattern.match(text, start)
|
| 613 |
+
if match is not None:
|
| 614 |
+
return None, match.group(0)
|
| 615 |
+
match = BARE_NUMBER_RIGHT.match(text, start)
|
| 616 |
+
if match is not None:
|
| 617 |
+
return match.span("number"), match.group("number")
|
| 618 |
+
return None
|
| 619 |
+
|
| 620 |
+
@staticmethod
|
| 621 |
+
def _is_plain_pair(text: str, left, right) -> bool:
|
| 622 |
+
"""Is `3-5` (no spaces) a range rather than a date, phone number or code?"""
|
| 623 |
+
for _, operand in (left, right):
|
| 624 |
+
integer_part = re.match(r"[\d,٬]*", digits.fa_to_en(operand)).group(0)
|
| 625 |
+
integer_part = integer_part.replace(",", "").replace("٬", "")
|
| 626 |
+
if not integer_part or len(integer_part) > 3:
|
| 627 |
+
return False
|
| 628 |
+
if len(integer_part) > 1 and integer_part.startswith("0"):
|
| 629 |
+
return False
|
| 630 |
+
return True
|
rules/times.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from rules.base import BaseProcessor
|
| 2 |
+
from datetime import time
|
| 3 |
+
from num2words import num2words
|
| 4 |
+
from num2fawords import words
|
| 5 |
+
import dateparser
|
| 6 |
+
from utils import convert_fa_num_to_en
|
| 7 |
+
import random
|
| 8 |
+
import re
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class TimeProcessor(BaseProcessor):
|
| 12 |
+
def __init__(self, epsilon:float=1.0):
|
| 13 |
+
super(TimeProcessor, self).__init__(epsilon=epsilon)
|
| 14 |
+
|
| 15 |
+
def process(self, str_time:str, language:str='en') -> str:
|
| 16 |
+
if random.random() > self.epsilon:
|
| 17 |
+
return str_time
|
| 18 |
+
|
| 19 |
+
str_time_format = convert_fa_num_to_en(str_time)
|
| 20 |
+
if not self._is_time(str_time_format):
|
| 21 |
+
return str_time
|
| 22 |
+
|
| 23 |
+
try:
|
| 24 |
+
time_ = self._text_to_time(str_time_format)
|
| 25 |
+
except ValueError:
|
| 26 |
+
return str_time
|
| 27 |
+
|
| 28 |
+
if language == 'en':
|
| 29 |
+
return self._english_time_to_text(time_)
|
| 30 |
+
elif language == 'fa':
|
| 31 |
+
return self._farsi_time_to_text(time_)
|
| 32 |
+
return str_time
|
| 33 |
+
|
| 34 |
+
@staticmethod
|
| 35 |
+
def _text_to_time(text: str) -> time:
|
| 36 |
+
dt = dateparser.parse(
|
| 37 |
+
text,
|
| 38 |
+
languages=["en"],
|
| 39 |
+
settings={
|
| 40 |
+
"RETURN_AS_TIMEZONE_AWARE": False,
|
| 41 |
+
"PREFER_DATES_FROM": "current_period",
|
| 42 |
+
},
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
if dt is None:
|
| 46 |
+
raise ValueError(f"Could not parse time: {text!r}")
|
| 47 |
+
|
| 48 |
+
return dt.time()
|
| 49 |
+
|
| 50 |
+
@staticmethod
|
| 51 |
+
def _english_time_to_text(time_:time) -> str:
|
| 52 |
+
hour = time_.hour
|
| 53 |
+
minute = time_.minute
|
| 54 |
+
second = time_.second
|
| 55 |
+
|
| 56 |
+
ampm = ""
|
| 57 |
+
if hour > 12:
|
| 58 |
+
hour -= 12
|
| 59 |
+
ampm = " PM"
|
| 60 |
+
|
| 61 |
+
if second > 1:
|
| 62 |
+
sec_name = "seconds"
|
| 63 |
+
else:
|
| 64 |
+
sec_name = "second"
|
| 65 |
+
|
| 66 |
+
if minute == 0 and second == 0:
|
| 67 |
+
return f"{num2words(hour)}{ampm}"
|
| 68 |
+
elif minute != 0 and second == 0:
|
| 69 |
+
return f"{num2words(hour)} {num2words(minute)}{ampm}"
|
| 70 |
+
elif minute == 0 and second != 0:
|
| 71 |
+
return f"{num2words(hour)}{ampm} and {num2words(second)} {sec_name}"
|
| 72 |
+
else:
|
| 73 |
+
return f"{num2words(hour)} {num2words(minute)}{ampm} and {num2words(second)} {sec_name}"
|
| 74 |
+
|
| 75 |
+
@staticmethod
|
| 76 |
+
def _farsi_time_to_text(time_: time) -> str:
|
| 77 |
+
hour = time_.hour
|
| 78 |
+
minute = time_.minute
|
| 79 |
+
second = time_.second
|
| 80 |
+
|
| 81 |
+
text = f"{words(hour)}"
|
| 82 |
+
if minute != 0:
|
| 83 |
+
text += " و " + words(minute) + " دقیقه"
|
| 84 |
+
if second != 0:
|
| 85 |
+
text += " و " + words(second) + " ثانیه"
|
| 86 |
+
return text
|
| 87 |
+
|
| 88 |
+
@staticmethod
|
| 89 |
+
def _is_time(text: str) -> bool:
|
| 90 |
+
pattern = r"^(?:[0-9]|1[0-9]|2[0-3]):[0-5]\d(?::[0-5]\d)?$"
|
| 91 |
+
return bool(re.fullmatch(pattern, text))
|
rules/units.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
import re
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from decimal import Decimal
|
| 5 |
+
|
| 6 |
+
from num2fawords import words
|
| 7 |
+
from num2words import num2words
|
| 8 |
+
from persiantools import digits
|
| 9 |
+
|
| 10 |
+
from rules.base import BaseProcessor
|
| 11 |
+
from rules.fraction import FRACTION_BODY, VULGAR_FRACTIONS, FractionProcessor
|
| 12 |
+
from rules.wrappers import CLOSERS, OPENERS
|
| 13 |
+
from utils import NO_LETTERS, detect_digit_language, detect_language
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass(frozen=True)
|
| 17 |
+
class Unit:
|
| 18 |
+
fa: str
|
| 19 |
+
en: str
|
| 20 |
+
en_plural: str
|
| 21 |
+
# Next smaller unit and how many of it make one of this unit, used to read
|
| 22 |
+
# `1000.5 m` as "1000 metres and 50 centimetres".
|
| 23 |
+
minor: "Unit" = None
|
| 24 |
+
factor: int = 1
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
MILLIMETRE = Unit("میلیمتر", "millimetre", "millimetres")
|
| 28 |
+
CENTIMETRE = Unit("سانتیمتر", "centimetre", "centimetres", MILLIMETRE, 10)
|
| 29 |
+
METRE = Unit("متر", "metre", "metres", CENTIMETRE, 100)
|
| 30 |
+
KILOMETRE = Unit("کیلومتر", "kilometre", "kilometres", METRE, 1000)
|
| 31 |
+
MILE = Unit("مایل", "mile", "miles")
|
| 32 |
+
FOOT = Unit("فوت", "foot", "feet")
|
| 33 |
+
|
| 34 |
+
MILLIGRAM = Unit("میلیگرم", "milligram", "milligrams")
|
| 35 |
+
GRAM = Unit("گرم", "gram", "grams", MILLIGRAM, 1000)
|
| 36 |
+
KILOGRAM = Unit("کیلوگرم", "kilogram", "kilograms", GRAM, 1000)
|
| 37 |
+
POUND = Unit("پوند", "pound", "pounds")
|
| 38 |
+
OUNCE = Unit("اونس", "ounce", "ounces")
|
| 39 |
+
|
| 40 |
+
MILLILITRE = Unit("میلیلیتر", "millilitre", "millilitres")
|
| 41 |
+
LITRE = Unit("لیتر", "litre", "litres", MILLILITRE, 1000)
|
| 42 |
+
|
| 43 |
+
MILLISECOND = Unit("میلیثانیه", "millisecond", "milliseconds")
|
| 44 |
+
SECOND = Unit("ثانیه", "second", "seconds", MILLISECOND, 1000)
|
| 45 |
+
MINUTE = Unit("دقیقه", "minute", "minutes", SECOND, 60)
|
| 46 |
+
HOUR = Unit("ساعت", "hour", "hours", MINUTE, 60)
|
| 47 |
+
DAY = Unit("روز", "day", "days", HOUR, 24)
|
| 48 |
+
WEEK = Unit("هفته", "week", "weeks", DAY, 7)
|
| 49 |
+
MONTH = Unit("ماه", "month", "months")
|
| 50 |
+
YEAR = Unit("سال", "year", "years")
|
| 51 |
+
|
| 52 |
+
PERCENT = Unit("درصد", "percent", "percent")
|
| 53 |
+
|
| 54 |
+
KILOMETRE_PER_HOUR = Unit("کیلومتر بر ساعت", "kilometre per hour", "kilometres per hour")
|
| 55 |
+
METRE_PER_SECOND = Unit("متر بر ثانیه", "metre per second", "metres per second")
|
| 56 |
+
|
| 57 |
+
CELSIUS = Unit("درجه سانتیگراد", "degree Celsius", "degrees Celsius")
|
| 58 |
+
FAHRENHEIT = Unit("درجه فارنهایت", "degree Fahrenheit", "degrees Fahrenheit")
|
| 59 |
+
|
| 60 |
+
# Symbols are case-sensitive on purpose (e.g. `5G` is a network, not grams).
|
| 61 |
+
# Single `s` and `d` are left out on purpose: `1990s` and `3d` are not units.
|
| 62 |
+
UNITS = {
|
| 63 |
+
"km": KILOMETRE,
|
| 64 |
+
"m": METRE,
|
| 65 |
+
"cm": CENTIMETRE,
|
| 66 |
+
"mm": MILLIMETRE,
|
| 67 |
+
"mi": MILE,
|
| 68 |
+
"ft": FOOT,
|
| 69 |
+
"kg": KILOGRAM,
|
| 70 |
+
"KG": KILOGRAM,
|
| 71 |
+
"Kg": KILOGRAM,
|
| 72 |
+
"g": GRAM,
|
| 73 |
+
"mg": MILLIGRAM,
|
| 74 |
+
"lb": POUND,
|
| 75 |
+
"lbs": POUND,
|
| 76 |
+
"oz": OUNCE,
|
| 77 |
+
"l": LITRE,
|
| 78 |
+
"L": LITRE,
|
| 79 |
+
"ml": MILLILITRE,
|
| 80 |
+
"mL": MILLILITRE,
|
| 81 |
+
"ms": MILLISECOND,
|
| 82 |
+
"sec": SECOND,
|
| 83 |
+
"secs": SECOND,
|
| 84 |
+
"second": SECOND,
|
| 85 |
+
"seconds": SECOND,
|
| 86 |
+
"min": MINUTE,
|
| 87 |
+
"mins": MINUTE,
|
| 88 |
+
"minute": MINUTE,
|
| 89 |
+
"minutes": MINUTE,
|
| 90 |
+
"h": HOUR,
|
| 91 |
+
"hr": HOUR,
|
| 92 |
+
"hrs": HOUR,
|
| 93 |
+
"hour": HOUR,
|
| 94 |
+
"hours": HOUR,
|
| 95 |
+
"day": DAY,
|
| 96 |
+
"days": DAY,
|
| 97 |
+
"week": WEEK,
|
| 98 |
+
"weeks": WEEK,
|
| 99 |
+
"month": MONTH,
|
| 100 |
+
"months": MONTH,
|
| 101 |
+
"year": YEAR,
|
| 102 |
+
"years": YEAR,
|
| 103 |
+
"%": PERCENT,
|
| 104 |
+
"٪": PERCENT,
|
| 105 |
+
"km/h": KILOMETRE_PER_HOUR,
|
| 106 |
+
"m/s": METRE_PER_SECOND,
|
| 107 |
+
"°C": CELSIUS,
|
| 108 |
+
"°F": FAHRENHEIT,
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
AMOUNT = r"\d+(?:[.,٫٬]\d+)*"
|
| 112 |
+
|
| 113 |
+
# Longest symbols first so `km/h` wins over `km`, and `mg` over `m`.
|
| 114 |
+
SYMBOLS = "|".join(re.escape(symbol) for symbol in sorted(UNITS, key=len, reverse=True))
|
| 115 |
+
|
| 116 |
+
PERCENT_SIGNS = ("%", "٪")
|
| 117 |
+
OTHER_SYMBOLS = "|".join(
|
| 118 |
+
re.escape(symbol)
|
| 119 |
+
for symbol in sorted(UNITS, key=len, reverse=True)
|
| 120 |
+
if symbol not in PERCENT_SIGNS
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
# A number or fraction, optional spaces, then a unit symbol: `5km`, `۵ km`,
|
| 124 |
+
# `2.5 kg`, `1/2 kg`, `2 1/2 kg`. The symbol must stand alone, so `5 km²`
|
| 125 |
+
# or `5G` don't match unless they are in the table.
|
| 126 |
+
# The amount and/or the symbol may be wrapped: `2 (kg)`, `3 {m}`, `(5) kg`.
|
| 127 |
+
# After a percent sign a unit symbol may follow (`5% kg`): that is `per_unit`.
|
| 128 |
+
UNIT_PATTERN = re.compile(
|
| 129 |
+
rf"""
|
| 130 |
+
(?<![\w./,٫٬])
|
| 131 |
+
(?:(?P<aopen>{OPENERS})[^\S\n]*)?
|
| 132 |
+
(?:{FRACTION_BODY}|(?P<amount>{AMOUNT}))
|
| 133 |
+
(?(aopen)[^\S\n]*(?P<aclose>{CLOSERS}))
|
| 134 |
+
[^\S\n]*
|
| 135 |
+
(?:(?P<sopen>{OPENERS})[^\S\n]*)?
|
| 136 |
+
(?P<symbol>{SYMBOLS})
|
| 137 |
+
(?(sopen)[^\S\n]*(?P<sclose>{CLOSERS}))
|
| 138 |
+
(?![\w/])
|
| 139 |
+
(?P<tail>[^\S\n]+(?P<per_unit>{OTHER_SYMBOLS})(?![\w/]))?
|
| 140 |
+
""",
|
| 141 |
+
re.VERBOSE,
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
# A percent sign typed before the number (`%20`, `% 20`, `٪ ۲۰`). It is moved
|
| 146 |
+
# after the number so the normal pattern can read it. `%d`, `%s`, `% 5d` and
|
| 147 |
+
# `a%20b` don't match.
|
| 148 |
+
PREFIX_PERCENT_PATTERN = re.compile(
|
| 149 |
+
rf"(?<![\w%٪])(?P<sign>[%٪])[^\S\n]*(?P<amount>{AMOUNT})(?![\w%٪/])"
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
class UnitProcessor(BaseProcessor):
|
| 154 |
+
"""Converts a number followed by a unit symbol into spoken text.
|
| 155 |
+
|
| 156 |
+
Persian: `5 km` -> `پنج کیلو��تر`
|
| 157 |
+
`۷۵kg` -> `هفتاد و پنج کیلوگرم`
|
| 158 |
+
English: `5 km` -> `five kilometres`
|
| 159 |
+
`1 kg` -> `one kilogram`
|
| 160 |
+
`25°C` -> `twenty-five degrees Celsius`
|
| 161 |
+
`2 %` -> `two percent` (Persian: `۲ ٪` -> `دو درصد`)
|
| 162 |
+
`10 sec` -> `ten seconds`
|
| 163 |
+
|
| 164 |
+
Decimals are split into smaller units when the unit has one:
|
| 165 |
+
`1000.5 m` -> `یک هزار متر و پنجاه سانتیمتر`
|
| 166 |
+
`2.5 kg` -> `two kilograms and five hundred grams`
|
| 167 |
+
`1.5 h` -> `one hour and thirty minutes`
|
| 168 |
+
`1/2 %` -> `one half percent`
|
| 169 |
+
Other decimals are read as a plain number: `2.5 mi` -> `two point five miles`.
|
| 170 |
+
|
| 171 |
+
A percent sign may come before the number (`% 5`, `٪ ۵`), and a unit symbol
|
| 172 |
+
after a percent sign is read on its own: `5% kg` -> `five percent kilogram`.
|
| 173 |
+
|
| 174 |
+
With nothing but units in the text, Persian digits give Persian
|
| 175 |
+
(`۱۰۰۰m` -> `یک هزار متر`) and ASCII digits give English.
|
| 176 |
+
|
| 177 |
+
Works on the whole text (not word by word), so both `5km` and `5 km`
|
| 178 |
+
are handled. Add a unit by adding one line to `UNITS`.
|
| 179 |
+
"""
|
| 180 |
+
|
| 181 |
+
def __init__(self, epsilon: float = 1.0):
|
| 182 |
+
super(UnitProcessor, self).__init__(epsilon=epsilon)
|
| 183 |
+
|
| 184 |
+
def process(self, text: str, language: str = None, *args, **kwargs) -> str:
|
| 185 |
+
if random.random() > self.epsilon:
|
| 186 |
+
return text
|
| 187 |
+
|
| 188 |
+
language = self._resolve_language(text, language)
|
| 189 |
+
if language is None:
|
| 190 |
+
return text
|
| 191 |
+
|
| 192 |
+
def convert(match: re.Match) -> str:
|
| 193 |
+
return self._convert(match, language)
|
| 194 |
+
|
| 195 |
+
text = PREFIX_PERCENT_PATTERN.sub(self._move_prefix_percent, text)
|
| 196 |
+
return UNIT_PATTERN.sub(convert, text)
|
| 197 |
+
|
| 198 |
+
@staticmethod
|
| 199 |
+
def _move_prefix_percent(match: re.Match) -> str:
|
| 200 |
+
"""`% 20 cm` -> `20% cm`, so it reads as `twenty percent centimetre`."""
|
| 201 |
+
before = match.string[: match.start()].rstrip(" \t")
|
| 202 |
+
# In `5 % 2` the sign belongs to the 5 before it, not to the 2 after it.
|
| 203 |
+
if before and (before[-1].isdigit() or before[-1] in VULGAR_FRACTIONS):
|
| 204 |
+
return match.group(0)
|
| 205 |
+
return f"{match.group('amount')}{match.group('sign')}"
|
| 206 |
+
|
| 207 |
+
@staticmethod
|
| 208 |
+
def _resolve_language(text: str, language: str):
|
| 209 |
+
"""Detects the language ignoring the unit symbols themselves.
|
| 210 |
+
|
| 211 |
+
Symbols like `km` are Latin letters and would otherwise tip a Persian
|
| 212 |
+
sentence towards English. If nothing but units is left (e.g. `۱۰۰۰m`),
|
| 213 |
+
Persian digits mean Persian; otherwise the given `language` is used,
|
| 214 |
+
and with no language given ASCII digits mean English.
|
| 215 |
+
"""
|
| 216 |
+
detected = detect_language(UNIT_PATTERN.sub(" ", text))
|
| 217 |
+
if detected in ("fa", "en"):
|
| 218 |
+
return detected
|
| 219 |
+
if detected != NO_LETTERS:
|
| 220 |
+
return None
|
| 221 |
+
|
| 222 |
+
digit_language = detect_digit_language(text)
|
| 223 |
+
if digit_language == "fa":
|
| 224 |
+
return "fa"
|
| 225 |
+
if language in ("fa", "en"):
|
| 226 |
+
return language
|
| 227 |
+
if language is None or language == NO_LETTERS:
|
| 228 |
+
return digit_language
|
| 229 |
+
return None
|
| 230 |
+
|
| 231 |
+
@staticmethod
|
| 232 |
+
def _convert(match: re.Match, language: str) -> str:
|
| 233 |
+
unit = UNITS[match.group("symbol")]
|
| 234 |
+
pieces = UnitProcessor._amount_pieces(match, unit, language)
|
| 235 |
+
if pieces is None:
|
| 236 |
+
return match.group(0)
|
| 237 |
+
|
| 238 |
+
# Wrappers stay where they were: `2 (kg)` -> `two (kilograms)`.
|
| 239 |
+
rendered = []
|
| 240 |
+
for index, (amount_text, unit_text) in enumerate(pieces):
|
| 241 |
+
if index == 0 and match.group("aopen"):
|
| 242 |
+
amount_text = f"{match.group('aopen')}{amount_text}{match.group('aclose')}"
|
| 243 |
+
if match.group("sopen"):
|
| 244 |
+
unit_text = f"{match.group('sopen')}{unit_text}{match.group('sclose')}"
|
| 245 |
+
rendered.append(f"{amount_text} {unit_text}")
|
| 246 |
+
|
| 247 |
+
if language == "fa" or len(rendered) == 1:
|
| 248 |
+
text = " و ".join(rendered) if language == "fa" else rendered[0]
|
| 249 |
+
else:
|
| 250 |
+
text = f"{', '.join(rendered[:-1])} and {rendered[-1]}"
|
| 251 |
+
|
| 252 |
+
per_unit = match.group("per_unit")
|
| 253 |
+
if per_unit is None:
|
| 254 |
+
return text
|
| 255 |
+
|
| 256 |
+
if unit is PERCENT:
|
| 257 |
+
# `5% kg` -> `five percent kilogram`: the unit has no number of its own.
|
| 258 |
+
other = UNITS[per_unit]
|
| 259 |
+
return f"{text} {other.fa if language == 'fa' else other.en}"
|
| 260 |
+
|
| 261 |
+
# A symbol after any other unit is not ours to convert: put it back.
|
| 262 |
+
return text + match.group("tail")
|
| 263 |
+
|
| 264 |
+
@staticmethod
|
| 265 |
+
def _amount_pieces(match: re.Match, unit: Unit, language: str):
|
| 266 |
+
"""Reads the amount as [(amount words, unit words), ...]; None if it
|
| 267 |
+
should stay unchanged. Several pieces mean a split decimal (`2.5 kg`)."""
|
| 268 |
+
if match.group("amount") is None:
|
| 269 |
+
return UnitProcessor._fraction_pieces(match, unit, language)
|
| 270 |
+
|
| 271 |
+
amount = digits.fa_to_en(match.group("amount"))
|
| 272 |
+
clean = amount.replace(",", "").replace("٬", "").replace("٫", ".")
|
| 273 |
+
|
| 274 |
+
parts = UnitProcessor._split_into_units(Decimal(clean), unit)
|
| 275 |
+
|
| 276 |
+
if language == "fa":
|
| 277 |
+
if parts is None:
|
| 278 |
+
return [(words(float(clean)), unit.fa)]
|
| 279 |
+
return [(words(number), part.fa) for number, part in parts]
|
| 280 |
+
|
| 281 |
+
if parts is None:
|
| 282 |
+
return [(num2words(Decimal(clean)), unit.en_plural)]
|
| 283 |
+
return [
|
| 284 |
+
(num2words(number), part.en if number == 1 else part.en_plural)
|
| 285 |
+
for number, part in parts
|
| 286 |
+
]
|
| 287 |
+
|
| 288 |
+
@staticmethod
|
| 289 |
+
def _split_into_units(value: Decimal, unit: Unit):
|
| 290 |
+
"""Splits a decimal amount into whole numbers of smaller units.
|
| 291 |
+
|
| 292 |
+
`1000.5 m` -> [(1000, metre), (50, centimetre)]. Returns None when the
|
| 293 |
+
fraction does not end exactly on the smallest known unit (or the unit
|
| 294 |
+
has no smaller unit), so the caller reads it as a plain decimal.
|
| 295 |
+
"""
|
| 296 |
+
parts = []
|
| 297 |
+
while True:
|
| 298 |
+
whole = int(value)
|
| 299 |
+
fraction = value - whole
|
| 300 |
+
if whole:
|
| 301 |
+
parts.append((whole, unit))
|
| 302 |
+
if fraction == 0:
|
| 303 |
+
return parts or [(0, unit)]
|
| 304 |
+
if unit.minor is None:
|
| 305 |
+
return None
|
| 306 |
+
value = fraction * unit.factor
|
| 307 |
+
unit = unit.minor
|
| 308 |
+
|
| 309 |
+
@staticmethod
|
| 310 |
+
def _fraction_pieces(match: re.Match, unit: Unit, language: str):
|
| 311 |
+
"""`1/2 kg` -> `one half` + `kilogram`, `1,000/2 kg` -> `one thousand halves` + `kilograms`."""
|
| 312 |
+
parsed = FractionProcessor.parse(match)
|
| 313 |
+
if parsed is None:
|
| 314 |
+
return None
|
| 315 |
+
|
| 316 |
+
whole, numerator, denominator = parsed
|
| 317 |
+
text = FractionProcessor.to_text(whole, numerator, denominator, language)
|
| 318 |
+
|
| 319 |
+
if language == "fa":
|
| 320 |
+
return [(text, unit.fa)]
|
| 321 |
+
|
| 322 |
+
# A part of one unit stays singular ("one half kilogram").
|
| 323 |
+
is_singular = whole is None and numerator <= denominator
|
| 324 |
+
return [(text, unit.en if is_singular else unit.en_plural)]
|
rules/wrappers.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Characters that can wrap a number, symbol or unit: `(kg)`, `[V]`, `{m}`, `"m"`, `«V»`."""
|
| 2 |
+
import re
|
| 3 |
+
|
| 4 |
+
OPEN_CHARS = "([{\"“«‘'"
|
| 5 |
+
CLOSE_CHARS = ")]}\"”»’'"
|
| 6 |
+
|
| 7 |
+
OPENERS = "[" + re.escape(OPEN_CHARS) + "]"
|
| 8 |
+
CLOSERS = "[" + re.escape(CLOSE_CHARS) + "]"
|
| 9 |
+
WRAPPERS = "[" + re.escape(OPEN_CHARS + CLOSE_CHARS) + "]"
|