Perl:RFC4180に準拠のCSVファイルを読み込む方法

 CSVファイルにRFC4180と言う標準化されたルールが存在している事を知ったので、そのファイルが読み込めるようなプログラムを書いてみた。

特徴
・順番に処理しているので処理速度は遅いです。遅いといってもCorei7(3.4GHz)+SSD環境で10000行×100フィールド(1フィールド10文字・計10MB)のファイルなら1秒程度で完了(ブラウザの表示時間は除く)
・できるだけ書式を壊さないよう読み込んでいる。読み込んだファイルはCSVベースの独自フォーマットに変換、改行はCRとLF別で認識しフィールドを囲むダブルクォーテーションも保持しているので、正確に処理すれば保存時に読み込み時と同じ書式で保存できる。


※プログラム初心者のため、このページのプログラムソースは正常に動作しない事がありますご注意下さい。


CSVファイルフォーマットの概要

 CSVファイルは、テキストデータをカンマと改行で区切ったファイル形式。基本的にはテキストファイルなのでWindowsのメモ帳などでも見る事ができ、ルールを守ればメモ帳を使い手動で編集する事もできるほど簡単な形式で、小規模のデータベースの保存や異なるデータベースシステム間のデータ移行などでも使用されるなど、使用範囲が広い汎用的なファイル。
 問題点として、かなり以前からあるファイル形式だが近年まで標準化されなかったため多数の仕様が乱立している。2005年に乱立していた仕様の中から利用頻度の高かった物を標準化(RFC4180)されたものの、RFC4180の書式が難解なのと以前のデータ・ソフトとの互換性のため新しいソフトやデータであってもRFC4180に準拠してない物も数多く存在しているので注意が必要。

RFC4180は下記アドレスから見れます。
http://www.ietf.org/rfc/rfc4180.txt


RFC4180のルール
※翻訳サイトで翻訳したものを要約したので不正確かも

2-1.各レコードは改行(CRLF)で区切られる。
2-2.最終レコードの最後の改行はあってもなくても良い。
2-3.先頭のヘッダ行はオプションで通常レコードと同じ書式(ヘッダはMIMEの設定で指定)
2-4.レコード内には1つ以上のフィールドがあってもよい、フィールドはコンマで区切る、各レコードのフィールド数はファイル全体で同一、スペースはフィールドの一部とみなし無視しない、レコード最後のフィールドはコンマで終わらない
2-5.フィールドはダブルクォーテーションで囲まなくても良い
2-6.フィールドに改行(CRLF)・ダブルクォーテーション・カンマがある場合はダブルクォーテーションで囲む
2-7.ダブルクォーテーションで囲まれたフィールド中のダブルクォーテーションはエスケープする。

※作ったプログラムでは、
・改行がCRLF以外でも動作します。
・ヘッダ行の判定はしていないので通常レコードとして表示される。
・フィールド数は同一でなくても読みこめるが、表示の際にテーブルの右側に空白ができる。
・レコード最後のフィールドはコンマで終わらっても読み込まれるが、表示の際にテーブルには表示されない。


大まかな処理の流れ

「rfc4180_conv」は、与えられたRCF4180準拠の文字列から、ダブルクォーテーション内の「改行・コンマ・ダブルクォーテーション」をエスケープ処理した独自フォーマットに変換します。
 この独自フォーマットの特徴は、「フィールドがコンマ区切り」「レコードが改行区切り」になるっているので単純に改行とコンマで分割すれば任意のフィールドの値を取得でき、エスケープされている「改行・コンマ・ダブルクォーテーション」を元に戻すと本来の値が取得できます。


 RFC4180のCSVファイル
“aaa”,”bbb”,”ccc”(crlf)
“a,a”,”b””b”,”c(crlf)c”(crlf)
“aaa”,”bbb”,”ccc”

独自フォーマット(ダブルクォーテーション内の改行・コンマ・ダブルクォーテーションを「!~」にエスケープ)
“aaa”,”bbb,”ccc”(crlf)
“a!1″,”b!2b”,”c!3!4c”(crlf)
“aaa”,”bbb”,”ccc”

1.処理用に1文字特殊文字を設定する。(今回は「!」を使用する)
 文中に「!」があると不具合になるため「!0」にエスケープする。
2.ダブルクォーテーションで分割する
3.ダブルクォーテーションで囲まれた範囲を特定するため偶数個で結合する
4.エスケープされたダブルクォーテーションの結合し
エスケープされたダブルクォーテーションは「””」→「!2」に置き換え
5.ダブルクォーテーション内のコンマと改行を特殊な文字列に置き換える
 コンマ「,」→「!1」
 改行(CR)→「!3」
 改行(LF)→「!4」
※ダブルクォーテーションの外はそのまま。
6.上記で処理した文字をすべて結合すると
フィールドがコンマ区切り、レコードが改行区切りになる。
7.表示方法
 改行で分割し→コンマで分割しフィールドが単位にする。
 ※フィールドがダブルクォーテーションで囲まれている場合は、
 ダブルクォーテーションもそのままなので不要なら削除する。
 (この処理の前に「$str = ~s/”//g;」とすれば一括削除できる)
8.フィールドごと特殊文字を戻す
 コンマ「!1」→「,」
 ダブルクォーテーションで囲む「!2」→「”」
 「!3」→改行(CR)
 「!4」→改行(LF)
 「!0」→「!」 ※!は最後に戻す。

これで完了


プログラム
前半はHTML表示するためのデモプログラム、後半の「rfc4180_conv」関数が本体


#!/usr/local/bin/perl
my $html;  #画面出力用
my $str;   #入力データ

#デモ用のCSVデータをロード
$str.='01,A,B,C'."\x0D\x0A";
$str.='"02","A","B","C"'."\x0D\x0A";
$str.='"03","A'."\x0D\x0A".'A","B,B","C""C';

$html.="<hr />1.入力時の状態(改行crlfは表示されてない)<br />$str";

#rfc4180から独自フォーマットへ
my $my_csv=&rfc4180_conv($str);

$html.="<hr />2.変換後の独自フォーマット状態<br />$my_csv";

#元に戻す
my $save=$my_csv;
$save=~s/!1/,/g;
$save=~s/!2/"/g;
$save=~s/!3!4/\x0D\x0A/g;
$save=~s/!3/\x0D/g;
$save=~s/!4/\x0A/g;
$save=~s/!0/!/g;
$html.="<hr />3.保存形式に戻す(1と同じはず)<br />$save";

#htmlのテーブルタグとして出力

#※「"値"」の「"」を一括削除
#↓「"値"」のように囲みが必要な場合はコメントアウトする
$my_csv=~s/"//g;

$my_csv=~s/\x0D\x0A/\x0D/g;   #改行コードをCRに統一してsplit
$my_csv=~s/\x0A/\x0D/g;
my @row=split(/\x0D/,$my_csv);
$html.="<hr />4.独自フォーマットからテーブル表示(改行はBRに置換してある)";

$html.="<table border=1>";
foreach my $val1(@row){
    $html.="<tr>";
    my @field=split(/,/,$val1);
    foreach my $val2(@field){
        #特殊文字を元に戻す(※改行はHTML用にBRにしてある)
        $val2=~s/!1/,/g;
        $val2=~s/!2/"/g;
        $val2=~s/!3!4/<br \/>/g;
        $val2=~s/!3/<br \/>/g;
        $val2=~s/!4/<br \/>/g;
        $val2=~s/!0/!/g;
        $html.="<td>$val2</td>";
    }
    $html.="</tr>";
}
$html.="</table>";


print "Content-type: text/html\n\n";
print "<html><head></head><body>$html</body></html>";
exit;


# CSVファイルの文字列を独自形式に変換
sub rfc4180_conv{
    my $data=$_[0];   #文字列
    my @d1="";        #ダブルクォーテーション分割した値を格納用
    my $count;        #ダブルクォーテーションの数
    my $i;            #ループ用
    my $line;         #出力用
    my $start;        #ループ開始位置

    $data=~ s/!/!0/g;        #処理用の特殊文字を決め「!」→「!0」にエスケープする。
    $count=(()=$data=~/"/g); #ダブルクォーテーションの数をカウント。
    @d1=split(/"/,$data);    #ダブルクォーテーションで分割

    #ループ開始始位置設定、ダブルクォーテーションで囲むのに偶数回で処理したいため
    #1つめがダブルクォーテーションで始まらない場合は2つめのデータから行う。
    if ($d1[0]=~/^"/){
        $start=0;
    }else{
        $start=1;
        $line=$d1[0];
    }

    #splitで消えた「"」を付加する
    for($i=$start;$i<=$count;$i++){            
        $d1[$i]='"'.$d1[$i];
	}

    #ダブルクォーテーションを偶数単位で処理する。
    for($i=$start;$i<=$count;$i=$i+2){
        $d1[$i]=~s/,/!1/g;    #コンマを特殊文字「!1」に置換
        $d1[$i]=~s/\x0D/!3/g; #改行(CR)を特殊文字「!3」に置換
        $d1[$i]=~s/\x0A/!4/g; #改行(CR)を特殊文字「!4」に置換

        #「""」を特殊文字「!2」に置換
        if($d1[$i] eq '"'){
            if($d1[$i+1] eq '"'){
                if($d1[$i+2] =~/^"/){
                    $d1[$i+1]="";
                    $d1[$i+2]=~s/^"/!2/g;
               }
            }
         }elsif($d1[$i+1] eq '"'){
                if($d1[$i+2] =~/^"/){
                    $d1[$i+1]="";
                    $d1[$i+2]=~s/^"/!2/g;
               }
         }
        $line.="$d1[$i]$d1[$i+1]";
    }
    return($line);
}


感想
 簡単そうなCSVファイルですが、思った以上に難しいフォーマットです。