Skip to content

Roman Numeral Converter — Go 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 Go implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Package roman is the Go twin of CosmoDev's src/lib/roman-numeral.ts (dual
// source: web lib is TypeScript, CLI lib is Go — kept in lock-step). Pure +
// deterministic, never panics. Table-driven tests share vectors with the TS suite.
//
// Range 1..3,999,999. Above 3,999 the canonical form uses VINCULUM notation:
// a combining overline (U+0305) on each symbol multiplies its value by 1,000,
// so the canonical string is overline(thousands) + plain remainder.
package roman

import "strings"

// Overline is the combining vinculum mark (U+0305): a bar above a glyph
// multiplies its value by 1,000.
const Overline = "\u0305"

// macron (U+0304) is visually near-identical; accepted on input, normalized.
const macron = "\u0304"

// MaxRoman is the largest representable value: 3,999 thousands + 999 remainder.
const MaxRoman = 3_999_999

var ordered = []struct {
	n int
	s string
}{
	{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"},
}

var letterValues = map[rune]int{
	'I': 1, 'V': 5, 'X': 10, 'L': 50, 'C': 100, 'D': 500, 'M': 1000,
}

const overlineRune = '\u0305'

// overline attaches the combining mark to every rune (value x 1,000).
func overline(s string) string {
	var b strings.Builder
	for _, r := range s {
		b.WriteRune(r)
		b.WriteString(Overline)
	}
	return b.String()
}

// toRomanBase greedily renders 1..3,999.
func toRomanBase(v int) string {
	out := strings.Builder{}
	for _, e := range ordered {
		for v >= e.n {
			out.WriteString(e.s)
			v -= e.n
		}
	}
	return out.String()
}

// ToRoman converts an integer (1..3,999,999) to a Roman numeral, or "" if out
// of range. Above 3,999 the thousands part carries a combining overline.
func ToRoman(n int) string {
	if n < 1 || n > MaxRoman {
		return ""
	}
	if n <= 3999 {
		return toRomanBase(n)
	}
	thousands, rest := n/1000, n%1000
	out := overline(toRomanBase(thousands))
	if rest > 0 {
		out += toRomanBase(rest)
	}
	return out
}

// scanValue values a known-valid MDCLXVI string with one left-to-right scan:
// a smaller letter before a larger one subtracts (IV = 4, CM = 900). Returns
// junk for non-canonical strings — the round-trip in FromRoman is the gate.
func scanValue(s string) int {
	rs := []rune(s)
	total := 0
	for i, r := range rs {
		v := letterValues[r]
		if i+1 < len(rs) && letterValues[rs[i+1]] > v {
			total -= v
		} else {
			total += v
		}
	}
	return total
}

// FromRoman parses a canonical Roman numeral (plain or vinculum), returning
// ok=false if invalid or non-canonical (e.g. "IIII", "VV", plain "MMMM" for
// 4,000). The round-trip check enforces canonical form.
func FromRoman(s string) (int, bool) {
	input := strings.ReplaceAll(strings.ToUpper(strings.TrimSpace(s)), macron, Overline)

	rs := []rune(input)
	var over, plain strings.Builder
	for i := 0; i < len(rs); i++ {
		if _, ok := letterValues[rs[i]]; !ok {
			return 0, false
		}
		if i+1 < len(rs) && rs[i+1] == overlineRune {
			over.WriteRune(rs[i])
			i++
		} else {
			plain.WriteRune(rs[i])
		}
	}

	total := 0
	if over.Len() > 0 {
		total += scanValue(over.String()) * 1000
	}
	if plain.Len() > 0 {
		total += scanValue(plain.String())
	}
	if total < 1 || total > MaxRoman || ToRoman(total) != input {
		return 0, false
	}
	return total, true
}

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 →