PHPのmb_convert_encodingでSJISにすると丸数字や㈱が「?」になる理由
PHPでUTF-8の文字列をShift_JISに変換すると「①」や「㈱」が「?」に化けることがあります。原因は変換先に SJIS を指定していることで、Windows拡張の文字を含められる SJIS-win(CP932)にすれば解決します。
取引先から「Excelで開けるCSVが欲しい」と言われて、SJISで出力する羽目になった経験は、PHP書きなら一度はあると思います。テストデータでは問題なかったのに、本番データで会社名の「㈱」だけ「?」になっていた、というのが典型的なハマり方ですね。
SJISとSJIS-winは何が違うのか
名前が似ているので同じものに見えますが、収録している文字の範囲が違います。SJIS は標準のShift_JISで、丸数字や㈱、「髙」(はしご高)のような機種依存文字は含まれていません。一方 SJIS-win はWindowsが使うCP932相当で、これらを含んでいます。
実際にPHP 8.3で変換して、バイト列を見てみた結果が次のとおりです。
<?php
foreach (['①', '㈱', '髙', '山'] as $c) {
$a = bin2hex(mb_convert_encoding($c, 'SJIS', 'UTF-8'));
$b = bin2hex(mb_convert_encoding($c, 'SJIS-win', 'UTF-8'));
echo "{$c} SJIS={$a} SJIS-win={$b}\n";
}
// ① SJIS=3f SJIS-win=8740
// ㈱ SJIS=3f SJIS-win=878a
// 髙 SJIS=3f SJIS-win=fbfc
// 山 SJIS=8e52 SJIS-win=8e52
3f は半角の「?」です。変換できない文字は、エラーにも例外にもならず、黙ってこの「?」に置き換わります。通知がないぶん、気づくのが遅れやすい点が一番厄介だと思います。
変換できない文字はどう扱われるのか
置き換えの挙動は mb_substitute_character() で変えられます。既定は「?」ですが、元の文字が分かる形で残すこともできます。
<?php
$s = '①';
mb_substitute_character('long');
var_dump(mb_convert_encoding($s, 'SJIS', 'UTF-8')); // string(6) "U+2460"
mb_substitute_character('entity');
var_dump(mb_convert_encoding($s, 'SJIS', 'UTF-8')); // string(8) "①"
mb_substitute_character('none');
var_dump(mb_convert_encoding($s . 'a', 'SJIS', 'UTF-8')); // string(1) "a"
なお、この設定はそのプロセス全体に効く点に注意が必要です。ライブラリが内部で変換しているコードにも影響するので、使うなら変換の前後で元の値に戻す、くらいの慎重さがあってもいいかもしれません。個人的には、消えてしまう none は一番避けたい設定です。
変換前に「SJISに入るか」を確かめるには
出力前に弾きたいときは、変換して戻した結果が元と一致するかを見るのが手軽です。往復して同じなら、情報が失われていないと言えます。
<?php
function fitsInSjisWin(string $utf8): bool
{
$sjis = mb_convert_encoding($utf8, 'SJIS-win', 'UTF-8');
return mb_convert_encoding($sjis, 'UTF-8', 'SJIS-win') === $utf8;
}
var_dump(fitsInSjisWin('①番 ㈱山田 髙橋')); // bool(true)
var_dump(fitsInSjisWin('😀')); // bool(false)
絵文字のようにSJIS-winにも存在しない文字は、ここで検出できます。CSVの1行ずつ、あるいは保存前の入力チェックに入れておくと、「相手先に渡してから文字化けに気づく」事態を減らせます。
なぜ第3引数を省略しないほうがいいのか
mb_convert_encoding() の引数は (文字列, 変換先, 変換元) の順で、変換元を省略すると内部エンコーディング(mb_internal_encoding())が使われます。既定はUTF-8なので普段は動きますが、設定が変わった環境では静かにおかしくなります。
逆方向、つまりSJISのCSVを読み込むときも同じで、変換元を明示しておくほうが安全です。
<?php
$raw = file_get_contents('customers_sjis.csv');
$utf8 = mb_convert_encoding($raw, 'UTF-8', 'SJIS-win');
SJISの2バイト目には、「表」(95 5c)や「ソ」(83 5c)のように、バックスラッシュと同じ 5c を使う文字があります。そのため、SJISのまま文字列を扱うと、エスケープ処理や文字列関数で壊れる可能性があります。読み込んだらすぐUTF-8に直し、出力の直前にだけSJIS-winにする、という運用が無難だと思います。
まとめ
Windows環境のExcel向けにCSVを出すなら、変換先は SJIS ではなく SJIS-win にしておくのが基本です。それでも入らない文字は「?」に変わるので、往復変換で確認しておくと安心できます。内部はUTF-8、境界でだけ変換、という分担にしておくのが、一番事故が少ない気がしています。
よくある質問
Q. SJIS-winとCP932は同じものですか?
A. PHPのmbstringではどちらも使えるエンコーディング名で、丸数字や㈱を変換できる点は同じでした。通常のWindows向け出力では、どちらを選んでも実務上は困らないと思います。
Q. 変換できない文字が混ざったとき、エラーにできますか?
A. mb_convert_encoding() 自体は例外を投げず、置換文字に置き換えて続行します。変換後に元へ戻して比較する関数を挟むと、検出できます。
Q. Excelで開くだけなら、UTF-8のままではだめですか?
A. 先頭にBOMを付ければUTF-8でも開ける場合がありますが、環境やバージョンによって挙動が異なります。相手先のExcelが分からないなら、SJIS-winのほうが無難というのが一般的な見方かもしれません。