[PHP] Mac版Excelと互換性のあるCSVファイルを出来るだけ効率よく作成する

  • 23
    いいね
  • 1
    コメント

ポイント

mpyw-yattemita/excel-csv-compatibility-check

上記リポジトリで互換性を確認した結果,一般的には,以下の2つのいずれかの選択肢をとるべきであることが分かりました。

  • Shift_JISでCSV形式にして拡張子csvで保存
  • UTF-16(LE)でTSV形式にして拡張子csvで保存

この記事では,UTF-16(LE)を取り扱うことを前提とします。

注意点

  • Unicodeを用いる場合,文字セットに応じたBOMが必要である。
    • UTF-8なら 0xEF 0xBB 0xBF
    • UTF-16(LE) なら 0xFE 0xFF
    • UTF-16(BE) なら 0xFF 0xFE
  • 「UTF-16(LE)」と「UTF-16LE」は別物なので注意。「UTF-16LE」にはBOMが付かない。
  • Shift_JISのほうが文字の守備範囲が狭く,絵文字やラテン文字・ハングル文字などに対応できない。文字化けを回避したい場合はUTF-16(LE)を選択すべき。

基本

「新規作成w」「読み出しr」の2パターンを紹介します。

ファイルポインタリソース版

新規作成
<?php

// 挿入するデータ
$rows = [
    ['佐倉綾音', '保登心愛'],
    ['水瀬いのり', '香風智乃'],
];

// ファイルをオープン
$fp = fopen(__DIR__ . '/data.csv', 'wb');

// 書き込み時にUTF-8からUTF-16LEに変換
// (UTF-16にするとBEになってしまうため,UTF-16LEとしてオープンして後からBOMを書き込む)
stream_filter_prepend($fp, 'convert.iconv.utf-8/utf-16le');

// BOMを書き込んだ後,1行ずつファイルに書き込む
fwrite($fp, "\xEF\xBB\xBF");
foreach ($rows as $row) {
    fputcsv($fp, $row, "\t");
}
読み出し
<?php

// ファイルをオープン
$fp = fopen(__DIR__ . '/data.csv', 'rb');

// 読み出し時にUTF-16(BE/LE)からUTF-8に変換する
// (自動でBOMは取り除かれる)
stream_filter_prepend($fp, 'convert.iconv.utf-16/utf-8');

// 1行ずつ取り出す
while ($row = fgetcsv($fp, 0, "\t")) {
    if ($row === [null]) continue; // 空行を読み飛ばす
    var_dump($row);
}

SplFileObject版

新規作成
<?php

// 挿入するデータ
$rows = [
    ['佐倉綾音', '保登心愛'],
    ['水瀬いのり', '香風智乃'],
];

// 書き込み時にUTF-8からUTF-16LEに変換するようにしてファイルオープン
// (UTF-16にするとBEになってしまうため,UTF-16LEとしてオープンして後からBOMを書き込む)
$filename = __DIR__ . '/data.csv';
$spec = "php://filter/write=convert.iconv.utf-8%2Futf-16le/resource=$filename";
$file = new \SplFileObject($spec, 'wb');
$file->setCsvControl("\t"); // デフォルトの区切り文字をタブに変更する

// BOMを書き込んだ後,1行ずつファイルに書き込む
$file->fwrite("\xEF\xBB\xBF");
foreach ($rows as $row) {
    $file->fputcsv($row);
}
読み出し
<?php

// 読み出し時にUTF-16(BE/LE)からUTF-8に変換するようにしてファイルオープン
// (自動でBOMは取り除かれる)
$filename = __DIR__ . '/data.csv';
$spec = "php://filter/read=convert.iconv.utf-16%2Futf-8/resource=$filename";
$file = new \SplFileObject($spec, 'rb');
$file->setFlags(
    \SplFileObject::READ_CSV | // foreachの反復処理をfgetcsvで行う
    \SplFileObject::SKIP_EMPTY | // 空行を読み飛ばす (これが無いと空行を [null] として読み取ってしまう)
    \SplFileObject::READ_AHEAD | // 空行を読み飛ばすためには必須
    \SplFileObject::DROP_NEW_LINE // 空行を読み飛ばすためには必須
);
$file->setCsvControl("\t"); // デフォルトの区切り文字をタブに変更する

// 1行ずつ取り出す
foreach ($file as $row) {
    var_dump($row);
}

応用

「新規作成or追記→読み出しa+」の場合を紹介します。これは掲示板スクリプトなどでの利用を想定しているため,ファイルロック処理も入れています。

ファイルポインタリソース版

新規作成or追記→読み出し
<?php

// 挿入するデータ
$rows = [
    ['佐倉綾音', '保登心愛'],
    ['水瀬いのり', '香風智乃'],
];

// ファイルをオープン
$filename = __DIR__ . '/data.csv';
$fp = fopen($filename, 'a+b');

// 書き込み時にUTF-8からUTF-16LEに変換する
// (UTF-16にするとBEになってしまうため,UTF-16LEとしてオープンして後からBOMを書き込む)
stream_filter_prepend($fp, 'convert.iconv.utf-8/utf-16le', STREAM_FILTER_WRITE);

// 排他ロックと初回のみBOMの書き込みを行った後末尾に移動し,1行ずつファイルに書き込む
flock($fp, LOCK_EX);
if (filesize($filename) < 2) {
    fwrite($fp, "\xEF\xBB\xBF");
    clearstatcache();
}
foreach ($rows as $row) {
    fputcsv($fp, $row, "\t");
}

// 先頭に戻る
rewind($fp);

// 読み出し時にUTF-16(BE/LE)からUTF-8に変換する
// (自動でBOMは取り除かれる)
stream_filter_prepend($fp, 'convert.iconv.utf-16/utf-8', STREAM_FILTER_READ);

// 共有ロックに切り替えた後,1行ずつ取り出す
flock($fp, LOCK_SH);
while ($row = fgetcsv($fp, 0, "\t")) {
    if ($row === [null]) continue; // 空行を読み飛ばす
    var_dump($row);
}

SplFileObject版

新規作成or追記→読み出し
<?php

// 挿入するデータ
$rows = [
    ['佐倉綾音', '保登心愛'],
    ['水瀬いのり', '香風智乃'],
];

// 書き込み時にUTF-8からUTF-16LE
// 読み出し時にUTF-16LEからUTF-8に変換するようにしてファイルオープン
// (新規作成する可能性がある場合はreadにUTF-16は指定出来ないのでUTF-16LEにする)
$filename = __DIR__ . '/data.csv';
$spec = "php://filter/read=convert.iconv.utf-16le%2Futf-8/write=convert.iconv.utf-8%2Futf-16le/resource=$filename";
$file = new \SplFileObject($spec, 'a+b');
$file->setFlags(
    \SplFileObject::SKIP_EMPTY | // 空行を読み飛ばす (これが無いと空行を [null] として読み取ってしまう)
    \SplFileObject::READ_AHEAD | // 空行を読み飛ばすためには必須
    \SplFileObject::DROP_NEW_LINE // 空行を読み飛ばすためには必須
);
$file->setCsvControl("\t"); // デフォルトの区切り文字をタブに変更する

// 排他ロックと初回のみBOMの書き込みを行った後末尾に移動し,1行ずつファイルに書き込む
$file->flock(LOCK_EX);
if (filesize($filename) < 2) {
    $file->fwrite("\xEF\xBB\xBF");
    clearstatcache();
}
foreach ($rows as $row) {
    $file->fputcsv($row);
}

// 共有ロックに切り替えた後2バイトを除いた先頭に戻り,1行ずつ取り出す
// イテレータは使えないので手動でfgetcsvする
$file->flock(LOCK_SH);
$file->fseek(2);
while ($row = $file->fgetcsv()) {
    var_dump($row);
}

fgetsに対してこのwhileループの書き方は出来ないので注意

なんかイマイチですね。どうせならクラス継承しちゃいましょうか。

新規作成or追記→読み出し (SplFileObject継承)
<?php

// 書き込むデータ
$rows = [
    ['佐倉綾音', '保登心愛'],
    ['水瀬いのり', '香風智乃'],
];

// 追記と読み出しが可能なCSVファイルとしてオープン
$file = new ExcelCsvFileObject(__DIR__ . '/data.csv');

// 排他ロックのあと1行ずつファイルに書き込む
$file->flock(LOCK_EX);
foreach ($rows as $row) {
    $file->fputcsv($row);
}

// 共有ロックに切り替えて1行ずつ取り出す
$file->flock(LOCK_SH);
foreach ($file as $row) {
    var_dump($row);
}



/**
 * Excel互換のCSVに特化したSplFileObjectのラッパー
 * 常にa+bモードでオープンする
 */
class ExcelCsvFileObject extends \SplFileObject
{   
    private $position = 0;
    private $current;

    /**
     * @param string $filename ファイル名
     * @throws RuntimeException
     */
    public function __construct($filename)
    {
        parent::__construct("php://filter/read=convert.iconv.utf-16le%2Futf-8/write=convert.iconv.utf-8%2Futf-16le/resource=$filename", 'a+b');
        $this->setFlags(
            \SplFileObject::READ_CSV |
            \SplFileObject::SKIP_EMPTY | 
            \SplFileObject::READ_AHEAD | 
            \SplFileObject::DROP_NEW_LINE
        );
        $this->setCsvControl("\t");
        if (filesize($filename) < 2) {
            $this->flock(LOCK_EX);
            $this->fwrite("\xEF\xBB\xBF");
            $this->flock(LOCK_UN);
            clearstatcache();
        }
        $this->rewind();
    }

    /**
     * @return string|null
     */
    public function current()
    {
        return $this->current;
    }

    /**
     * @return int
     */
    public function key()
    {
        return $this->position;
    }

    /**
     * @return null
     */
    public function next()
    {
        $this->current = $this->fgetcsv();
        ++$this->position;
    }

    /**
     * @return null
     */
    public function rewind()
    {
        $this->fseek(2);
        $this->position = 0;
        $this->current = $this->fgetcsv();
    }

    /**
     * @return bool
     */
    public function valid()
    {
        return $this->current !== null;
    }
}