背景
Excel で CSV を開くと日本語が化ける、というのを年に何度か踏みます。
直し方は知っているんですが、毎回メモ帳を開くのが面倒で…。
ブラウザだけで直すツールを作ったので、判定のところだけ書いておきます。
なぜ化けるのか
CSV はただのテキストです。
どの文字コードで書かれているかを、ファイル自身が持っていません。
Excel は日本語環境だと Shift_JIS として開こうとします。
一方、Web サービスやスクリプトが吐く CSV はたいてい UTF-8 です。
この組み合わせで化けます。
ここでいう BOM というのは Byte Order Mark の略で、ファイルの先頭に置く目印のことです。
UTF-8 の場合は EF BB BF の3バイトで、テキストとしては見えません。
Excel はこれを見つけると UTF-8 として読みます。
つまり中身を一切変えずに、先頭3バイトを足すだけで直ります。
判定を書く
問題は「もとのファイルが何で書かれているか」です。
BOM があるならそれに従えばいい。
無いときが困ります。
私が最初に考えたのは「UTF-8 として読めるか試す」でした。
TextDecoder に fatal: true を渡すと、読めないバイト列で例外が飛びます。
これで振り分けられる、と思ったんですが…うまくいきませんでした。
Shift_JIS のバイト列が、たまたま UTF-8 としても例外を出さずに読めてしまうことがあるからです。
そこで向きを変えました。
どれで読めるかではなく、どれが一番読めないかを数える。
fatal: false で読むと、読めなかった位置が U+FFFD(置換文字。いわゆる「�」)に置き換わります。
その個数を数えて、一番少ない候補を採ります。
下記が実際のコードです。bytes は FileReader で読んだ Uint8Array、BOM があれば先に切り落としてあります。
function guessEncoding(bytes) {
const cands = ['utf-8', 'shift_jis', 'euc-jp'];
let best = null;
for (const enc of cands) {
const text = new TextDecoder(enc, { fatal: false }).decode(bytes);
const bad = (text.match(/\uFFFD/g) || []).length; // 読めなかった位置の数
if (best === null || bad < best.bad) best = { enc, bad, text };
}
return best;
}
戻り値の bad がそのまま「この候補で何文字読めなかったか」になります。
enc が採用した文字コード、text がデコード済みの文字列です。
具体例
Shift_JIS で「あ,い」と書いたファイルは、バイト列としてはこうなります。
82 A0 2C 82 A2
これを3つの候補で読ませた実行結果が下記です。手元の Node.js で実際に流したものです。
入力: 82 A0 2C 82 A2
utf-8 bad=4 text="\ufffd\ufffd,\ufffd\ufffd"
shift_jis bad=0 text="あ,い"
euc-jp bad=2 text="\ufffd,\ufffd"
出力: shift_jis (bad が最小)
bad が 0 の shift_jis が選ばれます。
逆に UTF-8 で書いた同じ内容を入れると、utf-8 が bad 0 で勝ちます。
数えるだけなので、判定の理由がそのまま数字で出るのが気に入っています。
判定が付かない回をどう扱うか
この方法にも外れる場合があります。
短いファイルや、どの文字コードでも成立してしまう内容です。
そういうときに「たぶん UTF-8 でしょう」と黙って決めてしまうと、あとで直しようがなくなります。
なので、bad が 0 でなかった回は画面にその件数を出すようにしました。
「BOM が無いため候補から選びましたが、読めない位置が 4 件あります。文字コードを指定してください」と表示します。
自動判定は当たると便利ですが、外れたことを黙っているほうが害が大きいので…。
ツールはここです。
https://hashitosystem.com/tools/csv-encoding/
書き出しは TextEncoder で UTF-8 にして、先頭に EF BB BF を足すだけです。
const body = new TextEncoder().encode(text);
const out = new Uint8Array(body.length + 3);
out[0] = 0xEF; out[1] = 0xBB; out[2] = 0xBF;
out.set(body, 3);
なお、プログラムから CSV を読む用途では BOM が先頭カラムの値に混ざって壊れることがあります。
そのため BOM なしでも出せるようにしてあります。
結論
文字コードの自動判定は、「どれで読めるか」で書こうとすると分岐が増えて詰まります。
「どれが一番読めないか」を数える形にすると、比較が1つの整数に落ちて素直に書けます。
そのうえで、数え上げた結果が 0 でなかった回は自動判定を名乗らず、指定を促す。
当てにいくところと、当たらなかったと言うところを分けるのが、この手の判定では一番効きました。
本記事はAI補助で執筆した、個人開発の紹介記事です。