arbtok 0.0.0a2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
arbtok/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """
2
+ I'm just a dude trying to create TTS voices to help the visually impaired arabic community.
3
+
4
+ I am not an arabic speaker, I never had any previous interaction with the arabic script, this might be a pointless experiment powered by LLM hallucinations.
5
+
6
+ You have been warned.
7
+
8
+ if you speak MSA pull requests welcome!
9
+
10
+ - test.py contains a reference dataset to benchmark the code against, IPA transcriptions have been LLM generated
11
+ - main logic is in tokenizer.py
12
+ - see tests folder for some benchmarks and comparison against pre existing (low quality) datasets
13
+ """
arbtok/constants.py ADDED
@@ -0,0 +1,87 @@
1
+ import string
2
+ from typing import Set
3
+
4
+ # ==============================================================================
5
+ # ARABIC CONSTANTS
6
+ # ==============================================================================
7
+
8
+ # --- Diacritics (Tashkeel) ---
9
+ FATHA = '\u064E' # َ (short a)
10
+ DAMMA = '\u064F' # ُ (short u)
11
+ KASRA = '\u0650' # ِ (short i)
12
+ SHADDA = '\u0651' # ّ (consonant doubling/gemination)
13
+ SUKUN = '\u0652' # ْ (no vowel/silence)
14
+ TANWIN_FATH = '\u064B' # ً (an)
15
+ TANWIN_DAMM = '\u064C' # ٌ (un)
16
+ TANWIN_KASR = '\u064D' # ٍ (in)
17
+ DAGGER_ALIF = '\u0670' # ٰ (superscript alef, long a)
18
+ MADD = '\u0653' # ٓ (madda, vowel elongation)
19
+
20
+ # --- Letters & Variants ---
21
+ HAMZA = '\u0621' # ء (standalone hamza)
22
+ ALEF_MADDA = '\u0622' # آ (alef with madda)
23
+ ALEF_HAMZA_ABOVE = '\u0623' # أ (alef with hamza above)
24
+ WAW_HAMZA = '\u0624' # ؤ (waw with hamza)
25
+ ALEF_HAMZA_BELOW = '\u0625' # إ (alef with hamza below)
26
+ YA_HAMZA = '\u0626' # ئ (ya with hamza)
27
+ ALIF = '\u0627' # ا (bare alef)
28
+ TA_MARBUTA = '\u0629' # ة (tied ta, fem. marker)
29
+ WAW = '\u0648' # و
30
+ ALIF_MAKSURA = '\u0649' # ى (broken alef)
31
+ YA = '\u064A' # ي
32
+ LAM = '\u0644' # ل
33
+ HAMZAT_AL_WASL = '\u0671' # ٱ (wasl, connecting hamza)
34
+
35
+ ALEF_VARIANTS = {ALIF, ALEF_MADDA, ALEF_HAMZA_ABOVE, ALEF_HAMZA_BELOW, HAMZAT_AL_WASL}
36
+
37
+ # --- Punctuation ---
38
+ ARABIC_PUNCT = "،؛؟ـ…" # Comma, Semicolon, Question mark, Tatweel, Ellipsis
39
+ PUNCT = string.punctuation + ARABIC_PUNCT
40
+
41
+ # --- Sun Letters (Shamsiyya) ---
42
+ # These consonants assimilate the 'l' of the definite article 'al-'.
43
+ # e.g., al-shams -> ash-shams.
44
+ SUN_LETTERS: Set[str] = {
45
+ '\u062A', # ت (t)
46
+ '\u062B', # ث (th)
47
+ '\u062F', # د (d)
48
+ '\u0630', # ذ (dh)
49
+ '\u0631', # ر (r)
50
+ '\u0632', # ز (z)
51
+ '\u0633', # س (s)
52
+ '\u0634', # ش (sh)
53
+ '\u0635', # ص (sad)
54
+ '\u0636', # ض (dad)
55
+ '\u0637', # ط (ta)
56
+ '\u0638', # ظ (dha)
57
+ '\u0644', # ل (l)
58
+ '\u0646', # ن (n)
59
+ }
60
+
61
+ # --- Proclitics ---
62
+ # Short prepositions/conjunctions that attach to the following word in writing.
63
+ CLITIC_BASES = {
64
+ WAW, # و (and)
65
+ '\u0641', # ف (so/then)
66
+ '\u0628', # ب (by/with)
67
+ '\u0643', # ك (like/as)
68
+ LAM, # ل (for/to)
69
+ '\u0633' # س (future marker 'sa-')
70
+ }
71
+
72
+ # --- IPA Mappings ---
73
+ # for easy unambiguous phoneme reference in the code when the unicode maps to a specific IPA sound
74
+ B = '\u0628'
75
+ R = '\u0631'
76
+ Z = '\u0632'
77
+ S = '\u0633'
78
+ F = '\u0641'
79
+ Q = '\u0642'
80
+ K = '\u0643'
81
+ M = '\u0645'
82
+ N = '\u0646'
83
+ H = '\u0647'
84
+ T = '\u062A'
85
+ DJ = '\u062C'
86
+ X = '\u062E'
87
+ D = '\u062F'
arbtok/dialects.py ADDED
@@ -0,0 +1,111 @@
1
+ """
2
+ TODO: to be extended with different phoneme realizations per dialect.
3
+ logic in tokenizer might use ArabicDialect enum if needed for further contextual rules
4
+
5
+ currently only MSA is targeted
6
+ """
7
+ from enum import Enum
8
+ from arbtok.constants import (B, T, DJ, X, D, R, Z, S, F, Q, K, M, N, H, LAM, WAW, YA,
9
+ FATHA, DAMMA, KASRA, DAGGER_ALIF, MADD,
10
+ HAMZA, ALEF_HAMZA_ABOVE, ALEF_HAMZA_BELOW, WAW_HAMZA, YA_HAMZA,
11
+ TANWIN_FATH, TANWIN_KASR, TANWIN_DAMM,
12
+ ALIF_MAKSURA)
13
+
14
+
15
+ class ArabicDialect(str, Enum):
16
+ CLA = "CLA"
17
+ MSA = "MSA"
18
+
19
+
20
+ ARABIC_TO_IPA_CONSONANTS = {
21
+ HAMZA: "ʔ", ALEF_HAMZA_ABOVE: "ʔ", ALEF_HAMZA_BELOW: "ʔ", WAW_HAMZA: "ʔ", YA_HAMZA: "ʔ",
22
+ B: 'b',
23
+ T: 't',
24
+ '\u062B': 'θ',
25
+ DJ: 'dʒ', # DJ: 'ʤ',
26
+ '\u062D': 'ħ',
27
+ X: 'x',
28
+ D: 'd',
29
+ '\u0630': 'ð',
30
+ R: 'r',
31
+ Z: 'z',
32
+ S: 's',
33
+ '\u0634': 'ʃ',
34
+ '\u0635': 'sˤ',
35
+ '\u0636': 'dˤ',
36
+ '\u0637': 'tˤ',
37
+ '\u0638': 'ðˤ',
38
+ '\u0639': 'ʕ',
39
+ '\u063A': 'ɣ',
40
+ F: 'f',
41
+ Q: 'q',
42
+ K: 'k',
43
+ M: 'm',
44
+ N: 'n',
45
+ H: 'h',
46
+ LAM: 'l',
47
+ WAW: 'w',
48
+ YA: 'j',
49
+ # Other unicode variations
50
+ "ﻻ": "laː"
51
+ }
52
+
53
+ DIACRITIC_TO_IPA = {
54
+ FATHA: 'a',
55
+ DAMMA: 'u',
56
+ KASRA: 'i',
57
+ DAGGER_ALIF: 'aː',
58
+ MADD: 'aː'
59
+ }
60
+ TANWIN_TO_IPA = {
61
+ TANWIN_FATH: "an",
62
+ TANWIN_DAMM: "un",
63
+ TANWIN_KASR: "in"
64
+ }
65
+
66
+ VOWEL_MAP = {**DIACRITIC_TO_IPA,
67
+ **TANWIN_TO_IPA,
68
+ MADD: ":",
69
+ ALIF_MAKSURA: 'aː'}
70
+
71
+ # --- Word Exceptions ---
72
+ # TODO - LLM generated, needs validation from native speaker
73
+ # Dictionary for words with irregular orthography vs pronunciation.
74
+ # These words have "deficient" or "historical" spelling where vowels
75
+ # are pronounced but not written, or written letters are silent.
76
+ WORD_EXCEPTIONS = {
77
+ # === Unicode / orthographic variants ===
78
+
79
+ # Demonstratives with "Dagger Alif" (pronounced long /a:/ but written short or omitted)
80
+ "هَٰذَا": "haːðaː", # ha-dha (this, m.)
81
+ "هٰذَا": "haːðaː", # variant
82
+ "هَذَا": "haːðaː", # common deficient spelling
83
+ "هَٰذِهِ": "haːðihi", # ha-dhi-hi (this, f.)
84
+ "هٰذِهِ": "haːðihi", # variant
85
+ "ذَٰلِكَ": "ðaːlika", # dha-li-ka (that)
86
+ "ذٰلِكَ": "ðaːlika", # variant
87
+ "أُولَٰئِكَ": "ʔulaːʔika", # u-la-i-ka (those) - note medial hamza logic is complex, hardcoded here
88
+
89
+ # Particles
90
+ "لَٰكِن": "laːkin", # la-kin (but) - unwritten medial alif
91
+ "لَكِن": "laːkin", # deficient spelling
92
+ "لَٰكِنَّ": "laːkinna", # la-kin-na (but...)
93
+
94
+ # Divine Names
95
+ "ﷲ": "allaːh",
96
+ "اللّٰه": "allaːh", # Allah - heavy L, unwritten alif
97
+ "اللَّه": "allaːh", # variant
98
+ "إِلَٰه": "ʔilaːh", # ilah (god)
99
+ "الرَّحْمَٰن": "arraħmaːn", # Ar-Rahman
100
+
101
+ # Irregular pronunciations
102
+ "مِائَة": "miʔa", # mi'a (hundred) - silent extra Alif in spelling
103
+ "عَمْرٌو": "ʕamrun", # 'Amr - final Waw is silent (differentiates from 'Umar)
104
+ "أُولِي": "ʔuliː", # 'uli (owners of) - silent Waw
105
+ "أُولُو": "ʔuluː", # 'ulu - silent Waw
106
+
107
+ # TODO - why are these irregular?
108
+ "مُسْتَشْفَى": "mustashfaː",
109
+ "مَدْرَسَة": "madrasah"
110
+ }
111
+
@@ -0,0 +1,237 @@
1
+ import os
2
+ import re
3
+ import string
4
+ import subprocess
5
+ from typing import List, Optional
6
+ from typing import Tuple
7
+
8
+ import numpy as np
9
+ import onnxruntime
10
+ import requests
11
+ from quebra_frases import sentence_tokenize
12
+
13
+ from arbtok.tashkeel import TashkeelDiacritizer
14
+ from arbtok.util import normalize, match_lang
15
+
16
+ # list of (substring, terminator, end_of_sentence) tuples.
17
+ TextChunks = List[Tuple[str, str, bool]]
18
+ # list of (phonemes, terminator, end_of_sentence) tuples.
19
+ RawPhonemizedChunks = List[Tuple[str, str, bool]]
20
+
21
+ PhonemizedChunks = list[list[str]]
22
+
23
+
24
+ class EspeakError(Exception):
25
+ """Custom exception for espeak-ng related errors."""
26
+ pass
27
+
28
+
29
+ class EspeakPhonemizer:
30
+ """
31
+ A phonemizer class that uses the espeak-ng command-line tool to convert text into phonemes.
32
+ It segments the input text heuristically based on punctuation to mimic clause-by-clause processing.
33
+ """
34
+ ESPEAK_LANGS = ['es-419', 'ca', 'qya', 'ga', 'et', 'ky', 'io', 'fa-latn', 'en-gb', 'fo', 'haw', 'kl',
35
+ 'ta', 'ml', 'gd', 'sd', 'es', 'hy', 'ur', 'ro', 'hi', 'or', 'ti', 'ca-va', 'om', 'tr', 'pa',
36
+ 'smj', 'mk', 'bg', 'cv', "fr", 'fi', 'en-gb-x-rp', 'ru', 'mt', 'an', 'mr', 'pap', 'vi', 'id',
37
+ 'fr-be', 'ltg', 'my', 'nl', 'shn', 'ba', 'az', 'cmn', 'da', 'as', 'sw',
38
+ 'piqd', 'en-us', 'hr', 'it', 'ug', 'th', 'mi', 'cy', 'ru-lv', 'ia', 'tt', 'hu', 'xex', 'te', 'ne',
39
+ 'eu', 'ja', 'bpy', 'hak', 'cs', 'en-gb-scotland', 'hyw', 'uk', 'pt', 'bn', 'mto', 'yue',
40
+ 'be', 'gu', 'sv', 'sl', 'cmn-latn-pinyin', 'lfn', 'lv', 'fa', 'sjn', 'nog', 'ms',
41
+ 'vi-vn-x-central', 'lt', 'kn', 'he', 'qu', 'ca-ba', 'quc', 'nb', 'sk', 'tn', 'py', 'si', 'de',
42
+ 'ar', 'en-gb-x-gbcwmd', 'bs', 'qdb', 'sq', 'sr', 'tk', 'en-029', 'ht', 'ru-cl', 'af', 'pt-br',
43
+ 'fr-ch', 'ka', 'en-gb-x-gbclan', 'ko', 'is', 'ca-nw', 'gn', 'kok', 'la', 'lb', 'am', 'kk', 'ku',
44
+ 'kaa', 'jbo', 'eo', 'uz', 'nci', 'vi-vn-x-south', 'el', 'pl', 'grc', ]
45
+
46
+ def __init__(self, pausal: bool = True):
47
+ self.pausal = pausal # arabic only
48
+ self._tashkeel: Optional[TashkeelDiacritizer] = None
49
+
50
+ @classmethod
51
+ def get_lang(cls, target_lang: str) -> str:
52
+ """
53
+ Validates and returns the closest supported language code.
54
+
55
+ Args:
56
+ target_lang (str): The language code to validate.
57
+
58
+ Returns:
59
+ str: The validated language code.
60
+
61
+ Raises:
62
+ ValueError: If the language code is unsupported.
63
+ """
64
+ if target_lang.lower() == "en-gb":
65
+ return "en-gb-x-rp"
66
+ if target_lang in cls.ESPEAK_LANGS:
67
+ return target_lang
68
+ if target_lang.lower().split("-")[0] in cls.ESPEAK_LANGS:
69
+ return target_lang.lower().split("-")[0]
70
+ return cls.match_lang(target_lang, cls.ESPEAK_LANGS)
71
+
72
+ @property
73
+ def tashkeel(self) -> TashkeelDiacritizer:
74
+ if self._tashkeel is None:
75
+ self._tashkeel = TashkeelDiacritizer()
76
+ return self._tashkeel
77
+
78
+ @staticmethod
79
+ def _run_espeak_command(args: List[str], input_text: str = None, check: bool = True) -> str:
80
+ """
81
+ Helper function to run espeak-ng commands via subprocess.
82
+ Executes 'espeak-ng' with the given arguments and input text.
83
+ Captures stdout and stderr, and raises EspeakError on failure.
84
+
85
+ Args:
86
+ args (List[str]): A list of command-line arguments for espeak-ng.
87
+ input_text (str, optional): The text to pass to espeak-ng's stdin. Defaults to None.
88
+ check (bool, optional): If True, raises a CalledProcessError if the command returns a non-zero exit code. Defaults to True.
89
+
90
+ Returns:
91
+ str: The stripped standard output from the espeak-ng command.
92
+
93
+ Raises:
94
+ EspeakError: If espeak-ng command is not found, or if the subprocess call fails.
95
+ """
96
+ command: List[str] = ['espeak-ng'] + args
97
+
98
+ # Standard arguments for subprocess.run
99
+ subprocess_args = {
100
+ 'input': input_text,
101
+ 'capture_output': True,
102
+ 'text': True,
103
+ 'check': check,
104
+ 'encoding': 'utf-8',
105
+ 'errors': 'replace' # Replaces unencodable characters with a placeholder
106
+ }
107
+
108
+ # Add 'creationflags' to hide the terminal window on Windows
109
+ if os.name == 'nt':
110
+ subprocess_args['creationflags'] = 0x08000000
111
+
112
+ try:
113
+ process: subprocess.CompletedProcess = subprocess.run(
114
+ command,
115
+ **subprocess_args # Use the dynamic arguments
116
+ )
117
+ return process.stdout.strip()
118
+ except FileNotFoundError:
119
+ raise EspeakError(
120
+ "espeak-ng command not found. Please ensure espeak-ng is installed "
121
+ "and available in your system's PATH."
122
+ )
123
+ except subprocess.CalledProcessError as e:
124
+ raise EspeakError(
125
+ f"espeak-ng command failed with error code {e.returncode}:\n"
126
+ f"STDOUT: {e.stdout}\n"
127
+ f"STDERR: {e.stderr}"
128
+ )
129
+ except Exception as e:
130
+ raise EspeakError(f"An unexpected error occurred while running espeak-ng: {e}")
131
+
132
+ def phonemize_string(self, text: str, lang: str) -> str:
133
+ lang = self.get_lang(lang)
134
+ return self._run_espeak_command(
135
+ ['-q', '-x', '--ipa', '-v', lang],
136
+ input_text=text
137
+ )
138
+
139
+ def phonemize_to_list(self, text: str, lang: str = "ar") -> List[str]:
140
+ return list(self.phonemize_string(text, lang))
141
+
142
+ def add_diacritics(self, text: str, lang: str= "ar") -> str:
143
+ if lang.startswith("ar"):
144
+ return self.tashkeel.diacritize(text, pausal=self.pausal)
145
+ return text
146
+
147
+ def phonemize(self, text: str, lang: str= "ar") -> PhonemizedChunks:
148
+ if not text:
149
+ return [('', '', True)]
150
+ results: RawPhonemizedChunks = []
151
+ text = normalize(text, lang)
152
+ for chunk, punct, eos in self.chunk_text(text):
153
+ phoneme_str = self.phonemize_string(self.remove_punctuation(chunk), lang)
154
+ results += [(phoneme_str, punct, True)]
155
+ return self._process_phones(results)
156
+
157
+ @staticmethod
158
+ def _process_phones(raw_phones: RawPhonemizedChunks) -> PhonemizedChunks:
159
+ """Text to phonemes grouped by sentence."""
160
+ all_phonemes: list[list[str]] = []
161
+ sentence_phonemes: list[str] = []
162
+ for phonemes_str, terminator_str, end_of_sentence in raw_phones:
163
+ # Filter out (lang) switch (flags).
164
+ # These surround words from languages other than the current voice.
165
+ phonemes_str = re.sub(r"\([^)]+\)", "", phonemes_str)
166
+ sentence_phonemes.extend(list(phonemes_str))
167
+ if end_of_sentence:
168
+ all_phonemes.append(sentence_phonemes)
169
+ sentence_phonemes = []
170
+ if sentence_phonemes:
171
+ all_phonemes.append(sentence_phonemes)
172
+ return all_phonemes
173
+
174
+ @staticmethod
175
+ def match_lang(target_lang: str, valid_langs: List[str]) -> str:
176
+ """
177
+ Validates and returns the closest supported language code.
178
+
179
+ Args:
180
+ target_lang (str): The language code to validate.
181
+
182
+ Returns:
183
+ str: The validated language code.
184
+
185
+ Raises:
186
+ ValueError: If the language code is unsupported.
187
+ """
188
+ lang, score = match_lang(target_lang, valid_langs)
189
+ if score > 10:
190
+ # raise an error for unsupported language
191
+ raise ValueError(f"unsupported language code: {target_lang}")
192
+ return lang
193
+
194
+ @staticmethod
195
+ def remove_punctuation(text):
196
+ """
197
+ Removes all punctuation characters from a string.
198
+ Punctuation characters are defined by string.punctuation.
199
+ """
200
+ # Create a regex pattern that matches any character in string.punctuation
201
+ punctuation_pattern = r"[" + re.escape(string.punctuation) + r"]"
202
+ return re.sub(punctuation_pattern, '', text).strip()
203
+
204
+ @staticmethod
205
+ def chunk_text(text: str, delimiters: Optional[List[str]] = None) -> TextChunks:
206
+ if not text:
207
+ return [('', '', True)]
208
+
209
+ results: TextChunks = []
210
+ delimiters = delimiters or [":", ";", "...", "|"]
211
+
212
+ # Create a regex pattern that matches any of the delimiters
213
+ delimiter_pattern = re.escape(delimiters[0])
214
+ for delimiter in delimiters[1:]:
215
+ delimiter_pattern += f"|{re.escape(delimiter)}"
216
+
217
+ for sentence in sentence_tokenize(text):
218
+ # Default punctuation if no specific punctuation found
219
+ default_punc = sentence[-1] if sentence and sentence[-1] in string.punctuation else "."
220
+
221
+ # Use regex to split the sentence by any of the delimiters
222
+ parts = re.split(f'({delimiter_pattern})', sentence)
223
+
224
+ # Group parts into chunks (text + delimiter)
225
+ chunks = []
226
+ for i in range(0, len(parts), 2):
227
+ # If there's a delimiter after the text, use it
228
+ delimiter = parts[i + 1] if i + 1 < len(parts) else default_punc
229
+
230
+ # Last chunk is marked as complete
231
+ is_last = (i + 2 >= len(parts))
232
+
233
+ chunks.append((parts[i].strip(), delimiter.strip(), is_last))
234
+
235
+ results.extend(chunks)
236
+
237
+ return results
arbtok/num2words.py ADDED
@@ -0,0 +1,170 @@
1
+ import re
2
+ from functools import partial
3
+
4
+ from arbtok.pyarabic import araby
5
+ from arbtok.pyarabic import number as arnum
6
+ from arbtok.pyarabic.trans import normalize_digits
7
+
8
+ NUM_REGEX = re.compile(r"\d+")
9
+ PERCENT_NO_DIAC = "بالمئة"
10
+ PERCENT_DIAC = "بِالْمِئَة"
11
+
12
+
13
+ def _convert_num2words(m: re.Match, *, apply_tashkeel):
14
+ number = m.group(0)
15
+ word_representation = arnum.number2text(number)
16
+ if apply_tashkeel:
17
+ return " ".join(arnum.pre_tashkeel_number(word_representation.split(" ")))
18
+ return word_representation
19
+
20
+
21
+ def num2words(text: str, handle_percent=True, apply_tashkeel: bool = True) -> str:
22
+ """
23
+ Converts numbers in `text` to Arabic words.
24
+ Simple conversion. Does not check if the number is date/currency...etc.
25
+
26
+ Args:
27
+ text: input text that may contain numbers
28
+ apply_tashkeel: diacritize added words
29
+ """
30
+ text = normalize_digits(text)
31
+ output = NUM_REGEX.sub(
32
+ partial(_convert_num2words, apply_tashkeel=apply_tashkeel), text
33
+ )
34
+ if handle_percent:
35
+ replacement = PERCENT_DIAC if apply_tashkeel else PERCENT_NO_DIAC
36
+ output = output.replace("%", f" {replacement}")
37
+ return araby.fix_spaces(output)
38
+
39
+ # ------------------------------------------------------
40
+ # FEMININE HEAD INFLECTION
41
+ # ------------------------------------------------------
42
+
43
+ FEM_HEAD = {
44
+ "واحد": "واحدة",
45
+ "اثنان": "اثنتان",
46
+ "اثنين": "اثنتين",
47
+ "اثنانِ": "اثنتين",
48
+ "ثلاثة": "ثلاث",
49
+ "أربعة": "أربع",
50
+ "خمسة": "خمس",
51
+ "ستة": "ست",
52
+ "سبعة": "سبع",
53
+ "ثمانية": "ثمان",
54
+ "تسعة": "تسع",
55
+ }
56
+
57
+ TEENS_FEM = {
58
+ "أحد عشر": "إحدى عشرة",
59
+ "اثنا عشر": "اثنتا عشرة",
60
+ "اثني عشر": "اثنتي عشرة",
61
+ "ثلاثة عشر": "ثلاث عشرة",
62
+ "أربعة عشر": "أربع عشرة",
63
+ "خمسة عشر": "خمس عشرة",
64
+ "ستة عشر": "ست عشرة",
65
+ "سبعة عشر": "سبع عشرة",
66
+ "ثمانية عشر": "ثماني عشرة",
67
+ "تسعة عشر": "تسع عشرة",
68
+ }
69
+
70
+ CASE_M = {
71
+ "nom": "ٌ",
72
+ "acc": "ً",
73
+ "gen": "ٍ",
74
+ }
75
+
76
+ # ------------------------------------------------------
77
+ # ORDINALS
78
+ # ------------------------------------------------------
79
+
80
+ ORDINAL_BASE = {
81
+ 1: ("الأوّل", "الأولى"),
82
+ 2: ("الثاني", "الثانية"),
83
+ 3: ("الثالث", "الثالثة"),
84
+ 4: ("الرابع", "الرابعة"),
85
+ 5: ("الخامس", "الخامسة"),
86
+ 6: ("السادس", "السادسة"),
87
+ 7: ("السابع", "السابعة"),
88
+ 8: ("الثامن", "الثامنة"),
89
+ 9: ("التاسع", "التاسعة"),
90
+ 10: ("العاشر", "العاشرة"),
91
+ 11: ("الحادي عشر", "الحادية عشرة"),
92
+ 12: ("الثاني عشر", "الثانية عشرة"),
93
+ 13: ("الثالث عشر", "الثالثة عشرة"),
94
+ 14: ("الرابع عشر", "الرابعة عشرة"),
95
+ 15: ("الخامس عشر", "الخامسة عشرة"),
96
+ 16: ("السادس عشر", "السادسة عشرة"),
97
+ 17: ("السابع عشر", "السابعة عشرة"),
98
+ 18: ("الثامن عشر", "الثامنة عشرة"),
99
+ 19: ("التاسع عشر", "التاسعة عشرة"),
100
+ 20: ("العشرون", "العشرون"),
101
+ }
102
+
103
+ def ordinal_of_number(n: int, feminine=False):
104
+ if n in ORDINAL_BASE:
105
+ return ORDINAL_BASE[n][1 if feminine else 0]
106
+ # Synthetic fallback for 21–99
107
+ # Prepend definite article to the cardinals and add ي/ية to the head token.
108
+ base = arnum.number2text(n).split()
109
+ head = araby.strip_tashkeel(base[-1])
110
+ if feminine:
111
+ base[-1] = head + "ية"
112
+ else:
113
+ base[-1] = head + "ي"
114
+ return "ال" + " ".join(base)
115
+
116
+ # ------------------------------------------------------
117
+ # HEAD EXTRACTION + INFLECTION
118
+ # ------------------------------------------------------
119
+
120
+ def extract_head(tokens):
121
+ return tokens[-1], len(tokens) - 1
122
+
123
+ def apply_head_feminine(tokens):
124
+ phrase = " ".join(tokens)
125
+ if phrase in TEENS_FEM:
126
+ return TEENS_FEM[phrase]
127
+
128
+ head, idx = extract_head(tokens)
129
+ pure = araby.strip_tashkeel(head)
130
+ if pure in FEM_HEAD:
131
+ tokens[idx] = FEM_HEAD[pure]
132
+ return " ".join(tokens)
133
+
134
+ def apply_head_case(tokens, case):
135
+ head, idx = extract_head(tokens)
136
+ pure = araby.strip_tashkeel(head)
137
+ tokens[idx] = pure + CASE_M[case]
138
+ return " ".join(tokens)
139
+
140
+ # ------------------------------------------------------
141
+ # FULL GENERATION
142
+ # ------------------------------------------------------
143
+
144
+ def generate_variants_for_number(i: int, apply_tashkeel=False):
145
+ base = num2words(str(i), handle_percent=False, apply_tashkeel=apply_tashkeel)
146
+ tokens = base.split()
147
+
148
+ # Cardinals
149
+ masc = base
150
+ nom = apply_head_case(tokens.copy(), "nom")
151
+ acc = apply_head_case(tokens.copy(), "acc")
152
+ gen = apply_head_case(tokens.copy(), "gen")
153
+ fem = apply_head_feminine(tokens.copy())
154
+
155
+ # Ordinals (masc + fem)
156
+ try:
157
+ n = int(i)
158
+ ordm = ordinal_of_number(n, feminine=False)
159
+ ordf = ordinal_of_number(n, feminine=True)
160
+ except:
161
+ ordm = None
162
+ ordf = None
163
+
164
+ out = []
165
+
166
+ for w in [masc, nom, acc, gen, fem, ordm, ordf]:
167
+ if w:
168
+ out.append(w)
169
+
170
+ return sorted(set(out))