miauのブログ

はてなダイアリー「miauの避難所」をはてなブログに移行しました

PHP fputcsv/fgetcsv の $escape オプション

fputcsvfgetcsv には $escape オプションがあります。
(fputcsv のマニュアルが更新されていませんが、PHP 5.5.4 で $escape オプションが追加されています。)

この動作を勘違いしていたので、ちょっと整理しておきます。

前提

以下 fputcsv/fgetcsv ともにデフォルトの

  • $delimiter = ","
  • $enclosure = '"'

として呼び出した前提で書きます。(説明文中で $delimiter とか $enclosure とか書いても読みづらいと思うので。)

動作は PHP 5.6.0 で、ソースは 現時点の master 最新版 を確認しました。

期待した動作(勘違い)

期待した動作は、

  • fputcsv を $escape = "\\"(デフォルト)で呼び出した場合、「"」は「\"」として、「\」は「\\」として出力される
  • fgetcsv を $escape = "\\"(デフォルト)で呼び出した場合、「\"」は「"」として、「\\」は「\」として読み込まれる

というものです。なので、RFC 4180 の仕様や Excel の動作に合わせるならば、fgetcsv/fputcsv の呼び出し時に $escape = '"' を指定して

  • fputcsv 時に「"」を「""」として出力する
  • fgetcsv 時に「""」を「"」として読み込む

必要がある・・・のだと思っていました。

実際の動作

実際の動作は fputcsv/fgetcsv ともに、$escape の指定とは関係なく

  • fputcsv 時に「"」を「""」として出力する
  • fgetcsv 時に「""」を「"」として読み込む

の処理が入っています。

では $escape = "\\" の動作はどういうものかというと、以下のような動作になっている模様です。

  • fputcsv の $escape は、fputcsv に渡された文字列中の「"」がその文字でエスケープ済みであることを示す
    • 「"」は通常「""」として出力されるが、「\"」の場合は「\""」とせず「\"」としてそのまま出力される
  • fgetcsv の $escape は、その文字でエスケープされている場合はフィールド終端の「"」とみなさないよう指示する
    • 「"ab\",cd"」を読み込んだ場合に「ab\"」「cd"」として読み込むのではなく「ab\",cd」として読み込む
    • エスケープを解除して「ab",cd」としてくれるわけではない点に注意

上記の動作なので、もし「"」を「\"」にエスケープする仕様で CSV を作成したければ、

  • あらかじめ「"」を「\"」に置換した上で、fputcsv を $escape = "\\" で呼び出す
  • fgetcsv を $escape = "\\" として呼び出した上で、「\"」を「"」に置換する

必要があります。

また、RFC 4180 に従った CSV を扱いたいのであれば、fputcsv/fgetcsv ともデフォルトの $escape = "\\" のまま使うのはよろしくないですね。余計な処理が入ってしまうので。

書いてる途中で気付きましたが、stackoverflow で同じような疑問を挙げてる人がいました。やっぱりわかりにくい動作ですよね。

$escape = '"' を指定した場合の挙動

とまあ $escape の意味合いは期待した動作とは違ったわけですが、$escape = '"' を指定した場合に期待した通りに動いてくれれば問題はないわけで。どうなるか確認すると・・・

  • fputcsv では「"」を「""」ではなく「"」のまま出力してしまうため NG
  • fgetcsv では $escape の判定よりも「""」の判定が先に来ており、「""」を「"」としてうまく読み込んでくれるため OK

という結果でした。

まあ fputcsv は

  • そもそも PHP 5.5.4 まで $escape オプションがなかった
  • 改行を RFC 4180 で決められている CRLF ではなく、LF で出力してしまう
  • 自前で同じ処理を書くのもわりと簡単

ということで、自前で実装するケースも少なくなかったところではあるのですが・・・コミットメッセージ を見ると RFC 4180 に対応できるよう $escape オプションが追加されたようなのに、結局状況が変わってないのがちょっと残念ですね。

(追記)
書いた後で気付いたんですが、$escape の意図に沿って使うのであれば、あらかじめ「"」を「""」に置換した上で、$escape = '"' を指定して fputcsv を呼べばいいですね。
(追記ここまで)

keboola/csv の動作は?

Composer で CSV を扱うライブラリでは、keboola/csv というのが人気らしいです。

この実装(1.1.3)を確認したところ、

  • CSV の読み込み
    • fgetcsv を使用
    • コンストラクタに $escapedBy を渡すことで $escape オプションを変更可。デフォルトでは chr(0) が使われる
  • CSV の出力
    • 自前で処理を行っている
    • 「"」がある場合は「""」にして出力する形
    • 改行コードは LF 固定

ということで、「どうせフィールドの末尾に \0 なんて入らないよ」というのであればデフォルトのまま、そうでないならば $escapedBy = '"' として使用すればいいでしょう。(改行コード LF が許容できるならですが。)

ライブラリに頼りたくない&自前で実装したくない場合は、これを見習って fputcsv で $escpae = "\0" を指定しても実用上問題はない気がしますね。

ロケールの設定について

ただ、keboola/csv を使えばそれで OK ということはなく、マルチバイト文字には注意は必要です。fgetcsv は内部でロケールの情報を使ってマルチバイト文字の境界を調べるので、適切なロケールを設定しましょう。

適切なロケールというのは・・・ちょっと乱暴ですが、WindowsLinux を使ってる人であれば、基本的に C ロケールでいいんじゃないかと思っています。理由を以下に 3 つ挙げます。

理由その1: どこでも使える

ロケールを ja_JP.UTF-8 にしている例を見かけますが、en_US.UTF-8 じゃないと入っていない環境もあったりしますし、変に細かい指定をしてしまうと可搬性が下がってしまいます。C ロケールならどこでも使えるのでその点では安心です。

id:moriyoshi が fgetcsv がロケールに依存している理由(これを読むと納得せざるを得ない)とともに C ロケールを使う場合の注意を書いてくれていて、

ただし "C" ロカールでは portable characters 以外での標準関数の挙動は未定義なので、libcによってはうまく動かないかも。

とのことなので、C ロケールで portable characters 以外の文字を 1 バイトとして扱ってくれない例を調べてみたのですが、

によると Plan 9Minix、*BSD family あたりのようです。このあたりの環境でも使用するスクリプトを書く場合は C ロケール以外にしましょう。

理由その2: 文字境界を気にする必要はあまりない

RFC 4180 における CSV 上でのメタキャラクタは

  • LF (U+000A)
  • CR (U+000D)
  • 「"」(U+0022)
  • 「,」(U+002C)

の 4 つなので、UTF-8 だろうと Shift_JIS だろうと 2 バイト目以降に出現しない文字です。「"」を「""」でエスケープしている限りは 1 バイト単位で扱っても問題ありません。

一方「\」(U+005C) は Shift_JIS の 2 バイト目に出現します(UTF-8 の 2 バイト目以降には出現しない文字です)ので、Shift_JIS でかつ「"」が「\"」で表現された CSV ファイルを読む場合だけ文字境界に注意が必要です。とはいえ、現在の PHP ではフィルタが使えるので、あらかじめ UTF-8 に変換して読み込んでしまえば問題ありません。以下に例がありました。

理由その3: マルチバイトの処理がバグってそう?

もう一つ C ロケールを勧める理由があって。$escape = "\\" ではマルチバイトの扱いにバグありそうなのです。

ここで「1,"\あ",おはよう」が読めない例が挙がっていますが、ソースを見ると、エスケープ文字の直後であることを示す state=1 がマルチバイトを読んだときに解除されず、シングルバイトの文字に出会った時に始めて処理されるようになっていそう(「1,"あ\",おはよう」と書いたのと同じような動作になっていそう)でした。C ロケールならこの動作は回避できます。
(上のほうにも書きましたが、$escape = '"' の場合は $escape の判定よりも「""」の判定が先に来ているので、この影響を受けません。)

蛇足:「Excel で開いた時におかしくなるんだけど」と言われたら

蛇足ですが、CSV ファイルに「0123」のようなコードを出力していたとしても、Excel で開くと「123」になったりするので、「なんとかならないの?」と言われることもわりとあります。

この場合は「'0123」のように文字列型であることを明示したり「="0123"」のように計算式を埋め込んだり、といった胡散臭い対策が知られてますが、人間が Excel で開くのが目的なら .xls やら .xlsx を出力したほうがいいんじゃないかと思います。

2014/12/16 追記

ソースを読み返してみると、ロケールが合っていなかった場合(mblen が -1 を返した場合)もシングルバイトの文字として処理しているので、Shift_JISCSV ファイルを $escape = "\\" で読む場合を除いては、ロケールが合っていなくてもうまく読めるケースがほとんどのような気がします。というかこれ以外のケースで問題になるケースが思いつかない・・・。

そのわりに「ロケールを設定したらうまくいった」と書いている人がわりと多いので、何か勘違いしているかもしれません。余裕のある時にもうちょっと調べてみます。