「Excelに貼ったデータの全角と半角が混在していてVLOOKUPがマッチしない」「フォームに全角英数で入力されて弾かれる」——日本語の実務でやたら遭遇する全角・半角問題。変換ツールはいくらでもあるが、中身を自分で書いてみると、英数字記号は驚くほど簡単なのに、カタカナだけ別世界だと気づく。
全角・半角変換をブラウザ完結・ライブラリなしで実装したツールをぱんだツールズの1機能として作った。
この記事では、その変換ロジックを通して「Unicode 上で全角と半角がどう対応しているか」を解説する。0xFEE0 という魔法の数字と、半角カナがなぜ単純なオフセットで変換できないのかが本題。
英数字・記号は「0xFEE0 を足し引きするだけ」
まず簡単な方から。半角の ASCII 印字可能文字(! = 0x21 〜 ~ = 0x7E)と、全角形(! = 0xFF01 〜 ~ = 0xFF5E)は、Unicode 上できれいに並行している。両者の差はちょうど 0xFEE0(10進で 65248)。
半角 '!' = U+0021 全角 '!' = U+FF01 差 = 0xFEE0
半角 'A' = U+0041 全角 'A' = U+FF21 差 = 0xFEE0
半角 '0' = U+0030 全角 '0' = U+FF10 差 = 0xFEE0
「全角英数記号」は Unicode の Halfwidth and Fullwidth Forms ブロックに、ASCII と同じ並び順で配置されている。だからコードポイントを足し引きするだけで変換できる。
// 全角 → 半角
if (code >= 0xFF01 && code <= 0xFF5E) {
const half = String.fromCharCode(code - 0xFEE0)
// ...
}
// 半角 → 全角
if (code >= 0x21 && code <= 0x7E) {
const full = String.fromCharCode(code + 0xFEE0)
// ...
}
ただし1つ例外がある。スペースだ。半角スペース(U+0020)と全角スペース(U+3000)は、この 0xFEE0 の並びに乗っていない。0x20 に 0xFEE0 を足しても全角スペースにはならない。なので別途ハードコードで対応する。
if (c === ' ' && opts.symbol) { result += ' '; continue } // 全角→半角
if (c === ' ' && opts.symbol) { result += ' '; continue } // 半角→全角
「全角スペースだけ変換されない」というありがちなバグは、この例外を忘れることで起きる。
変換対象を英数字・記号で出し分けたいので、変換後の文字が英数字かどうかを正規表現で判定して、オプションのフラグと突き合わせている。
const half = String.fromCharCode(code - 0xFEE0)
const isAlpha = /[A-Za-z0-9]/.test(half)
const isSym = !isAlpha
if ((isAlpha && opts.alphaNum) || (isSym && opts.symbol)) {
result += half
continue
}
これで「英数字だけ半角にして記号は全角のまま」といった細かい制御ができる。
半角カタカナは「オフセットで変換できない」
問題はカタカナ。全角カタカナ(ア = U+30A2 など)と半角カタカナ(ア = U+FF71 など)は、並び順が一致していない。U+FF61〜U+FF9F に詰め込まれた半角カナ(と 。「」、・ などの関連記号)は、五十音順ではあるものの全角カナブロックとはオフセットがズレており、0xFEE0 のような一律の足し引きが効かない。
そこで素直にマッピングテーブルを持つ。
const ZENKAKU_KANA_TO_HANKAKU: Record<string, string> = {
'ア': 'ア', 'イ': 'イ', 'ウ': 'ウ', 'エ': 'エ', 'オ': 'オ',
'カ': 'カ', 'キ': 'キ', /* ... */
'ガ': 'ガ', 'ギ': 'ギ', 'グ': 'グ', /* ... */
'パ': 'パ', 'ピ': 'ピ', /* ... */
'。': '。', '「': '「', '」': '」', '、': '、', '・': '・', 'ー': 'ー',
}
逆方向(半角→全角)のテーブルは、わざわざ手で書かずにこのテーブルを反転して作る。
const HANKAKU_KANA_TO_ZENKAKU: Record<string, string> = Object.fromEntries(
Object.entries(ZENKAKU_KANA_TO_HANKAKU).map(([k, v]) => [v, k])
)
ここで効いてくるのが、テーブルをよく見ると分かる濁点・半濁点の扱い。全角の ガ は1文字だが、半角だと カ(カ)+ ゙(濁点)の 2文字で表現する。半角カナには合成済みの濁音文字が存在しないからだ。
全角 'ガ'(1文字, U+30AC) ⇔ 半角 'ガ'(2文字, U+FF76 + U+FF9E)
全角 'パ'(1文字, U+30D1) ⇔ 半角 'パ'(2文字, U+FF8A + U+FF9F)
全角→半角は、テーブルの値が 'ガ' のように2文字になっているだけなので、1文字ずつ引くだけで済む。問題は半角→全角で、カ を見た瞬間に「次が濁点かもしれない」と先読みしないと、ガ を カ + ゛(濁点単体)に分解してしまう。
なので半角→全角では、次の文字が濁点・半濁点なら2文字をまとめて引き、ヒットしたらインデックスを1つ余分に進める。
if (opts.kana) {
const next = text[i + 1]
if (next === '゙' || next === '゚') {
const combined = c + next
const zenkaku = HANKAKU_KANA_TO_ZENKAKU[combined]
if (zenkaku) { result += zenkaku; i++; continue } // i++ で濁点を読み飛ばす
}
if (HANKAKU_KANA_TO_ZENKAKU[c]) {
result += HANKAKU_KANA_TO_ZENKAKU[c]
continue
}
}
順序が重要で、先に2文字の合成(ガ)を試してから、単独文字(カ)にフォールバックする。逆にすると カ だけ先にマッチして濁点が取り残される。ガ のような濁音が正しく1文字の全角カナにまとまるのは、この先読みのおかげ。
ひらがなと漢字は対象外、という割り切り
このツールはひらがな・漢字を変換しない。理由は単純で、ひらがなと漢字には「半角」が存在しないから。半角ひらがなという文字は Unicode に定義されていないし、半角漢字もない。全角・半角の区別があるのは英数字・記号・カタカナだけなので、変換対象もその3種に限っている。
変換対象外の文字はそのまま素通しする実装なので、漢字とひらがなを含む普通の文章を流しても、英数字・カナ部分だけが変換されて他は保持される。「文章の一部だけ全半角を揃える」という実務でよくある使い方にそのまま乗る。
function toHankaku(text: string, opts: ConvertOptions): string {
let result = ''
for (let i = 0; i < text.length; i++) {
const c = text[i]
// ... 該当すれば変換、しなければ
result += c // 対象外はそのまま
}
return result
}
まとめ
全角・半角変換は「全部まとめてオフセット計算」では実装できない。文字種ごとに事情が違う。
- 英数字・記号は半角と全角がきれいに並んでいて、
0xFEE0の足し引きで変換できる - ただしスペースだけは並びから外れているので個別対応が要る
- カタカナはオフセットが効かないのでマッピングテーブルを持つ。逆引きテーブルは反転で生成
- 半角の濁音は
カ+゙の2文字。半角→全角では濁点を先読みして合成してから単独にフォールバック - ひらがな・漢字はそもそも半角が存在しないので対象外
0xFEE0 を知っていると「英数記号は一瞬」だが、半角カナの濁点合成を踏むと急に泥臭くなる。日本語テキスト処理の地味な手触りが詰まった題材だった。
ぱんだツールズ では他にも PDF・画像・CSV・テキスト処理など、開発者向けのツールを多数公開している。全部無料・登録不要・ブラウザ完結で使える。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。