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ファイルの解析で悩んでいる人のお役に立てば幸いです。