チェック・トラブル対処Read this article in English

「ãÂ」「’」の文字化けを直す — UTF-8二重エンコードの見分け方と復元手順

アクセント付き文字のはずの場所にé、アポストロフィのはずの場所に’、日本語のはずの場所にãÂの連続が出ているなら、原因はUTF-8のバイト列を一度Windows-1252(またはLatin-1)として読み、その結果をもう一度UTF-8として保存した二重エンコードです。誤りが2回起きていて、そのどちらもエラーを出しません。だからファイルはUTF-8の妥当性検証を通り、中身は間違っています。

この記事では、実測した対応表、いま何層壊れているかの数え方、ファイルを直すコマンド、そのコマンドが日本語のどこでデータを切り捨てるかを示します。

2段階の誤りをバイト列で追う

ASCII以外のUTF-8文字は2〜4バイトの並びです。Windows-1252のような1バイト系エンコーディングはほぼ全バイト値に単独で文字を割り当てており、複数バイトで1文字という考え方を持ちません。そのため1つのUTF-8文字の各バイトを、それぞれ別の文字や記号に変えます。ここまでは普通の文字化けで、文字化けの原因と直し方の範囲です。

二重エンコードはここに2段階目が加わります。分解後の文字はどれも正当なUnicode文字なので、その文字列をUTF-8として書き出す処理は失敗しません。

次に何が見えるかは、読む側の想定エンコーディングで変わります。二重エンコードされたバイト列を正しくUTF-8として読むと1段階だけ壊れた形が見え、同じバイト列をWindows-1252として読むと二重の形が見えます。つまりéやãÂが見えている状態は、保存されたバイト列が二重エンコードで、さらに表示側もエンコーディングを読み違えている、という2つの誤りが同時に起きています。

元の文字   café
           バイト列: 63 61 66 C3 A9

段階1     このバイト列をWindows-1252として読む
           C3 -> Ã   A9 -> ©
           文字列は café になる(5文字、エラーは出ない)

段階2     その5文字をUTF-8として書き出す
           バイト列: 63 61 66 C3 83 C2 A9      <- いま手元にあるファイル

読み直すと、2通りの見え方になる:
  UTF-8として         -> café        1段階の誤りに見える
  Windows-1252として  -> café      検索したときの二重の形

化けた文字列から元の文字を引く対応表

下の表の各行は、この2段階を実際に実行して得た結果です。波括弧は、多くの表示環境で何も表示されないか空白に見える文字です。

表は深さを測る道具として使います。手元の文字列が中央の列にあれば1層、右の列にあれば2層で、復元処理を2回かけます。

元の文字             1層            2層
------------------------------------------------
é                    é             é
ü                    ü             ü
’ (アポストロフィ)   ’            ’
– (ダッシュ)         –            –
… (三点リーダー)     …            …
€ (ユーロ)           €            €
あ                   ã{81}‚         ãÂ{81}‚
日本                 日本         日本
ゲーム               ゲーãƒ{A0}   ゲーãƒÂ{A0}
保存                 ä¿{9D}å{AD}˜   ä¿Â{9D}Ã¥Â{AD}Ëœ

{81} = U+0081   {9D} = U+009D   {A0} = U+00A0 ノーブレークスペース   {AD} = U+00AD ソフトハイフン

日本語だけ復元ツールが失敗する理由

Windows-1252は5つのバイト値に文字を割り当てていません。0x81、0x8D、0x8F、0x90、0x9Dです。この5つの扱いで実装が分かれます。

WHATWGのEncoding Standardに従うデコーダー、つまりブラウザとNodeのTextDecoderは、この5バイトを制御文字U+0081、U+008D、U+008F、U+0090、U+009Dに写します。往復しても情報は失われません。一方macOS付属のiconvコマンドは、この5バイトを不正なバイト列として拒否します。実装によって挙動が異なるため、復元に使う前に手元のものを確かめてください。

影響の広さを数えました。UTF-8表現にこの5バイトのいずれかを含む文字は、U+3041からU+3096のひらがな86字のうち67字、U+4E00からU+9FFFの漢字20,992字のうち3,115字。対してU+30A1からU+30FAのカタカナ90字では5字だけ、U+00C0からU+00FFの64文字でも5字だけです。ラテン側の5字はÁ、Í、Ï、Ð、Ýで、すべて大文字です。ひらがなを含む文にはほぼ必ず該当文字があります。

ここから日本語特有の落とし穴が2つ出ます。1つ目は、1層の日本語文字化けに目に見えない文字が混ざることです。「ゲームを保存」を1層壊すとU+009D、U+00A0、U+00ADを含む18文字になり、表の波括弧がこれにあたります。ログからコピーして復元ツールに貼った時点で、復元に必要なバイトは失われています。実測では、この文字を落とした文字列に復元処理をかけると、復元可能だったテキストに失敗を返しました。画面の見た目ではなくファイルそのものを処理してください。

2つ目は、Encoding Standardのラベル表がiso-8859-1とlatin1をwindows-1252の別名として扱うことです。ISO-8859-1と宣言したページも、規格に準拠したブラウザでは実際にはWindows-1252としてデコードされます。出力の形から組み合わせを特定するなら文字化けパターン早見表が速いです。

ファイルを直す手順と、コマンドが途中で切れる条件

復元は壊れた過程を逆にたどります。間違った文字列を、それを生んだ1バイト系エンコーディングでバイト列に戻すと、出てくるのが元のUTF-8です。

1層の欧文ファイルでは説明どおり動きます。約物とアクセント付き文字が1層の形で入ったファイルで試すと、元のバイト列が1バイトも違わず復元され、終了ステータスは0でした。

日本語では動きません。「ゲームを保存」を1層壊した19バイトのファイルに同じコマンドをかけると、不正なバイト列というエラーで終了ステータス1になり、それでも出力ファイルを14バイト書きました。文字の途中で切れているため、その出力はUTF-8として妥当ですらありません。止まったのは「保」の3バイト目、0x9Dです。2層の欧文ファイルも同じです。1回目の復元後に右ダブルクォーテーションマークが残り、その文字の3バイト目も0x9Dだからです。

対策は2つあります。実行ごとに終了ステータスと出力サイズを必ず確認すること。もう1つは、Windows-1252が原因のときに変換先をISO-8859-1で代用しないことです。macOSで試すと黙って似た文字に置き換わりました。U+201Aはバッククォート、U+0192は英字のf、U+02DCはチルダ、右シングルクォーテーションマークは0xB4になり、終了ステータスはいずれも0です。

iconvが拒否する場合はEncoding Standardに従うデコーダーを使います。次のコードは対応表をデコーダー自身から作るので推測がなく、厳格なUTF-8デコードを安全弁にしています。実測では1層と2層の欧文と日本語を正確に復元し、素のASCII、正しい日本語、正しいcaféには手を付けませんでした。

CSVは一部の行だけ壊れている状態が普通なので、ファイル単位ではなくセル単位でかけます。返り値が変わり続ける間ループさせれば1回で1層ずつ剥がれます。

ループで1点だけ注意します。nullが返った時点で止め、1回目でnullが返ったセルは元の値のまま残すことです。壊れていなかったセルに復元処理をかけるのが、この作業で唯一、後から取り返せない形でデータを失う経路です。

# ファイル全体、1層、欧文なら成功する
iconv -f UTF-8 -t WINDOWS-1252 broken.csv > fixed.csv
echo $?                      # 0 以外なら出力は途中で切れている

# 同じコマンドを日本語にかけると 19バイト中14バイトで終了ステータス1
# 代用は禁止。黙って別の文字に置き換わり、終了ステータスは0になる:
#   iconv -f UTF-8 -t ISO-8859-1 broken.csv > fixed.csv

# ---- セル単位で戻す Node / ブラウザ版 ----

const WIN1252 = new TextDecoder("windows-1252");
const toByte = new Map();
for (let b = 0; b < 256; b++) toByte.set(WIN1252.decode(Uint8Array.of(b)), b);

// 動作確認。規格準拠のデコーダーは 0x83 を U+0192 に写す(U+0083 ではない)。
// false ならその実行環境の windows-1252 は標準のものではない。
WIN1252.decode(Uint8Array.of(0x83)).codePointAt(0) === 0x192;

// 1層だけ戻した文字列を返す。Windows-1252経由の
// 二重エンコードでなければ null を返す。
function undoOneLayer(text) {
  const bytes = [];
  for (const ch of text) {
    const b = toByte.get(ch);
    if (b === undefined) return null;   // Windows-1252由来ではない
    bytes.push(b);
  }
  try {
    return new TextDecoder("utf-8", { fatal: true }).decode(Uint8Array.from(bytes));
  } catch {
    return null;                        // 二重ではない。触らない
  }
}

// 1層の例。{9D} と {AD} は実際には1文字(U+009D と U+00AD)
undoOneLayer("ä¿{9D}å{AD}˜")   // "保存"
undoOneLayer("保存")            // null(すでに正しい)

2段階目を起こした境界を特定する

データだけ直して境界を直さなければ、来週また同じ作業をします。原因の大半は4か所で、それぞれ違う痕跡を残します。

1つ目はデータベースの接続文字コードとカラムの文字コードの食い違いで、対処が正反対になる2つの形があります。1つは、サーバーが受け取ったUTF-8のバイト列を1バイト系の文字として扱い、書き込み時に再エンコードする形です。これは段階2そのもので、カラムの中身は本当に二重になっています。1バイト系の接続で読み直すと正しく見えますが、それは読みが書きを打ち消しているだけで、保存データは修復が必要です。もう1つは、カラムには元のUTF-8が入っていて、読み出し側の宣言だけが誤っている形です。こちらは設定を直せば終わりで、修復するものはありません。見分けるには、カラムの生バイト列を16進で取り出し、期待するテキストのUTF-8と比べます。設定名はエンジンと版で違うので、公式マニュアルの文字セットと照合順序の章で確認してください。

2つ目は表計算ソフトの往復です。UTF-8のCSVを1バイト系と推測するツールで開き、UTF-8で保存し直すと2段階が一度に起きます。

3つ目はHTTPのContent-Typeヘッダーに文字コードが無い場合です。受け取った側はロケール依存の既定値に落ちます。欧米のロケールではWindows-1252で、ISO-8859-1と宣言したヘッダーも同じくWindows-1252になります。痕跡は保存データが正しく表示だけが誤っていることで、ヘッダーを直せば修復作業自体が不要です。

4つ目はエディタの別エンコーディングで保存する機能で、痕跡は明快です。特定の人が触ったファイルだけ、ある特定のコミット以降だけが壊れます。4つに共通する手がかりは一様性です。同じファイル内で壊れた行と正しい行が混在するなら、行やセル単位の経路が原因なので、個々の値を書き出すコードを見てください。なお日本語で生き残っているのがひらがなではなくカタカナなら、二重エンコードではなくコードページ変換を疑い、Shift_JISとCP932の落とし穴とWindowsのロケールで日本語が化ける問題を先に確認してください。

2段階目を起こさない仕組みにする

本当の対策は、すべての境界でUTF-8を明示することです。接続文字列、レスポンスヘッダー、ファイルを開く処理、書き出し処理という中間を監査してください。アクセント付きラテン文字1字、ひらがなの「あ」、絵文字1字を含む文字列を1本通す往復テストで、ここまでの原因は検出できます。順に2・3・4バイトで、「あ」は未割り当てバイト0x81を含みます。

内容のチェックは、バイト列がUTF-8として妥当かの確認だけでは足りません。代わりに目印の並びを探します。U+00C3の直後にU+0192が来る2文字の並び、U+00E2とU+20ACが続く並びは、ビルドを落とす条件にできる程度に信頼できます。修復は1回で終わらせて結果を保存し、読み出し時に修復する作りにはしないでください。

最後に、修復前のファイルは、影響のあった全言語で修復後のテキストを人が読み終えるまで残してください。本来あるべきテキストかを確認できるのは人だけです。

関連記事