Skip to content

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

// roman-numeral-converter - Roman <-> Arabic (vinculum, 1..3,999,999).
//
// Language: C# (.NET 8+, 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: BCL 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).

using System;
using System.Diagnostics;
using System.Text;

namespace Roman;

public static class RomanNumeral
{
    public const long MaxRoman = 3_999_999;
    private const string Overline = "\u0305";  // vinculum: value x 1,000
    private const string Macron = "\u0304";    // accepted on input, normalized

    private static readonly (long Value, string Symbol)[] 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"),
    };

    private static long LetterValue(char c) => c switch
    {
        'I' => 1, 'V' => 5, 'X' => 10, 'L' => 50,
        'C' => 100, 'D' => 500, 'M' => 1000,
        _ => 0,
    };

    private static string WithOverline(string s)
    {
        var sb = new StringBuilder();
        foreach (var c in s) sb.Append(c).Append(Overline);
        return sb.ToString();
    }

    /// <summary>Greedy render of 1..3,999.</summary>
    private static string ToRomanBase(long v)
    {
        var sb = new StringBuilder();
        foreach (var (value, symbol) in Base)
        {
            while (v >= value)
            {
                sb.Append(symbol);
                v -= value;
            }
        }
        return sb.ToString();
    }

    /// <summary>Converts 1..3,999,999 ("" when out of range). Above 3,999 the
    /// thousands part carries a combining overline per glyph.</summary>
    public static string ToRoman(long n)
    {
        if (n < 1 || n > MaxRoman) return "";
        if (n <= 3999) return ToRomanBase(n);
        var sb = new StringBuilder(WithOverline(ToRomanBase(n / 1000)));
        if (n % 1000 > 0) sb.Append(ToRomanBase(n % 1000));
        return sb.ToString();
    }

    /// <summary>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.</summary>
    private static long ScanValue(string s)
    {
        long total = 0;
        for (var i = 0; i < s.Length; i++)
        {
            var v = LetterValue(s[i]);
            var next = i + 1 < s.Length ? LetterValue(s[i + 1]) : 0;
            total += next > v ? -v : v;
        }
        return total;
    }

    /// <summary>Parses a canonical numeral (plain or vinculum), or null.
    /// Trimmed and uppercased first; a pasted macron counts as the overline.</summary>
    public static long? FromRoman(string s)
    {
        var input = s.Trim().ToUpperInvariant().Replace(Macron, Overline);

        var over = new StringBuilder();
        var plain = new StringBuilder();
        var i = 0;
        while (i < input.Length)
        {
            var c = input[i];
            if (LetterValue(c) == 0) return null;
            if (i + 1 < input.Length && input[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 > MaxRoman) return null;
        return ToRoman(total) == input ? total : null;
    }

    // ---------- showcase (run: dotnet run roman.cs) ----------
    public static void Main()
    {
        // ToRoman - known values, both scales
        Debug.Assert(ToRoman(1) == "I");
        Debug.Assert(ToRoman(1994) == "MCMXCIV");
        Debug.Assert(ToRoman(3999) == "MMMCMXCIX");
        Debug.Assert(ToRoman(4000) == "I\u0305V\u0305");
        Debug.Assert(ToRoman(4001) == "I\u0305V\u0305I");
        Debug.Assert(ToRoman(3_999_999) == "M\u0305M\u0305M\u0305C\u0305M\u0305X\u0305C\u0305I\u0305X\u0305CMXCIX");
        // ToRoman - out of range
        Debug.Assert(ToRoman(0) == "");
        Debug.Assert(ToRoman(4_000_000) == "");
        // FromRoman - canonical, with case/whitespace/macron tolerance
        Debug.Assert(FromRoman("MCMXCIV") == 1994);
        Debug.Assert(FromRoman("  mcmxciv  ") == 1994);
        Debug.Assert(FromRoman("I\u0305V\u0305") == 4000);
        Debug.Assert(FromRoman("I\u0304V\u0304") == 4000);  // macron
        // FromRoman - non-canonical / invalid
        Debug.Assert(FromRoman("IIII") is null);
        Debug.Assert(FromRoman("VV") is null);
        Debug.Assert(FromRoman("IC") is null);
        Debug.Assert(FromRoman("MMMM") is null);  // 4,000 must be vinculum
        Debug.Assert(FromRoman("ABC") is null);
        Debug.Assert(FromRoman("") is null);
        Console.WriteLine("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 →