Source code for hebrewcal.numerals

"""Convert between integers and Hebrew numerals (gematria).

Hebrew numerals are additive letter values. Conventions implemented here:

- 15 and 16 are written ט״ו and ט״ז (9+6, 9+7) to avoid spelling fragments of
  the divine name.
- A geresh (׳) marks a single-letter number; a gershayim (״) is inserted before
  the last letter of a multi-letter number.
- Thousands are written as the hundreds-or-less value followed by a geresh, then
  the remainder (e.g. 5785 -> ה׳תשפ״ה).
"""

from __future__ import annotations

GERESH = "׳"
GERSHAYIM = "״"

# Letter values in descending order. 15 and 16 are handled specially below.
_VALUES: tuple[tuple[int, str], ...] = (
    (400, "ת"),
    (300, "ש"),
    (200, "ר"),
    (100, "ק"),
    (90, "צ"),
    (80, "פ"),
    (70, "ע"),
    (60, "ס"),
    (50, "נ"),
    (40, "מ"),
    (30, "ל"),
    (20, "כ"),
    (10, "י"),
    (9, "ט"),
    (8, "ח"),
    (7, "ז"),
    (6, "ו"),
    (5, "ה"),
    (4, "ד"),
    (3, "ג"),
    (2, "ב"),
    (1, "א"),
)
_LETTER_TO_VALUE = {letter: value for value, letter in _VALUES}


def _letters_for(value: int) -> str:
    """Return the bare letters (no punctuation) for 1..999."""
    out: list[str] = []
    remaining = value
    for amount, letter in _VALUES:
        # Special-case the tens-and-units 15 and 16.
        if remaining in (15, 16):
            out.append("ט")  # 9
            out.append("ו" if remaining == 15 else "ז")  # 6 / 7
            remaining = 0
            break
        while remaining >= amount:
            out.append(letter)
            remaining -= amount
    return "".join(out)


def _punctuate(letters: str) -> str:
    """Add geresh/gershayim to a bare letter string."""
    if len(letters) == 1:
        return letters + GERESH
    return letters[:-1] + GERSHAYIM + letters[-1]


def _sum_letters(letters: str, text: str) -> int:
    """Sum the values of bare numeral letters, raising on any non-letter."""
    total = 0
    for char in letters:
        value = _LETTER_TO_VALUE.get(char)
        if value is None:
            raise ValueError(f"not a Hebrew numeral: {text!r}")
        total += value
    return total


[docs] def to_hebrew_numeral(number: int) -> str: """Convert a positive integer to its Hebrew numeral string. Note: an exact multiple of 1000 with no remainder (e.g. 1000) is ambiguous in this additive notation and round-trips to its sub-1000 value; year-style numbers (a thousands group followed by a remainder, e.g. 5785) are unambiguous. """ if number <= 0: raise ValueError("Hebrew numerals represent positive integers only") thousands, rest = divmod(number, 1000) parts: list[str] = [] if thousands: parts.append(_letters_for(thousands) + GERESH) if rest: parts.append(_punctuate(_letters_for(rest))) return "".join(parts)
[docs] def from_hebrew_numeral(text: str) -> int: """Convert a Hebrew numeral string back to an integer.""" cleaned = text.strip().replace(GERSHAYIM, "") thousands = 0 geresh_index = cleaned.find(GERESH) if geresh_index != -1 and geresh_index != len(cleaned) - 1: # A geresh with letters after it separates the thousands group. thousands = _sum_letters(cleaned[:geresh_index], text) cleaned = cleaned[geresh_index + 1:] # Any remaining geresh is the single-number marker; drop it. cleaned = cleaned.replace(GERESH, "") total = thousands * 1000 + _sum_letters(cleaned, text) if total <= 0: raise ValueError(f"not a Hebrew numeral: {text!r}") return total