Spaces:
Sleeping
Sleeping
File size: 5,604 Bytes
ed76d6e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 | import random
import re
from num2fawords import ordinal_words, words
from num2words import num2words
from persiantools import digits
from rules.base import BaseProcessor
from utils import NO_LETTERS, detect_digit_language
VULGAR_FRACTIONS = {
"½": (1, 2),
"⅓": (1, 3),
"⅔": (2, 3),
"¼": (1, 4),
"¾": (3, 4),
"⅕": (1, 5),
"⅖": (2, 5),
"⅗": (3, 5),
"⅘": (4, 5),
"⅙": (1, 6),
"⅚": (5, 6),
"⅛": (1, 8),
"⅜": (3, 8),
"⅝": (5, 8),
"⅞": (7, 8),
}
VULGAR = "".join(VULGAR_FRACTIONS)
# A whole number, optionally with thousands separators: `12`, `1,000`, `۱٬۰۰۰`.
NUMBER = r"(?:\d{1,3}(?:[,٬]\d{3})+|\d+)"
# The fraction itself: `1/3`, `۱/۳`, `1,000/2`, `2 1/2` (mixed number), `½`, `1½`.
# Shared with `UnitProcessor`, which reads `1/2 kg`.
FRACTION_BODY = rf"""
(?:
(?:(?P<whole>{NUMBER})[ ])?(?P<numerator>{NUMBER})/(?P<denominator>{NUMBER})
|
(?:(?P<vulgar_whole>{NUMBER})[ ]?)?(?P<vulgar>[{VULGAR}])
)
"""
# Does not match parts of dates (`1402/05/10`), decimals (`3.1/2`), or fractions
# glued to a word or currency/percent sign (`and/or`, `1/2$`).
FRACTION_PATTERN = re.compile(
rf"""
(?<![\w/])(?<!\d[.,٫٬])
{FRACTION_BODY}
(?![\w/$€£%°])(?![.,٫٬]\d)
""",
re.VERBOSE,
)
class FractionProcessor(BaseProcessor):
"""Converts fractions into spoken text.
Persian: `۱/۳` -> `یک سوم`
`۳/۴` -> `سه چهارم`
`۲ ۱/۲` -> `دو و یک دوم`
English: `1/3` -> `one third`
`3/4` -> `three quarters`
`2 1/2` -> `two and one half`
`½` -> `one half`
`1,000/2` -> `one thousand halves`
Works on the whole text (not word by word) because a mixed number such as
`2 1/2` spans two words. If the text has no letters at all, the digits pick
the language: Persian digits -> Persian, ASCII digits -> English.
A fraction followed by a unit (`1/2 kg`) is handled by `UnitProcessor`.
"""
def __init__(self, epsilon: float = 1.0):
super(FractionProcessor, self).__init__(epsilon=epsilon)
def process(self, text: str, language: str = None, *args, **kwargs) -> str:
if random.random() > self.epsilon:
return text
if language in (None, NO_LETTERS):
language = detect_digit_language(text)
if language not in ("fa", "en"):
return text
return FRACTION_PATTERN.sub(lambda match: self._convert(match, language), text)
@staticmethod
def _convert(match: re.Match, language: str) -> str:
parsed = FractionProcessor.parse(match)
if parsed is None:
return match.group(0)
return FractionProcessor.to_text(*parsed, language=language)
@staticmethod
def parse(match: re.Match):
"""Returns (whole, numerator, denominator) from a match of `FRACTION_BODY`.
`whole` is None when there is no mixed-number part. Returns None for
`1/0` (not a fraction) and `3/1` (just a ratio), which are left alone.
"""
if match.group("vulgar"):
numerator, denominator = VULGAR_FRACTIONS[match.group("vulgar")]
whole = match.group("vulgar_whole")
else:
numerator = FractionProcessor._to_int(match.group("numerator"))
denominator = FractionProcessor._to_int(match.group("denominator"))
whole = match.group("whole")
if denominator in (0, 1):
return None
return (
None if whole is None else FractionProcessor._to_int(whole),
numerator,
denominator,
)
@staticmethod
def to_text(whole, numerator: int, denominator: int, language: str) -> str:
if language == "fa":
fraction = FractionProcessor._persian_fraction(numerator, denominator)
joiner, spell = " و ", words
else:
fraction = FractionProcessor._english_fraction(numerator, denominator)
joiner, spell = " and ", num2words
if whole is None:
return fraction
return f"{spell(whole)}{joiner}{fraction}"
@staticmethod
def _to_int(number: str) -> int:
clean = digits.fa_to_en(number).replace(",", "").replace("٬", "")
return int(clean)
@staticmethod
def _persian_fraction(numerator: int, denominator: int) -> str:
ordinal = ordinal_words(denominator)
# The library writes 30 as `سیم`; the correct form is `سیام`.
if ordinal.endswith("سیم"):
ordinal = ordinal[: -len("سیم")] + "سیام"
# `یک صدم` / `یک هزارم`, not `یک یکصدم` / `یک یک هزارم`.
if ordinal == "یکصدم":
ordinal = "صدم"
elif ordinal.startswith("یک "):
ordinal = ordinal[len("یک "):]
return f"{words(numerator)} {ordinal}"
@staticmethod
def _english_fraction(numerator: int, denominator: int) -> str:
singular = numerator == 1
if denominator == 2:
unit = "half" if singular else "halves"
elif denominator == 4:
unit = "quarter" if singular else "quarters"
else:
ordinal = num2words(denominator, to="ordinal")
# `one hundredth`, not `one one hundredth`.
if ordinal.startswith("one "):
ordinal = ordinal[len("one "):]
unit = ordinal if singular else f"{ordinal}s"
return f"{num2words(numerator)} {unit}" |