文字コードの自動判定は推測にすぎない:精度と変換速度を実測
mb_detect_encoding と標準ライブラリだけの判定器を9,000サンプルで比較。短い文字列ほど外れ、半角カナや①㈱でも外れる。10MB の変換速度とチャンク境界の文字化けも。
シリーズ:文字コード講座 6/6(上級)。 自分が管理していないシステム(古い基幹システムの CSV、取引先のデータ、昔のメールアーカイブ)からファイルを取り込んでいて、「文字コードは自動判定で」と言われた人向けです。読み終えると、自動判定がどのくらい当たるのか、なぜ短い文字列で外れるのか、変換は実際どのくらい速いのか、そして運に頼らない取り込みの流れをどう組むかが分かります。
mb_detect_encoding($data) の結果をそのまま信じたことがある人、UTF-8 のメールが 縺ゅj縺後→縺� と表示されて頭を抱えた人のための記事です。根本の問題は単純で、バイト列は自分がどのエンコーディングで作られたかを語りません。自動判定はサンプルに対する統計で、4文字のサンプルは証拠としては少なすぎます。この記事では日本語・韓国語・スペイン語・英語の固定シードのサンプルでそれを測り、PHP の判定器と40行の Python 判定器を比べ、10MB の変換時間を計り、第1回で予告したチャンク境界のデコードバグを直します。
実行環境:Apple Silicon Mac(M1 Pro)、macOS 26.6、Python 3.9.6(標準ライブラリのみ)、PHP 8.5.6(mbstring・iconv)、macOS の iconv コマンド。Python でよく使われる chardet などのライブラリはこの Mac に入っておらず、今回も何もインストールしていないので試していません。nkf、Windows、Excel も動かしていません。
なぜ自動判定は推測にしかならないのか
短い文字列を4つ、それぞれの典型的な古いエンコーディングで保存し、同じバイト列を候補ごとに読んでみます。
| 元の文字列 | 保存した形式 | バイト列 | cp1252 で読む | cp932 で読む | cp949 で読む |
|---|---|---|---|---|---|
| It’s | cp1252 | 49 74 92 73 |
It’s | It痴 | It뭩 |
| año | cp1252 | 61 f1 6f |
año | a + U+E0EB(外字領域) | エラー |
| 文字化け | cp932 | 95 b6 8e 9a 89 bb 82 af |
•¶Žš‰»‚¯ | 文字化け | 빒럻돸궚 |
| 한글 | cp949 | c7 d1 b1 db |
ÇÑ±Û | ヌムアロ | 한글 |
ほとんどの読み方がエラーなしでデコードできてしまいます。Windows-1252 はほぼすべてのバイトを何かの文字にするので、「デコードできた」は何の証拠にもなりません。CP932 と CP949 はどちらも重なり合う範囲の2バイト列を使うため、韓国語のバイトはそれらしい日本語(ヌムアロ のような半角カナ)になり、逆もまた起きます。判定器が問えるのは「どの読み方が本物の文章らしいか」だけで、それは推測であり、テキストが長いほど当たりやすくなります。
逆方向も同じです。UTF-8 のバイト列を CP932 として読むと、ありがとう は 縺ゅj縺後→縺�、文字化け は 譁�蟄怜喧縺� になりました(errors='replace')。おなじみの「縺」だらけの文字化けです(仕組みは第2回)。
ただし、とても強い手がかりが1つあります。UTF-8 として正しいかどうかです。UTF-8 はどのバイトの後にどのバイトが来てよいかが厳密に決まっていて、古いエンコーディングの文章はまずその規則を満たしません。後で使う日本語・韓国語・スペイン語・英語のレガシーサンプル10,000件のうち、UTF-8 として正しかったのは 0件 でした。最初に UTF-8 を確かめるだけで、現実のファイルの大半は片が付きます。例外は「すでに文字化けした文字列」で、cp1252 の é のバイトは UTF-8 の é とまったく同じです。
mb_detect_encoding の3つの落とし穴
精度の数字を見る前に、PHP の判定器の振る舞いを押さえておきます。
落とし穴1:既定の候補リスト。 候補を渡さないと mb_detect_order() が使われ、この環境では ASCII, UTF-8 でした。しかも非 strict モードでは、どれにも当てはまらなくても「いちばん近い」候補を返します。
$b = mb_convert_encoding("文字化け", "SJIS-win", "UTF-8");
var_dump(mb_detect_encoding($b)); // string(5) "ASCII"
var_dump(mb_detect_encoding("\xff\xfe", ["UTF-8", "ASCII"])); // string(5) "UTF-8"
var_dump(mb_detect_encoding("\xff\xfe", ["UTF-8", "ASCII"], true)); // bool(false)
Shift_JIS のバイト列が ASCII、UTF-8 に絶対現れない2バイトが UTF-8 と判定されました。候補リストを必ず明示し、第3引数に true(strict)を渡します。
落とし穴2:候補の並び順も答えの一部。 2つの候補がどちらも当てはまるとき、並び順と PHP 内部の採点で決まります。下の結果は「日本語系が先」と「韓国語・欧文が先」で劇的に変わります。
落とし穴3:分かるのは家族まで、変換表までは分からない。 判定は「Shift_JIS 系」までは言えても、どの変換表かは言えません。0x81 0x60 は PHP でも Python でも、SJIS では U+301C(WAVE DASH)、SJIS-win/CP932 では U+FF5E(全角チルダ)です。Python で U+301C の 〜 を cp932 でエンコードして戻すと U+FF5E に変わり、元の文字列と一致しなくなりました。これが日本語データでおなじみの「〜(波ダッシュ)問題」です。EUC-KR と CP949、ISO-8859-1 と Windows-1252 でも同じことが起きます。
標準ライブラリだけの判定器:バージョン1と2
比較用に、Python の標準ライブラリだけで判定器を書きました。バージョン1は、まず UTF-8 を試し、だめなら各候補でデコードして文字の種類で採点します(かな・ハングル +2、漢字 +1、印字可能な ASCII +1、半角カナ −1、制御文字・外字 −5、1文字あたりの平均)。これは日本語 CP932 が苦手で、4文字のサンプルで200件中60件しか当たりませんでした。CP932 のバイトを CP949 として読むと、本物のハングル音節になってしまうからです。
バージョン2では、失敗例を見て2点を変えました。1バイトあたりで採点する(2バイトの漢字1文字が1バイトのラテン文字2つに勝たないように)、そして KS X 1001 の常用2,350音節の外にあるハングル(CP949 の拡張領域)を減点する。本物の韓国語ではめったに使わないのに、誤読した日本語のバイトはよくそこに落ちるからです。
import unicodedata
CANDIDATES = ["cp1252", "cp932", "euc_jp", "cp949"] # on a tie, earlier wins
def char_score(ch):
o = ord(ch)
if o < 0x80: return 1.0 if (ch.isprintable() or ch in "\t\r\n") else -5.0
if 0x3041 <= o <= 0x30FF: return 2.0 # kana
if 0xAC00 <= o <= 0xD7A3: # hangul syllable
b = ch.encode("cp949")
return 2.0 if (b[0] >= 0xB0 and b[1] >= 0xA1) else -1.0 # rare outside KS X 1001
if 0x4E00 <= o <= 0x9FFF: return 1.5 # kanji / hanja
if 0x3000 <= o <= 0x303F or 0xFF01 <= o <= 0xFF5E: return 1.5
if 0x2010 <= o <= 0x2026: return 1.0 # dashes, smart quotes
if 0xFF61 <= o <= 0xFF9F or 0x3131 <= o <= 0x318E: return -1.0
cat = unicodedata.category(ch)
if cat in ("Cc", "Co", "Cn"): return -5.0
if cat.startswith("L"): return 1.0 if 0xC0 <= o <= 0xFF else 0.0
return 0.0
def guess(b):
if b.startswith(b"\xef\xbb\xbf"): return "utf-8-sig"
try:
b.decode("utf-8"); return "utf-8"
except UnicodeDecodeError:
pass
best, best_score = None, float("-inf")
for enc in CANDIDATES:
try: s = b.decode(enc)
except UnicodeDecodeError: continue
score = sum(char_score(c) for c in s) / len(b)
if score > best_score: best, best_score = enc, score
return best
正直に書いておくと、バージョン2はある文章セットの失敗を見ながら調整しました。自分の宿題を自分で採点しないよう、以下の数字はすべて調整に使っていない別の文章セット(スマホ、料理、天気といった日常の段落)で測ったものです。どちらのセットも自分たちで書いた文章なので、実データでは結果が変わります。
精度を測る:短い文字列と長い文字列
方法:言語ごとに、4・8・16・64・256文字のランダムな部分文字列を取り出し(random.seed(2026))、ASCII だけのもの(どの推測でも正解になる)と、そのエンコーディングで表せないもの(㈱ や ① は EUC-JP に存在しない)を除外してエンコードし、各判定器に聞きます。その推測でデコードして元の文字列と完全一致すれば正解です。1セル N = 200、合計9,000サンプル。PHP のリスト A は UTF-8, SJIS-win, EUC-JP, UHC, Windows-1252、リスト B は UTF-8, UHC, Windows-1252, SJIS-win, EUC-JP です(UHC は PHP での CP949 の名前)。
200件中の正解数、4文字のサンプル:
| ケース | 標準ライブラリ v1 | 標準ライブラリ v2 | PHP リスト A | PHP リスト B |
|---|---|---|---|---|
| 日本語 CP932 | 60 | 185 | 200 | 200 |
| 日本語 EUC-JP | 195 | 195 | 200 | 60 |
| 韓国語 CP949 | 200 | 199 | 47 | 200 |
| スペイン語 Windows-1252 | 107 | 200 | 153 | 170 |
| 英語 Windows-1252 | 135 | 200 | 135 | 135 |
16文字のサンプル:
| ケース | 標準ライブラリ v1 | 標準ライブラリ v2 | PHP リスト A | PHP リスト B |
|---|---|---|---|---|
| 日本語 CP932 | 80 | 200 | 200 | 200 |
| 日本語 EUC-JP | 200 | 200 | 200 | 152 |
| 韓国語 CP949 | 200 | 200 | 132 | 200 |
| スペイン語 Windows-1252 | 117 | 200 | 166 | 200 |
| 英語 Windows-1252 | 125 | 200 | 125 | 200 |
256文字では、v1 と PHP リスト A の英語 CP1252(どちらも179件)を除き、すべての判定器が200/200でした。UTF-8 のサンプル4,000件はどの判定器でも全問正解です。
日本語の現場で気になるのは、v2 が4文字で外した CP932 の15件です。中身を見ると、すべて cp1252 と誤判定されていました。天気予報 は “V‹C—\•ñ、から毛布 は ‚©‚ç–Ñ•z です。ひらがなの1バイト目 0x82 は cp1252 では下付きの引用符 ‚ で、英語のために入れた「スマートクォートは +1」の加点が、そのまま日本語の誤判定を招いていました。ある言語のための調整が別の言語を壊す、という判定器の典型です。
さらに、半角カナと機種依存文字を v2 に直接渡してみると、ヤマダ タロウ は cp1252 と判定されて ÔÏÀÞ ÀÛ³ に、ガイドブック セット 1,200円 も cp1252 で ¶Þ²ÄÞÌÞ¯¸ ¾¯Ä 1,200‰~ に、①㈱ は ‡@‡Š になりました。半角カナを減点しているので当然の結果です。PHP も ヤマダ タロウ をリスト A では SJIS-win、リスト B では Windows-1252 と、並び順で答えが割れました。半角カナだらけの古い基幹システムの氏名カナ欄は、自動判定にとって最悪の入力です。
よくある間違い:大きなファイルの先頭だけで判定する
間違い。 10MB 全体の判定は遅い(v2 は10MiB の CP932 で894ms)ので、先頭64KiB だけを見る:guess(data[:65536])。
実際に起きること。 切り口は3回に2回、文字の途中に入ります。strict な guess には、壊れた UTF-8 と壊れたレガシーテキストに見えます。
big_cp949_utf8.txt [:65535] None [:65536] None [:65537] utf-8
big_cp932.txt [:65535] None [:65536] cp932 [:65537] None
big_cp949.txt [:65535] cp1252 [:65536] cp949 [:65537] cp1252
UTF-8 のファイルが「不明」、韓国語 CP949 のファイルが Windows-1252 と判定されました。答えがないより悪い結果です。取り込みは成功し、文字化けしたデータが保存されるからです。
直し方。 先頭部分を逐次デコーダと final=False でデコードします。末尾の書きかけの文字を「続きが来る」とみなし、エラーにしません。
import codecs
def decode_prefix(b, enc):
return codecs.getincrementaldecoder(enc)().decode(b, final=False)
guess の中の b.decode を decode_prefix に置き換えると、15通りの切り口すべてで正解し、64KiB での判定は894ms ではなく6ms で済みました。
ストリーミングのバグ:チャンクをまたぐ文字
同じ分断は、ストリームをチャンクごとにデコードしたときにも起きます。1MB の日本語ファイル(UTF-8 と、同じ内容の CP932)を4,096バイトずつ読みます。
with open(path, "rb") as f:
while chunk := f.read(4096):
chunk.decode(enc) # バグ:チャンクを1つずつ別々にデコード
strict: chunk #1 failed: 'utf-8' codec can't decode bytes in position 4094-4095: unexpected end of data
replace: U+FFFD count = 412
strict: chunk #3 failed: 'cp932' codec can't decode byte 0x83 in position 4095: incomplete multibyte sequence
replace: U+FFFD count = 86
CP932 の場合は数字以上にたちが悪いです。先頭バイトが置換されると、その2バイト目が次のチャンクの先頭になり、単独の文字として読まれます。基幹システム は 幹シス�eムか になりました。テ(0x83 0x65)の2バイト目 0x65 がアルファベットの e になったのです。出力は元より85文字長く、元のファイルにない半角カナが59文字混ざり、エラーは1つも出ませんでした。
Python での直し方: 逐次デコーダは書きかけのバイトを次の呼び出しまで持ち越します。
import codecs
dec = codecs.getincrementaldecoder(enc)()
with open(path, "rb") as f:
parts = [dec.decode(c) for c in iter(lambda: f.read(4096), b"")]
parts.append(dec.decode(b"", final=True)) # ファイルが文字の途中で終わっていれば例外
結果は両ファイルとも U+FFFD count = 0 で、ファイル全体を一度にデコードした結果と一致しました。テキストモードで開く(open(path, encoding="cp932"))だけでも同じ仕組みが使われます。PHP では、fread したチャンクごとに mb_convert_encoding すると「?」が412個(UTF-8)と87個(CP932)増えました。ファイルハンドルに convert.iconv.CP932/UTF-8 ストリームフィルタを付けると、ファイル全体の変換と同一の出力になりました。jsc には TextDecoder がないため、JavaScript の {stream: true} は今回実行していません。
10MiB の変換速度
各ファイルは古いエンコーディングで保存した10MiB の実際の文章で、メモリ上で UTF-8 に変換し、5回の中央値を取りました。iconv コマンドの行はプロセス起動とファイル読み込みを含みます(同じファイルの cat は4ms)。
| UTF-8 への変換 | CP932 | CP949 | CP1252 |
|---|---|---|---|
Python data.decode(enc).encode("utf-8") |
36 ms | 33 ms | 10 ms |
PHP mb_convert_encoding |
22 ms | 37 ms | 19 ms |
PHP iconv() |
283 ms | 197 ms | 186 ms |
iconv コマンド(macOS) |
284 ms | 212 ms | 196 ms |
4つとも出力はバイト単位で一致しました(cmp と === で確認)。Python の codecs と mbstring はおよそ毎秒280MB〜1,000MB、macOS の libiconv はその10分の1程度でした。それでも10MiB で0.3秒弱なので、夜間バッチならどれでも十分です。リクエスト処理の中なら mbstring か Python の codecs を選びます。おまけの数字:15.6MB の UTF-8 の検証は Python の decode で10.3ms。PHP の mb_check_encoding は新しい文字列で3.5〜4.1ms でしたが、同じ文字列への2回目は約0.0002ms でした。PHP が文字列に「正しい UTF-8」フラグをキャッシュするためで、ベンチマークは中央値ではなく初回を測るべきです。
運に頼らない取り込みの流れ
- エンコーディングを宣言してもらう。 HTTP の
Content-Type: text/csv; charset=...(HTTP ヘッダの講座)、XML や HTML の宣言、BOM、あるいは取引先との取り決めの一行。宣言はどんな判定器にも勝ります。 - BOM、次に UTF-8 として正しいかを確認。 これで UTF-8 のサンプル4,000件は全問正解し、レガシーの10,000件はすべて弾けました。
- 送り元の既知のエンコーディングを使う。 ある古いシステムはいつも同じエンコーディングで書き出します。ファイルごとに推測せず、「送り元 → エンコーディング」の表を持ちます。Windows 由来なら
Shift_JISではなくCP932と書いておくと 〜 と ①㈱ で困りません。 - strict にデコードする。 Python なら
errors="strict"、PHP ならmb_convert_encodingの前にmb_check_encoding。取り込み失敗のほうが、黙った文字化けより安くつきます。 - 推測は最後の手段。 数百バイト以上で、実際に受け取る地域に絞った候補リストで行い、結果は人の確認か隔離フォルダへ。
- ストリームは逐次デコーダで。 チャンクごとの
decodeは使わない。 - 正規化して UTF-8 で保存。 NFC(第3回)、MySQL なら
utf8mb4(第5回)、Excel 利用者向けの CSV は第4回のとおりに。
正直な限界とバージョンの注意
- サンプルは自分たちで書いた2つの小さな文章セットから取りました。住所、商品コード、言語の混在といった実データでは結果が変わります。表は短い文字列の効果を示すデモであり、PHP のベンチマークではありません。
mb_detect_encodingの採点は PHP 8.1 で作り直されています。PHP 7 や 8.0 では結果が違う可能性があります。実行したのは 8.5.6 だけです。chardet、charset-normalizer、ICU のCharsetDetector、nkfは使えず、試していません。Linux と GNU iconv も動かしていません。- 同じエンコーディング名でもツールによって振る舞いが違います。Python の
euc_krは 똠 のような音節を8バイトの「組み合わせ」列で受け付けましたが、macOS のiconv -t EUC-KRは 똠 をIllegal byte sequenceで拒否しました。 - 速度は Apple M1 Pro 1台での値です。持ち帰ってほしいのは「mbstring と codecs が速く、macOS の libiconv は遅い」という順序です。
練習問題
1. mb_detect_encoding($b) が Shift_JIS のバイト列に "ASCII" を返したのはなぜか。どう直せばよいか。
2. 逐次デコーダに1バイトずつ渡してみてください。"똠방각하, 뷁! 한글 깨짐 테스트".encode("cp949") を、codecs.getincrementaldecoder("cp949")() に1バイトずつ渡してデコードすると、一度にデコードした結果と一致しますか。
解答
1. 候補リストがないため PHP は判定順 ASCII, UTF-8 を使い、非 strict モードではどれも正しくなくても最も近い候補を返したからです。候補を明示して strict にします。mb_detect_encoding($b, ["UTF-8", "SJIS-win"], true) は "SJIS-win" を返しました。
2. 一致します。出力は 똠방각하, 뷁! 한글 깨짐 테스트 True でした。デコーダは 0x8c のような先頭バイトを2バイト目が来るまで保持するので、チャンクサイズ1でも安全です。UTF-8 で見るとよく分かります。dec.decode(b"caf\xc3") は 'caf' を返し、続けて b"\xa9!" を渡すと 'é!' を返しました。新しいデコーダで decode(b"\xc3", final=True) を呼ぶと unexpected end of data の例外になりました。
シリーズのまとめ
これで最終回です。バイトとコードポイント(1)、文字化け(2)、正規化(3)、CSV と Excel(4)、文字列の長さ(5)、そして今回の自動判定まで、ルールはずっと同じでした。どのエンコーディング、どの単位を扱っているかを知り、すべての境界で明示すること。
よくある質問
mb_detect_encoding はどのくらい当たりますか?
文字列の長さと候補の並び順で大きく変わります。4文字のサンプルでは、日本語系を先に並べた候補リストで韓国語 CP949 が200件中47件しか当たらず、韓国語を先にすると今度は EUC-JP の日本語が200件中60件に落ちました。64文字以上なら、適切な並び順で今回のサンプルはすべて正解でした。UTF-8 は4,000件中4,000件正解です。
ファイルを分割して読むと文字化けや UnicodeDecodeError が出るのはなぜですか?
マルチバイト文字が2つのチャンクにまたがり、それぞれを別々にデコードしたからです。1MB の日本語 CP932 ファイルを4,096バイトずつ読むと、errors='replace' で U+FFFD が86個、さらに元のファイルにない半角カナが59文字混入しました。Python の codecs.getincrementaldecoder(または open(..., encoding=...))、PHP の convert.iconv ストリームフィルタを使うと、ファイル全体を一度に変換した結果と完全に一致しました。
Shift_JIS と判定されたのに 〜 が化けるのはなぜですか?
判定できるのは「Shift_JIS 系」という家族までで、どの変換表かは分からないからです。0x81 0x60 は SJIS では U+301C(WAVE DASH)、SJIS-win・CP932 では U+FF5E(全角チルダ)になります。Python でも U+301C を cp932 でエンコードして戻すと U+FF5E に変わりました。送り元が Windows 由来なら CP932 を明示します。