CSV のパーサ作ってみた。

まぁ、タイトルの通りだわな。

とりあえず使えるレベルまでは持って行けたのでエントリにしてみる。

基本的には読み込みと書き込みに対応してるお。

ちょぉ長いから覚悟して下さい。

<?php
/**
 * lib/File/CSV.php
 *
 * @author Monry
 * @package Monry
 * @version $Id$
 */

// {{{ Monry_File_CSV
/**
 * 汎用 CSV ファイル操作クラス
 *
 * @author Monry
 * @access public
 * @package Monry
 */
class Monry_File_CSV {

    // {{{ protected variables
    /**#@+
     * @access protected
     */

    /** @var string デリミタ */
    protected $delimiter = ",";

    /** @var string デリミタ (エイリアス) */
    protected $d = ",";

    /** @var string クォーテーション */
    protected $quotation = "\"";

    /** @var string クォーテーション (エイリアス) */
    protected $q = "\"";

    /** @var string CSV ファイルのエンコーディング */
    protected $file_encoding = "Shift_JIS";

    /** @var string PHP ソースのエンコーディング */
    protected $source_encoding = "EUC-JP";

    /** @var array ファイルポインタ (オープンモード毎に管理) */
    protected $pointer = array();

    /** @var string ファイル名 */
    protected $file_name = "";

    /** @var integer フィールド数 */
    protected $fields_count = false;

    /**#@- */
    // }}}

    // {{{ outputHeader
    /**
     * CSV のヘッダを出力する
     * 
     * @param string $file_name 出力時のファイル名
     * @static
     * @access public
     * @return void
     */
    public static function outputHeader($file_name = "output.csv") {
        header("Content-Type: application/octet-stream");
        header("Content-Disposition: attachment; filename={$file_name}");
    }
    // }}}

    // {{{ Monry_File_CSV
    /**
     * コンストラクタ
     * 
     * @param array $conf 設定情報
     * @access public
     * @return void
     */
    public function __construct($file_name = "", $conf = array()) {
        if ($file_name) {
            $this->setFileName($file_name);
        }
        if (valid_array($conf)) {
            $this->setConfig($conf);
        }
    }
    // }}}

    // {{{ read
    /**
     * 一行読み込み、一次元配列にして返す
     * 
     * @access public
     * @return array 読み込んだデータ
     */
    public function read() {
        $pointer =& $this->_getPointer('r');
        return $this->_parse($pointer);
    }
    // }}}

    // {{{ readAll
    /**
     * 全行読み込み、二次元配列にして返す
     * 
     * @access public
     * @return array 読み込んだデータ
     */
    public function readAll() {
        $csv = array();
        while (($row = $this->read()) !== false) {
            $csv[] = $row;
        }
        return $csv;
    }
    // }}}

    // {{{ write
    /**
     * 一次元配列をストリームに書き込む
     * 
     * @param mixed $data 書き込む対象の一次元配列
     * @param string $open_mode 書き込みモード
     * @access public
     * @return void
     */
    public function write($data, $open_mode = "a") {
        $data = $this->_escape($data);
        $pointer = $this->_getPointer($open_mode);
        fwrite($pointer, $data);
    }
    // }}}

    // {{{ writeAll
    /**
     * 二次元配列をストリームに書き込む
     * 
     * @param mixed $data 書き込む対象の二次元配列
     * @param string $open_mode 書き込みモード
     * @access public
     * @return void
     */
    public function writeAll($data, $open_mode = "a") {
        if (valid_aray($data)) {
            foreach ($data as $row) {
                $this->write($row, $open_mode);
            }
        }
    }
    // }}}

    // {{{ close
    /**
     * ファイルポインタをクローズする
     * 
     * @param mixed $open_mode false: 全てを閉じる, string: 指定したモードを閉じる
     * @access public
     * @return void
     */
    public function close($open_mode = false) {
        if ($open_mode === false) {
            $pointers =& $this->_getPointer();
            if (valid_array($pointers)) {
                foreach ($pointers as &$pointer) {
                    if ($pointer !== false && is_resource($pointer)) {
                        fclose($pointer);
                    }
                }
            }
        } else {
            $pointer =& $this->getPointer($open_mode);
            if ($pointer !== false && is_resource($pointer)) {
                fclose($pointer);
            }
        }
    }
    // }}}

    // {{{ getDelimiter
    /**
     * デリミタを取得
     * 
     * @access public
     * @return string デリミタ
     */
    public function getDelimiter() {
        return $this->delimiter;
    }
    // }}}

    // {{{ setDelimiter
    /**
     * デリミタを設定
     * 
     * @param string $delimiter デリミタ
     * @access public
     * @return void
     */
    public function setDelimiter($delimiter) {
        $this->delimiter = $delimiter;
        $this->d =& $this->delimiter;
    }
    // }}}

    // {{{ getQuotation
    /**
     * クォーテーションを取得
     * 
     * @access public
     * @return string クォーテーション
     */
    public function getQuotation() {
        return $this->quotation;
    }
    // }}}

    // {{{ setQuotation
    /**
     * クォーテーションを設定
     * 
     * @param string $quotation クォーテーション
     * @access public
     * @return void
     */
    public function setQuotation($quotation) {
        $this->quotation = $quotation;
        $this->q =& $this->quotation;
    }
    // }}}

    // {{{ getFileEncoding
    /**
     * ファイルエンコーディングを取得
     * 
     * @access public
     * @return string
     */
    public function getFileEncoding() {
        return $this->file_encoding;
    }
    // }}}

    // {{{ setFileEncoding
    /**
     * ファイルエンコーディングを設定
     * 
     * @param string $file_encoding ファイルエンコーディング
     * @access public
     * @return void
     */
    public function setFileEncoding($file_encoding) {
        $this->file_encoding = $file_encoding;
    }
    // }}}

    // {{{ getSourceEncoding
    /**
     * PHP ソースエンコーディングを取得
     * 
     * @access public
     * @return string
     */
    public function getSourceEncoding() {
        return $this->source_encoding;
    }
    // }}}

    // {{{ setSourceEncoding
    /**
     * PHP ソースエンコーディングを設定
     * 
     * @param string $source_encoding ファイルエンコーディング
     * @access public
     * @return void
     */
    public function setSourceEncoding($source_encoding) {
        $this->source_encoding = $source_encoding;
    }
    // }}}

    // {{{ getFileName
    /**
     * ファイル名を取得
     * 
     * @access public
     * @return string ファイル名
     */
    public function getFileName() {
        return $this->file_name;
    }
    // }}}

    // {{{ setFileName
    /**
     * ファイル名を設定
     * 
     * @param string $file_name ファイル名
     * @access public
     * @return void
     */
    public function setFileName($file_name) {
        $this->file_name = $file_name;
    }
    // }}}

    // {{{ getFieldsCount
    /**
     * フィールド数を取得
     * 
     * @access public
     * @return integer フィールド数
     */
    public function getFieldsCount() {
        return $this->fields_count;
    }
    // }}}

    // {{{ setFieldsCount
    /**
     * フィールド数を設定
     * 
     * @param integer $fields_count フィールド数
     * @access public
     * @return void
     */
    public function setFieldsCount($fields_count) {
        $this->fields_count = $fields_count;
    }
    // }}}

    // {{{ setConfig
    /**
     * 設定情報をメンバに保持する
     * 
     * @param array $conf 設定情報
     * @access public
     * @return void
     */
    public function setConfig($conf = array()) {
        $conf_names = array("quotation", "delimiter", "file_encoding", "source_encoding", "fields_count");
        if (valid_array($conf)) {
            foreach ($conf_names as $conf_name) {
                if (array_key_exists($conf_name, $conf)) {
                    $this->$conf_name = $conf[$conf_name];
                }
            }
        }
        // 短縮名設定
        $this->q =& $this->quotation;
        $this->d =& $this->delimiter;
    }
    // }}}

    // {{{ _getPointer
    /**
     * ポインタ取得
     * 
     * @param string $mode ファイルオープンモード
     * @access protected
     * @return resource ファイルポインタ
     */
    protected function &_getPointer($open_mode = false) {
        if ($open_mode === false) {
            return $this->pointer;
        }
        if (array_key_exists($open_mode, $this->pointer) && is_resource($this->pointer[$open_mode])) {
            return $this->pointer[$open_mode];
        }
        $pointer = @fopen($this->file_name, $open_mode);
        if (!is_resource($pointer)) {
            // TODO: throw new Mds_Exception_Manager
            $false = false;
            return $false;
        }
        $this->pointer[$open_mode] =& $pointer;
        return $this->pointer[$open_mode];
    }
    // }}}

    // {{{ _parse
    /**
     * パースする
     *   フォーマットは Microsoft Excel 形式の CSV とする
     * 
     * @param resource $pointer ファイルポインタ
     * @access protected
     * @return array パース結果
     */
    protected function _parse(&$pointer) {
        if ($pointer !== false && !feof($pointer)) {
            $line = mb_convert_encoding(fgets($pointer), $this->source_encoding, $this->file_encoding);
            // 改行コードを Line feed に統一する
            $line = str_replace(array("\r\n", "\r"), array("\n", "\n"), $line);
            $row = array();
            $data = "";
            $is_data = false;
            $is_quot = false;
            for ($i = 0; $i < mb_strlen($line, $this->source_encoding); $i++) {
                $c = mb_substr($line, $i, 1, $this->source_encoding);
                switch ($c) {
                    case $this->d:
                        // {{{ デリミタ
                        if ($is_data && $is_quot) {
                            $data .= $c;
                        } elseif ($is_data && !$is_quot) {
                            $data = "";
                            $is_data = false;
                            $next = mb_substr($line, $i + 1, 1, $this->source_encoding);
                            if ($next == $this->d || $next == "\n") {
                                $row[] = $data;
                            }
                        } elseif (!$is_data && !$is_quot) {
                            $data = "";
                            $next = mb_substr($line, $i + 1, 1, $this->source_encoding);
                            if ($next == $this->d || $next == "\n") {
                                $row[] = $data;
                            }
                        }
                        break;
                        // }}}
                    case $this->q:
                        // {{{ クォーテーション
                        if ($is_data && $is_quot) {
                            $next = mb_substr($line, $i + 1, 1, $this->source_encoding);
                            if ($next == $this->q) {
                                $data .= $c;
                                $i++;
                            } elseif ($next == $this->d) {
                                $is_data = false;
                                $is_quot = false;
                                // フィールドを追加
                                $row[] = $data;
                                $data = "";
                            } else {
                                // TODO: Exception を throw する
                            }
                        } elseif ($is_data && !$is_quot) {
                            $data .= $c;
                        } elseif (!$is_data && !$is_quot) {
                            $is_data = true;
                            $is_quot = true;
                        }
                        break;
                        // }}}
                    case "\n":
                        // {{{ 改行コード
                        if ($is_data && $is_quot) {
                            $line .= mb_convert_encoding(fgets($pointer), $this->source_encoding, $this->file_encoding);
                            $line = str_replace(array("\r\n", "\r"), array("\n", "\n"), $line);
                            $data .= $c;
                        } elseif ($is_data && !$is_quot) {
                            $row[] = $data;
                            $data = "";
                        }
                        break;
                        // }}}
                    default:
                        // {{{ その他
                        if (!$is_data) {
                            $is_data = true;
                        }
                        $data .= $c;
                        break;
                        // }}}
                }
            }
            return $row;
        }
        return false;
    }
    // }}}

    // {{{ _escape
    /**
     * 配列を CSV 文字列にエスケープする
     * 
     * @param mixed $data string: 単一データ, array: 行データ
     * @access protected
     * @return mixed string: 単一データのエスケープ結果, array: 行データのエスケープ結果
     */
    protected function _escape($data) {
        if (valid_array($data)) {
            foreach ($data as $index => $column) {
                $data[$index] = $this->_escape($column);
            }
            // フィールド数の指定がある場合は空文字で埋める
            if ($this->fields_count !== false) {
                $data = array_pad($data, $this->fields_count, "");
            }
            return implode($this->d, $data) . "\n";
        }
        $data = mb_ereg_replace("{$this->d}", "\"\"", $data, $this->source_encoding);
        $data = mb_ereg_replace("\r\n|\r", "\n", $data, $this->source_encoding);
        if (mb_strpos($data, $this->d, 0, $this->source_encoding) !== false
            || mb_strpos($data, "\n", 0, $this->source_encoding) !== false) {
            $data = "\"{$data}\"";
        }
        return mb_convert_encoding($data, $this->file_encoding, $this->source_encoding);
    }
    // }}}

}
// }}}
?>

まぁ、肝となるのは Monry_File_CSV::_parse() かな?

mb_substr で一文字ずつ読んで行って、文字によって処理を切り替えてます。

これ以上短くすると可読性落ちると思ったんで、ちょっと長い感じになってるのは気にしない :-P

本当は配列とかでカラム名とかを渡して、readAll とか writeAll とかの時に自動で連想配列に出来れば良いかとも思ったけど、面倒だから放置。

寧ろ誰か書いて下さい(ぇ