import random import re from num2words import num2words from rules.base import BaseProcessor from rules.phone import PhoneNumberProcessor from utils import NO_LETTERS, detect_digit_language # ASCII, Persian (۰-۹) and Arabic-Indic (٠-٩) digits. DIGIT = "0-9۰-۹٠-٩" TO_ASCII = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789") GROUP = rf"[{DIGIT}]{{4}}" SEPARATOR = r"(?:[ \t]*[-–][ \t]*|[ \t]+)" # Four groups of four digits (`6219 - 8765 - 2345 - 9031`, `6219-8765-2345-9031`, # `6219 8765 2345 9031`) or sixteen digits in a row. It must not be glued to a # word, a longer number, a decimal or a URL path. CARD_PATTERN = re.compile( rf"(? `شصت و دو، نوزده - هشتاد و هفت، شصت و پنج - بیست و سه، چهل و پنج - نود، سی و یک` English: `4111 1111 1111 1111` -> `forty-one, eleven - eleven, eleven - eleven, eleven - eleven, eleven` `5105-1051-0510-5100` -> `fifty-one, oh five - ten, fifty-one - oh five, ten - fifty-one, double zero` Each group of four digits is said as two numbers separated by a pause (comma), and the groups are separated by ` - `. A pair that starts with `0` keeps its zero: Persian `05` -> `صفر پنج`, `00` -> `دو صفر`; English `05` -> `oh five`, `00` -> `double zero`. Recognised: four groups of four digits separated by spaces or hyphens (or sixteen digits in a row), written with ASCII, Persian or Arabic-Indic digits, with any first digit. Left alone: other lengths (15-digit Amex, 19-digit), masked numbers (`6219-****-****-9031`), numbers glued to words, and decimals. With no letters in the text, the digits pick the language: Persian digits -> Persian, ASCII digits -> English. This processor must run before `PhoneNumberProcessor`, because a word like `شماره` ("number") before a card would otherwise make the phone processor read it. """ def __init__(self, epsilon: float = 1.0): super(CardNumberProcessor, self).__init__(epsilon=epsilon) def process(self, text: str, language: str = None, *args, **kwargs) -> str: if random.random() > self.epsilon: return text if re.search(rf"[{DIGIT}]", text) is None: return text if language in (None, NO_LETTERS): language = detect_digit_language(text) if language not in ("fa", "en"): return text return CARD_PATTERN.sub(lambda match: self._convert(match, language), text) def _convert(self, match: re.Match, language: str) -> str: digits = re.sub(rf"[^{DIGIT}]", "", match.group(0)).translate(TO_ASCII) if len(digits) != 16: return match.group(0) groups = [digits[index:index + 4] for index in range(0, 16, 4)] return GROUP_JOINER.join( PAIR_JOINER[language].join( self._pair_to_words(group[start:start + 2], language) for start in (0, 2) ) for group in groups ) @staticmethod def _pair_to_words(pair: str, language: str) -> str: if language == "fa": # Same reading as inside a phone number: `62` -> `شصت و دو`, # `05` -> `صفر پنج`, `00` -> `دو صفر`. return PhoneNumberProcessor._persian_number(pair) if pair == "00": return ENGLISH_DOUBLE_ZERO if pair.startswith("0"): return f"{ENGLISH_ZERO} {num2words(int(pair))}" return num2words(int(pair))