ローマ数字のⅫを読む — NFKCと往復判定で正規形だけ通す
ローマ数字を整数に読むコードは、左から順に足し、次の文字の方が大きければ引く、という数行で書ける。ただしそれだけだと、IIII を 4、IC を 99、MCMC を 2000 として読んでしまう。標準の書き方から外れた入力をどう弾くか、そして「Ⅻ」のような 1 文字のローマ数字をどう扱うかが、実装で決めておくべき 2 点になる。
この記事では、ハシトシステムのローマ数字変換ツール の実装を題材に、正規形の判定を「往復」で行う方法と、Unicode のローマ数字文字を NFKC で読む前処理を Node.js で確かめる。
足し引きだけの読み取りは崩れた表記も数にする
減算記法を含めた素朴な読み取りは次のとおりである。
1 | |
これに標準から外れた表記を入れると、どれも何かしらの数になる(Node.js 24.11.1 で実行)。
| 入力 | naive の結果 | 標準の書き方 |
|---|---|---|
| IIII | 4 | IV |
| VX | 5 | V |
| IC | 99 | XCIX |
| MCMC | 2000 | MM |
| XM | 990 | CMXC |
| IIX | 10 | X |
| VV | 10 | X |
減算に使える組は IV・IX・XL・XC・CD・CM の 6 通りだけで、同じ文字の連続は 3 回まで、V・L・D は繰り返さない。これを条件分岐で全部書くこともできるが、ツールはもっと短い方法を取っている。
往復判定: 合計してから正規形に戻して比べる
ツールの fromRoman は、合計を出したあとで toRoman(大きい値から貪欲に記号を並べる関数)に戻し、入力と一致するときだけ受け付ける。
1 | |
toRoman が出す文字列は 1〜3999 のそれぞれに 1 つしかないので、「入力 = toRoman(合計)」が成り立つのは入力がその 1 つと同じときだけになる。IIII は合計 4 だが toRoman(4) は IV なので弾かれ、MCMC は合計 2000 だが toRoman(2000) は MM なので弾かれる。崩れ方を列挙しなくても、正しい形を 1 つ作れる関数があれば判定ができる。
ローマ数字の検証には、次の正規表現もよく使われる。
1 | |
往復判定とこの正規表現が同じ集合を受け付けるかを、I・V・X・L・C・D・M からなる長さ 1〜8 の全文字列(6,725,600 通り)で突き合わせた。両者の判定が食い違った文字列は 0 件で、受け付けたのは 2,690 件だった。1〜3999 の正規形のうち長さが 8 以下のものがちょうど 2,690 個なので、数も合っている。正規形の最長は 3888 の MMMDCCCLXXXVIII(15 文字)で、長さ 9 以上は総当たりしていないが、1〜3999 の正規形 3,999 個はすべて正規表現にも一致した。
Ⅻ は 1 文字で、NFKC にすると XII になる
時計の文字盤や箇条書きの番号で見かける「Ⅻ」は、ラテン文字の X・I・I を並べたものではなく、Unicode の Number Forms ブロックにある 1 文字(U+216B ROMAN NUMERAL TWELVE)である。
UnicodeData.txt(Unicode 18.0.0)を見ると、U+2160〜U+217F の 32 文字には <compat> の分解が定義されている。
1 | |
U+2163(Ⅳ)は I と V に、U+216B(Ⅻ)は X・I・I に互換分解される。大文字の Ⅰ〜Ⅻ と Ⅼ・Ⅽ・Ⅾ・Ⅿ の 16 文字、小文字の ⅰ〜ⅿ の 16 文字が該当する。一方、U+2180(ↀ)以降の古い字形は数値(1000 など)は持つが分解が定義されていない。
Unicode 18.0.0 の Core Specification 22.3 節 は、これらのローマ数字文字を東アジアの文字コード規格との互換のために収録したもので、多くの用途ではラテン文字の並びでローマ数字を組み立てる方が望ましい、としている。縦書きで正立したまま表示される点が、ラテン文字の並びとの違いとして挙げられている。
互換分解を適用する正規化が NFKC(UAX #15)なので、String.prototype.normalize('NFKC') を通せば Ⅻ は XII になる。
ツールの入力判定は /^[ivxlcdmIVXLCDM\s]+$/ で、Ⅻ や全角の MCM はこの時点で数字でもローマ数字でもない入力として扱われ、エラーになる。日本語の文書からコピーした Ⅻ を読ませたいなら、判定の前に NFKC をかければよい。
NFKC をかけても往復判定は外せない
NFKC は文字ごとに分解するだけで、ローマ数字として正しいかどうかは見ない。
| 入力 | NFKC 後 | 往復判定 |
|---|---|---|
| Ⅻ | XII | 12 |
| ⅩⅣ | XIV | 14 |
| ⅫⅠ | XIII | 13 |
| ⅣⅠ | IVI | 不可 |
| ↀ | ↀ(変わらない) | 不可 |
ⅣⅠ は 1 文字ずつなら 4 と 1 だが、つなげると IVI になり、標準の書き方ではない。Ⅻ の数値(Numeric_Value = 12)を文字ごとに足すような実装にすると、ⅣⅠ を 5 と読んでしまう。NFKC で文字列に戻してから、ASCII の文字列と同じ往復判定にかけるのが安全である。
実際に試す
Node.js 24.11.1(Windows 11)で動作を確かめた。依存パッケージは要らない。次の内容を roman.mjs として保存する。
1 | |
1 | |
出力(全探索は手元で数秒かかる):
1 | |
全角の MCMXCIV も NFKC で半角になるので、同じ前処理で読める。小文字のローマ数字文字(ⅿⅽⅿ…)は NFKC で小文字の mcm… になり、toUpperCase で大文字にそろう。
実装するときの注意
- 正規形の判定は往復で書く。崩れ方(IIII・IC・VV・MCMC…)を列挙するより、正しい形を 1 つ作る関数に戻して比べる方が短く、漏れもない。
- Ⅻ を受け付けるなら NFKC を最初にかける。U+2160〜U+217F は互換分解でラテン文字になる。ただし ↀ(U+2180)以降は分解されないので、NFKC 後に ASCII だけかを確かめる。
- 文字ごとの数値を足さない。ⅣⅠ のように、1 文字ずつは正しくても並べると標準の表記にならない入力がある。
- 出力にはラテン文字を使う。Unicode の Core Specification も、ローマ数字文字は互換用で、通常はラテン文字の並びを使う方が望ましいとしている。縦書きで正立させたいなど、明確な理由があるときだけ U+2160 系を出す。
まとめ
- 足し引きだけの読み取りは IIII を 4、MCMC を 2000 と読む。合計を toRoman に戻して入力と比べる往復判定で、標準の書き方だけを通せる。
- 往復判定は、よく知られた正規表現と長さ 8 までの全 6,725,600 通りで判定が一致した。
- Ⅻ は U+216B の 1 文字で、NFKC で XII になる。ↀ は分解されない。NFKC は表記の正しさを見ないので、往復判定と組み合わせる。
ツール: ローマ数字変換(ハシトシステム)。1〜3999 の数字とローマ数字を相互変換し、IIII や VX のような標準外の表記はエラーにする。漢数字や大字の変換は「漢数字変換をBigIntで書く」、全角半角の扱いは「JavaScriptで全角・半角を相互変換する」も参照。