教材を作っていて、漢字混じりの本文に ふりがな (ルビ) を一括で付けたい場面がよくある。Web の既存サービスはほぼサーバ送信前提で、原稿を未公開段階で外に出すのが気持ち悪い。完全にブラウザ内で動く ふりがな付けツールが欲しかったので、kuromoji.js の辞書 4 MB を CDN から lazy load して使う形で書いた。約 350 行。
🌐 デモ: https://sen.ltd/portfolio/kanji-yomi/
📦 GitHub: https://github.com/sen-ltd/kanji-yomi
何ができるのか
テキストエリアに日本語を貼ると、各漢字の上に <ruby> でひらがなが乗る。動作は完全にブラウザ内で完結し、サーバには 1 byte も送らない。
形態素解析は kuromoji.js (mecab-ipadic ベースの JS port)。辞書 (約 4 MB、12 個の .dat.gz) は jsDelivr CDN から kuromoji.builder({ dicPath }).build(...) の中で順次取得される。初回ロードに 1〜3 秒、以降はブラウザキャッシュから瞬時に立ち上がる。
純粋ロジックを DOM から分離する
kuromoji.js を読み込まないと話が進まないが、テストでは読み込みたくない (4 MB の辞書ファイルを node --test 環境で fetch するのは過剰)。なので トークンを扱う関数だけ yomi.js に切り出して、合成トークンでテストする。
// yomi.js — DOM 非依存、kuromoji 非依存
const KANJI_RE = /[一-鿿㐀-䶿豈-]/;
export function hasKanji(s) {
return KANJI_RE.test(s);
}
// kuromoji の reading は全角カタカナ。0x60 シフトでひらがなに変換。
// 長音符 ー (U+30FC) は範囲外なので保持される (「コーヒー」→「こーひー」)。
export function katakanaToHiragana(s) {
return s.replace(/[ァ-ヶ]/g, (ch) =>
String.fromCharCode(ch.charCodeAt(0) - 0x60),
);
}
export function shouldRuby(token) {
if (!token || !token.reading || token.reading === "*") return false;
if (!hasKanji(token.surface_form)) return false;
const hiraReading = katakanaToHiragana(token.reading);
return hiraReading !== token.surface_form;
}
export function renderToken(token) {
if (token.surface_form === "\n") return "<br>";
if (shouldRuby(token)) {
const reading = katakanaToHiragana(token.reading);
return `<ruby>${escapeHtml(token.surface_form)}<rt>${escapeHtml(reading)}</rt></ruby>`;
}
return escapeHtml(token.surface_form);
}
shouldRuby は 3 つのケースで false を返す:
-
reading が
*または空 — kuromoji が辞書未収録 (OOV) と判定したトークン。固有名詞や記号や latin 語が該当 - surface_form に漢字が 1 文字もない — ひらがな/カタカナの単語に「ねこ」をルビ付けしても無意味
- reading == surface_form — kuromoji が稀に reading を surface と同じにする (OOV のフォールバック)。ルビ追加が完全に冗長になる
これだけのシンプルな述語だが、ユニットテストで境界条件を全部固定しないと、後でリファクタしたとき静かに壊れるタイプの関数。19 ケースで覆っている。
カタカナ → ひらがな変換の罠
「カタカナの readings をひらがなに変換する」は単純な 0x60 シフトに見えて、いくつか罠がある:
// 動く範囲: U+30A1 (ァ) 〜 U+30F6 (ヶ)
// → U+3041 (ぁ) U+3096 (ゖ)
"カタカナ" → "かたかな" // 各文字 -0x60
"トウキョウ" → "とうきょう"
範囲外の文字は触らない:
- 長音符 ー (U+30FC) — 範囲外なので保持される。「コーヒー」→「こーひー」。読者の期待通り。試しに「こうひい」に変換すると、漢字交じり中の半角数字や英字を考慮しないと辞書ヒットが減る
- 「ヴ」(U+30F4) — 範囲内 (シフトで「ゔ」U+3094) なので変換される
- 「ヵ」「ヶ」(U+30F5, U+30F6) — 範囲内で「ゕ」「ゖ」になる。実際にはあまり見ない仮名だが、kuromoji が「一ヶ月」等で出してくる
正規表現 [ァ-ヶ] の境界はちょうどこの範囲をカバーする。[ァ-ヴ] だと「ヵ」「ヶ」が漏れる。
CDN lazy load にした理由
辞書ファイル合計は約 4 MB。bundle してデプロイに混ぜることもできたが、CDN 経由にした:
- デプロイサイズが小さい — repo / S3 sync が秒で終わる。100+ 件のポートフォリオを毎日デプロイし直す運用なので、無視できない差
-
キャッシュが共有される —
kuromoji@0.1.2を使う他のサイトを訪れたユーザーは、辞書を再 DL しなくて済む。jsDelivr のキャッシュヒット率にただ乗りできる
トレードオフは「jsDelivr が落ちたら動かない」こと。落ちる確率は低く、そのときは画面に 赤いエラーメッセージ を出して fail loud にしている (静かに壊れない)。
kuromoji.builder({ dicPath: KUROMOJI_DICT_PATH }).build((err, t) => {
if (err) {
setStatus("error", "辞書のロードに失敗", ` — ${err.message ?? err}`);
return;
}
tokenizer = t;
buildDurationMs = performance.now() - buildStartedAt;
setStatus("ready", "辞書ロード完了", ` (${(buildDurationMs / 1000).toFixed(2)}s)`);
renderInput();
});
加えて、ロード時間 (buildDurationMs) を画面下部のステータス行に出している。「辞書ロード完了 (1.98s)」のように。これで「重い」「軽い」の体感を数値化できる + ブラウザキャッシュの効きを目で確認できる (リロードすると 0.0X s に縮む)。
kanji coverage という統計
下部のステータス行に出ている "tokens 42 / annotated 12 / kanji chars 17 / coverage 100% / dict load 1.98s" の意味:
- tokens — kuromoji がトークン化した総数。句読点・改行・助詞も 1 トークン
-
annotated —
shouldRubyが true を返した トークン数。漢字付き単語の数 -
kanji chars — 入力テキスト中の漢字 文字数 (
countKanjiCharsで正規表現一致) - coverage — annotated トークン中の漢字文字数 / 全漢字文字数
「coverage が 100% でない」のは大抵以下:
-
固有名詞 (人名・地名) で IPA 辞書に無い → reading が
*になり ruby を付けない。「百済」「伊勢神宮」等は出ても、現代の人名固有や新地名は出ないことが多い - OCR 誤字 や 崩し字混じり の漢字
- 造語 や アルファベット混じり
カバーできなかった漢字は単に <ruby> なしで表示されるので、見た目で「ここは辞書未収録」と分かる。教材用途なら、coverage が低い箇所だけ手動で校正する、という運用ができる。
想定外だった効能
ルビ付けが本来の目的だが、kuromoji.js の出すトークン情報は副作用としてかなり使える:
-
品詞タグ —
pos,pos_detail_1フィールドで「名詞」「動詞」「助詞」等が取れる。本ツールでは表示していないが、教材で「動詞だけ赤くする」みたいな表示は数行で足せる -
基本形 —
basic_formフィールドで活用形が原形に正規化される (「歩いた」→「歩く」)。検索インデックス向け - 読み仮名 — そもそも本ツールが使っているもの
つまりこれは「ルビ付けツール」というより「ブラウザ内で動く形態素解析の UI」と言った方が近い。ルビは表示の 1 形式に過ぎない。
やらなかったこと
- 同形異音語の選択肢提示 — 「行った」は「いった (= 移動した)」と「おこなった (= 実行した)」の両方が可能。kuromoji は文脈を見ずに辞書の最頻出 reading を返すだけなので、本当はどちらか UI で選ばせたい。本ツールはそこまでやっていない (やるなら品詞列で簡易判定 + ユーザー上書きの仕組みが必要)
- ルビなし文字を視覚的にマークする — coverage が低い箇所が見つけやすくなる。手抜きの結果、現状は普通の漢字とまったく区別がつかない
- 画像 OCR 入力 — Tesseract.js を組み合わせれば写真 → ルビ付き HTML が完結する。それは別エントリ
テスト
$ npm test
✔ hasKanji recognises CJK ideographs
✔ hasKanji catches Extension A and Compatibility blocks
✔ katakanaToHiragana shifts U+30A1–U+30F6 by 0x60
✔ katakanaToHiragana preserves chōonpu and punctuation
✔ shouldRuby keeps kanji tokens with a real reading
✔ shouldRuby skips hiragana-only tokens
✔ shouldRuby skips katakana-only tokens
✔ shouldRuby skips when reading is missing or unknown
✔ shouldRuby skips when reading equals surface (would be redundant)
✔ renderToken wraps a kanji token in <ruby><rt>...</rt></ruby>
✔ renderToken converts newline tokens to <br>
✔ renderToken HTML-escapes surface and reading
✔ renderTokens preserves order and concatenates results
✔ stripRuby removes <rt> annotations and <ruby> wrappers
✔ countAnnotations matches what renderTokens would mark
✔ countKanjiChars counts CJK ideographs only
ℹ tests 19 ℹ pass 19 ℹ fail 0
kuromoji 自体はテストでは読まない。yomi.js の責務範囲だけを覆っている。kuromoji が変なトークンを返してもこのレイヤーが壊れないこと、を保証するのがテストの目的。
触る
https://sen.ltd/portfolio/kanji-yomi/ でサンプルボタン (吾輩は猫である / 走れメロス / 雪国 / 銀河鉄道の夜) を押すか、自分のテキストを貼る。
ソース: https://github.com/sen-ltd/kanji-yomi — MIT、合計 ~350 行、19 ユニットテスト、ビルド不要。
🛠 本記事は SEN 合同会社 が公開している小さな開発者ツール群の 1 つ。他は portfolio 一覧 から。
