Skip to content

Roman Numeral Converter — Python source

Convert integers up to 3,999,999 to Roman numerals and back. Vinculum overline above 3,999, canonical-form validation, a step-by-step greedy breakdown, and 14 language sources. Runs entirely in your browser.

This is the Python implementation — the same logic the interactive tool runs, in a shareable, citable form.

"""roman-numeral-converter — Roman <-> Arabic (vinculum, 1..3,999,999).

Language: Python (3.9+, standard library only)
Source:   CosmoDev polyglot showcase port of the Roman Numeral Converter tool,
          ported from src/lib/roman-numeral.ts (the canonical TypeScript
          implementation); kept in lock-step with the Go twin at
          cli/roman-numeral-converter/roman-numeral-converter.go.
License:  display source — part of CosmoDev's polyglot tool pages.

Design goals:
  - Pure + deterministic; never raises on bad input (returns ``None``).
  - Functionally equivalent to the TS/Go reference: same inputs -> same outputs.
  - Self-contained: stdlib only (no pip packages).

Algorithm: one ordered (value, symbol) table for 1..3,999 drives both
directions. ``to_roman`` greedily subtracts the largest fitting symbol; above
3,999 the thousands part is rendered with the same table and each glyph gains
a combining overline (U+0305) meaning x 1,000. ``from_roman`` scans
left-to-right where a smaller letter before a larger one subtracts (IV = 4,
CM = 900), then RE-RENDERS the parsed total and rejects anything that doesn't
round-trip — that one check enforces canonical form (rejecting "IIII", "VV",
"IC", plain "MMMM" for 4,000).
"""

from __future__ import annotations

__all__ = ["to_roman", "from_roman", "ROMAN_MAP", "MAX_ROMAN"]

OVERLINE = "̅"  # combining overline, U+0305 (vinculum: value x 1,000)
MACRON = "̄"    # accepted on input, normalized to the overline
MAX_ROMAN = 3_999_999

# Ordered (value, symbol) pairs for 1..3,999, largest first — including the
# subtractive pairs (CM, CD, XC, XL, IX, IV) so greedy subtraction yields
# canonical form.
ROMAN_MAP: list[tuple[int, str]] = [
    (1000, "M"), (900, "CM"), (500, "D"), (400, "CD"),
    (100, "C"), (90, "XC"), (50, "L"), (40, "XL"),
    (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I"),
]

LETTER_VALUES = {"I": 1, "V": 5, "X": 10, "L": 50, "C": 100, "D": 500, "M": 1000}


def _overline(s: str) -> str:
    """Attach the combining mark to every character (value x 1,000)."""
    return "".join(c + OVERLINE for c in s)


def _to_roman_base(v: int) -> str:
    """Greedy render of 1..3,999."""
    out: list[str] = []
    for val, sym in ROMAN_MAP:
        while v >= val:
            out.append(sym)
            v -= val
    return "".join(out)


def to_roman(n: int) -> str | None:
    """Convert an integer (1..3,999,999) to a Roman numeral, or ``None`` if out
    of range or non-integer. Above 3,999 the thousands part carries a combining
    overline per glyph. ``bool`` is rejected even though it subclasses int,
    matching TypeScript's ``Number.isInteger``."""
    if not isinstance(n, int) or isinstance(n, bool) or n < 1 or n > MAX_ROMAN:
        return None
    if n <= 3999:
        return _to_roman_base(n)
    out = _overline(_to_roman_base(n // 1000))
    rest = n % 1000
    if rest:
        out += _to_roman_base(rest)
    return out


def _scan_value(s: str) -> int:
    """One left-to-right pass where a smaller letter before a larger one
    subtracts. Returns junk for non-canonical strings — the round-trip in
    ``from_roman`` is the canonicality gate."""
    total = 0
    for i, c in enumerate(s):
        v = LETTER_VALUES[c]
        nxt = LETTER_VALUES[s[i + 1]] if i + 1 < len(s) else 0
        total += -v if nxt > v else v
    return total


def from_roman(s: str) -> int | None:
    """Parse a canonical Roman numeral (plain or vinculum), or ``None``. Input
    is trimmed and uppercased first; a pasted macron counts as the overline
    mark. Rejects "IIII", "VV", "IC", plain "MMMM", stray letters, empty."""
    input_ = s.strip().upper().replace(MACRON, OVERLINE)
    if not input_:
        return None

    # Split into the overlined glyphs and the plain glyphs.
    over = ""
    plain = ""
    i = 0
    while i < len(input_):
        c = input_[i]
        if c not in LETTER_VALUES:
            return None
        if input_[i + 1 : i + 2] == OVERLINE:
            over += c
            i += 2
        else:
            plain += c
            i += 1

    total = 0
    if over:
        total += _scan_value(over) * 1000
    if plain:
        total += _scan_value(plain)
    if total < 1 or total > MAX_ROMAN:
        return None
    return total if to_roman(total) == input_ else None


# ---------- showcase (run: python roman-numeral-converter.py) ----------
if __name__ == "__main__":
    # to_roman — known values, both scales
    assert to_roman(1) == "I"
    assert to_roman(1994) == "MCMXCIV"
    assert to_roman(3999) == "MMMCMXCIX"
    assert to_roman(4000) == "I̅V̅"
    assert to_roman(4001) == "I̅V̅I"
    assert to_roman(3_999_999) == "M̅M̅M̅C̅M̅X̅C̅I̅X̅CMXCIX"
    # to_roman — out of range / non-integer
    assert to_roman(0) is None
    assert to_roman(4_000_000) is None
    assert to_roman(-1) is None
    # from_roman — canonical, with case/whitespace/macron tolerance
    assert from_roman("MCMXCIV") == 1994
    assert from_roman("LVIII") == 58
    assert from_roman("  mcmxciv  ") == 1994
    assert from_roman("I̅V̅") == 4000
    assert from_roman("ĪV̄") == 4000  # macron
    # from_roman — non-canonical / invalid
    assert from_roman("IIII") is None
    assert from_roman("VV") is None
    assert from_roman("IC") is None
    assert from_roman("MMMM") is None  # 4,000 must be vinculum
    assert from_roman("ABC") is None
    assert from_roman("") is None
    print("all showcase assertions passed")

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →