【PHP】エクセル方言のcsv(SJIS)を配列にする

CSV(**C**omma-**S**eparated **V**alues)の処理は、日本のECサイトでは未だに需要があると思いますが、Web上にはチョット惜しいコードが散見されるので、気分転換に書いてみました。

偶然、10年以上前に書いた、正規表現を利用したコードを発掘したので、そちらも掲載しています。

--------------------------------------------------------------------------------

## 想定しているエクセル方言のCSV

- 文字コードはShift_JIS(CP932として扱う)
	- `CP932(SJIS-win)` Windowsのエクセル
	- `MacJapanese(SJIS-mac)` Macintoshのエクセル
- 改行コードは問わない
	-  `"\r\n"` 一般的なWindowsのエクセル
	-  `"\r"` Macintoshのエクセル
	-  `"\n"` 大昔のWindowsのエクセル 
- デリミタは `,`
- 囲み文字は `"`
- セル内の `"` のエスケープは `""`
- `"` で囲まれたセルは改行を含む場合がある
	- Windowsのエクセルではセル内の改行コードは `"\n"`

--------------------------------------------------------------------------------

## コード

都合上、PHP5.4未満でも動くように書いてます。(未検証ですが5.1.2以上で動く筈です)

```php
function fgetExcelCSV($pPath)
{
    $csvAry = array();

    //-- ファイルを読み込む
    $data = file_get_contents($pPath);

    //-- 文字コードUTF-8 改行コード"\r\n" に変換
    $data = mb_convert_encoding($data, 'UTF-8', 'SJIS-win');
    $data = strtr($data, array("\r\n" => "\r\n", "\r" => "\r\n", "\n" => "\r\n"));

    //-- fopen('php://temp', 'w') と同義
    $oTmp = new SplTempFileObject();

    //-- UTF-8に変換したデータを書き込む
    $oTmp->fwrite($data);

    //-- ファイルポインタを先頭へ(書き込みしたデータの先頭から読み直す)
    $oTmp->rewind();

    //-- csvモードで空行を読み飛ばすように設定
    $oTmp->setFlags(SplFileObject::READ_CSV |
                    SplFileObject::READ_AHEAD |
                    SplFileObject::SKIP_EMPTY |
                    SplFileObject::DROP_NEW_LINE);

    //-- 配列へ格納
    foreach ($oTmp as $lineAry) {
        $csvAry[] = $lineAry;
    }

    return $csvAry;
}

/** オマケ：$oTmp の内容
SplTempFileObject Object
(
    [pathName:SplFileInfo:private] => php://temp
    [fileName:SplFileInfo:private] => php://temp
    [openMode:SplFileObject:private] => w
    [delimiter:SplFileObject:private] => ,
    [enclosure:SplFileObject:private] => "
)
**/
```

### 解説

PHPには便利な入出力ストリームが幾つか用意されています。

その中で `php://temp` は、デフォルトで2Mまでをメモリ上で扱い、それを超えるとテンポラリファイル（[tmpfile()](http://php.net/manual/ja/function.tmpfile.php)とほぼ同様）を作りそちらで扱ってくれるストリームです。

今回利用しているのは、その `php://temp` に該当する処理をOOPで簡単に扱える **SplTempFileObject** です。
**SplFileObject** の継承クラスなので、SplFileObject と同じように扱えます。

このコードでメモリの上限を2Mから変更したい場合は...

```php
new SplTempFileObject(4*1024*1024);    // 4Mを指定
new SplTempFileObject(0);              // メモリを使用せずに初めからファイルで処理する
```

...のように書きます。（単位はbyte）

その他の解説は、コード内にコメントとして記載しました。

#### 参考リンク

全てPHPマニュアルへのリンクです。

- [php:// で始まる入出力ストリーム](http://php.net/manual/ja/wrappers.php.php)
- [SplTempFileObject](http://php.net/manual/ja/class.spltempfileobject.php)
- [SplFileObject](SplFileObject)
- [SplFileObjectで定義済みの定数](http://php.net/manual/ja/class.splfileobject.php#splfileobject.constants)

--------------------------------------------------------------------------------

### Web上のコードの惜しいところ

tmpfile() と SplFileObject や [fgetcsv()](http://php.net/manual/ja/function.fgetcsv.php) とを併用したコードを幾つか見つけましたが、SplTempFileObject の方がスッキリと書けるのではないかと思います。

また、`php://temp` と同様に、まずはファイルではなくメモリ上で処理しようと試みますので、特にメモリに収まる小さなCSVファイルであれば、tmpfile()等でテンポラリファイルを作成するのに比べると、高速に動作するのではないかと思います。（理論上の話であって未検証ですスミマセン）

--------------------------------------------------------------------------------

## おまけ：正規表現を利用したコード(PHP4～7)

```php
function fgetExcelCSV($pPath)
{
    $csvAry = array();

    if (!is_readable($pPath) || (!$fp = fopen($pPath, 'r'))) { return FALSE; }
    flock($fp, LOCK_SH);

    while (($line = fgets($fp)) !== FALSE) {
        //-- '"' が奇数個の行はセル内に改行を含むため、対となる '"' が見つかるまで読み進める
        while ((substr_count($line, '"') % 2) && !feof($fp)) { $line .= fgets($fp); }

        //-- 末尾の改行コードをデリミタ ',' に置換(異なる改行コードの吸収と後に続く正規表現の簡略化が目的)
        $line = rtrim($line, "\r\n") . ',';

        //-- もし今の時代に合わせてUTF-8への文字コード変換を入れるなら上記の代わりに...
        // $line = mb_convert_encoding(rtrim($line, "\r\n"), 'UTF-8', 'SJIS-win') . ',';

        //-- 正規表現については別で解説
        if (preg_match_all('/("[^"]*(?:""[^"]*)*"|[^,]*),/', $line, $matchAllAry)) {
            $tmpAry = array();
            foreach ($matchAllAry[1] as $cell) {
                //-- 両端の '"' を取り除き、セル内のエスケープされた '"' をアンエスケープ
                $tmpAry[] = (preg_match('/^"(.*)"$/s', $cell, $matchAry)) ? str_replace('""', '"', $matchAry[1])
                                                                          : $cell;
            }
            $csvAry[] = $tmpAry;
        }
    }

    flock($fp, LOCK_UN);
    fclose($fp);

    return $csvAry;
}
```

### 解説

一度に全てのデータを読み込まずに行単位で処理する事で、メモリの節約を狙ってました。(もっとも、PHP4の頃は file_get_contents() は使えませんでしたが…。)

文字コードを変換する部分は、元のコードには含まれていませんでしたが、コメントとして書き足しておきました。
行単位で処理していますが、もちろん tmpfile() 等を利用してテンポラリ領域で一気に変換しても良いと思います。

その他の解説はコード内へコメントを追記しましたが、正規表現 `'/("[^"]*(?:""[^"]*)*"|[^,]*),/'` については…

- `("[^"]*(?:""[^"]*)*"),`
	- `"セル,1","セル""2",...` のような場合の `"セル,1"` `"セル""2"` にマッチ
- `([^,]*),`
	- `セル1,セル2,...` の場合の `セル1` `セル2` にマッチ

…の2つを合体させたという意味になります。

それなりに実績のあるコードなので、PHP4～7まで、バージョンや環境を問わず幅広く動作すると思います。

また、PHPの `preg_match_all()` は正規表現の **/g修飾子** に該当する点に気をつければ、他言語への移植も比較的容易ではないかと思います。（各言語に優れたパーサーがある今となってはその需要はないと思いますが…）

こういう化石のようなコードを発掘して眺めていると、ちょっとノスタルジックな気分に浸れます。

--------------------------------------------------------------------------------

## 余談：CSVの方言とRFC

コメントを頂きましたので、**方言**という表現を採用した経緯等について余談を。

私は古い人間なので、大昔にIRC[^1]上で…

[^1]: [Internet Relay Chat](https://ja.wikipedia.org/wiki/Internet_Relay_Chat) という大昔に使われていたチャットの仕組み。今でもありますが、若い方には馴染みが薄いのではないかと思います。

- A：単にCSVではダメ。カンマ区切りのCSVと呼ぶべき。
- B：頭痛が痛いみたいだ。CSVは **C**omma-**S**eparated **V**alues の略だから分かるだろ。
- A：CSVは **C**haracter-**S**eparated **V**alues の略だ。

…という~~いわゆる「面倒臭い」人達の物凄く不毛でどうでもいい~~やり取りに何度か遭遇した事がありました。[^2]

[^2]: 大昔のIRCには、チャンネルにもよりますが、お作法教室の先生のような~~とても面倒くさい~~人が少なからずいたものです。

今でこそ、業務メールに機種依存文字を使っていても誰にも咎められないご時世ですが、このような~~面倒な~~方達ともお付き合いしながら、漢字Talk時代のMacintoshでネチケット(死語)を覚えた世代なので、「CP932＝SJISのマイクロソフト**方言**」という印象が未だに強く残っていたりもします。

また、エクセルで扱える**C**haracter-**S**eparated **V**aluesには…

- **C**omma-**S**eparated **V**alues (CSV)
- **T**ab-**S**eparated **V**alues (TSV)
- **S**pace-**S**eparated **V**alues (SSV)

…等があり、`\r\n` 以外の改行コードでCSVを吐き出す環境も存在します。
 
以上のような事から、今回扱うCSVのフォーマットを端的に表現する手段として、**方言**という言葉を採用した次第です。

…と、もっともらしい事を書いてますが、実際はもっと脊髄反射的に書いたものですが(笑)


### 改行コードについて

改めてWeb上の情報を確認してみると、CSVの改行コードの違いに困っている方もいるようなので、対象とするCSVについて **改行コードは問わない** という条件に修正しました。

SplTempFileObject を使ったコードの方は、改行コードを `\r\n` に統一するためのコードを1行追加してます。
正規表現を使った化石コードの方は、元々改行コードの違いも考慮していたものなので、特に修正していません。


### RFCについて

- [RFC 4180](https://www.ietf.org/rfc/rfc4180.txt)
	- [日本語訳](http://www.kasai.fm/wiki/rfc4180jp)

既にコメントでも触れて頂いていますが…

> Category: Informational

> It does not specify an Internet standard of any kind.

> there is no formal specification in existence, which allows for a wide variety of interpretations of CSV files.

…といった記述もある通り、RFC 4180は**いわゆる「標準」を定めたものではなく**、乱立した独自仕様に対し完全に後付けで「CSVとはこんなものだよ」と文章化したものです。

> October 2005

> Surprisingly, while this format is very common, it has never been formally documented.

成文化されたのはなんと2005年！遅っ！！ そりゃ Surprisingly, とも書きたくなります(笑)

よって、最も広く使われていたであろうエクセルのCSVのフォーマットが、RFC 4180に極めて近いのは至極当然ですし、方言という表現に違和感を覚える方がいらっしゃるのも無理はないかと思います。

日本の首都が東京であるという法令は存在しませんが、日本の首都は東京だというのが共通の認識です。「東京の言葉は方言だべさ」と言われても、納得する人は居ないですよね。