frznkshn commited on
Commit
ed76d6e
·
verified ·
1 Parent(s): 0a4e172

Upload 13 files

Browse files
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 `&amp;`.
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 (`&amp;`) 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) + "]"