背景
自分の会社のサイトで、漢数字と数字を相互に変換するツールを置いています。
領収書や契約書で「金壱萬円也」みたいな書き方をする、あれを作るためのツールです。
「数字を漢字に置き換えるだけでしょ」と思いがちなのですが…
実際には、漢数字から数字へ戻す向きのほうがずっと面倒で、そこの組み方を備忘録として残しておきます。
漢数字の位取りのおさらい
漢数字には2種類の書き方がある。
1つは位取り(「二千二十六」のように十・百・千などの単位を挟む書き方)。
もう1つは数字並び(「二〇二六」のように1桁ずつ並べる書き方)。
位取りのほうは、さらに2段の単位でできている。
十・百・千は「4桁の中の位」を表す小さい単位。
万・億・兆は「4桁のかたまりが何個目か」を表す大きい単位だ。
一億二千三百四十五万は、「一」億と「二千三百四十五」万に分かれる、という読み方になる。
大字(だいじ)は、改ざんされにくいように画数を増やした字で、壱・弐・参・拾・萬などを指します。
ツールでは、壱は一、拾は十、萬は万、と読み替えて同じ処理に流しています。
数字から漢数字へ(簡単なほう)
先に簡単なほうです。
下記が、ツールの中で実際に動いている関数です(numStr は入力欄の文字列、daiji は大字で出すかどうかのフラグ)。
function numToKanji(numStr,daiji){
var s=String(numStr).replace(/[,,\s]/g,'');
if(!/^\d+$/.test(s))return null;
s=s.replace(/^0+(?=\d)/,'');
if(s.length>16)return null;
if(s==='0')return '零';
var digits=daiji?['','壱','弐','参','四','五','六','七','八','九']:['','一','二','三','四','五','六','七','八','九'];
var units=daiji?['','拾','佰','仟']:['','十','百','千'];
var groups=daiji?['','萬','億','兆']:['','万','億','兆'];
var out='',gi=0;
while(s.length){
var chunk=s.slice(-4);s=s.slice(0,-4);
var part='';
for(var i=0;i<chunk.length;i++){
var d=+chunk[chunk.length-1-i];
if(!d)continue;
var dg=(d===1&&i>0&&!daiji)?'':digits[d];
part=dg+units[i]+part;
}
if(part)out=part+groups[gi]+out;
gi++;
}
return out;
}
下の桁から4桁ずつ切り出して、かたまりごとに十・百・千を付け、最後に万・億・兆を付けているだけです。
d===1&&i>0&&!daiji の行は、「一十」ではなく「十」、「一百」ではなく「百」と書くための分岐です。
大字のときだけ一を省かないのは、「拾」の前に何も無いと、あとから「壱」を書き足されても気づけないからです(大字を使う理由そのものなので、ここは省けない)。
かたまりが全部0なら part が空になり、「万」だけが出る事故も起きません。
漢数字から数字へ(面倒なほう)
こっちが本題です。
下記も、ツールの実物からそのまま持ってきました。
KDIG は一〜九と壱〜玖などを数値にする表、KSMALL は十・百・千(拾・佰・仟を含む)、KBIG は万・億・兆(萬を含む)の表です。
ZEN は全角数字 '0123456789' で、全角で打たれた数字を半角として読むためのものです。
function kanjiToNum(str){
var s=String(str).replace(/[\s,,]/g,'');
if(!s)return null;
var total=0n,section=0n,num=0n,hasNum=false,used=false;
for(var i=0;i<s.length;i++){
var ch=s[i];
var z=ZEN.indexOf(ch);
if(z>=0)ch=String(z);
if(ch>='0'&&ch<='9'){num=num*10n+BigInt(ch);hasNum=true;used=true;continue;}
if(Object.prototype.hasOwnProperty.call(KDIG,ch)){num=num*10n+BigInt(KDIG[ch]);hasNum=true;used=true;continue;}
if(Object.prototype.hasOwnProperty.call(KSMALL,ch)){section+=(hasNum?num:1n)*BigInt(KSMALL[ch]);num=0n;hasNum=false;used=true;continue;}
if(Object.prototype.hasOwnProperty.call(KBIG,ch)){
var g=BigInt(KBIG[ch]);
var head=section+num;
if(head===0n)head=1n;
total+=head*g;
section=0n;num=0n;hasNum=false;used=true;continue;
}
return null;
}
if(!used)return null;
var result=total+section+num;
if(result.toString().length>17)return null;
return result.toString();
}
変数は3つの段になっています。
num はいま読んでいる数字(二〇二六のように並んでいれば、10倍して足していく)。
section は千までの合計で、十・百・千が来るたびに num を掛けて足し込む。
total は確定済みの合計で、万・億・兆が来たときに section + num をまとめて掛けて移す。
タイトルの「万の前で一度締める」は、この total への移し替えのことです。
これをやらずに1つの変数に足していくと、一億二千三百四十五万の「二千三百四十五」が万倍されず、桁がずれます。
千までを section に貯めておき、万が来た瞬間にかたまりとして掛けるから、4桁ごとの区切りがそのまま再現できる、という理屈です。
hasNum?num:1n と if(head===0n)head=1n は、「十」「百」「万」のように前に数字が無いときに1とみなすための分岐です。
「万」だけ入力されたら10000、「十万」なら100000になります。
実際に通してみる
ツールのページからこの2つの関数と表を切り出して、Node.js(v24.11.1)で入力と出力を並べたのが下記です。
2026 -> 二千二十六 / 弐仟弐拾六
123450000 -> 一億二千三百四十五万 / 壱億弐仟参佰四拾五萬
二千二十六 -> 2026
二〇二六 -> 2026
万 -> 10000
一千万 -> 10000000
壱萬 -> 10000
1万2千 -> 12000
九千九百九十九兆九千九百九十九億九千九百九十九万九千九百九十九 -> 9999999999999999
位取り(二千二十六)と数字並び(二〇二六)が、同じループで両方 2026 になっているのがわかると思います。
「1万2千」のように算用数字が混ざっていても読めます。
なぜ BigInt なのか
最後の行の 9999兆… は16桁で、ツールの上限にしてある値です。
これを普通の Number で扱うと、下記のようになります。
$ node -e "console.log(Number.MAX_SAFE_INTEGER, 9999999999999999)"
9007199254740991 10000000000000000
Number は 9007199254740991(約9007兆)までしか整数を正確に持てず、それを超えた 9999999999999999 は 10000000000000000 に丸められてしまいます。
兆の位まで扱うと言った時点で Number では足りないので、0n や 10n のように BigInt で計算しています。
その代わり、BigInt と Number は混ぜて計算できない(1n + 1 は TypeError になる)ので、表の値もいちいち BigInt(...) で包む手間が増えます(地味に面倒です…)。
漢数字の読み取りを自分で書くなら、千までを貯める変数と、万・億・兆で確定させる変数を分けて持つのが一番の近道です。
本記事はAI補助で執筆した、個人開発の紹介記事です。