Spaces:
Sleeping
Sleeping
Download rules/fraction.py from frznkshn/TextNormalizer: direct link, hf CLI and curl.
- Browser
- Download file 5.6 kB
-
https://huggingface.co/spaces/frznkshn/TextNormalizer/resolve/main/rules/fraction.py
- Command line
-
hf download hf://spaces/frznkshn/TextNormalizer/rules/fraction.py
-
curl -L -o fraction.py https://huggingface.co/spaces/frznkshn/TextNormalizer/resolve/main/rules/fraction.py
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) | |
| 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) | |
| 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, | |
| ) | |
| 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}" | |
| def _to_int(number: str) -> int: | |
| clean = digits.fa_to_en(number).replace(",", "").replace("٬", "") | |
| return int(clean) | |
| 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}" | |
| 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}" |