Roman Numeral Converter — JavaScript 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 JavaScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
// roman-numeral-converter - Roman <-> Arabic (vinculum, 1..3,999,999).
//
// Language: JavaScript (ES2020+, no dependencies)
// 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.
//
// 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).
'use strict';
const OVERLINE = '̅'; // combining overline, U+0305 (vinculum: value x 1,000)
const MACRON = '̄'; // accepted on input, normalized to the overline
const MAX_ROMAN = 3_999_999;
// Ordered (value, symbol) pairs for 1..3,999, largest first - including the
// subtractive pairs (CM, CD, XC, XL, IX, IV) so greedy yields canonical form.
const ROMAN_MAP = [
[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'],
];
const LETTER_VALUES = { I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000 };
function overline(s) {
return [...s].map((c) => c + OVERLINE).join('');
}
function toRomanBase(v) {
let out = '';
for (const [val, sym] of ROMAN_MAP) {
while (v >= val) {
out += sym;
v -= val;
}
}
return out;
}
/**
* Convert an integer (1..3,999,999) to a Roman numeral, or null if out of
* range or non-integer. Above 3,999 the thousands part carries a combining
* overline per glyph.
*/
function toRoman(n) {
if (!Number.isInteger(n) || n < 1 || n > MAX_ROMAN) return null;
if (n <= 3999) return toRomanBase(n);
let out = overline(toRomanBase(Math.floor(n / 1000)));
const rest = n % 1000;
if (rest > 0) out += toRomanBase(rest);
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.
*/
function scanValue(s) {
let total = 0;
for (let i = 0; i < s.length; i++) {
const v = LETTER_VALUES[s[i]];
const next = i + 1 < s.length ? LETTER_VALUES[s[i + 1]] : 0;
total += next > v ? -v : v;
}
return total;
}
/**
* Parse a canonical Roman numeral (plain or vinculum), or null. Input is
* trimmed and uppercased first; a pasted macron counts as the overline mark.
*/
function fromRoman(s) {
const input = s.trim().toUpperCase().split(MACRON).join(OVERLINE);
if (!input) return null;
// Split into the overlined glyphs and the plain glyphs.
let over = '';
let plain = '';
let i = 0;
while (i < input.length) {
const c = input[i];
if (!(c in LETTER_VALUES)) return null;
if (input[i + 1] === OVERLINE) {
over += c;
i += 2;
} else {
plain += c;
i++;
}
}
let total = 0;
if (over) total += scanValue(over) * 1000;
if (plain) total += scanValue(plain);
if (total < 1 || total > MAX_ROMAN) return null;
return toRoman(total) === input ? total : null;
}
// ---------- showcase (run: node javascript.js) ----------
// toRoman - known values, both scales
console.assert(toRoman(1) === 'I');
console.assert(toRoman(1994) === 'MCMXCIV');
console.assert(toRoman(3999) === 'MMMCMXCIX');
console.assert(toRoman(4000) === 'I̅V̅');
console.assert(toRoman(4001) === 'I̅V̅I');
console.assert(toRoman(3999999) === 'M̅M̅M̅C̅M̅X̅C̅I̅X̅CMXCIX');
// toRoman - out of range / non-integer
console.assert(toRoman(0) === null);
console.assert(toRoman(4000000) === null);
console.assert(toRoman(-1) === null);
// fromRoman - canonical, with case/whitespace/macron tolerance
console.assert(fromRoman('MCMXCIV') === 1994);
console.assert(fromRoman(' mcmxciv ') === 1994);
console.assert(fromRoman('I̅V̅') === 4000);
console.assert(fromRoman('ĪV̄') === 4000); // macron
// fromRoman - non-canonical / invalid
console.assert(fromRoman('IIII') === null);
console.assert(fromRoman('VV') === null);
console.assert(fromRoman('IC') === null);
console.assert(fromRoman('MMMM') === null); // 4,000 must be vinculum
console.assert(fromRoman('ABC') === null);
console.assert(fromRoman('') === null);
console.log('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 →