Skip to content

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

// roman-numeral-converter - Roman <-> Arabic (vinculum, 1..3,999,999).
//
// Language: Kotlin (1.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 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).

object RomanNumeral {
    const val OVERLINE = "\u0305"  // vinculum: value x 1,000
    const val MACRON = "\u0304"    // accepted on input, normalized
    const val MAX_ROMAN = 3_999_999L

    private val base = listOf(
        1000L to "M", 900L to "CM", 500L to "D", 400L to "CD",
        100L to "C", 90L to "XC", 50L to "L", 40L to "XL",
        10L to "X", 9L to "IX", 5L to "V", 4L to "IV", 1L to "I",
    )

    private val letterValues = mapOf(
        'I' to 1L, 'V' to 5L, 'X' to 10L, 'L' to 50L,
        'C' to 100L, 'D' to 500L, 'M' to 1000L,
    )

    private fun overline(s: String): String = buildString {
        for (c in s) {
            append(c)
            append(OVERLINE)
        }
    }

    /** Greedy render of 1..3,999. */
    private fun toRomanBase(v: Long): String = buildString {
        var v2 = v
        for ((value, symbol) in base) {
            while (v2 >= value) {
                append(symbol)
                v2 -= value
            }
        }
    }

    /** Converts 1..3,999,999 ("" when out of range). Above 3,999 the thousands
     *  part carries a combining overline per glyph. */
    fun toRoman(n: Long): String {
        if (n < 1 || n > MAX_ROMAN) return ""
        if (n <= 3999) return toRomanBase(n)
        val out = StringBuilder(overline(toRomanBase(n / 1000)))
        if (n % 1000 > 0) out.append(toRomanBase(n % 1000))
        return out.toString()
    }

    /** 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. */
    private fun scanValue(s: String): Long {
        var total = 0L
        for (i in s.indices) {
            val v = letterValues[s[i]] ?: 0L
            val next = if (i + 1 < s.length) letterValues[s[i + 1]] ?: 0L else 0L
            total += if (next > v) -v else v
        }
        return total
    }

    /** Parses a canonical numeral (plain or vinculum), or null. Trimmed and
     *  uppercased first; a pasted macron counts as the overline mark. */
    fun fromRoman(s: String): Long? {
        val input = s.trim().uppercase().replace(MACRON, OVERLINE)

        val over = StringBuilder()
        val plain = StringBuilder()
        var i = 0
        while (i < input.length) {
            val c = input[i]
            if (letterValues[c] == null) return null
            if (i + 1 < input.length && input[i + 1] == '\u0305') {
                over.append(c)
                i += 2
            } else {
                plain.append(c)
                i++
            }
        }

        var total = 0L
        if (over.isNotEmpty()) total += scanValue(over.toString()) * 1000
        if (plain.isNotEmpty()) total += scanValue(plain.toString())
        if (total < 1 || total > MAX_ROMAN) return null
        return if (toRoman(total) == input) total else null
    }
}

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