RFC4180に従わないfgetcsv/str_getcsvの独自仕様
「バックスラッシュを削除するとうまくいく」。そんな問い合わせを受けて調べてみたら、驚愕の事実が発覚。その調査メモ。
CSVファイル
さすがにオリジナルを載せるわけにはいかないので、簡略化したCSVファイルを示す。文字エンコーディングはWindows-31J。
a,b,c,d,e A,B,C,D,E "カンマ(comma)",",","","でぃ","" "ダブルクォーテーション(double quotation)","""","","でぃ","" "改行"," ","","でぃ","" "バックスラッシュ(back slash)","\","","でぃ",""
ついでに、CSVで気を付けるべき、カンマ、ダブルクォーテーション、改行についても合わせてテストしてみる。
str_getcsvでのテスト
そもそもの発端である問い合わせの対象システムがPHPのstr_getcsvを使ってCSVファイル解析しているので、それを模倣したテストプログラムを作ってみる。
<?php mb_internal_encoding('UTF-8'); $header = array(); while (($str = fgets(STDIN))) { $str = mb_convert_encoding($str, mb_internal_encoding(), 'SJIS-win'); if (empty($header)) { $header = str_getcsv($str); } else { $ss = str_getcsv($str); $a = array(); for ($i = 0; $i < count($header); ++$i) { if (isset($ss[$i])) { $a[$header[$i]] = $ss[$i]; } else { $a[$header[$i]] = 'N/A'; } } var_dump($a); } }
実行結果。
$ php Main.php < test.csv array(5) { ["a"]=> string(1) "A" ["b"]=> string(1) "B" ["c"]=> string(1) "C" ["d"]=> string(1) "D" ["e"]=> string(1) "E" } array(5) { ["a"]=> string(16) "カンマ(comma)" ["b"]=> string(1) "," ["c"]=> string(0) "" ["d"]=> string(6) "でぃ" ["e"]=> string(0) "" } array(5) { ["a"]=> string(51) "ダブルクォーテーション(double quotation)" ["b"]=> string(1) """ ["c"]=> string(0) "" ["d"]=> string(6) "でぃ" ["e"]=> string(0) "" } array(5) { ["a"]=> string(6) "改行" ["b"]=> string(3) " " ["c"]=> string(3) "N/A" ["d"]=> string(3) "N/A" ["e"]=> string(3) "N/A" } array(5) { ["a"]=> string(10) ",",でぃ"" ["b"]=> string(0) "" ["c"]=> string(3) "N/A" ["d"]=> string(3) "N/A" ["e"]=> string(3) "N/A" } array(5) { ["a"]=> string(36) "バックスラッシュ(back slash)" ["b"]=> string(12) "\",",でぃ"" ["c"]=> string(0) "" ["d"]=> string(3) "N/A" ["e"]=> string(3) "N/A" }
まず、バックスラッシュの行だが、事前に予想した通り、バックスラッシュがエスケープ文字として機能している「ように見える」。 「ように見える」というのは、以下の2点の疑問をはらんでいるからである。
- エスケープ文字であるならば、値としての文字列にバックスラッシュが残っているのはなぜか?
- 元データが
"バックスラッシュ(back slash)","\","","でぃ",""
であるので、「でぃ」の直前が閉じダブルクォーテーションとなるはずで、その後ろの「でぃ」が連結されたのだと考えても、その後ろのダブルクォーテーションで値が終了しているのがおかしい(開きダブルクォーテーションとなるべきもののはず)。
更に、入力が「ヘッダ1行+データ5行」に対して、出力が「データ6行」になっている。これは、fgetsで1行ずつ読んでいるため、CSV形式の値に含まれる改行コードとして扱われていないことに起因する。ただ、それに対処しようとすると、自前でCSV形式の解析をすることになり、str_getcsvの存在意義がない。
fgetcsvでのテスト
普通に考えたら結果が違うはずはないが、一応確かめておく。
<?php mb_internal_encoding('SJIS-win'); $header = array(); while (($row = fgetcsv(STDIN))) { if (empty($header)) { $header = $row; } else { $ss = $row; $a = array(); for ($i = 0; $i < count($header); ++$i) { if (isset($ss[$i])) { $a[$header[$i]] = mb_convert_encoding($ss[$i], 'UTF-8', mb_internal_encoding()); } else { $a[$header[$i]] = 'N/A'; } } var_dump($a); } }
実行結果。
$ php Main2.php < test.csv array(5) { ["a"]=> string(1) "A" ["b"]=> string(1) "B" ["c"]=> string(1) "C" ["d"]=> string(1) "D" ["e"]=> string(1) "E" } array(5) { ["a"]=> string(16) "カンマ(comma)" ["b"]=> string(1) "," ["c"]=> string(0) "" ["d"]=> string(6) "でぃ" ["e"]=> string(0) "" } array(5) { ["a"]=> string(51) "ダブルクォーテーション(double quotation)" ["b"]=> string(1) """ ["c"]=> string(0) "" ["d"]=> string(6) "でぃ" ["e"]=> string(0) "" } array(5) { ["a"]=> string(6) "改行" ["b"]=> string(2) " " ["c"]=> string(0) "" ["d"]=> string(6) "でぃ" ["e"]=> string(0) "" } array(5) { ["a"]=> string(36) "バックスラッシュ(back slash)" ["b"]=> string(12) "\",",でぃ"" ["c"]=> string(0) "" ["d"]=> string(3) "N/A" ["e"]=> string(3) "N/A" }
str_getcsvの場合と同様に、バックスラッシュはエスケープ文字として働いているように見える。一方で、改行コードを値に含むケースは期待通りに処理されている。
JavaのSuper CSVではどうか?
身近で、JavaのSuper CSV(https://super-csv.github.io/super-csv/)が使用されているので、比較のために同じCSVファイルを読ませてみた。
import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.util.Arrays; import java.util.List; import org.supercsv.io.CsvListReader; import org.supercsv.io.ICsvListReader; import org.supercsv.prefs.CsvPreference; public class Main { public static void main(String[] args) throws IOException { CsvPreference csvPref = new CsvPreference.Builder( CsvPreference.STANDARD_PREFERENCE).surroundingSpacesNeedQuotes(true).build(); ICsvListReader reader = new CsvListReader( new BufferedReader(new InputStreamReader(System.in, "Windows-31J")), csvPref); String[] header = reader.getHeader(false); System.out.println(Arrays.toString(header)); List<String> row; while ((row = reader.read()) != null) { int i = 0; for (String val : row) { System.out.println((header.length >= i + 1 ? header[i++] : "N/A") + " => " + (val != null ? "\"" + val + "\"" : val)); } System.out.println(); } } }
実行結果。
$ javac -cp lib/super-csv-2.4.0.jar Main.java $ java -cp .:lib/super-csv-2.4.0.jar Main < test.csv [a, b, c, d, e] a => "A" b => "B" c => "C" d => "D" e => "E" a => "カンマ(comma)" b => "," c => null d => "でぃ" e => null a => "ダブルクォーテーション(double quotation)" b => """ c => null d => "でぃ" e => null a => "改行" b => " " c => null d => "でぃ" e => null a => "バックスラッシュ(back slash)" b => "\" c => null d => "でぃ" e => null
こちらは、値が空文字列の場合にnullを返すという厄介な仕様なのだが、バックスラッシュについての扱いが適切である。
Microsoft Excelで開いてみる
もともとが仕事の調査ということで、社用のMicrosoft 365を拝借して、同じCSVファイルを開いてみた。
ご覧の通り、バックスラッシュは単なる文字として扱われる。
CSV形式のファイルに関するRFC
以下の1つ目が対象のRFC(RFC4180)。その日本語訳も見付けたので2つ目に挙げている。
- Common Format and MIME Type for Comma-Separated Values (CSV) Files
- CSVファイルの一般的書式 (RFC4180 日本語訳) - アルプス登山の玄関口・笠井家
これによると、そもそも2005年10月というのもあるが、
TEXTDATA = %x20-21 / %x23-2B / %x2D-7E
という辺りが時代を感じさせる。
それはともかく、重要なのは以下の部分である。
6. Fields containing line breaks (CRLF), double quotes, and commas should be enclosed in double-quotes. For example: "aaa","b CRLF bb","ccc" CRLF zzz,yyy,xxx 7. If double-quotes are used to enclose fields, then a double-quote appearing inside a field must be escaped by preceding it with another double quote. For example: "aaa","b""bb","ccc" (中略) field = (escaped / non-escaped) escaped = DQUOTE *(TEXTDATA / COMMA / CR / LF / 2DQUOTE) DQUOTE non-escaped = *TEXTDATA
まず、バックスラッシュでエスケープするという記述はどこにもない。更に、値は両端がダブルクォーテーションでくくられているかいないかの二択であり、先のテスト結果にあるような、「\",",でぃ"」という混合形式がそもそも有り得ない。
まぁ、fgetcsvが追加されたPHP 4がリリースされたのが2000年5月(https://ja.wikipedia.org/wiki/PHP_(%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B0%E8%A8%80%E8%AA%9E)#PHP_4)であるので、仕方がないのだろうが。(ちなみにstr_getcsvはPHP 5.3.0から)
- fgetcsv
- str_getcsv
その挙動はPHP 8に至るまで変わっていない。
が、よく読むと、
escape
オプションの escape パラメータで、エスケープ文字 (シングルバイト文字 最大で1文字) を設定します。 空文字列("") を指定すると、(RFC 4180 に準拠していない) 独自仕様のエスケープ機構が無効になります。
と書いてあるので、一応意識はされているのか(PHP 7.4.0以降)。