LoginSignup
45
47

More than 5 years have passed since last update.

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

Last updated at Posted at 2016-03-29

ポイント

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;
    }
}
45
47
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
45
47