Roman Numeral Converter — PHP 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 PHP implementation — the same logic the interactive tool runs, in a shareable, citable form.
<?php
// roman-numeral-converter - Roman <-> Arabic (vinculum, 1..3,999,999).
//
// Language: PHP (8.1+, 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: ext-standard 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).
declare(strict_types=1);
const OVERLINE = "̅"; // combining overline, U+0305 (vinculum: value x 1,000)
const MACRON = "̄"; // accepted on input, normalized to the overline
const MAX_ROMAN = 3999999;
// 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(string $s): string
{
$out = '';
foreach (mb_str_split($s) as $c) {
$out .= $c . OVERLINE;
}
return $out;
}
function to_roman_base(int $v): string
{
$out = '';
foreach (ROMAN_MAP as [$val, $sym]) {
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. Above 3,999 the thousands part carries a combining overline per glyph.
*/
function to_roman(?int $n): ?string
{
if ($n === null || $n < 1 || $n > MAX_ROMAN) {
return null;
}
if ($n <= 3999) {
return to_roman_base($n);
}
$out = overline(to_roman_base(intdiv($n, 1000)));
$rest = $n % 1000;
if ($rest > 0) {
$out .= to_roman_base($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
* from_roman is the canonicality gate.
*/
function scan_value(string $s): int
{
$total = 0;
$len = strlen($s);
for ($i = 0; $i < $len; $i++) {
$v = LETTER_VALUES[$s[$i]];
$next = $i + 1 < $len ? 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 from_roman(string $s): ?int
{
$input = str_replace(MACRON, OVERLINE, strtoupper(trim($s)));
if ($input === '') {
return null;
}
// Split into the overlined glyphs and the plain glyphs (bytewise: the
// letters are ASCII and the overline is the fixed 2-byte sequence CC 85).
$over = '';
$plain = '';
$len = strlen($input);
$i = 0;
while ($i < $len) {
$c = $input[$i];
if (!isset(LETTER_VALUES[$c])) {
return null;
}
if (substr($input, $i + 1, 2) === OVERLINE) {
$over .= $c;
$i += 3;
} else {
$plain .= $c;
$i++;
}
}
$total = 0;
if ($over !== '') {
$total += scan_value($over) * 1000;
}
if ($plain !== '') {
$total += scan_value($plain);
}
if ($total < 1 || $total > MAX_ROMAN) {
return null;
}
return to_roman($total) === $input ? $total : null;
}
// ---------- showcase (run: php php.php) ----------
// 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̅V̅");
assert(to_roman(4001) === "I̅V̅I");
assert(to_roman(3999999) === 'M̅M̅M̅C̅M̅X̅C̅I̅X̅CMXCIX');
// to_roman - out of range
assert(to_roman(0) === null);
assert(to_roman(4000000) === null);
// from_roman - canonical, with case/whitespace/macron tolerance
assert(from_roman('MCMXCIV') === 1994);
assert(from_roman(' mcmxciv ') === 1994);
assert(from_roman("I̅V̅") === 4000);
assert(from_roman("ĪV̄") === 4000); // macron
// from_roman - non-canonical / invalid
assert(from_roman('IIII') === null);
assert(from_roman('VV') === null);
assert(from_roman('IC') === null);
assert(from_roman('MMMM') === null); // 4,000 must be vinculum
assert(from_roman('ABC') === null);
assert(from_roman('') === null);
echo 'all showcase assertions passed', PHP_EOL;
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 →