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++ (C++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 "" / false).
//   - 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. to_roman 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. from_roman 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).

#include <cctype>
#include <cstring>
#include <string>
#include <utility>

namespace roman {

constexpr long kMax = 3'999'999;
// U+0305 combining overline (vinculum: value x 1,000), as UTF-8 bytes.
constexpr const char* kOverline = "\314\205";
// U+0304 macron - visually near-identical; accepted on input, normalized.
constexpr const char* kMacron = "\314\204";

const std::pair<long, std::string> kBase[13] = {
    {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"},
};

long letter_value(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;
    }
}

std::string overline(const std::string& s) {
    std::string out;
    for (char c : s) out += std::string(1, c) + kOverline;
    return out;
}

// Greedy render of 1..3,999.
std::string to_roman_base(long v) {
    std::string out;
    for (const auto& [val, sym] : kBase) {
        while (v >= val) {
            out += sym;
            v -= val;
        }
    }
    return out;
}

// Converts 1..3,999,999 ("" when out of range). Above 3,999 the thousands
// part carries a combining overline per glyph.
std::string to_roman(long n) {
    if (n < 1 || n > kMax) return "";
    if (n <= 3999) return to_roman_base(n);
    std::string out = overline(to_roman_base(n / 1000));
    if (n % 1000) out += to_roman_base(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
// from_roman is the canonicality gate.
long scan_value(const std::string& s) {
    long total = 0;
    for (size_t i = 0; i < s.size(); ++i) {
        long v = letter_value(s[i]);
        long next = i + 1 < s.size() ? letter_value(s[i + 1]) : 0;
        total += next > v ? -v : v;
    }
    return total;
}

// Parses a canonical numeral (plain or vinculum). Trimmed and uppercased
// first; a pasted macron counts as the overline mark.
bool from_roman(std::string s, long& out) {
    // Normalize: trim ends, uppercase, macron -> overline.
    size_t a = 0, b = s.size();
    while (a < b && std::isspace(static_cast<unsigned char>(s[a]))) ++a;
    while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1]))) --b;
    std::string input;
    for (size_t i = a; i < b;) {
        if (s.compare(i, 2, kMacron) == 0) {
            input += kOverline;
            i += 2;
        } else {
            input += static_cast<char>(std::toupper(static_cast<unsigned char>(s[i])));
            ++i;
        }
    }

    // Split into overlined glyphs (letter + mark) and plain letters.
    std::string over, plain;
    for (size_t i = 0; i < input.size();) {
        if (letter_value(input[i]) == 0) return false;
        if (input.compare(i + 1, 2, kOverline) == 0) {
            over += input[i];
            i += 3;  // letter (1 byte) + overline (2 bytes)
        } else {
            plain += input[i];
            ++i;
        }
    }

    long total = 0;
    if (!over.empty()) total += scan_value(over) * 1000;
    if (!plain.empty()) total += scan_value(plain);
    if (total < 1 || total > kMax) return false;
    if (to_roman(total) != input) return false;
    out = total;
    return true;
}

}  // namespace roman

// ---------- showcase (run: c++ -std=c++17 cpp.cpp && ./a.out) ----------
#include <cassert>
#include <iostream>

int main() {
    using namespace roman;
    // to_roman - known values, both scales
    assert(to_roman(1) == "I");
    assert(to_roman(1994) == "MCMXCIV");
    assert(to_roman(3999) == "MMMCMXCIX");
    assert(to_roman(4000) == "I\314\205V\314\205");
    assert(to_roman(4001) == "I\314\205V\314\205I");
    assert(to_roman(3999999) ==
           "M\314\205M\314\205M\314\205C\314\205M\314\205X\314\205C\314\205I\314\205X\314\205CMXCIX");
    // to_roman - out of range
    assert(to_roman(0).empty());
    assert(to_roman(4000000).empty());
    // from_roman - canonical, with case/whitespace/macron tolerance
    long v = 0;
    assert(from_roman("MCMXCIV", v) && v == 1994);
    assert(from_roman("  mcmxciv  ", v) && v == 1994);
    assert(from_roman("I\314\205V\314\205", v) && v == 4000);
    assert(from_roman("I\314\204V\314\204", v) && v == 4000);  // macron
    // from_roman - non-canonical / invalid
    assert(!from_roman("IIII", v));
    assert(!from_roman("VV", v));
    assert(!from_roman("IC", v));
    assert(!from_roman("MMMM", v));  // 4,000 must be vinculum
    assert(!from_roman("ABC", v));
    assert(!from_roman("", v));
    std::cout << "all showcase assertions passed\n";
    return 0;
}

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 →