Reactで、数字を漢数字に変換する入力欄を作る方法

​​React​BigInt,Intl,JavaScript,Playwright,TypeScript

数字を漢数字にする書き方は2つあります。「2026」を「二〇二六」のように1けたずつ置き換える書き方と、「二千二十六」のように十・百・千と万・億を使う書き方です。1けたずつのほうは Intl.NumberFormat でも作れます。万・億を使うほうは、検証した Node.js と Chromium の Intl.NumberFormat では作れなかったので、自分で書きます。

ここでは、数字を打つたびに漢数字が出る KanjiNumberInput というコンポーネントを作りました。2つの書き方を切り替えられ、京の位(20けた)までの整数を扱えます。

補足

検証環境は 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 です。.tsx を esbuild で変換し、tsc で型エラーがないことを確かめたうえで、StrictMode で包んだ開発用のビルドを Playwright で操作しました。日本語入力の変換中の動きは、Chromium の開発者向けの機能(DevTools Protocol)で未確定の文字を入れて再現したもので、実際の IME では試していません。Firefox と Safari、スマートフォン、スクリーンリーダーでの読み上げも試していません。

白い背景に、「数字」の欄に 123456789 が入り、「書き方」で「万・億を使う(二千二十六)」が選ばれている。その下に水色の大きな文字で「一億二千三百四十五万六千七百八十九」が2行に折り返して表示され、「コピー」ボタンがある
123456789 を入れ、万・億を使う書き方で表示したところ

変換の関数

変換の処理は kanji.ts にまとめました。

ts
// 数字の文字列を読み、漢数字にする関数

const DIGITS = '〇一二三四五六七八九';
const SMALL = ['', '十', '百', '千'];
const LARGE = ['', '万', '億', '兆', '京'];

// 京の位まで(20けた)
export const MAX = 10n ** 20n - 1n;

export type ParseResult =
  | { kind: 'empty' }
  | { kind: 'ok'; value: bigint }
  | { kind: 'error'; message: string };

// 入力欄の文字列を、0以上の整数として読む
export function parseInteger(text: string): ParseResult {
  // 全角の数字を半角にし、空白と桁区切りのカンマを取り除く
  const s = text.normalize('NFKC').replace(/[\s,]/g, '');
  if (s === '') return { kind: 'empty' };
  if (!/^\d+$/.test(s)) return { kind: 'error', message: '0以上の整数を、数字で入力してください' };
  const value = BigInt(s);
  if (value > MAX) return { kind: 'error', message: '京の位(20けた)までの数にしてください' };
  return { kind: 'ok', value };
}

// 1けたずつ漢数字にする(2026 → 二〇二六)
export function toKanjiDigits(value: bigint): string {
  return value.toString().replace(/\d/g, (d) => DIGITS[Number(d)]);
}

// 0〜9999 を、十・百・千を使って書く。十・百・千の前の「一」は書かない
function fourDigits(n: number): string {
  let out = '';
  for (let i = 3; i >= 0; i--) {
    const d = Math.floor(n / 10 ** i) % 10;
    if (d === 0) continue;
    out += (d === 1 && i > 0 ? '' : DIGITS[d]) + SMALL[i];
  }
  return out;
}

// 万・億・兆・京を使って書く(2026 → 二千二十六)
export function toKanjiNumber(value: bigint): string {
  if (value === 0n) return '〇';
  let out = '';
  let i = 0;
  // 下から4けたずつ区切り、0でない区切りに万・億・兆・京を付ける
  for (let v = value; v > 0n; v /= 10000n, i++) {
    const group = Number(v % 10000n);
    if (group > 0) out = fourDigits(group) + LARGE[i] + out;
  }
  return out;
}

万・億を使う書き方は、4けたずつ区切って組み立てる

toKanjiNumber は、数を下から4けたずつに区切ります。それぞれの区切り(0〜9999)を fourDigits で「二千二十六」のように書き、いちばん下の区切りには何も付けず、その上の区切りから順に万・億・兆・京を付けてつなぎます。区切りが0のときは、その区切りごと飛ばします。

十・百・千の前の「一」は書かず、万・億・兆・京の前の「一」は書くことにしました。Unicode CLDR の日本語の数詞の規則(spellout-cardinal)も、1000 は「千」、10000 は前に数を付けて「一万」とする決め方で、0 は「〇」です。Node.js で試した結果は次のとおりです。

数字 万・億を使う 1けたずつ
0 〇 〇
10 十 一〇
1111 千百十一 一一一一
2026 二千二十六 二〇二六
10000 一万 一〇〇〇〇
11000 一万千 一一〇〇〇
11000000 千百万 一一〇〇〇〇〇〇
100010000 一億一万 一〇〇〇一〇〇〇〇
1234567890123 一兆二千三百四十五億六千七百八十九万百二十三 一二三四五六七八九〇一二三

1000万は「千万」、1億は「一億」になります。100010000 のように間の区切りが0でも、「一億一万」と、0の区切りを飛ばしてつながりました。

変換の誤りを探すため、確かめ用に漢数字を数に戻す関数を別に書き、往復させました。0〜200000 のすべての整数と、20けたまでの乱数10万個など、合わせて300,005個で、すべて元の数に戻りました。

1けたずつの書き方は、Intl.NumberFormat でも作れる

Intl.NumberFormat は、numberingSystem オプションか、ロケールの -u-nu- で、数字に使う文字を変えられます。hanidec を指定すると、0〜9 が「〇一二三四五六七八九」になります。

ts
new Intl.NumberFormat('ja-JP-u-nu-hanidec').format(2026);
// → '二,〇二六'
new Intl.NumberFormat('ja-JP-u-nu-hanidec', { useGrouping: false }).format(2026);
// → '二〇二六'

何も指定しないと、3けたごとのカンマが入りました。useGrouping: false にすると、toKanjiDigits と同じ文字列になります。0〜100000 の整数と20けたまでの乱数10万個で比べたところ、Node.js では両者がすべて一致しました。Chromium でも、2026 は「二〇二六」になりました。

万・億を使う書き方は、検証環境の Intl.NumberFormat では作れませんでした。ECMA-402 の仕様では、表にある記数法は、0〜9 に当たる10文字で数字を置き換えます。hanidec はこの表にあります。表にない記数法の扱いは、実装に任されています。CLDR には、「二千二十六」の書き方を表す jpan という記数法もあります。ただし、CLDR の記数法の一覧では、jpan は10文字の置き換えではなく、前に挙げた数詞の規則で書く記数法です。検証環境の Node.js と Chromium では、ja-JP-u-nu-jpan を指定しても resolvedOptions().numberingSystem は latn になりました。Node.js で試すと、2026 は「2,026」のままで、Intl.supportedValuesOf('numberingSystem') にも jpan はありませんでした。

notation: 'compact' にすると、123456789 は「1.2億」になりました。億のような単位は付きますが、数字はアラビア数字のままで、下の位も丸められます。

20けたまでは BigInt で扱う

入力欄の文字列は、Number ではなく BigInt で数にしています。Number.MAX_SAFE_INTEGER の説明にあるとおり、Number で正しく表せる整数は 253 − 1(9007199254740991)までで、それより大きい整数には BigInt を使います。

検証環境の Node.js で、Number("9007199254740993") は 9007199254740992 になりました。BigInt("9007199254740993") は 9007199254740993 のままです。16けたの数を Number で読むと、入力とは違う数を漢数字にしてしまうことがあります。BigInt で読めば、この数も「九千七兆千九百九十二億五千四百七十四万九百九十三」と、末尾の三まで正しく出ます。

扱う上限は、京の位の最後(1020 − 1)にしました。99999999999999999999 は「九千九百九十九京…九千九百九十九」になり、21けたの数は「京の位(20けた)までの数にしてください」というエラーにしています。

全角の数字とカンマを読む

parseInteger は、最初に normalize('NFKC') をかけて、全角の数字を半角にします。そのあと空白とカンマを取り除き、数字だけが残ったときに BigInt に渡します。

入力 結果
123(全角) 百二十三
1,234(前後に空白) 千二百三十四
00042 四十二
-5、1.5、1e3 0以上の整数を、数字で入力してください
一二三 0以上の整数を、数字で入力してください
(空) 何も出さない

BigInt は、整数として読めない文字列を渡すと SyntaxError を投げます。正規表現で数字だけかを確かめてから渡しているので、その文字列は渡りません。負の数と小数は、この記事では扱わない入力として、エラーにしています。

入力欄のコンポーネントにまとめる

tsx
import { useId, useState } from 'react';
import { parseInteger, toKanjiDigits, toKanjiNumber } from './kanji';

type Style = 'number' | 'digits';

export function KanjiNumberInput({ initial = '' }: { initial?: string }) {
  const id = useId();
  const [text, setText] = useState(initial);
  const [style, setStyle] = useState<Style>('number');
  const [copied, setCopied] = useState(false);

  const result = parseInteger(text);
  const kanji =
    result.kind !== 'ok' ? '' :
    style === 'number' ? toKanjiNumber(result.value) : toKanjiDigits(result.value);

  async function copy() {
    await navigator.clipboard.writeText(kanji);
    setCopied(true);
  }

  return (
    <div className="kanji">
      <label htmlFor={`${id}-num`}>数字</label>
      <input
        id={`${id}-num`}
        inputMode="numeric"
        autoComplete="off"
        value={text}
        aria-invalid={result.kind === 'error'}
        aria-describedby={`${id}-msg`}
        onChange={(e) => {
          setText(e.target.value);
          setCopied(false);
        }}
      />
      <p id={`${id}-msg`} className="error">
        {result.kind === 'error' ? result.message : ''}
      </p>

      <fieldset>
        <legend>書き方</legend>
        <label>
          <input type="radio" name={`${id}-style`} checked={style === 'number'} onChange={() => setStyle('number')} />
          万・億を使う(二千二十六)
        </label>
        <label>
          <input type="radio" name={`${id}-style`} checked={style === 'digits'} onChange={() => setStyle('digits')} />
          1けたずつ(二〇二六)
        </label>
      </fieldset>

      <output htmlFor={`${id}-num`}>{kanji}</output>
      <button type="button" disabled={kanji === ''} onClick={copy}>
        {copied ? 'コピーしました' : 'コピー'}
      </button>
    </div>
  );
}
css
.kanji {
  display: grid;
  gap: 6px;
  max-width: 360px;
}
.kanji input:not([type='radio']) {
  font-size: 1.125rem;
  padding: 6px 8px;
}
.kanji fieldset {
  display: grid;
  gap: 4px;
  border: none;
  padding: 0;
  margin: 0;
}
.kanji .error {
  min-height: 1.5em;
  margin: 0;
  color: #d85878;
}
.kanji output {
  min-height: 1.6em;
  font-size: 1.5rem;
  color: #067a98;
}
.kanji button {
  justify-self: start;
}

state に持つのは、入力欄の文字列、書き方、コピーしたかどうかの3つです。漢数字は state に持たず、レンダリングのたびに文字列から作ります。入力欄は type="text" のままにして、inputMode="numeric" を付けました。入力された文字列をそのまま parseInteger に渡し、全角の数字やカンマも読めるようにするためです。

Chromium で、空の欄に「2」「0」「2」「6」と1文字ずつ打つと、表示は「二」「二十」「二百二」「二千二十六」と変わりました。書き方を「1けたずつ」に切り替えると「二〇二六」になり、入力欄の文字列はそのまま残ります。「1.5」のように読めない文字列のときは、入力欄の aria-invalid が true になり、エラーの文が出て、コピーボタンは押せなくなりました。

コピーボタン

漢数字を別の場所に貼り付けられるように、navigator.clipboard.writeText() でコピーするボタンを付けました。このメソッドは、MDN の説明にあるとおり、保護されたコンテキスト(HTTPS など)でだけ使えます。保護されたコンテキストの説明によると、HTTPS でなくても、http://127.0.0.1 や http://localhost は信頼できる配信元として扱われます。検証では、Playwright でクリップボードの権限を与えたうえで、http://127.0.0.1:8080/ のページで試しました。100010000 を入れてボタンを押すと、クリップボードの中身は「一億一万」になり、ボタンの文字は「コピーしました」に変わりました。続けて数字を打つと、onChange の中で copied を false に戻しているので、ボタンの文字は「コピー」に戻りました。

長い漢数字は、CSS を足さなくても折り返される

万・億を使う書き方は、20けたの数だと40文字近くになります。画面の幅を 320px にして 99999999999999999999 を入れると、出力の欄(幅 272px)の中で折り返され、高さが 96px になりました。中身の幅は 272px で、横にはみ出していません。CSS で折り返しの指定はしていません。

変換中の未確定の文字でも、漢数字が出る

DevTools Protocol の Input.imeSetComposition を使い、日本語入力で変換を確定する前の状態として、全角の「20」「2026」を入力欄に入れました。確定前から onChange が呼ばれ、表示は「二十」「二千二十六」に変わりました。Input.insertText で確定したあとも、表示は「二千二十六」のままです。

​Posted by 86