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 →