スポンサーリンク

Windows版PHPでUTF8なCSVファイルを読むならロケールに注意

PHPのfgetcsvやstr_getcsvなどの関数はロケール設定に依存しており、Windows環境で使う場合はデフォルトのロケールがJapanese_Japan.932になっているので、そのままではUTF-8なCSVは正常に解析できないよー、というだけのお話です。

お急ぎの方は setlocale(LC_ALL, ‘C’); というオマジナイだけ覚えて帰って頂ければと思います。

最近ではjsonだのxmlだのyamlだの、便利な書式が増えたおかげで、相対的にCSVを扱う機会が減っており、あまりに久しぶりだとこのへんの仕様、ついつい忘れてしまうんですよね。開発環境も運用環境もLinuxならロケール設定もCかUTF-8にしているから問題ないでしょうし。

今回たまたまWindows上でCSVファイルを解析する必要に迫られ、慣れているPHPでサクサクっと組むか、と思ったらきちんと切り分けてくれず、解決まで半日近く潰してしまったので、備忘録がてらブログに残しておこうと思いました。

スポンサーリンク

PHPでCSVファイルを読み込む基本

CSVファイルの中身

"品番","品名","販売金額"

今回使うのは、ダブルクォートで囲んだ値が3項目、1行だけ入ったファイルです。

PHPサンプル

$fp = fopen('[CSVファイル名]', 'r');
$row = fgetcsv($fp);
print_r($row);

とりあえずテストなのでエラーチェックもしないし、最初の1行だけ読んで配列に格納しています。

結果

Array ( [0] => 品番 [1] => 品名 [2] => 販売金額 )

CSVファイルがShiftJISで保存されている場合はこのとおりきちんと解析できています。

ところが、CSVファイルがUTF-8で保存されていた場合、Windows環境では以下のようになります。
Array ( [0] => 品番 [1] => 品名",販売金額" )

あれあれ~? 品名と販売金額という項目が同じ1つの項目として扱われてしまいました。2個目のカンマ区切りが反映されていません。

最近は文字コードはUTF-8にするのがデファクトスタンダードですし、PHP自体もphp.iniでinternal_encodingをUTF-8に設定していたりするでしょう。

だから、むしろCSVはUTF-8で保存されていたほうが素直に動きそうにも感じてしまいます。

がしかし、php.iniで設定しているのはこんな感じですよね?

mbstring.language = Japanese
mbstring.internal_encoding = UTF-8

この設定が影響するのはその名のとおりmbstring系の関数だけです。mb_strposとかmb_strlenとか、mb_ほにゃららと付いた関数群。

fgetcsvはmbstringの設定なんぞ見ておらず、参照しているのはロケール設定です。

Windows版PHPのデフォルトのロケールはJapanese_Japan.932

ロケールの確認方法は下記のとおり。

echo setlocale(LC_ALL, 0);
LC_COLLATE=C;
LC_CTYPE=Japanese_Japan.932;
LC_MONETARY=C;
LC_NUMERIC=C;
LC_TIME=C

このLC_CTYPEの設定がfgetcsvやstr_getcsv関数に影響するようで、基本的にcp932(=MSが拡張したShiftJIS)じゃないと正常に動作しないようです。

ややこしいのは、微妙にちゃんと動いているように見えてしまうケースもある点。

ロケールがJapanese_Japan.932でも正常動作に見えてしまう例

print_r(str_getcsv('品番,品名,販売金額')); echo '<br>';
print_r(str_getcsv('"品番","品名","販売金額"'));
Array ( [0] => 品番 [1] => 品名 [2] => 販売金額 )
Array ( [0] => 品番 [1] => 品名",販売金額" )

このように、ダブルクォートを付けない「品番,品名,販売金額」だと正常に動作しているように見えてしまうので、より一層混乱する原因になっています。

ダブルクォートを付けなければ読めるのか、と勘違いしそうになりますが、そうではありません。

例えば、こんなコードを書いてみます。

print_r(str_getcsv('あ,かきくけこ')); echo '<br>';
print_r(str_getcsv('あい,かきくけこ')); echo '<br>';
print_r(str_getcsv('あいう,かきくけこ')); echo '<br>';
print_r(str_getcsv('あいうえ,かきくけこ')); echo '<br>';
print_r(str_getcsv('あいうえお,かきくけこ')); echo '<br>';
Array ( [0] => あ,かきくけこ )
Array ( [0] => あい [1] => かきくけこ )
Array ( [0] => あいう,かきくけこ )
Array ( [0] => あいうえ [1] => かきくけこ )
Array ( [0] => あいうえお,かきくけこ )

結果はこのとおり。「あい」「あいうえ」を使った2パターンだけ正常。それ以外は切り分けが出来ていません。

深く調査したわけではないので仮説ですが、UTF-8の3バイト文字の3バイト目が0x81~0x9fあたりだった場合で、かつダブルクォートを付けた場合、もしくは奇数回その文字が入っていた場合に、正常な切り分けが出来ていないように見えます。

つまり、1バイトずつ読み取り、0x81~0x9fだったらShiftJISの漢字コードのはじまりを示すので、2バイト目を読み取ろうとしてバグる、というMS-DOS時代の文字化けあるあるみたいな。

どうせなら全部化けてくれればわかりやすいのに、微妙に正常に表示されて見えるところが腹立た…ごほん…ややこしいです。

UTF-8なPHPで、ShiftJISなCSVを読む例

まずは素直な例から。

$fp = fopen('[CSVファイル名]', 'r');
$row = fgetcsv($fp);
foreach ($row as $key => $value) {
	echo "[".mb_convert_encoding($value, 'UTF-8', 'SJIS-win')."]";
}

デフォルトのロケールがcp932なんだから、そのまま解析させて、表示するときにUTF-8に変換すれば良いじゃない、というケース。

Excelで保存したCSVなど、通常はShiftJISでしょうし、理想はこのやり方なのかなとは思います。

ただ、Windows環境とLinux環境で同じスクリプトを使いまわす場合、今度は逆にWindowsでは正常だけど、LinuxではきちんとCSVを解析できない、なんてこともありえるので、結局環境によってsetlocaleを変更する必要があります。Linuxだと setlocale(LC_ALL, ‘ja_JP.Shift_JIS’); あたりかな?

更に言うと、昨今ではUTF-8にしか存在しない文字が入力される例も多いと思います。絵文字なんか代表的な例ですね。そういうデータをCSV化する場合、そもそもShiftJISじゃ保存できません。なので、書いておいて何ですが、この例はWindows環境でShiftJISなCSVしか読まないというレガシーなシステム向けかなぁ。

UTF-8なPHPで、UTF-8なCSVを読む例

setlocale(LC_ALL, 'C');
$fp = fopen('[CSVファイル名]', 'r');
$row = fgetcsv($fp);
print_r($row);

はい、わざわざ記事にするほどかって感じがするくらいシンプルですが、setlocale(LC_ALL, ‘C’);を入れるだけ。

CロケールはPOSIX標準のうんたらかんたらって説明がされていて、なんのことやらわからんのですが、要するに余計なことしないロケールと認識しています。

どこで見かけたんだったか、たしかGitHubのEC-CUBE関連のスレッドだったかなぁ。

setlocale(LC_ALL, 'English_United States.1252');

という例が書かれていた記憶がありますが、結局同じことでしょう。

3バイト文字(≒漢字)だろうと1バイトずつ読んで処理してるんだと思うけど、カンマ(0x2c)やダブルクォート(0x22)はUTF-8の漢字に含まれないからええやろ、っていう強引なやり方に見えます。正直ちょっと不安を感じなくもない。

まとめ

いよいよとなったら、mb_substr関数で1文字ずつ切り抜いて自前で解析関数作ろうかと思いましたが、一応setlocale(LC_ALL, ‘C’);付けるだけで問題なく動いてるっぽいので、ひとまずこれでヨシとしました。

PHPではなくJavaScriptですが、前回の記事「cPanelのメールフィルターをエクスポート/インポートする拡張機能を作ってみた」ではダブルクォートや改行も考慮したCSV解析処理を書いているので気になる方はそちらもどうぞ。

そんなに難しい処理じゃないし、昔書いた記憶があるので、HDD探せばPHPの例も出てきそうですが、自前で書くと処理がちょっと遅いんですよね。今回、1万行くらいのCSVを処理したかったので、できればネイティブな関数に任せたかったというのもあります。

ただ、今後PHP8が主流になればネイティブコードと同じくらいスクリプトも早くなるのかなぁ?

ちょっと脱線しましたが、この記事がUTF-8なCSVファイルの解析で悩んでいる人のお役に立てば幸いです。

コメント

タイトルとURLをコピーしました