🐘

league/csvでUTF-16のCSVファイルがなぜか正常に読めなかったときにやったこと

2020/03/28に公開

PHPのCSVライブラリ league/csv を使って、文字コードがUTF-16のCSVファイルを扱っていたら、謎の現象で正常に読めない場面があり、強引に対応したのでその顛末を記録しておきたいと思います😓

league/csvについては こちらの記事 で詳しく紹介していますので、よろしければあわせてご参照ください。

発生した現象

今回使用していたのはleague/csvのバージョン 9.5.0 です。

UTF-16(LE) で書かれたCSVファイルを、league/csvを使って以下のように読み込んだところ、

$csv = Reader::createFromPath('/path/to/file.csv');
CharsetConverter::addTo($csv, 'UTF-16', 'UTF-8');
$csv->setDelimiter("\t");
$csv->setHeaderOffset(0);

なぜか、 ある程度以上の行数のファイルの場合にのみ、途中から文字化けして正常に全体を読み込めない という現象が発生しました。

ファイルの先頭には、LE(Little Endian)を示すBOM 0xFFFE が確かに書かれているので、

CharsetConverter::addTo($csv, 'UTF-16', 'UTF-8');

というふうに UTF-16LE ではなく UTF-16 を指定すればleague/csvがBOMを頼りに UTF-16LE として読み込んでくれるのが正しい動作に思えます。

多分league/csvのバグなんじゃないかと思ったのですが、残念ながら原因を調べて修正するだけの時間的余裕はありませんでした💨

関連情報

Converting Csv records character encoding - CSV
https://csv.thephpleague.com/9.0/converter/charset/

PHP: サポートされる文字エンコーディング - Manual
https://www.php.net/manual/ja/mbstring.supported-encodings.php

実施した強引な対策

そこで、今回はかなり強引な方法でその場しのぎの対策をしました😓

具体的なコードは以下のとおりです。

$content = ltrim(file_get_contents('/path/to/file.csv'), "\xff\xfe");
$csv = Reader::createFromString($content);
CharsetConverter::addTo($csv, 'UTF-16LE', 'UTF-8');
$csv->setDelimiter("\t");
$csv->setHeaderOffset(0);

file_get_contents() で一旦CSVファイルの内容を文字列として読み込んで、先頭のBOMを物理的に削除してから、 Reader::createFromString() でCSVのインスタンスを作ります。

この時点で、CSVのコンテンツは BOMなしUTF-16LE で書かれている状態になっているので、 CharsetConverter には UTF-16 ではなく UTF-16LE を明示的に指定します。

一応、この方法でちゃんと読み込めるようになりました。

CSVファイルの1行目を使わない場合

ちなみに、一般的なCSVファイルのように、ファイルの1行目がヘッダー行になっている(つまり、ファイルの先頭箇所に1つ目のヘッダーが書いてある)場合は、上記のように物理的にBOMを削除する必要があります。

なぜなら、そうしないと、league/csvが「1つ目のヘッダーを表す文字列」だと思っている文字列の先頭に、BOMである 0xFFFE という 見えない文字が含まれてしまう からです。

この状態になってしまうと、例えば以下のようにヘッダー名をハードコードした場合に、 league/csvは「そんなヘッダーはない」と思っている ということが起こりえます。

foreach ($csv as $row) {
    if ($row['ID'] === '100') {
        // do something
    }
}

この例では、 ID というヘッダー名に該当する列の値を取得して 100 と等しいかどうかをチェックしているつもりですが、もしファイルの先頭が ID というヘッダー名で、かつ 実は先頭にBOMが残っている という状態だと、league/csv的にはヘッダー名は ID ではなく [0xFFFE]ID だと思っている、ということになります。

なので、先述したとおり、ファイルのコンテンツから BOMを物理的に削除しておく ということが必須でした。

ただ、もしヘッダー行が2行目以降にあって、結果的にファイルの1行目を使わないのであれば、物理的にはBOMが残ったままでも、league/csvに「BOMは無い」と思わせるだけでも事足ります。

具体的には、以下のようなコードで問題なく読み込めるようになります。

$csv = Reader::createFromPath('/path/to/file.csv');
CharsetConverter::addTo($csv, 'UTF-16LE', 'UTF-8');
$csv->setDelimiter("\t");
$csv->setHeaderOffset(1);
$csv->input_bom = '';

$csv->input_bom = ''; で、BOMがなかったことにしています。

いずれにせよ強引な方法であることに変わりはないので、どちらがおすすめとかいう話ではないです😅

参考までに。

まとめ

  • league/csv 9.5.0でUTF-16のCSVファイルを読み込むと、なぜかある程度行数が多いファイルの場合のみ、途中から文字化けする現象が発生した
  • BOMを物理的または論理的に削除した上で、 UTF-16LE を指定して文字コード変換を行うことで、強引に、正しく読み込めるように対応した
  • (多分league/csvのバグな気がするので、余裕ができたら調べて直せたらいいなと思ってます)
GitHubで編集を提案

Discussion