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 →