Edited at

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

More than 1 year has passed since last update.


ポイント

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;
}
}