Skip to content

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

// roman-numeral-converter - Roman <-> Arabic (vinculum, 1..3,999,999).
//
// Language: Java (17+, 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 throws on bad input (returns "" / null).
//   - 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. toRoman 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. fromRoman 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).

import java.util.Locale;

public final class RomanNumeral {

    static final String OVERLINE = "\u0305";  // vinculum: value x 1,000
    static final String MACRON = "\u0304";    // accepted on input, normalized
    static final long MAX_ROMAN = 3_999_999L;

    static final long[] BASE_VAL = {1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1};
    static final String[] BASE_SYM = {"M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"};

    private RomanNumeral() {}

    static long letterValue(char c) {
        switch (c) {
            case 'I': return 1;
            case 'V': return 5;
            case 'X': return 10;
            case 'L': return 50;
            case 'C': return 100;
            case 'D': return 500;
            case 'M': return 1000;
            default: return 0;
        }
    }

    static String overline(String s) {
        StringBuilder out = new StringBuilder();
        for (int i = 0; i < s.length(); i++) {
            out.append(s.charAt(i)).append(OVERLINE);
        }
        return out.toString();
    }

    /** Greedy render of 1..3,999. */
    static String toRomanBase(long v) {
        StringBuilder out = new StringBuilder();
        for (int i = 0; i < BASE_VAL.length; i++) {
            while (v >= BASE_VAL[i]) {
                out.append(BASE_SYM[i]);
                v -= BASE_VAL[i];
            }
        }
        return out.toString();
    }

    /** Converts 1..3,999,999 ("" when out of range). Above 3,999 the thousands
     *  part carries a combining overline per glyph. */
    public static String toRoman(long n) {
        if (n < 1 || n > MAX_ROMAN) return "";
        if (n <= 3999) return toRomanBase(n);
        String out = overline(toRomanBase(n / 1000));
        if (n % 1000 > 0) out += toRomanBase(n % 1000);
        return out;
    }

    /** One left-to-right pass where a smaller letter before a larger one
     *  subtracts. Returns junk for non-canonical strings - the round-trip in
     *  fromRoman is the canonicality gate. */
    static long scanValue(String s) {
        long total = 0;
        for (int i = 0; i < s.length(); i++) {
            long v = letterValue(s.charAt(i));
            long next = i + 1 < s.length() ? letterValue(s.charAt(i + 1)) : 0;
            total += next > v ? -v : v;
        }
        return total;
    }

    /** Parses a canonical numeral (plain or vinculum), or null. Trimmed and
     *  uppercased first; a pasted macron counts as the overline mark. */
    public static Long fromRoman(String s) {
        String input = s.trim().toUpperCase(Locale.ROOT).replace(MACRON, OVERLINE);

        // Split into overlined glyphs (letter + mark) and plain letters.
        StringBuilder over = new StringBuilder();
        StringBuilder plain = new StringBuilder();
        int i = 0;
        while (i < input.length()) {
            char c = input.charAt(i);
            if (letterValue(c) == 0) return null;
            if (i + 1 < input.length() && input.charAt(i + 1) == '\u0305') {
                over.append(c);
                i += 2;
            } else {
                plain.append(c);
                i++;
            }
        }

        long total = 0;
        if (over.length() > 0) total += scanValue(over.toString()) * 1000;
        if (plain.length() > 0) total += scanValue(plain.toString());
        if (total < 1 || total > MAX_ROMAN) return null;
        return toRoman(total).equals(input) ? total : null;
    }

    // ---------- showcase (run: java RomanNumeral.java) ----------
    public static void main(String[] args) {
        // toRoman - known values, both scales
        assert toRoman(1L).equals("I");
        assert toRoman(1994L).equals("MCMXCIV");
        assert toRoman(3999L).equals("MMMCMXCIX");
        assert toRoman(4000L).equals("I\u0305V\u0305");
        assert toRoman(4001L).equals("I\u0305V\u0305I");
        assert toRoman(3_999_999L).equals("M\u0305M\u0305M\u0305C\u0305M\u0305X\u0305C\u0305I\u0305X\u0305CMXCIX");
        // toRoman - out of range
        assert toRoman(0L).isEmpty();
        assert toRoman(4_000_000L).isEmpty();
        // fromRoman - canonical, with case/whitespace/macron tolerance
        assert fromRoman("MCMXCIV") == 1994;
        assert fromRoman("  mcmxciv  ") == 1994;
        assert fromRoman("I\u0305V\u0305") == 4000;
        assert fromRoman("I\u0304V\u0304") == 4000;  // macron
        // fromRoman - non-canonical / invalid
        assert fromRoman("IIII") == null;
        assert fromRoman("VV") == null;
        assert fromRoman("IC") == null;
        assert fromRoman("MMMM") == null;  // 4,000 must be vinculum
        assert fromRoman("ABC") == null;
        assert fromRoman("") == null;
        System.out.println("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 →