Skip to content

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

// roman-numeral-converter - Roman <-> Arabic (vinculum, 1..3,999,999).
//
// Language: Swift (5.9+, Foundation only for the showcase entry point)
// 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 crashes on bad input (returns "" / nil).
//   - Functionally equivalent to the TS/Go reference: same inputs -> same outputs.
//   - Self-contained: no third-party dependencies.
//
// 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).
//
// Swift note: a letter plus its combining overline forms ONE Character
// (grapheme cluster), so parsing iterates unicodeScalars - never characters.

import Foundation

enum RomanNumeral {
    static let overline = "\u{0305}"  // vinculum: value x 1,000
    static let macron = "\u{0304}"    // accepted on input, normalized
    static let maxRoman = 3_999_999

    static let base: [(value: Int, symbol: 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"),
    ]

    static let letterValues: [Character: Int] = [
        "I": 1, "V": 5, "X": 10, "L": 50, "C": 100, "D": 500, "M": 1000,
    ]

    static func overline(_ s: String) -> String {
        var out = ""
        for c in s {
            out.append(c)
            out += overlineMark
        }
        return out
    }

    /// Greedy render of 1..3,999.
    static func toRomanBase(_ v: Int) -> String {
        var out = ""
        var v = v
        for (value, symbol) in base {
            while v >= value {
                out += symbol
                v -= value
            }
        }
        return out
    }

    /// Converts 1..3,999,999 ("" when out of range). Above 3,999 the
    /// thousands part carries a combining overline per glyph.
    static func toRoman(_ n: Int) -> String {
        guard n >= 1, n <= maxRoman else { return "" }
        if n <= 3999 { return toRomanBase(n) }
        var 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. Codepoints only (marks stripped).
    static func scanValue(_ scalars: [Unicode.Scalar]) -> Int {
        var total = 0
        for (i, scalar) in scalars.enumerated() {
            guard let v = letterValues[Character(scalar)] else { continue }
            let next = i + 1 < scalars.count ? letterValues[Character(scalars[i + 1])] ?? 0 : 0
            total += next > v ? -v : v
        }
        return total
    }

    /// Parses a canonical numeral (plain or vinculum), or nil. Trimmed and
    /// uppercased first; a pasted macron counts as the overline mark.
    static func fromRoman(_ s: String) -> Int? {
        let input = s.trimmingCharacters(in: .whitespacesAndNewlines)
            .uppercased()
            .replacingOccurrences(of: macron, with: overlineMark)

        var over: [Unicode.Scalar] = []
        var plain: [Unicode.Scalar] = []
        let scalars = Array(input.unicodeScalars)
        var i = 0
        while i < scalars.count {
            guard letterValues[Character(scalars[i])] != nil else { return nil }
            if i + 1 < scalars.count && scalars[i + 1].value == 0x0305 {
                over.append(scalars[i])
                i += 2
            } else {
                plain.append(scalars[i])
                i += 1
            }
        }

        var total = 0
        if !over.isEmpty { total += scanValue(over) * 1000 }
        if !plain.isEmpty { total += scanValue(plain) }
        guard total >= 1, total <= maxRoman else { return nil }
        return toRoman(total) == input ? total : nil
    }
}

// Rename clarity: the mark constant ("overline" is already a function name).
private extension RomanNumeral {
    static let overlineMark = "\u{0305}"
}

// ---------- showcase (run: swift swift.swift) ----------
print(RomanNumeral.showcase())

extension RomanNumeral {
    static func showcase() -> String {
        // toRoman - known values, both scales
        assert(toRoman(1) == "I")
        assert(toRoman(1994) == "MCMXCIV")
        assert(toRoman(3999) == "MMMCMXCIX")
        assert(toRoman(4000) == "I\u{0305}V\u{0305}")
        assert(toRoman(4001) == "I\u{0305}V\u{0305}I")
        assert(toRoman(3_999_999) == "M\u{0305}M\u{0305}M\u{0305}C\u{0305}M\u{0305}X\u{0305}C\u{0305}I\u{0305}X\u{0305}CMXCIX")
        // toRoman - out of range
        assert(toRoman(0) == "")
        assert(toRoman(4_000_000) == "")
        // fromRoman - canonical, with case/whitespace/macron tolerance
        assert(fromRoman("MCMXCIV") == 1994)
        assert(fromRoman("  mcmxciv  ") == 1994)
        assert(fromRoman("I\u{0305}V\u{0305}") == 4000)
        assert(fromRoman("I\u{0304}V\u{0304}") == 4000)  // macron
        // fromRoman - non-canonical / invalid
        assert(fromRoman("IIII") == nil)
        assert(fromRoman("VV") == nil)
        assert(fromRoman("IC") == nil)
        assert(fromRoman("MMMM") == nil)  // 4,000 must be vinculum
        assert(fromRoman("ABC") == nil)
        assert(fromRoman("") == nil)
        return "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 →