Skip to content

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 →