UTF-8の二重エンコード — ãÂ?のような文字化けの正体と直し方
おなじみのÃパターンより長く奇妙な、ãÂ?や’のような文字列を見たことはないでしょうか。アクセント付き文字やカーブしたクォーテーションマークが出るはずの場所にこれが現れるのは、UTF-8の二重エンコードという特定の現象です。正しいUTF-8のバイト列が一度1バイト系エンコーディングとして誤読されて文字になり、その誤った文字が再びUTF-8としてエンコードされることで、デコードすれば完璧に成功するのに、中身は依然として間違っているという状態が生まれます。
二段階の文字化けが起きる仕組み
UTF-8のマルチバイト文字は複数のバイトの並びで、それぞれのバイト単体としては有効な値です。ISO-8859-1やWindows-1252のような1バイト系エンコーディングは、あらゆるバイト値に何らかの文字を割り当てています。マルチバイトという概念自体を持たないため、UTF-8文字を構成する各バイトを、それぞれ別々のアクセント付きラテン文字や記号として何の問題もなくデコードしてしまいます。
プログラムがUTF-8のバイト列を読み込む際、1バイト系エンコーディングを指定された、あるいは既定値としてそう想定してしまうと、まさにこれが起こります。1つのUTF-8文字が、無関係な1バイト文字2〜3個に分解されるのです。これは通常の文字化けであり、単発のエンコード誤りとして別記事で扱う、おなじみのäやöのパターンです。
二重エンコードはそこにもう1段階が加わります。すでに間違った文字の並びになった文字列が、さらに別の工程でUTF-8として再エンコードされて保存・送信されます。間違った文字自体はそれぞれ有効なUnicode文字なので、この再エンコード自体はエラーなく成功し、より長い、有効なUTF-8バイト列が生成されます。それは「間違った文字」を忠実に表現したバイト列です。これを正しくUTF-8としてデコードすると、まさにその間違った文字列がそのまま得られます。そのデコーダーの視点からは何一つ不正なところがないからです。
// 単発エンコード(1回の誤り): café → café // 二重エンコード(2回の誤りだが、どちらも「成功」に見える): // 1. café (UTF-8バイト列: 63 61 66 C3 A9) // 2. Windows-1252として誤読 → "café"(5文字、間違っているが有効な文字列) // 3. その文字列を再びUTF-8としてエンコード → "café"用の新しいバイト列 // 4. UTF-8として(正しく!)デコード → "café" — 依然として間違いだが、 // 「有効なUTF-8か」しかチェックしないツールには問題なく見える
見分け方
見分ける手がかりは、単純な文字化けよりも化け方が長く、多層になっている点です。1文字のアクセント付き文字が入るべき場所に余分な文字が2〜3個重なることが多く、右シングルクォーテーションマーク1文字が’になるのはよくある例です。その句読点のUTF-8バイト列は3バイトで構成されており、その3バイトそれぞれが個別に1バイト系として誤読された上で、全体が再エンコードされたためです。
もう1つの確実な手がかりは、ファイルがUTF-8の妥当性検証を何のエラーもなく通ってしまう点です。二重エンコードされたファイルのバイト列はすべて正当なUTF-8であり、単に文字の中身が間違っているだけです。バイト列がUTF-8として整形式かどうかしか確認しないバリデーターはこれを検出できません。実際にテキストを読んで初めて間違いに気づくことになります。
元に戻す方法
直し方は、化けた過程を逆になぞることです。二重エンコードされた文字列を、まずUTF-8としてバイト列にエンコードし直します(これで、1バイト系として誤読された中間状態のテキストがバイト列として復元できます)。次に、そのバイト列を、最初の誤読の原因となった1バイト系エンコーディング(多くはWindows-1252かISO-8859-1)としてデコードすれば、元の正しいテキストが復元できます。
const doubled = "café"; const bytes = encodeWith(doubled, "UTF-8"); // 中間状態(誤った文字列)のバイト列 const fixed = decodeWith(bytes, "Windows-1252"); // "café" — 元のテキストを復元
なぜ繰り返し起きやすいのか
二重エンコードは、複数の境界がそれぞれ独立に間違った前提を既定値にしているパイプラインで起きやすい現象です。よくあるのは、正しくUTF-8を使っている2つのシステムの間に、1バイト系エンコーディングを既定値とするデータベースのカラムやAPIが挟まっているケースです。1つ目の境界がUTF-8の入力を誤って読み込み、その結果の間違ったテキストを保存します。2つ目の境界は、それを普通のテキストだと信じて、すでに間違っているそのテキストを正しくUTF-8としてエンコードして出力します。どちらの工程も、それ単体の設定通りに正しく動いているだけであり、それこそがこの文字化けが複数のシステムを無事にすり抜けて、誰かが実際のテキストを読むまで気づかれない理由です。
再発を防ぐには
根本的な対策は単発エンコードの場合と同じ原則です。データベースの接続設定、APIのcontent-type宣言、ファイルの入出力処理など、パイプライン上のすべての境界でUTF-8を明示的に指定し、既定値に任せないことです。特に見落としやすいのは、パイプラインの入口と出口という目に見える2箇所だけでなく、その中間に潜む設定です。1バイト系の既定値が黙って混じり込むのは、まさにそうした中間工程であることが多いため、そこを重点的に監査する価値があります。