charsetの指定が適当なページがMobileSafariで文字化けするんで、XHR+FileReaderでエンコーディングを指定して読み込むブックマークレットを書いたんですが、自分で指定するのも面倒なので自動判定したいなと。当然既存のライブラリとかもあるんですが、どうせブックマークレットにするならシンプルなアルゴリズムを埋め込んでしまえないかなと。
そもそも日本語なのかもわからないものを厳密に判定しようとすると、結局各エンコーディングでデコード(のシミュレーション)をしてみるという方法をとるようですが、今回は日本語、それも多分UTF-8でないということまでわかっています。またISO-2022-JPはESC文字(0x1B)が特徴的ですが、この文字はSJIS・EUCJP・UTF-8のいずれにおいても使いませんから、これが現れたらISO-2022-JPとほぼ断言できます。
となると、あとはSJISとEUCJPの見分け方がわかれば解決しそうです。
SJISとEUCJPを見比べてみる
ざっくり塗り分けるとこんな感じ*1。
0x81-0x9F
EUCJPしか考えられない領域はわずかですが、SJISしか考えられない領域は結構ありますね。特に1バイト目の0x81-0x9Fは非漢字(ひらがな・カタカナ・句読点・記号・罫線など)と第一水準漢字が完全に収まっているので、普通の文章ならここが使われるのはまず間違いないでしょう。
ただし0x8Eと0x8Fは別です。EUCJPでは0x8Eから始まる2バイトで半角カタカナを、0x8Fから始める3バイトで補助漢字(第三水準など)を表現します。とはいえ、2バイト目が0x40-0xA0の範囲であればEUCJPではないと言い切ることができます。
0xA1-0xDF
SJISではこの領域の1バイトで半角カナを表します。一方EUCJPでも非漢字と漢字の多くを含む、よく使う領域です。仮にEUCJPだとすると2バイト目もやはりSJISの半角カナ領域なので、EUCJP一文字なのかSJIS半角カナ2文字なのかを判別できません。
とはいえ、半角カナ(と第二水準漢字以降)だけで書かれた文章はまずありません。多分私は読みたくないです。ですからこの領域はSJISの判断条件には使わないことにします。むしろここは1バイト目2バイト目共にEUCJPでよく使われるはずですから、「EUCJPっぽさ」としてカウントしてもよいでしょう。
0xE0-0xFC
SJIS・EUCJP共に第二水準の途中からこの領域が使われます…が、適当なサイトで文字コード表を見るとわかりますが、どちらにしても日常的に使う漢字は少ないです。一応0xEB以降はSJISでも拡張や外字でしか使われていないのでほぼEUCJPと確定できますが、前述のとおりここをわざわざチェックする甲斐は薄いです。
実装してみる
SJISの2バイト目はASCII領域に入ってくるので、文字の区切りを間違えると領域の判定すら面倒です。一方EUCJPは全てのバイトが0x80-0xFFに収まっているので、実際にコードポイントを取得しないなら文字区切りは多少無視してもよいでしょう。
ということで、SJISに沿ってポインタを進めながら、主に1バイト目を見て判断していくことにします。0x00-0x7Fと0xA1-0xDFは1バイト、それ以外は2バイトですから簡単です。適当な文字数読み込んでもSJISらしいバイトが現れなければEUCJPとします。
なにで書いてもいいんですが、LINQPadで試行錯誤していたのでC#です。
Encoding DetectEncoding(byte[] bytes) { var JIS = Encoding.GetEncoding("iso-2022-jp"); var SJIS = Encoding.GetEncoding(932); var EUCJP = Encoding.GetEncoding("euc-jp"); var nonAsciiCount = 0; // 2バイト目を読む余裕をもって終わる for (int i = 0; i < bytes.Length - 1; ++i) { var b1 = bytes[i]; if (b1 == 0x1B) return JIS; if (b1 < 0x81) continue; if (b1 < 0xA0) { if ((b1 == 0x8E || b1 == 0x8f) && 0xA1 <= bytes[i + 1]) ++i; else return SJIS; } else if (b1 >= 0xE0) ++i; if (50 <= ++nonAsciiCount) break; } return EUCJP; }
ついでにUTF-8も
0xA1-0xDFをEUCJPっぽさとしてカウントするという話をしましたが、じゃあSJISもSJISっぽさとしてカウントしたら、どちらでもないものとしてUTF-8も判定できないでしょうか。
Encoding DetectEncoding(byte[] bytes) { var JIS = Encoding.GetEncoding("iso-2022-jp"); var SJIS = Encoding.GetEncoding(932); var EUCJP = Encoding.GetEncoding("euc-jp"); var eucjpScore = 0; var sjisScore = 0; var nonAsciiCount = 0; for (int i = 0; i < bytes.Length-1; ++i) { var b1 = bytes[i]; if (b1 == 0x1B) return JIS; if (b1 < 0x81) continue; nonAsciiCount++; if (b1 < 0xA0) { // SJISの非漢字~第二水準漢字の一部。 // 0x8EはEUC-JPの半角カナ、0x8Fは補助漢字の可能性もあるので、その場合は保留。 // 補助漢字だと3バイトなのでずれるが、EUC-JPは1バイト目も2バイト目も範囲が // ほとんど変わらないので割とどうでもいい。たとえASCII文字を巻き込んだとしても。 // ...といっても、出現頻度は少ないだろうから、気にせずSJISに加算してもいい気はする。 // (スコア制にせず即returnするなら駄目だけども) if (!(b1 == 0x8E || b1 == 0x8f) || bytes[i + 1] < 0xA1) ++sjisScore; ++i; } else if (b1 < 0xE0) { // SJISの半角カナかEUC-JPの非漢字~第二水準漢字の一部。見分けがつかない。 // とはいえまともな文章(?)なら非漢字~第一水準漢字(<0xA0)がそのうち出るはずだし、 // 逆にEUC-JPならここは頻出するはずなので、この範囲が多ければEUC-JPと判断する。 ++eucjpScore; } else ++i; //2バイト目次第ではSJISと確定できる可能性もあるが、どうせ他でわかるので無視。 // 0xEB以降はSJISの拡張や外字でしか使われてないのでEUC-JPと判断してもいいかもしれないが、 // EUC-JPで割り当てられてる文字もあまり使わない文字ばかりなので。 if (50 <= nonAsciiCount) break; } Console.Write($"sjis:{sjisScore} euc:{eucjpScore} nonasc:{nonAsciiCount} "); // sjisScoreの方がUTF-8食わせたときも割合が上がりやすい。ちょうどいい場所なのかも。 // (例えば"けもの"は E3 81 "91 E3" "82 82" E3 81 AE で4文字中2文字見つかる) if (sjisScore > nonAsciiCount * 0.7) return SJIS; if (eucjpScore > nonAsciiCount * 0.7) return EUCJP; else return Encoding.UTF8; }
細かく検証したわけではありませんが、ぼちぼちいけそうです。
メモ: 元の用途としては問題ないはずだが、BOMのことを忘れていた
追記:やっぱり既出でした
このライブラリの GetEscapeCodeType が似たようなアプローチですね。私のより細かく見ていたり、UTF8もちゃんと調べてたりします。
ちなみに私のJS版というか、冒頭で触れたBookmarkletがこちら。XHRからArrayBufferで取得してるので、そのあたりは簡単になってます。
現在のドキュメントを文字コード自動判定で読み直す - Hatena::Let
参考サイト
塗り分け表の作成にあたっては以下のサイトが参考になりました。
またWebAPIとして TextEncoder/TextDecoder を実装する Encoding Standard というものもあります。Safariがサポートしていないので今回は使いませんでしたが、著名なエンコーディングのエンコーダ・デコーダの具体的な実装が載っていて参考になります。
*1:Shift_JISに第三・第四水準漢字や丸数字などを足したShift_JIS-2004では0x85~0x86にも割り当てがあります。Encoding Standardには記載がなく、おそらくFileReaderでも使えませんが…。