文字が化けた — 推測せず判定する
一言でいうと
ファイルにはエンコーディングが書かれていません。そのため、エンコーディングは突き止めるものではなく、候補を順番に排除しながら判定するものであり、その判定の最後の質問は「このファイルは復元できるか」です。
なぜ必要なのか
顧客が送ってきた名簿を開いたところ、名前の列がすべて四角形か、わけのわからない記号になっていました。ここで、ほとんどの人が真っ先にすることは、エディターのエンコーディングメニューを1つずつ押してみることです。その方法で当たることもありますが、当たったかどうかを目だけで判断することになるのが問題です。ファイルが300個あれば、目は使えません。
もっと困るのは、開いてみると問題なさそうなのに間違っているファイルがあるという事実です。ハングルがくっきり見えるのに、照会できません。名前が同じに見えるのに、結合が0件です。目で見る判定が通用しない場所は、いつもこうしたところです。
テキストファイルはバイトの並びにすぎず、そのバイトをどの表で読むかは、ファイルの中に書かれていません。UnicodeコンソーシアムのUTF-8・BOM案内は、規格に従うプログラムが、不正な、あるいは規格から外れたバイト列を文字として解釈してはならないと明記しており、RFC 3629は、どのバイト列が正しいUTF-8であるかを規定しています。この厳密さが私たちにただで与えてくれるものが1つあります。どんなバイトでもUTF-8になるわけではありません。そのため、「UTF-8として読める」という事実そのものが、強い証拠になります。
どう動くのか
判定は4つのステップで進みます。上から確実な順です。
- BOMを見ます。ファイルが
EF BB BFで始まっていればUTF-8であり、その3バイトはデータではありません。Pythonでこの3バイトを取り除いて読むコーデックがutf-8-sigです(codecsドキュメント)。これを取り除かずに読むと、最初のカラム名がmember_idになり、ヘッダーの比較が静かに失敗します。 - 厳密なUTF-8デコードを試します。成功すれば、ほぼ確実にUTF-8です。ハングル1文字が3バイトで、後続バイトの上位ビットまで決まっているので、他のエンコーディングのハングルのバイトが偶然UTF-8として通る確率は非常に低いです。
- 失敗したら候補を立てます。韓国語の資料なら、CP949とEUC-KRです。CP949はEUC-KRの拡張なので、EUC-KRで読めるものはCP949でも読めます。そのため、2つを区別する代わりにCP949に統一して読み、必要なときだけ区別します。
- サンプルを照合します。候補で読み出した文字が意味をなしているかを、機械が判定するための基準を決めます。韓国語の名簿なら、ASCIIを除いた文字のうち、完成形のハングル音節(U+AC00からU+D7A3まで)の割合が基準になります。この基準がないと、「デコードがエラーなしに終わった」というだけで済ませてしまいますが、それでは足りません。
そのあとが、このラボの本当のテーマです。判定が終わると、ファイルは3種類に分かれます。
바이트
├─ 올바른 인코딩으로 읽힌다 → 정상. 읽어서 UTF-8 로 통일한다
├─ 한 번 잘못 읽혀 그대로 굳었다(mojibake) → 잘못 읽은 그 표로 되돌려 다시 읽으면 살아난다
└─ 잘못 읽으면서 글자가 뭉개졌다 → 못 되살린다. 원본을 다시 받아야 한다
真ん中が二重エンコーディングです。UTF-8のバイトをlatin-1で読むと、ハングル1文字がアルファベット3文字に見えます。このとき、バイトは1つも失われていません。latin-1は、0から255までのすべてのバイトに文字を1つずつ割り当てた表なので、元に戻す道がふさがれません。そのため、깨진문자열.encode("latin-1").decode("utf-8")(プレースホルダーは壊れた文字列です)の1行で、原文が戻ってきます。
右側が失われたファイルです。誤って読む側が寛容なときに、こうなります。読めないバイトをU+FFFD(置換文字)に置き換えて先へ進むと、異なるバイトが複数、同じ1文字にまとめて潰れます。そのファイルには、元のバイトを突き止める情報が残っていません。この区別は判定であって、努力の問題ではありません。U+FFFDが見えたら、そのファイルは取り直す必要があります。
現場での姿
1つ目に、見た目は同じなのに結合が0件になります。ハングルの「パク」姓の1文字は、完成した音節1つ(U+BC15)でも、子音字と母音字の3つの要素(U+1107 U+1161 U+11A8)でも書けます。画面には同じに見えますが、別の文字列です。macOSで作られたファイル名やデータが、特によくこうなります。UAX #15が、この2つを行き来する正規化を定義しており、合成する側がNFC、分解する側がNFDです。比較する前に、両方とも1つの形に正規化する必要があります。Pythonでは、unicodedata.normalizeです。
2つ目に、目に見えない文字がキーに混ざります。Web画面からコピーした値には、改行を防ぐために入れたNBSP(U+00A0)や、改行位置を決めるために入れたゼロ幅スペース(U+200B)が付いてきます。どちらもstrip()では消えません。NBSPはPythonでは空白として扱われて消えますが、Javaや一部のDB関数では残ります。ゼロ幅文字は、どこでも消えません。そのため、キーとして使う値は、コードポイント単位でたどり、リストにあるものだけを消します。
3つ目に、エンコーディングを直したあと、原本を上書きしてしまいます。復元したファイルを原本の場所に保存してしまうと、判定が間違っていたときに戻る場所がありません。受け取ったバイトはそのまま残し、直したバージョンを別のディレクトリに作ります。
4つ目に、ファイル1つを見て全体を断定します。1回の納品の中でも、ファイルごとにエンコーディングが違うことがよくあります。システムごとに出力の経路が違うからです。判定はファイル単位で行い、結果を表に残します。
実務で本当に大切なこと
- 推測を手順に変えます。エディターのメニューを押してみる代わりに、同じ順序でたどるコードを作ります。そうすれば、ファイルが300個でも同じ答えが出ます。
- 復元できるものと失ったものを分けて伝えます。「壊れていました」は報告ではありません。「3ファイルのうち2つは復元でき、1つは原本をもう一度いただく必要があります」が報告です。
- 比較の前に正規化します。正規化は比較のためのものであって、データを直すものではありません。原本は原本のまま置いておきます。
- 目に見えない文字は、リストで消します。何を消したかを数えて残しておけば、あとで件数が合わないときに説明できます。
次のラボですること
同じ名簿を7つの状態で作る再現ツールを自分で実行して受信箱を用意したあと、判定ツールenc_probe.pyを1ステップずつ育てます。BOMとUTF-8のデコード可否の検査から始めて、候補エンコーディングとサンプルの照合を加え、二重エンコーディングを復元し、U+FFFDが残ったファイルを失われたものとして分けます。そのあと、NFDで届いた名前がなぜ1件も合わないのかを数字で確認し、目に見えない文字をコードポイントごとに数えて消します。採点ツールは、毎回違う名前で自分のファイルを作り、作成したツールを実際に実行して判定を照合します。