文字化けの仕組みと直し方:縺ゅ・譁�の正体をPythonで再現
縺ゅj縺後→や�はなぜ出るのか。UTF-8とShift_JIS(CP932)を取り違えて文字化けを再現し、パターンの読み方、波ダッシュと機種依存文字の問題、errors引数の違い、戻せる化けと戻せない化けを1,000件で実測します。
シリーズ:文字コード講座 2/6(初級)。 アプリや DB、メールで「縺ゅj縺後→」や「�」を見て、何が起きたのか、元に戻せるのかを知りたい人向けです。読み終えると、文字化けをわざと再現し、よくあるパターンを見ただけで原因を言い当て、二重エンコードを安全に直し、二度と戻らないケースを見分けられるようになります。
古い基幹システムが吐く Shift_JIS の CSV、取引先から届いた ISO-2022-JP のメール、UnicodeDecodeError: 'utf-8' codec can't decode byte 0x95 で止まったバッチ処理。こうした「文字化け」の問い合わせを受ける人がこの講座の読者です。文字化けとは、正しいバイト列を間違った文字コードでデコードした結果です。そしてここが重要ですが、日本語の文字化けは英語やスペイン語の文字化けに比べて「化けた文字列から」は戻しにくく、元のバイト列を残しておくことが何より大事です。以下はすべて Apple Silicon Mac(M1 Pro)上の Python 3.9.6、PHP 8.5.6、macOS の iconv で実行した結果で、トレースバックのパスは ~/demo/ に置き換えています。
バイト・コードポイント・UTF-8 が初めての人は、先に第1回を読んでください。この回は e3 81 82 を3バイトとして読めることを前提にしています。
文字化けの仕組み:バイトは正しく、デコーダが違う
向きは2つしかなく、それぞれ見た目が違います。
UTF-8 のバイト列をレガシー文字コード(CP932 など)で読む。 1文字3バイトが2バイトずつに切られて、見慣れない漢字が並びます。「縺」「繧」「繝」「譁」が多いのが特徴です。
レガシーのバイト列を UTF-8 で読む。 Shift_JIS のバイト列はほとんどが UTF-8 として不正なので、厳密なデコーダは UnicodeDecodeError で止まり、寛容なデコーダは �(U+FFFD、REPLACEMENT CHARACTER)を表示します。
2行で両方を作れます。
print("ありがとう".encode("utf-8").decode("cp932", "replace")) # 縺ゅj縺後→縺�
print("テスト".encode("cp932").decode("utf-8", "replace")) # �e�X�g
なぜ「縺」なのか。ひらがなの UTF-8 は e3 81 xx か e3 82 xx で始まります。e3 81 を CP932 の2バイト文字として読むと「縺」、e3 82 は「繧」、カタカナの e3 83 は「繝」です。だから「縺」が並んでいたら元はひらがな、「繝」「繧」が多ければカタカナです。逆向きの �e�X�g では、Shift_JIS の2バイト目が ASCII 範囲(65 = e、58 = X、67 = g)にあるため、英字だけが生き残っています。
同じ仕組みは言語を問わず起きます。このサイトの読者がよく出会う組み合わせを実行した結果です。
| 元の文字列 | 保存 | 読み込み | 見える文字列 |
|---|---|---|---|
ありがとう |
UTF-8 | cp932 | 縺ゅj縺後→縺�(errors="replace") |
文字化け |
UTF-8 | cp932 | 譁�蟄怜喧縺� |
テスト |
UTF-8 | cp932 | 繝�繧ケ繝� |
文字化け |
cp932 | UTF-8 | �������� |
文字化け |
cp932 | cp1252 | •¶Žš‰»‚¯ |
文字化け |
ISO-2022-JP | UTF-8 | $BJ8;z2=$1(B(エスケープ文字は見えない) |
カナ |
cp932 | cp1252 | ¶Å |
don’t |
UTF-8 | cp1252 | don’t |
最後の ISO-2022-JP は昔のメールで標準だった文字コードです。ESC $ B で漢字モードに入り ESC ( B で ASCII に戻る方式なので、UTF-8 で読むと $B と (B に挟まれた英数字の並びになります。メールのソースでこれを見たら ISO-2022-JP です。
最初の5分:本物のエラーメッセージを読む
古い Windows のプログラムと同じように Shift_JIS でファイルを書き、それを何も指定せずに開きます。
# make_old.py
open("old.txt", "wb").write("文字化け\n".encode("cp932"))
# readfile.py python3 readfile.py old.txt
import sys
with open(sys.argv[1]) as f: # encoding= なし → macOS・Linux では UTF-8
print(f.read())
Traceback (most recent call last):
File "~/demo/readfile.py", line 3, in <module>
print(f.read())
File ".../lib/python3.9/codecs.py", line 322, in decode
(result, consumed) = self._buffer_decode(data, self.errors, final)
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x95 in position 0: invalid start byte
最終行を探偵のように読みます。位置0の 0x95 は「文」の Shift_JIS の1バイト目(95 b6)です。invalid start byte は、UTF-8 で文字の先頭が来るべき場所に 0x80〜0xBF のバイトが来たという意味。0x81〜0x9F の後に 0x40 以上が続く並びは Shift_JIS の典型です。直し方は、ファイルの正体を宣言するだけです。open("old.txt", encoding="cp932") で 文字化け と表示されました。
iconv で確かめる場合も同じです。iconv -f SHIFT_JIS -t UTF-8 old.txt は 文字化け を出し、逆に UTF-8 のファイルを -f SHIFT_JIS で読ませると、縺ゅj縺後→縺 まで出力したところで iconv: iconv(): Illegal byte sequence を出し、終了コード1で止まりました。
Shift_JIS と CP932 は同じではない:波ダッシュと機種依存文字
日本語特有の落とし穴が、「Shift_JIS」と呼ばれるものが実は複数あることです。JIS 規格どおりの Shift_JIS と、Windows が使う CP932(Windows-31J、MS932 とも)は、大部分は同じですが一部の対応が違います。
def t(s, enc):
try: return s.encode(enc).hex(" ")
except UnicodeEncodeError as e: return "ERR " + str(e)
for s in ["〜", "~", "①", "㈱", "髙", "カナ"]:
print(repr(s), f"U+{ord(s[0]):04X}", "shift_jis:", t(s, "shift_jis"), "| cp932:", t(s, "cp932"))
'〜' U+301C shift_jis: 81 60 | cp932: 81 60
'~' U+FF5E shift_jis: ERR 'shift_jis' codec can't encode character '~' in position 0: illegal multibyte sequence | cp932: 81 60
'①' U+2460 shift_jis: ERR 'shift_jis' codec can't encode character '①' in position 0: illegal multibyte sequence | cp932: 87 40
'㈱' U+3231 shift_jis: ERR 'shift_jis' codec can't encode character '㈱' in position 0: illegal multibyte sequence | cp932: 87 8a
'髙' U+9AD9 shift_jis: ERR 'shift_jis' codec can't encode character '髙' in position 0: illegal multibyte sequence | cp932: ee e0
'カナ' U+FF76 shift_jis: b6 c5 | cp932: b6 c5
- 機種依存文字:
①や㈱、はしご高の髙は CP932 の拡張領域(NEC 特殊文字・IBM 拡張文字)にしかありません。shift_jisで書こうとするとエラーになります。逆に CP932 で書いたバイト列をshift_jisで読むと、①㈱髙は'�@�基�'になりました。髙のバイト列の一部が別の漢字「基」として読まれている点に注意してください。名前の漢字が別の字に化けるのは、�よりも気づきにくい事故です。 - 波ダッシュ問題:バイト
81 60は、JIS の Shift_JIS では WAVE DASH(U+301C、〜)、CP932 では FULLWIDTH TILDE(U+FF5E、~)に対応します。Python のcp932は〜を81 60にエンコードしますが、デコードすると~が返ってきます。"10時〜12時"を cp932 で往復させると10時~12時になり、元の文字列と==で比較するとFalseでした。
ツールによって振る舞いが違うのも厄介です。macOS の iconv で 10時〜12時 を変換すると、-t SHIFT_JIS は 81 60 を出しましたが、-t CP932 と -t SJIS-WIN は 〜 のところで Illegal byte sequence になりました。PHP 8.5 の mb_convert_encoding は SJIS-win でも CP932 でも 〜 を 81 60 にし、① は SJIS 指定だと 3f(?)に置き換えました。Mac で入力した「〜」(U+301C)と Windows で入力した「~」(U+FF5E)が同じデータに混ざるのはよくあることなので、CSV を Shift_JIS 系で書き出す前に、どちらに揃えるかを決めておく必要があります。
パターンの見分け方
慣れると、化けた文字列が原因を教えてくれます。上の実行結果からまとめた表です。
| 見えるもの | よくある原因 | 対処 |
|---|---|---|
縺、繧、繝、譁 が並ぶ |
UTF-8 を cp932 で読んだ | 元のバイト列を UTF-8 で読み直す |
�e�X�g のように英字だけ残る |
Shift_JIS を UTF-8 で読んだ | 元のバイト列を cp932 で読み直す |
•¶Žš‰»‚¯ |
Shift_JIS を cp1252(欧米向け Windows)で読んだ | s.encode("cp1252").decode("cp932") |
æ–‡å—化ã�‘ |
UTF-8 を cp1252 で読んだ | s.encode("cp1252").decode("utf-8")(�がなければ) |
$B...(B |
ISO-2022-JP(メール)を UTF-8 で読んだ | iso2022_jp で読み直す |
¶Å のような1文字ずつの記号 |
半角カナ(1バイト)を cp1252 で読んだ | s.encode("cp1252").decode("cp932") |
二重エンコードを直す
UTF-8 を cp1252 として読み、それをまた UTF-8 で保存した「二重エンコード」は、バイトが失われていないので逆順にたどれば戻ります。英語圏のデータでよく見る形です。
print("don’t".encode("cp1252").decode("utf-8")) # don’t
print("テスト".encode("cp1252").decode("utf-8")) # テスト
ところが cp932 で化けた日本語は、化けた文字列からはほとんど戻せません。UTF-8 の3バイト単位と CP932 の2バイト単位がずれるため、どこかで CP932 として不正な並びに当たり、そこに � が入るからです。
r = "ありがとう".encode("utf-8").decode("cp932", "replace")
print(repr(r)) # '縺ゅj縺後→縺�'
print(repr(r.encode("cp932", "replace").decode("utf-8", "replace"))) # 'ありがと�?'
「ありがと」までは戻りましたが、最後の「う」は失われました。だから、文字化けの報告を受けたら、まず化ける前のファイルやバイト列が残っていないかを探します。
PHP でもこのバグは1行で作れます。すでに UTF-8 の文字列に対して mb_convert_encoding($s, "UTF-8", "SJIS-win") を呼ぶと二重変換になります。正しくは、外から来たバイト列にだけ1回変換をかけることです。mb_check_encoding($bytes, "UTF-8") で Shift_JIS のファイルを調べると false が返りました。
strict・replace・ignore・surrogateescape
Python のデコードには errors 引数があります。文字化け を cp932 で保存したバイト列(95 b6 8e 9a 89 bb 82 af)に全部試しました。
| errors= | 結果 | 元に戻せるか |
|---|---|---|
strict(既定) |
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x95 in position 0: invalid start byte |
何もデコードされない |
replace |
'��������' |
戻せない |
ignore |
''(空文字列!) |
戻せない、しかも無言 |
backslashreplace |
'\\x95\\xb6\\x8e\\x9a\\x89\\xbb\\x82\\xaf' |
手作業なら |
surrogateescape |
'\udc95\udcb6...' |
戻せる:.encode("utf-8", "surrogateescape") で元のバイト列に一致 |
ignore では4文字がまるごと消えて空文字列になりました。日本語のバイトはほぼすべて 0x80 以上なので、ignore は日本語を全部捨てるのと同じです。
エンコード側も同じです。"10時〜12時 ①👍" を shift_jis で errors="replace" にすると b'10\x8e\x9e\x81`12\x8e\x9e ??' になり、① と絵文字が本物の ? と区別できない 3f になりました。
よくある間違い:replace や ignore でエラーを「直す」
間違い。 夜間バッチが UnicodeDecodeError で落ちたので、errors="ignore"(または iconv -c)を付けて緑にする。
実際に起きること。 エラーは消えますが、データは黙って消えます。上で見たとおり、Shift_JIS の日本語を UTF-8 として ignore で読むと文字列ごと空になります。iconv -c も変換できない文字を捨て、iconv: warning: invalid characters: 1 という警告を出すだけで続行します。
PHP には別の静かな罠があります。不正な UTF-8(ここでは Shift_JIS のファイル)を htmlspecialchars($s, ENT_QUOTES) に渡すと、PHP 8.5 で空文字列が返りました。PHP 8.1 以降の既定フラグには ENT_SUBSTITUTE が含まれていて、既定のままなら1バイトごとに � になります。PHP 8.1 より前に書かれたコードは ENT_QUOTES だけを渡していることが多く、不正なバイトが1つあるだけで項目全体が消えます。
直し方。 バイト列の正体を調べ(xxd、上のパターン表、判定は第6回)、その文字コードでデコードします。正体の分からないバイトを通すしかないなら、往復できる surrogateescape を使います。replace は表示用に限り、保存するデータには使いません。なお、日本で定番の nkf はこの Mac には入っていないため、ここでは試していません。
戻せない文字化け:実測
どの文字化けが元に戻せるのかを測りました。言語ごとにサンプル文の文字からランダムに20文字を選んだ文字列を1,000個(random.Random(42))作り、UTF-8 にしてから間違った文字コードでデコードし、修復を試みます。修復関数は「間違えた文字コードで再エンコードして UTF-8 で読む」だけのものです。
rng = random.Random(42)
for _ in range(1000):
s = "".join(rng.choice(POOL) for _ in range(20))
raw = s.encode("utf-8")
try:
wrong = raw.decode(codec) # そもそも間違ったデコードが成功するか
except UnicodeDecodeError:
wrong = raw.decode(codec, handler) # 寛容なツールが表示したであろう文字列
fixed += unmojibake(wrong, codec) == s
ja read as cp932 (replace): decoded without error 49/1000, repaired 49/1000
ko read as cp949 (replace): decoded without error 0/1000, repaired 0/1000
es read as cp1252 (replace): decoded without error 584/1000, repaired 584/1000
es read as cp1252 (c1 ): decoded without error 584/1000, repaired 1000/1000
es read as latin-1 (strict ): decoded without error 1000/1000, repaired 1000/1000
en read as cp1252 (replace): decoded without error 688/1000, repaired 688/1000
en read as cp1252 (c1 ): decoded without error 688/1000, repaired 1000/1000
日本語は1,000件中49件しか � なしでデコードできず、修復できたのもその49件だけでした。欧米の言語では、すべてのバイトを残すデコード(Latin-1 や、ブラウザと同じく未定義バイトを残す c1)なら1,000件すべてが戻ります。つまり「化けた文字列から直す」作戦は欧米語では通用しても、日本語ではほぼ通用しません。
戻せないケースをまとめると次のとおりです。
- 保存された
�。その裏のバイトはもうありません。 - エンコーダが
errors="replace"で書いた?(①を Shift_JIS に、絵文字を Shift_JIS に)。 ignoreやiconv -cで捨てられた文字。- 波ダッシュのように、往復で別のコードポイントに変わった文字(見た目はほぼ同じでも
==はFalse)。
正直な限界とバージョンの注記
- Python 3.9.6、PHP 8.5.6(mbstring)、macOS 26 の
iconv(--versionがなく GNU libiconv とは別物で、メッセージも Linux と違います)。Linux と Windows では実行していません。 - Windows 上の Python は 3.15 より前だと、
encoding=を省略したとき日本語版 Windows では cp932 でファイルを開きます。PEP 686 により 3.15 から UTF-8 が既定になります。これは PEP の記述によるもので、ここでは実行していません。 nkfとftfyはインストールされておらず(何もインストールしない方針)、比較していません。- 1,000件の実験は短いサンプル文の文字から選んでいるので、実データの分布とは違います。自分のデータで測ってから「直せます」と約束してください。
練習問題
- 顧客データに
繝�繧ケ繝�という文字列が見つかりました。元はひらがなとカタカナのどちらだった可能性が高いですか。 - ファイルの先頭が
95 b6 8e 9aです。どの文字コードで、何と書いてありますか。 "10時〜12時"を cp932 で保存して読み直した文字列が、元の文字列と==にならないのはなぜですか。
解答
- カタカナです。カタカナの UTF-8 は
e3 82 xx・e3 83 xxで始まり、cp932 で読むと「繧」「繝」になります。ひらがななら「縺」が多くなります。 - Shift_JIS(CP932)の「文字」です。
95 b6が「文」、8e 9aが「字」です。UTF-8 として読むと位置0の0x95でinvalid start byteになります。 - Python の cp932 は
〜(U+301C)を81 60にエンコードし、81 60を~(U+FF5E)にデコードするためです。見た目はほぼ同じでもコードポイントが違います。
次回
両側ともバイト列は正しい UTF-8 なのに、文字列が一致しないことがあります。それが Unicode 正規化です。
よくある質問
「縺ゅj縺後→縺」のような文字化けは何が原因ですか?
UTF-8 で保存された日本語を Shift_JIS(CP932)として読んだときの典型的なパターンです。ひらがなの UTF-8 は E3 81 xx で始まり、E3 81 を CP932 で読むと「縺」になります。元のバイト列が残っていれば UTF-8 として読み直せば戻ります。
化けた文字列をそのまま元に戻せますか?
日本語の場合はほとんど戻せません。ランダムな20文字の文字列1,000件を UTF-8→cp932 で化けさせたところ、�なしでデコードできたのは49件だけで、修復できたのもその49件でした。�が入った時点で元のバイトは失われます。元ファイルから読み直すのが正解です。
「〜」が「~」に変わったり、変換エラーになったりするのはなぜ?
WAVE DASH(U+301C)と FULLWIDTH TILDE(U+FF5E)の対応が Shift_JIS と CP932 で違うためです。Python の cp932 は U+301C を 81 60 にエンコードしますが、81 60 をデコードすると U+FF5E になり、往復で別の文字に変わりました。macOS の iconv の CP932 は U+301C を変換エラーにしました。