Reactで、数字をローマ数字に変換する方法

​​React​CSS,JavaScript,Playwright,TypeScript,Unicode

数字をローマ数字にするには、「M = 1000」「CM = 900」…「I = 1」の組を大きい順に並べた表を用意し、大きい記号から順に引いていきます。逆にローマ数字を数にするときは、先に正規表現で正しい並びかどうかを確かめてから、同じ表で足し合わせます。

ここでは、変換の関数を roman.ts にまとめ、数字を打てばローマ数字が、ローマ数字を打てば数字が出る RomanNumeralConverter というコンポーネントを作りました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。関数の結果は Node.js で確かめ、tsc で型エラーがないことを確かめました。コンポーネントは StrictMode で包んだ開発用のビルドを、幅 375px の Chromium で Playwright から操作しました。日本語入力(IME)で打ったときの動き、Firefox と Safari、スマートフォンの実機、スクリーンリーダーでの読み上げは試していません。

幅375pxの画面で撮ったコンポーネント。「数字かローマ数字」の欄に 2026 が入り、その下に水色の大きな文字で MMXXVI と出ている
2026 を入れたところ。打つたびに下の表示が変わる

数字をローマ数字にする関数

ここでは、CSS の仕様にある upper-roman の定義と同じく、4 を「IV」、9 を「IX」と書く形にそろえます。同じように、引き算で書く6つの組(IV、IX、XL、XC、CD、CM)も、ふつうの記号と同じ表に入れておきます。そうすると、表の上から順に「引けるだけ引く」をくり返すだけで変換できます。

ts
// 数字とローマ数字を相互に変換する

// 大きい順に並べた、記号と値の組。4 や 9 を表す「IV」「IX」なども入れておく
const TABLE: [string, number][] = [
  ['M', 1000], ['CM', 900], ['D', 500], ['CD', 400],
  ['C', 100], ['XC', 90], ['L', 50], ['XL', 40],
  ['X', 10], ['IX', 9], ['V', 5], ['IV', 4], ['I', 1],
];

export const MIN = 1;
export const MAX = 3999;

// 1〜3999 の整数をローマ数字にする。範囲の外なら null を返す
export function toRoman(n: number): string | null {
  if (!Number.isInteger(n) || n < MIN || n > MAX) return null;
  let rest = n;
  let result = '';
  for (const [symbol, value] of TABLE) {
    while (rest >= value) {
      result += symbol;
      rest -= value;
    }
  }
  return result;
}

// 正しい並びのローマ数字だけに一致する正規表現(千・百・十・一の位の順)
const PATTERN = /^M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$/;

// ローマ数字を数にする。正しくない並びなら null を返す
export function fromRoman(input: string): number | null {
  // 全角の「XII」や1文字の「Ⅻ」を「XII」にし、小文字は大文字にそろえる
  const s = input.normalize('NFKC').trim().toUpperCase();
  if (s === '' || !PATTERN.test(s)) return null;
  let result = 0;
  let i = 0;
  for (const [symbol, value] of TABLE) {
    while (s.startsWith(symbol, i)) {
      result += value;
      i += symbol.length;
    }
  }
  return result;
}

たとえば 1999 なら、M(1000)を引いて残り 999、CM(900)を引いて 99、XC(90)を引いて 9、IX(9)を引いて 0 になり、「MCMXCIX」ができます。検証環境の Node.js で toRoman() を実行した結果の一部は、次のとおりです。

渡した数 結果
4 IV
944 CMXLIV
2026 MMXXVI
3888 MMMDCCCLXXXVIII
3999 MMMCMXCIX
0、4000、-1、1.5、NaN null

この関数では M を3つまでにしているので、書けるのは 3999(MMMCMXCIX)までです。CSS の仕様でも、upper-roman の定義は CSS Counter Styles Level 3 の定義済みのカウンタースタイルで range: 1 3999 になっています。範囲の外(0 や負の数を含む)と整数でない数には null を返し、呼び出し側でメッセージを出します。

1〜3999 のすべての数を toRoman() でローマ数字にし、それを fromRoman() で数に戻したところ、3999個すべてが元の数に戻りました。できた文字列に重複はなく、いちばん長いのは 3888 の「MMMDCCCLXXXVIII」(15文字)でした。

ローマ数字を数にするときは、先に並びを確かめる

文字を左から読んで足すだけでは、「IIII」のような正しくない並びも、そのまま足されて数になります。そこで fromRoman() では、足す前に正規表現 PATTERN で並びを確かめています。千の位は M を0〜3個、百の位は「CM」「CD」か「D のあとに C を0〜3個」、十の位と一の位も同じ形です。この形に合わない文字列は null になります。

渡した文字列 結果 補足
MMXXVI 2026 正しい並び
mmxxvi 2026 大文字にそろえてから読む
IIII null I・X・C は3つまで
VV null V・L・D は1つまで
IC null I を前に置けるのは V と X だけ
XM null X を前に置けるのは L と C だけ
VX null V を前に置く組がない
MMMM null M は3つまで

1文字の「Ⅻ」や全角の文字も読む

Unicode には、ローマ数字を1文字で表す「Ⅰ」(U+2160)から「Ⅻ」(U+216B)などの文字があります。検証環境の Python の unicodedata で Ⅰ、Ⅳ、Ⅻ、Ⅿ、ⅰ を調べると、どれにも互換分解(<compat>)が定められていて、「Ⅻ」は X・I・I の3文字に当たります。

そのため、MDN の normalize() の説明にある normalize('NFKC') をかけると、ふつうの英字になります。検証環境では、Ⅻ は XII に、小文字の ⅰ(U+2170)は i になりました。fromRoman() は最初に NFKC をかけているので、fromRoman('Ⅻ') は 12、全角の XIV は 14、小文字の1文字のローマ数字で書いた ⅿⅿⅹⅹⅵ は 2026 になりました。

ただし、「ↀ」(U+2180、ROMAN NUMERAL ONE THOUSAND C D)のように分解が定められていない文字は、NFKC をかけてもそのまま残ります。fromRoman() に渡すと、正規表現に合わずに null になります。

打つたびに変換する入力欄

RomanNumeralConverter は、欄に数字が入っていればローマ数字に、それ以外ならローマ数字として読んで数字にします。変換の結果は state に持たず、描画のたびに欄の文字から計算しています。

tsx
import { useId, useState } from 'react';
import { MAX, MIN, fromRoman, toRoman } from './roman';

export function RomanNumeralConverter() {
  const id = useId();
  const [text, setText] = useState('2026');

  const trimmed = text.trim();
  let result = '';
  let error = '';
  if (trimmed === '') {
    result = '';
  } else if (/^[0-90-9]+$/.test(trimmed)) {
    // 数字が入っていたらローマ数字にする
    const n = Number(trimmed.normalize('NFKC'));
    const roman = toRoman(n);
    if (roman === null) error = `${MIN}から${MAX}までの数を入れてください`;
    else result = roman;
  } else {
    // それ以外はローマ数字として読む
    const n = fromRoman(trimmed);
    if (n === null) error = 'ローマ数字として読めませんでした';
    else result = String(n);
  }

  return (
    <div className="roman-converter">
      <label htmlFor={id}>数字かローマ数字</label>
      <input
        id={id}
        value={text}
        autoComplete="off"
        onChange={(e) => setText(e.target.value)}
        aria-describedby={`${id}-result`}
      />
      <output id={`${id}-result`} htmlFor={id} className={error ? 'error' : ''}>
        {error || result}
      </output>
    </div>
  );
}
css
.roman-converter { display: grid; gap: 8px; max-width: 480px; }
.roman-converter input { font-size: 16px; padding: 6px 8px; }
.roman-converter output { min-height: 1.5em; font-size: 28px; color: #067a98; overflow-wrap: anywhere; }
.roman-converter output.error { font-size: 16px; color: #d85878; }

結果は <output> 要素に出しています。MDN の output 要素の説明にあるとおり、<output> の暗黙の ARIA ロールは status です。検証環境の Chromium でもアクセシビリティツリー上のロールは status になっていたので、role="status" は付けていません。

Playwright で欄を空にして1文字ずつ打ち、表示を確かめた結果は次のとおりです。

打った文字 表示
3888 MMMDCCCLXXXVIII
4000 1から3999までの数を入れてください
0 1から3999までの数を入れてください
2026(全角) MMXXVI
mcmxcix 1999
IIII ローマ数字として読めませんでした
Ⅻ 12

「MCMXCIX」を1文字ずつ打つと、表示は 1000、1100、1900、1910、1990、1991、1999 と変わりました。途中の「MC」や「MCM」も正しいローマ数字なので、エラーにならずにその時点の数が出ます。幅 375px の画面で、横にはみ出す表示はありませんでした。

Intl.NumberFormat と CSS でできること

MDN の Intl.supportedValuesOf() の説明には、数字の書き方(numberingSystem)の値として roman(大文字のローマ数字)と romanlow(小文字のローマ数字)が「アルゴリズム」の種類として載っています。しかし、検証環境の Node.js と Chromium で new Intl.NumberFormat('en', { numberingSystem: 'roman' }) を作ると、resolvedOptions().numberingSystem は latn になり、format(2026) は 2,026 でした。Intl.supportedValuesOf('numberingSystem') にも roman は入っていませんでした。検証環境では Intl.NumberFormat でローマ数字の文字列を作れなかったので、上のような関数を使います。

画面の上で番号として見せるだけなら、CSS でもできます。MDN の list-style-type の説明にある upper-roman を <ol> に指定すると、項目の番号が大文字のローマ数字になります。

css
.css-roman ol { list-style-type: upper-roman; list-style-position: inside; padding-left: 0; margin: 0; font-size: 18px; }
番号付きリストの画面。上から MMMCMXCVIII. 3998番目、MMMCMXCIX. 3999番目、4000. 4000番目、4001. 4001番目、0. 0番目 と並んでいる
upper-roman のリスト。4000 からと 0 は普通の数字になる

<ol start="3998"> に4つの項目を入れたところ、3998 と 3999 はローマ数字になり、4000 と 4001 は普通の数字になりました。start="0" の項目も「0.」です。CSS Counter Styles の仕様では、範囲(range)の外の数は代わりのスタイル(fallback)で書くと定められていて、fallback の初期値は decimal です。上の結果はこの定めのとおりです。

CSS でできるのは、リストやカウンターの番号の見た目を変えるところまでです。入力欄で数字とローマ数字を行き来させたり、変換した文字列を JavaScript で使ったりするときは、roman.ts のような関数を使います。

​Posted by 86