ローマ数字のⅫを読む — NFKCと往復判定で正規形だけ通す

ローマ数字を整数に読むコードは、左から順に足し、次の文字の方が大きければ引く、という数行で書ける。ただしそれだけだと、IIII を 4、IC を 99、MCMC を 2000 として読んでしまう。標準の書き方から外れた入力をどう弾くか、そして「Ⅻ」のような 1 文字のローマ数字をどう扱うかが、実装で決めておくべき 2 点になる。

この記事では、ハシトシステムのローマ数字変換ツール の実装を題材に、正規形の判定を「往復」で行う方法と、Unicode のローマ数字文字を NFKC で読む前処理を Node.js で確かめる。

足し引きだけの読み取りは崩れた表記も数にする

減算記法を含めた素朴な読み取りは次のとおりである。

1
2
3
4
5
6
7
8
9
const MAP = { I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000 };
function naive(s) {
let t = 0;
for (let i = 0; i < s.length; i++) {
const v = MAP[s[i]], n = MAP[s[i + 1]] || 0;
t += v < n ? -v : v;
}
return t;
}

これに標準から外れた表記を入れると、どれも何かしらの数になる(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
2
3
4
5
6
var total=0;
for(var i=0;i<s.length;i++){
var v=MAP[s.charAt(i)],next=i+1<s.length?MAP[s.charAt(i+1)]:0;
total+=v<next?-v:v;
}
if(toRoman(total)!==s)return null; // 正規形チェック(IIII・VX 等を弾く)

toRoman が出す文字列は 1〜3999 のそれぞれに 1 つしかないので、「入力 = toRoman(合計)」が成り立つのは入力がその 1 つと同じときだけになる。IIII は合計 4 だが toRoman(4) は IV なので弾かれ、MCMC は合計 2000 だが toRoman(2000) は MM なので弾かれる。崩れ方を列挙しなくても、正しい形を 1 つ作れる関数があれば判定ができる。

ローマ数字の検証には、次の正規表現もよく使われる。

1
/^M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$/

往復判定とこの正規表現が同じ集合を受け付けるかを、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
2
3
2163;ROMAN NUMERAL FOUR;Nl;0;L;<compat> 0049 0056;;;4;N;;;;2173;
216B;ROMAN NUMERAL TWELVE;Nl;0;L;<compat> 0058 0049 0049;;;12;N;;;;217B;
2180;ROMAN NUMERAL ONE THOUSAND C D;Nl;0;L;;;;1000;N;;;;;

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// roman.mjs : ローマ数字の変換(往復で正規形を判定し、Ⅻ などは NFKC で読む)
const VALS = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];
const SYMS = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I'];
const MAP = { I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000 };

export function toRoman(n) {
if (!Number.isInteger(n) || n < 1 || n > 3999) return null;
let s = '';
for (let i = 0; i < VALS.length; i++) while (n >= VALS[i]) { s += SYMS[i]; n -= VALS[i]; }
return s;
}

// 減算記法で合計し、toRoman で戻した文字列と一致するときだけ受け付ける
export function fromRoman(str) {
const s = String(str).normalize('NFKC').toUpperCase().replace(/\s+/g, '');
if (!/^[IVXLCDM]+$/.test(s)) return null; // ↀ(U+2180)など NFKC で崩れない文字はここで落ちる
let total = 0;
for (let i = 0; i < s.length; i++) {
const v = MAP[s[i]], next = MAP[s[i + 1]] || 0;
total += v < next ? -v : v;
}
return toRoman(total) === s ? total : null;
}

const cases = [
['MCMXCIV', 1994], ['mmxxvi', 2026], ['MMMCMXCIX', 3999],
['IIII', null], ['VX', null], ['IC', null], ['MCMC', null],
['Ⅻ', 12], ['Ⅳ', 4], ['ⅩⅣ', 14], ['ⅿⅽⅿⅹⅽⅰⅴ', 1994], ['MCMXCIV', 1994],
['ⅣⅠ', null], ['ↀ', null],
];
let ng = 0;
for (const [input, want] of cases) {
const got = fromRoman(input);
if (got !== want) ng++;
console.log(`${got === want ? 'OK' : 'NG'} ${input.padEnd(9)} NFKC=${input.normalize('NFKC').padEnd(9)} -> ${got}`);
}
console.log(`NG ${ng} / ${cases.length}`);

// 往復判定が、よく知られた正規表現と同じ集合を受け付けるかを長さ 8 まで全探索する
const RE = /^M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$/;
let checked = 0, accepted = 0, diff = 0;
(function walk(p, d) {
if (p) { checked++; const a = fromRoman(p) !== null; if (a) accepted++; if (a !== RE.test(p)) diff++; }
if (d) for (const c of 'IVXLCDM') walk(p + c, d - 1);
})('', 8);
console.log({ checked, accepted, diff });
1
node roman.mjs

出力(全探索は手元で数秒かかる):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
OK  MCMXCIV   NFKC=MCMXCIV   -> 1994
OK mmxxvi NFKC=mmxxvi -> 2026
OK MMMCMXCIX NFKC=MMMCMXCIX -> 3999
OK IIII NFKC=IIII -> null
OK VX NFKC=VX -> null
OK IC NFKC=IC -> null
OK MCMC NFKC=MCMC -> null
OK Ⅻ NFKC=XII -> 12
OK Ⅳ NFKC=IV -> 4
OK ⅩⅣ NFKC=XIV -> 14
OK ⅿⅽⅿⅹⅽⅰⅴ NFKC=mcmxciv -> 1994
OK MCMXCIV NFKC=MCMXCIV -> 1994
OK ⅣⅠ NFKC=IVI -> null
OK ↀ NFKC=ↀ -> null
NG 0 / 14
{ checked: 6725600, accepted: 2690, diff: 0 }

全角の 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で全角・半角を相互変換する」も参照。


ローマ数字のⅫを読む — NFKCと往復判定で正規形だけ通す
https://blog.hashito.biz/2026/10/10/roman-numeral-unicode-nfkc-xii-roundtrip-canonical-check/
著者
hashito
作成日
2026年10月10日
著作権