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}"