Skip to content

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 →