TextNormalizer / rules /fraction.py
frznkshn's picture
Upload 13 files
ed76d6e verified
Raw History Blame Contribute Delete
5.6 kB
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}"