"""Dynamic Accent Flash text normaliser: numbers, ordinals-as-cardinals, years, dates, times, currency and percent -> words, per language (eng, pcm, ibo, hau, yor). Pure Python, no dependencies. from normalize import normalize normalize("Ọ bụ ₦5,000 n'ụbọchị 21/10/2026", "ibo") Coverage: integers 0 .. 999,999,999 (larger stay as digits), decimals (point-by-digit), ₦/N/NGN/naira, $/USD, £, kobo, %, dd/mm/yyyy and "21 October 2026" dates, hh:mm times, 1st/2nd... in eng/pcm. The Igbo and Hausa systems are decimal and regular. Yorùbá uses the traditional vigesimal/subtractive system for 1-99 (with tone marks) and decimal composition above 100 (ọgọ́rùn-ún, ẹgbẹ̀rún, mílíọ̀nù + "ó lé"); the Yorùbá forms and all date/currency phrasings need a native-speaker check before release (see TEXT_FRONTEND.md). """ import re import unicodedata # ------------------------------------------------------------------ English / Pidgin _E1 = "zero one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen".split() _E10 = "_ _ twenty thirty forty fifty sixty seventy eighty ninety".split() def eng(n): if n < 20: return _E1[n] if n < 100: return _E10[n // 10] + ("" if n % 10 == 0 else "-" + _E1[n % 10]) if n < 1000: return _E1[n // 100] + " hundred" + ("" if n % 100 == 0 else " and " + eng(n % 100)) for v, w in ((10 ** 9, "billion"), (10 ** 6, "million"), (1000, "thousand")): if n >= v: r = n % v tail = "" if r == 0 else ((" and " if r < 100 else " ") + eng(r)) return eng(n // v) + " " + w + tail return str(n) def eng_year(n): if 2000 <= n <= 2009: return eng(n) if 1100 <= n <= 2099 and n % 100 != 0: return eng(n // 100) + " " + (("oh " + _E1[n % 100]) if n % 100 < 10 else eng(n % 100)) return eng(n) _ORD = {"one": "first", "two": "second", "three": "third", "five": "fifth", "eight": "eighth", "nine": "ninth", "twelve": "twelfth"} def eng_ord(n): w = eng(n) head, _, last = w.rpartition(" ") if " " in w else ("", "", w) h2, dash, l2 = last.rpartition("-") base = l2 if dash else last o = _ORD.get(base) or (base[:-1] + "ieth" if base.endswith("y") else base + "th") last = (h2 + "-" + o) if dash else o return (head + " " + last).strip() # ------------------------------------------------------------------ Igbo (decimal) _I1 = ["efu", "otu", "abụọ", "atọ", "anọ", "ise", "isii", "asaa", "asatọ", "itoolu"] def ibo(n): if n < 10: return _I1[n] if n < 100: t, u = divmod(n, 10) s = "iri" if t == 1 else "iri " + _I1[t] return s if u == 0 else s + " na " + _I1[u] for v, w in ((10 ** 9, "ijeri"), (10 ** 6, "nde"), (1000, "puku"), (100, "narị")): if n >= v: q, r = divmod(n, v) s = w if q == 1 else w + " " + ibo(q) return s if r == 0 else s + " na " + ibo(r) return str(n) # ------------------------------------------------------------------ Hausa (decimal) _H1 = ["sifili", "ɗaya", "biyu", "uku", "huɗu", "biyar", "shida", "bakwai", "takwas", "tara"] _H10 = [None, "goma", "ashirin", "talatin", "arba'in", "hamsin", "sittin", "saba'in", "tamanin", "casa'in"] def hau(n): if n < 10: return _H1[n] if n < 20: return "goma" if n == 10 else "goma sha " + _H1[n - 10] if n < 100: t, u = divmod(n, 10) return _H10[t] if u == 0 else _H10[t] + " da " + _H1[u] for v, w in ((10 ** 9, "biliyan"), (10 ** 6, "miliyan"), (1000, "dubu"), (100, "ɗari")): if n >= v: q, r = divmod(n, v) s = w if q == 1 else w + " " + hau(q) return s if r == 0 else s + " da " + hau(r) return str(n) # ------------------------------------------------------------------ Yorùbá # counting forms 1-10, then vigesimal/subtractive 11-99 (standard grammar tables); decimal composition >= 100 _Y1 = ["òdo", "oókan", "eéjì", "ẹẹ́ta", "ẹẹ́rin", "aárùn-ún", "ẹẹ́fà", "eéje", "ẹẹ́jọ", "ẹẹ́sàn-án", "ẹẹ́wàá"] _Y11 = {11: "ọ̀kanlá", 12: "èjìlá", 13: "ẹ̀tàlá", 14: "ẹ̀rìnlá"} # tens: name, locative form used after lé/dín ("added to / less than") _YT = {20: ("ogún", "lógún"), 30: ("ọgbọ̀n", "lọ́gbọ̀n"), 40: ("ogójì", "lógójì"), 50: ("àádọ́ta", "láàádọ́ta"), 60: ("ọgọ́ta", "lọ́gọ́ta"), 70: ("àádọ́rin", "láàádọ́rin"), 80: ("ọgọ́rin", "lọ́gọ́rin"), 90: ("àádọ́rùn-ún", "láàádọ́rùn-ún"), 100: ("ọgọ́rùn-ún", "lọ́gọ́rùn-ún")} _YLE = {1: "ọ̀kàn", 2: "éjì", 3: "ẹ́tà", 4: "ẹ́rìn"} # +1..+4 _YDIN = {1: "ọ̀kàn", 2: "éjì", 3: "ẹẹ́tà", 4: "ẹẹ́rìn"} # -1..-4 _YFIVE = {20: "ẹ̀ẹ́dógún", 30: "ẹ̀ẹ́dọ́gbọ̀n", 40: "ẹ̀ẹ́dógójì", 50: "ẹ̀ẹ́dáàádọ́ta", 60: "ẹ̀ẹ́dọ́gọ́ta", 70: "ẹ̀ẹ́dáàádọ́rin", 80: "ẹ̀ẹ́dọ́gọ́rin", 90: "ẹ̀ẹ́dáàádọ́rùn-ún", 100: "ẹ̀ẹ́dọ́gọ́rùn-ún"} # (T - 5) _YADJ = {1: "kan", 2: "méjì", 3: "mẹ́ta", 4: "mẹ́rin", 5: "márùn-ún", 6: "mẹ́fà", 7: "méje", 8: "mẹ́jọ", 9: "mẹ́sàn-án", 10: "mẹ́wàá"} _YH = {200: "igba", 300: "ọ̀ọ́dúnrún", 400: "irinwó", 500: "ẹ̀ẹ́dẹ́gbẹ̀ta", 600: "ẹgbẹ̀ta", 700: "ẹ̀ẹ́dẹ́gbẹ̀rin", 800: "ẹgbẹ̀rin", 900: "ẹ̀ẹ́dẹ́gbẹ̀rún"} def _y99(n): if n <= 10: return _Y1[n] if n in _Y11: return _Y11[n] if n in _YT: return _YT[n][0] T = (n // 10) * 10 u = n - T if u <= 4: # T + u (u-lé-T) return _YLE[u] + "lé" + _YT[T][1] if u == 5: # (T+10) - 5 return _YFIVE[T + 10] return _YDIN[10 - u] + "dín" + _YT[T + 10][1] # (T+10) - (10-u) def yor(n): if n < 100: return _y99(n) if n < 1000: h, r = divmod(n, 100) s = _YT[100][0] if h == 1 else _YH[h * 100] return s if r == 0 else s + " ó lé " + _y99(r) for v, w in ((10 ** 9, "bílíọ̀nù"), (10 ** 6, "mílíọ̀nù"), (1000, "ẹgbẹ̀rún")): if n >= v: q, r = divmod(n, v) s = w + " " + (_YADJ[q] if q in _YADJ else yor(q)) return s if r == 0 else s + " ó lé " + yor(r) return str(n) NUM = {"eng": eng, "pcm": eng, "ibo": ibo, "hau": hau, "yor": yor} MONTHS = { "eng": "January February March April May June July August September October November December".split(), "ibo": "Jenụwarị Febụwarị Maachị Epreel Mee Juun Julaị Ọgọọst Septemba Ọktoba Novemba Disemba".split(), "hau": "Janairu Fabrairu Maris Afrilu Mayu Yuni Yuli Agusta Satumba Oktoba Nuwamba Disamba".split(), "yor": "Ṣẹ́rẹ́ Èrèlé Ẹrẹ̀nà Ìgbé Ẹ̀bìbí Òkúdu Agẹmọ Ògún Owewe Ọ̀wàrà Bélú Ọ̀pẹ̀".split(), } MONTHS["pcm"] = MONTHS["eng"] _MIDX = {m.lower(): i for i, m in enumerate(MONTHS["eng"])} _MIDX.update({m[:3].lower(): i for i, m in enumerate(MONTHS["eng"])}) CUR = { # (major, minor) per language; phrase order handled in money() "NGN": {"eng": ("naira", "kobo"), "pcm": ("naira", "kobo"), "ibo": ("naịra", "kobo"), "hau": ("naira", "kobo"), "yor": ("náírà", "kọ́bọ̀")}, "USD": {"eng": ("dollars", "cents"), "pcm": ("dollars", "cents"), "ibo": ("dọla", "senti"), "hau": ("dala", "santi"), "yor": ("dọ́là", "sẹ́ǹtì")}, "GBP": {"eng": ("pounds", "pence"), "pcm": ("pounds", "pence"), "ibo": ("paụnd", "pens"), "hau": ("fam", "fens"), "yor": ("pọ́n-ùn", "pẹ́ńsì")}, } def num(n, lang): return NUM[lang](n) if n < 10 ** 9 else str(n) def money(amount, code, lang): whole, _, frac = amount.partition(".") whole = int(whole.replace(",", "") or 0) major, minor = CUR[code][lang] if lang in ("eng", "pcm"): s = f"{num(whole, lang)} {major}" if code == "USD" and whole == 1: s = "one dollar" if frac and int(frac[:2].ljust(2, "0")): s += f" {num(int(frac[:2].ljust(2, '0')), lang)} {minor}" return s s = f"{major} {num(whole, lang)}" # ibo/hau/yor: currency word first ("naịra puku ise") if lang == "yor": s = f"{major} {yor_adj(whole)}" if frac and int(frac[:2].ljust(2, "0")): k = int(frac[:2].ljust(2, "0")) s += {"ibo": " na ", "hau": " da ", "yor": " ó lé "}[lang] + f"{minor} {yor_adj(k) if lang == 'yor' else num(k, lang)}" return s def yor_adj(n): """Yorùbá number after a noun (adjectival form for 1-10, counting form otherwise).""" return _YADJ.get(n) or yor(n) def date(d, m, y, lang): M = MONTHS[lang][m - 1] if lang in ("eng", "pcm"): return f"the {eng_ord(d)} of {M}" + (f", {eng_year(y)}" if y else "") if lang == "ibo": return f"ụbọchị {ibo(d)} n'ọnwa {M}" + (f", afọ {ibo(y)}" if y else "") if lang == "hau": return f"ranar {hau(d)} ga watan {M}" + (f", shekarar {hau(y)}" if y else "") return f"ọjọ́ {yor(d)} oṣù {M}" + (f", ọdún {yor(y)}" if y else "") def clock(h, mi, lang): if lang in ("eng", "pcm"): return eng(h) + ("" if mi == 0 else (" oh " + eng(mi) if mi < 10 else " " + eng(mi))) + (" o'clock" if mi == 0 else "") if lang == "ibo": return f"elekere {ibo(h)}" + ("" if mi == 0 else f" na nkeji {ibo(mi)}") if lang == "hau": return f"ƙarfe {hau(h)}" + ("" if mi == 0 else f" da minti {hau(mi)}") return f"agogo {yor_adj(h)}" + ("" if mi == 0 else f" ó lé ìṣẹ́jú {yor_adj(mi)}") def percent(x, lang): w = decimal(x, lang) return {"eng": f"{w} percent", "pcm": f"{w} percent", "ibo": f"pasentị {w}", "hau": f"kashi {w} cikin ɗari", "yor": f"ìdá {w} nínú ọgọ́rùn-ún"}[lang] _POINT = {"eng": "point", "pcm": "point", "ibo": "ntụpọ", "hau": "digo", "yor": "ààmì"} def decimal(x, lang): a, _, b = x.replace(",", "").partition(".") s = num(int(a or 0), lang) if b: s += f" {_POINT[lang]} " + " ".join(num(int(c), lang) for c in b) return s _AMT = r"\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?" _RE_MONEY = re.compile(r"(?:(₦|NGN\s?|N(?=\d)|\$|USD\s?|£)\s?(" + _AMT + r")(\s?(?:k|m|bn)\b)?)|(?:(" + _AMT + r")\s?(naira|kobo|dollars?)\b)", re.I) _RE_DATE = re.compile(r"\b(\d{1,2})[/.-](\d{1,2})[/.-](\d{4})\b") _RE_DATE2 = re.compile(r"\b(\d{1,2})(?:st|nd|rd|th)?\s+(January|February|March|April|May|June|July|August|September|October|November|December|Jan|Feb|Mar|Apr|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)\.?,?\s*(\d{4})?\b", re.I) _RE_TIME = re.compile(r"\b(\d{1,2}):(\d{2})(?:\s?(am|pm)\b)?", re.I) _RE_TIME2 = re.compile(r"\b(\d{1,2})\s?(am|pm)\b", re.I) _RE_PCT = re.compile(r"(" + _AMT + r")\s?%") _RE_ORD = re.compile(r"\b(\d+)(st|nd|rd|th)\b", re.I) _RE_YEAR = re.compile(r"\b(1[5-9]\d\d|20\d\d)\b") _RE_NUM = re.compile(_AMT) _DUP = re.compile(r"\b(n'elekere|elekere|karfe|ƙarfe|ranar|agogo|ọjọ́|ụbọchị)\s+(elekere|ƙarfe|ranar|agogo|ọjọ́|ụbọchị)\b", re.I) _SCALE = {"k": 1000, "m": 10 ** 6, "bn": 10 ** 9} def normalize(text, lang): """Verbalise numbers etc. for one language segment (no tags inside).""" lang = lang if lang in NUM else "eng" t = unicodedata.normalize("NFC", text) def _money(m): if m.group(1): sym, amt, sc = m.group(1).strip().upper(), m.group(2), (m.group(3) or "").strip().lower() code = {"₦": "NGN", "N": "NGN", "NGN": "NGN", "$": "USD", "USD": "USD", "£": "GBP"}[sym] else: amt, word, sc = m.group(4), m.group(5).lower(), "" if word == "kobo": k = int(amt.replace(",", "")) return f"{num(k, lang)} kobo" if lang in ("eng", "pcm") else f"{CUR['NGN'][lang][1]} {yor_adj(k) if lang == 'yor' else num(k, lang)}" code = "NGN" if word == "naira" else "USD" if sc: amt = str(int(round(float(amt.replace(",", "")) * _SCALE[sc]))) return money(amt, code, lang) t = _RE_MONEY.sub(_money, t) t = _RE_PCT.sub(lambda m: percent(m.group(1), lang), t) t = _RE_DATE.sub(lambda m: date(int(m.group(1)), int(m.group(2)), int(m.group(3)), lang) if 1 <= int(m.group(1)) <= 31 and 1 <= int(m.group(2)) <= 12 else m.group(0), t) t = _RE_DATE2.sub(lambda m: date(int(m.group(1)), _MIDX[m.group(2).lower()[:3]] + 1, int(m.group(3)) if m.group(3) else None, lang), t) def _time(m): h, mi, ap = int(m.group(1)), int(m.group(2)), (m.group(3) or "").lower() s = clock(h, mi, lang) if ap and lang in ("eng", "pcm"): s += " " + {"am": "a m", "pm": "p m"}[ap] return s t = _RE_TIME.sub(_time, t) t = _RE_TIME2.sub(lambda m: (eng(int(m.group(1))) + " " + {"am": "a m", "pm": "p m"}[m.group(2).lower()]) if lang in ("eng", "pcm") else clock(int(m.group(1)), 0, lang), t) if lang in ("eng", "pcm"): t = _RE_ORD.sub(lambda m: eng_ord(int(m.group(1))), t) t = _RE_YEAR.sub(lambda m: eng_year(int(m.group(1))), t) else: t = _RE_ORD.sub(lambda m: num(int(m.group(1)), lang), t) t = _RE_NUM.sub(lambda m: decimal(m.group(0), lang) if "." in m.group(0) else num(int(m.group(0).replace(",", "")), lang), t) t = _DUP.sub(lambda m: m.group(1) if m.group(1).lower().endswith(m.group(2).lower()) or m.group(1).lower() == "karfe" else m.group(0), t) return re.sub(r"\s+", " ", t).strip() def normalize_tagged(text, default="eng"): """Apply normalize() to every … segment of a tagged string (untagged text uses `default`).""" out, pos = [], 0 for m in re.finditer(r"<(\w{3})>(.*?)", text, re.S): if m.start() > pos: out.append(normalize(text[pos:m.start()], default) if text[pos:m.start()].strip() else text[pos:m.start()]) out.append(f"<{m.group(1)}>{normalize(m.group(2), m.group(1))}") pos = m.end() if pos < len(text): rest = text[pos:] out.append(normalize(rest, default) if rest.strip() else rest) return "".join(out) if __name__ == "__main__": import sys for L in ["eng", "pcm", "ibo", "hau", "yor"]: print(L, "|", normalize("₦5,000 on 21/10/2026 at 7:30, 45% of 1,235 people, 3.5 km", L))