League/Csvで5C問題を回避してCSVを読む

PHPのCSV操作ライブラリ[League/Csv](https://csv.thephpleague.com/)では文字コードがShift-JISのCSVを読むことができますが、5C問題のためにフィールドがうまく区切られない場合があります。**5C問題**はShift-JIS固有の問題で、バックスラッシュとして解釈される`0x5c`が一部の文字コードの先頭に含まれることに起因する問題を指します。これを発生させる文字はいわゆる[ダメ文字](https://sites.google.com/site/fudist/Home/grep/sjis-damemoji-jp)として知られており、例として「貼」「表」「能」などが挙げられます。

この記事では、League/Csvを利用してCSVを読む際に5C問題を回避する方法について記します。記事中で利用するLeague/Csvのバージョンは9.8.0、PHPのバージョンは8.1.0となります。

## フィールドをうまく区切れない例
読み込むCSVファイルが

- 文字コードがShift-JISである
- ダメ文字がエンクロージャの**直前**で用いられている

の条件をすべて満たす場合にフィールドがうまく区切れない現象が発生します。具体的には次のようなCSVおよび読み込みの実装で再現できます。

```csv:data.csv
"0160841","養蚕","ようさん","能代市","秋田県"
"4370061","久能","くの","袋井市","静岡県"
"9071542","西表","いりおもて","八重山郡竹富町","沖縄県"
```

```php
<?php
use League\Csv\CharsetConverter;
use League\Csv\Reader;

$csv = Reader::createFromPath($path, 'r');
$encoder = (new CharsetConverter())->inputEncoding('SJIS');
$records = $encoder->convert($csv);

$data = [];
foreach ($records as $record) {
    $data[] = $record;
}
print_r($data);
```

```plaintext:出力結果
Array
(
    [0] => Array
        (
            [0] => 0160841
            [1] => 養蚕",ようさん"
            [2] => 能代市
            [3] => 秋田県
        )

    [1] => Array
        (
            [0] => 4370061
            [1] => 久能",くの"
            [2] => 袋井市
            [3] => 静岡県
        )

    [2] => Array
        (
            [0] => 9071542
            [1] => 西表",いりおもて"
            [2] => 八重山郡竹富町
            [3] => 沖縄県
        )

)
```

ここで「蚕」「能」「表」はいずれもダメ文字で、`久能",くの"`のように、きちんと区切られていないことが分かります。`"能代市"`のように、ダメ文字が含まれていてもエンクロージャの直前でない場合はフィールドは正常に区切られます。

## 回避方法1: エスケープ文字に`\`を利用しない場合
League/Csvではデフォルトのエスケープ文字が`\`です[^league-csv-escape-char]。したがって、フィールドが区切られない原因としてダメ文字に含まれる`0x5c`が直後のエンクロージャーをエスケープしている、つまり`"久能","くの"`を`"久能\","くの"`と解釈していると考えられます。そこで、エスケープ文字を`\`以外のものにすることで回避します。

[^league-csv-escape-char]: https://csv.thephpleague.com/9.0/connections/controls/#the-escape-character

```php
<?php
use League\Csv\CharsetConverter;
use League\Csv\Reader;

$csv = Reader::createFromPath($path, 'r');
$csv->setEscape(''); // エスケープ文字を設定
$encoder = (new CharsetConverter())->inputEncoding('SJIS');
$records = $encoder->convert($csv);

$data = [];
foreach ($records as $record) {
    $data[] = $record;
}
```

[フィールドをうまく区切れない例](#フィールドをうまく区切れない例)の実装に`$csv->setEscape('')`を追加し、エスケープ文字を空文字に設定しています。エスケープ文字を空文字にすると[RFC4180](https://datatracker.ietf.org/doc/html/rfc4180)に従った形式となり、`"`は`""`としてエスケープできます。

## 回避方法2: エスケープ文字に`\`を利用する場合
読み込むCSVで`\`をエスケープ文字に利用している場合は上記の方法は利用できません。その場合は`CharsetConverter`の代わりに`mb_convert_encoding`で文字コードの変換を行ったうえで読み込みます。

```php
<?php
use League\Csv\Reader;

$data = mb_convert_encoding(file_get_contents('data.csv'), 'UTF-8', 'SJIS');
$csv = Reader::createFromString($data);
$records = $csv->getRecords();

$data = [];
foreach ($records as $record) {
    $data[] = $record;
}
```

CSVファイルの容量が大きい場合、この方法ではメモリ使用量が増えるため注意が必要です[^handling-sjis-large-csv]。容量の大きいファイルを扱う場合は[ストリームフィルタ](https://csv.thephpleague.com/9.0/connections/filters/)を利用する方法があります[^sjis-csv-stream-filter]。また、エスケープ文字が削除されないSplFileObjectのバグも報告されており[^splfileobject-escape-bug]、これを解決するためにもストリームフィルタが利用できます。

[^handling-sjis-large-csv]: [容量の大きいCSVファイルの扱い (Shift-JIS)](https://qiita.com/reflet/items/d4c8a167e52ac91225d9)
[^sjis-csv-stream-filter]: [PHP: fgetcsvでもSJISのCSVをUTF-8として《安全》に読む方法(ストリームフィルタ使用)](https://qiita.com/suin/items/3edfb9cb15e26bffba11)
[^splfileobject-escape-bug]: [PHP bug: escape characters not stripped by SplFileObject:fgetcsv · Issue #149 · thephpleague/csv · GitHub](https://github.com/thephpleague/csv/issues/149)

## 参考文献
- [PHPでLeague/CSVによるCSV(SJIS-win)の読み込み](https://qiita.com/kiyc/items/f70280e13e5194e5dd94)