JavaScriptで全角・半角を変換する方法|normalize(NFKC)の落とし穴と半角カナの濁点

JavaScriptで全角・半角を変換する開発メモのアイキャッチ。キーボードでコードを書くトット

JavaScriptで全角の英数字を半角にする(またはその逆)方法と、半角カナを全角カナにするときの注意点をまとめます。当サイトのテキスト整形ツールで使っている処理です。

全角・半角の変換は「normalize('NFKC')を呼べば終わり」と紹介されることがありますが、NFKCは英数字以外の文字まで書き換えます。また、半角カナの濁点には、NFKCだけでは片付かない場合があります。英数字は文字コードの差で、半角カナはNFKCに後始末を足して、全角カナから半角カナへは対応表で変換するのが、結果を予測しやすいやり方です。

トット
トット
normalizeひとつで済むんじゃないの?

使っているもの

ライブラリはありません。String.prototype.replace・String.fromCharCode・String.prototype.normalize・Intl.Collatorだけです。以下の実行結果は、Node.js 22で実際に動かして確かめたものです。

全角英数字は「0xFEE0」ずらすだけ

Unicodeでは、全角の英数字・記号(U+FF01〜U+FF5E)が、半角の英数字・記号(U+0021〜U+007E)と同じ順番で並んでいます。たとえば「A」はU+FF21、「A」はU+0041で、差は0xFEE0です。数字もアルファベットも記号も、この差は同じです。

function toHalfAlnum(s) {
  return s.replace(/[0-9A-Za-z]/g, function (c) {
    return String.fromCharCode(c.charCodeAt(0) - 0xFEE0);
  });
}
function toFullAlnum(s) {
  return s.replace(/[0-9A-Za-z]/g, function (c) {
    return String.fromCharCode(c.charCodeAt(0) + 0xFEE0);
  });
}
toHalfAlnum('ABC123abc-');  // → "ABC123abc-"
toFullAlnum('Tel 03-1234');         // → "Tel 03-1234"

正規表現の範囲を[0-9A-Za-z]にしているので、英数字だけが変わり、全角のハイフン「-」はそのまま残ります。範囲を[\uFF01-\uFF5E]に広げれば記号もすべて変わりますが、次の問題が出てきます。

記号をどこまで半角にするか

記号をすべて半角にすると、「電話:03-1234」の「:」まで「:」になります。日本語の文章では全角のままにしておきたい記号です。一方で、電話番号のハイフンやメールアドレスの「@」「.」は、半角でないとフォームで受け付けられないことがあります。

そこで、英数字にはさまれた記号だけを半角にする処理を分けています。ハイフンの代わりに打たれがちな「ー」(長音記号)や「−」(マイナス記号)も、数字の間にあればハイフンにそろえます。

// 英数字にはさまれた全角記号と、ハイフンに似た文字だけを半角にする
function midSymbolsToHalf(s) {
  return s.replace(/([0-9A-Za-z])([\uFF01-\uFF5E\u2010-\u2015\u2212\u30FC])(?=[0-9A-Za-z])/g,
    function (m, left, sym) {
      var k = sym.charCodeAt(0);
      var half = (k >= 0xFF01 && k <= 0xFF5E) ? String.fromCharCode(k - 0xFEE0) : '-';
      return left + half;
    });
}
midSymbolsToHalf(toHalfAlnum('電話:03ー1234-5678'));
// → "電話:03-1234-5678"(「:」は全角のまま)
midSymbolsToHalf(toHalfAlnum('totto@example.com'));
// → "totto@example.com"

先読み((?=...))で右側の文字を消費しないようにしているので、「A.B.C」のように記号が続けて出てきても、すべて変換されます。

normalize('NFKC')で済ませない理由

normalize('NFKC')は、Unicodeの「互換分解」を使って文字を標準的な形にそろえる機能です。全角英数字は半角になりますが、それ以外の文字も書き換えます。

'ABC123'.normalize('NFKC');   // → "ABC123"(ここまでは便利)
'①㈱㌔ガ ~'.normalize('NFKC');   // → "1(株)キロガ ~"

丸数字は「1」に、㈱は「(株)」に、㌔は「キロ」に、全角スペースは半角スペースに、全角チルダは「~」になります。全角・半角をそろえたいだけのつもりが、文章の意味まで変わってしまうので、文章全体にはかけず、使う範囲を絞っています。

トット
トット
NFKCは、そろえすぎちゃうんだね。

半角カナ→全角カナは、NFKCに後始末を足す

半角カナ(U+FF61〜U+FF9F)の部分だけを取り出してNFKCをかけると、全角カナになります。「ガ」のように2文字で書かれた濁音も、1文字の「ガ」にまとまります。ここまではNFKCが便利です。

function kanaToFull(s) {
  return s.replace(/[\uFF61-\uFF9F]+/g, function (m) { return m.normalize('NFKC'); });
}
kanaToFull('ガイド');   // → "ガイド"(3文字)
kanaToFull('ア゙');      // → "ア" + U+3099(結合用の濁点)
kanaToFull('゚');       // → U+309A(結合用の半濁点だけ)

困るのは、濁点を付けられない文字に濁点が付いている場合です。半角の濁点「゙」(U+FF9E)は、NFKCで結合用の濁点(U+3099)に分解されます。「ガ」なら「ガ」という1文字にまとまりますが、「ア゙」には対応する1文字がないので、「ア」のうしろに結合用の濁点が残ります。結合用の濁点は前の文字に重ねて表示される文字なので、環境によって表示が崩れたり、文字数の数え方がずれたりします。

そこで、残った結合用の濁点・半濁点を、単独で表示できる「゛」(U+309B)「゜」(U+309C)に置き換えます。

function kanaToFull(s) {
  return s.replace(/[\uFF61-\uFF9F]+/g, function (m) {
    return m.normalize('NFKC')
      .replace(/\u3099/g, '\u309B')    // 結合用の濁点 → 単独の「゛」
      .replace(/\u309A/g, '\u309C');   // 結合用の半濁点 → 単独の「゜」
  });
}
kanaToFull('ア゙ ガイド ゚');   // → "ア゛ ガイド ゜"

全角カナ→半角カナは、対応表を使う

反対向きの変換には、NFKCのような標準の機能がありません。半角カナのブロック(U+FF61〜U+FF9F)の63文字に対応する全角の文字を並べた表を作って変換します。濁音は、先にNFD(正準分解)で「ガ」→「カ」+結合用の濁点に分けておくと、表を引くだけで「ガ」になります。

// U+FF61〜U+FF9F の並びに対応する全角文字(63文字)
var FULL = '。「」、・ヲァィゥェォャュョッーアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゛゜';
var TO_HALF = {};
for (var i = 5; i < FULL.length; i++) {           // 句読点とかぎかっこ(先頭5文字)は変換しない
  TO_HALF[FULL.charAt(i)] = String.fromCharCode(0xFF61 + i);
}
TO_HALF['\u3099'] = '\uFF9E';                       // 分解した濁点 → ゙
TO_HALF['\u309A'] = '\uFF9F';                       // 分解した半濁点 → ゚

function kanaToHalf(s) {
  return s.replace(/[\u30A1-\u30FC]+/g, function (m) {
    var d = m.normalize('NFD'), out = '';
    for (var i = 0; i < d.length; i++) {
      var ch = d.charAt(i);
      out += TO_HALF[ch] || ch.normalize('NFC');   // 表にない文字は元に戻す
    }
    return out;
  });
}
kanaToHalf('ガイドブック・ヴァイオリン・パン・ヶ月');
// → "ガイドブック・ヴァイオリン・パン・ヶ月"

「ヮ」「ヰ」「ヱ」「ヵ」「ヶ」は、半角カナのブロックに対応する文字がないので、全角のまま残ります。「・」も、全角カナの範囲(U+30A1〜U+30FC)に含まれますが、表から外しているので変わりません。句読点やかぎかっこを半角にするかどうかは用途によるので、この処理ではカナと長音記号だけを変換しています。

並べ替えはIntl.Collatorで

行の並べ替えにArray.prototype.sort()をそのまま使うと、文字コードの順に並びます。数字は「10」が「2」より先に来て、カタカナはひらがなのうしろに回ります。

['10番', '2番', 'りんご', 'アイス', 'あめ'].sort();
// → ["10番", "2番", "あめ", "りんご", "アイス"]

var coll = new Intl.Collator('ja', { numeric: true });
['10番', '2番', 'りんご', 'アイス', 'あめ'].sort(coll.compare);
// → ["2番", "10番", "アイス", "あめ", "りんご"]

Intl.Collator('ja', { numeric: true })を使うと、数字は数の大きさの順に、ひらがなとカタカナは区別せずに五十音順に並びます。

はまりどころ

その1:改行コードをそろえてから処理する

貼り付けられる文章には、Windowsの改行(CR+LF)や、古いMacの改行(CR)が混ざっていることがあります。行ごとの処理(行頭・行末の空白の削除、重複行の削除など)をする前に、LFにそろえておきます。

var text = src.split('\r\n').join('\n').split('\r').join('\n');

CR+LFを先に置き換えないと、1つの改行が2つに数えられてしまいます。改行コードが2種類ある理由は、改行コードCRとLFの違いにまとめています。

その2:WordPressの本文にscriptを書くときの3つの制約

当サイトのツールは、WordPressの固定ページ本文に<script>を直接書いています。この書き方には、保存時の自動整形による制約があります。

  • script内に「&」を書かない:保存時に&#038;へ変換されて、&&が壊れます。論理積はif文を入れ子にして書いています。
  • script内に空行を入れない:空行が段落の区切りとみなされ、script内に<p>が差し込まれます。
  • JavaScriptの文字列にHTMLタグを書かない:同じく整形の対象になるので、要素はdocument.createElementで作ります。

上のコード例は読みやすさのために&&や<を使っていますが、ツールに組み込んだ版では、比較の向きをlength > iのように逆にし、論理積をif文に分けています。

トット
トット
変換の順番と、残った濁点の後始末がだいじなんだね。

まとめ

全角の英数字と半角の英数字は、Unicode上で0xFEE0ずつずれて並んでいるので、差を足し引きするだけで変換できます。記号は、すべて変えるか、英数字にはさまれたものだけ変えるかを分けておくと、日本語の「:」や「()」を崩さずに済みます。normalize('NFKC')は、丸数字や㈱まで書き換えるので半角カナの部分だけにかけ、残った結合用の濁点を単独の「゛」「゜」に置き換えます。全角カナから半角カナへはNFDで濁点を分けてから対応表で変換し、並べ替えにはIntl.Collatorを使います。

この処理で動いているのがテキスト整形ツールです。改行や空白の削除、重複行の削除もできます。

参考: Unicode Code Chart: Halfwidth and Fullwidth Forms(U+FF00〜U+FFEF)/Unicode Standard Annex #15: Unicode Normalization Forms/MDN: String.prototype.normalize()/MDN: Intl.Collator

あわせてどうぞ

タイトルとURLをコピーしました