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のほうが無難というのが一般的な見方かもしれません。

類似投稿

コメントを残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です