HHeLiBeXの日記 正道編

日々の記憶の記録とメモ‥

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点の疑問をはらんでいるからである。

  1. エスケープ文字であるならば、値としての文字列にバックスラッシュが残っているのはなぜか?
  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 CSVhttps://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ファイルを開いてみた。

f:id:hhelibex:20211104154645p:plain

ご覧の通り、バックスラッシュは単なる文字として扱われる。

CSV形式のファイルに関するRFC

以下の1つ目が対象のRFC(RFC4180)。その日本語訳も見付けたので2つ目に挙げている。

これによると、そもそも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から)

その挙動はPHP 8に至るまで変わっていない。

が、よく読むと、

escape
オプションの escape パラメータで、エスケープ文字 (シングルバイト文字 最大で1文字) を設定します。 空文字列("") を指定すると、(RFC 4180 に準拠していない) 独自仕様のエスケープ機構が無効になります。

と書いてあるので、一応意識はされているのか(PHP 7.4.0以降)。