1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ブラウザだけで漢字に自動でふりがなを付けるツールを作った — kuromoji.js 4 MB 辞書を CDN lazy load する話

1
Posted at

教材を作っていて、漢字混じりの本文に ふりがな (ルビ) を一括で付けたい場面がよくある。Web の既存サービスはほぼサーバ送信前提で、原稿を未公開段階で外に出すのが気持ち悪い。完全にブラウザ内で動く ふりがな付けツールが欲しかったので、kuromoji.js の辞書 4 MB を CDN から lazy load して使う形で書いた。約 350 行。

kanji-yomi で「吾輩は猫である」冒頭にルビが付いた状態。各漢字単語の上にひらがなで読み仮名がティール色で表示され、画面下部に "tokens 42 / annotated 12 / kanji chars 17 / coverage 100% / dict load 1.98s" の統計が出ている

🌐 デモ: 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 を返す:

  1. reading が * または空 — kuromoji が辞書未収録 (OOV) と判定したトークン。固有名詞や記号や latin 語が該当
  2. surface_form に漢字が 1 文字もない — ひらがな/カタカナの単語に「ねこ」をルビ付けしても無意味
  3. 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 トークン
  • annotatedshouldRuby が 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 一覧 から。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?