Roman Numeral Converter — Ruby 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 Ruby implementation — the same logic the interactive tool runs, in a shareable, citable form.
# roman-numeral-converter - Roman <-> Arabic (vinculum, 1..3,999,999).
#
# Language: Ruby (3.0+, 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 nil / "").
# - Functionally equivalent to the TS/Go reference: same inputs -> same outputs.
# - Self-contained: stdlib only.
#
# 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).
module RomanNumeral
module_function
OVERLINE = "̅" # vinculum: value x 1,000
MACRON = "̄" # accepted on input, normalized
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 yields canonical form.
BASE = [
[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']
].freeze
LETTER_VALUES = { 'I' => 1, 'V' => 5, 'X' => 10, 'L' => 50,
'C' => 100, 'D' => 500, 'M' => 1000 }.freeze
def overline(s) = s.each_char.map { |c| c + OVERLINE }.join
# Greedy render of 1..3,999.
def to_roman_base(v)
out = +''
BASE.each do |val, sym|
while v >= val
out << sym
v -= val
end
end
out
end
# Converts 1..3,999,999 (nil when out of range). Above 3,999 the thousands
# part carries a combining overline per glyph.
def to_roman(n)
return nil if !n.is_a?(Integer) || n < 1 || n > MAX_ROMAN
return to_roman_base(n) if n <= 3999
out = overline(to_roman_base(n / 1000))
out += to_roman_base(n % 1000) if (n % 1000).positive?
out
end
# 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.
def scan_value(s)
cps = s.codepoints
total = 0
cps.each_with_index do |cp, i|
v = LETTER_VALUES[cp.chr(Encoding::UTF_8)] || 0
nxt = i + 1 < cps.length ? LETTER_VALUES[cps[i + 1].chr(Encoding::UTF_8)] || 0 : 0
total += nxt > v ? -v : v
end
total
end
# Parses a canonical numeral (plain or vinculum), or nil. Trimmed and
# upcased first; a pasted macron counts as the overline mark. Works on
# codepoints because Ruby's String#chars yields grapheme clusters (a letter
# plus its combining overline is ONE char) - the mark must be split apart.
def from_roman(s)
input = s.to_s.strip.upcase.gsub(MACRON, OVERLINE)
return nil if input.empty?
over = +''
plain = +''
cps = input.codepoints
i = 0
while i < cps.length
c = cps[i].chr(Encoding::UTF_8)
return nil if LETTER_VALUES[c].nil?
if cps[i + 1] == 0x305 # combining overline
over << c
i += 2
else
plain << c
i += 1
end
end
total = 0
total += scan_value(over) * 1000 unless over.empty?
total += scan_value(plain) unless plain.empty?
return nil if total < 1 || total > MAX_ROMAN
to_roman(total) == input ? total : nil
end
end
# ---------- showcase (run: ruby ruby.rb) ----------
if $PROGRAM_NAME == __FILE__
# to_roman - known values, both scales
raise unless RomanNumeral.to_roman(1) == 'I'
raise unless RomanNumeral.to_roman(1994) == 'MCMXCIV'
raise unless RomanNumeral.to_roman(3999) == 'MMMCMXCIX'
raise unless RomanNumeral.to_roman(4000) == "I̅V̅"
raise unless RomanNumeral.to_roman(4001) == "I̅V̅I"
raise unless RomanNumeral.to_roman(3_999_999) == 'M̅M̅M̅C̅M̅X̅C̅I̅X̅CMXCIX'
# to_roman - out of range / non-integer
raise unless RomanNumeral.to_roman(0).nil?
raise unless RomanNumeral.to_roman(4_000_000).nil?
raise unless RomanNumeral.to_roman(-1).nil?
# from_roman - canonical, with case/whitespace/macron tolerance
raise unless RomanNumeral.from_roman('MCMXCIV') == 1994
raise unless RomanNumeral.from_roman(' mcmxciv ') == 1994
raise unless RomanNumeral.from_roman("I̅V̅") == 4000
raise unless RomanNumeral.from_roman("ĪV̄") == 4000 # macron
# from_roman - non-canonical / invalid
raise unless RomanNumeral.from_roman('IIII').nil?
raise unless RomanNumeral.from_roman('VV').nil?
raise unless RomanNumeral.from_roman('IC').nil?
raise unless RomanNumeral.from_roman('MMMM').nil? # 4,000 must be vinculum
raise unless RomanNumeral.from_roman('ABC').nil?
raise unless RomanNumeral.from_roman('').nil?
puts 'all showcase assertions passed'
end
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 →