今更感満載のテーマではありますが、改めて挑戦してみたいと思います。CSVはwikipediaにもあります通り(...フィールドにコンマやダブルクォートが含まれている場合、エスケープされている場合でも、ソフトウェアによって解釈が異なり、区切り方が変わることがある。...)、厳密なフォーマットというものがありません。ここでは、一般的に受け入れられていると思われるExcelで採用されているCSVフォーマットを前提に検討したいと思います。
能書きはさておき、ソースが見たいという方はこちら。
VBA用 https://github.com/biwaz-design/opencode/blob/main/BD-CSV/importcsv.bas
VBA用 https://github.com/biwaz-design/opencode/blob/main/BD-CSV/importcsv.cls
VBS用 https://github.com/biwaz-design/opencode/blob/main/BD-CSV/importcsv.vbs
Excel は CSV を、どうやって解いているのか
⑴ Unicode変換
読み込むテキストファイルのエンコードは SJIS, (BOM付)UTF-8, (BOM付)UTF-16 のいずれかを前提にしており、まず内部フォーマット UTF-16 に変換して利用されているものと思われます。
このUTF-16への変換(UTF-8⇒UTF-16)は極めて(手抜きな?)オリジナルなものになっていますが、ここでは話が長くなるだけですので、また別の機会にしたいと思います。
⑵ \0コードの除去
元の文字エンコードがなんであれ関係なく\0は全て取り除かれます。文字列と云えばNULL終端が当たり前であった時代よりExcelが使われていることから、こうした規格が引き続き採用されているモノと思われます。
⑶ 改行コードによるレコード分割
レコードを分割するための改行コードとしては下記の3パターンがあります。
(a) \r (\x0d)
(b) \n (\x0a)
(c) \r\n (\x0d\x0a)
従って、\n\rは改行が2つ連続しているものとみなします。Excelに限らずWord、notepadでもテキストファイルからの読み込みにおいて、行末の認識は同じ規格になっています。
⑷ , や \t(\x09)のセパレータによるカラム分割
元のエンコードがSJIS, UTF-8の場合 セパレータは , で、UTF-16の場合 \t(\x09)になるようです。また、クオート処理のルールは、基本「...フィールドがコンマ、ダブルクォート、改行を含む場合は、かならずダブルクォートで囲む。...」に沿っているものの、微妙に安全側に改善された仕様になっていますので細かく記します。
(a) クオート終端
カラムの先頭がダブルクオートであれば、次にダブルクオートを検出するまで、その文字コードのままカラムの文字とする。後者のダブルクオートを「クオート終端」と呼ぶことにします。
(b) 連続ダブルクオート
但し、ダブルクオートが2つ連続する場合は、ダブルクオート1つに纏め、クオート終端とみなさない。
(c) クオート終端の後
クオート終端の次の文字からセパレータ、或いは改行コードまでの文字が存在する場合、連続ダブルクオートを纏める処理は行わずに、それらもカラムの文字とする。最近のアプリでは、終端クオートとセパレータ、或いは行末の間に文字が存在するとエラー、例外を生成するものもあるようです。
以上のクオート処理の例を示します。
"a,""b"","c"d,B"C"
⇒
1st-カラム:a,"b",c"d
2nd-カラム:B"C"
VBA/VBS の制限
⑴ Unicode変換
エンコードが shift-jis の場合は、TextStream, ADODB.Stream のいずれを利用した読み込みでも、Excel のものと等価になるようです。エンコードが UTF-8 の場合は、先にも書きました通り Excel の読み出しは平たく言うと手抜きです。
⑵ 改行コードによるレコード分割
TextStream の readline を利用すると、先述の \r \n \r\n の3パターンで区切られる1行分を得ることができ、大変便利です。
ただ、クオートされた区間内の改行文字は、Excelでは正確に保存されますが、TextStream の readline を利用すると改行文字自体は取り除かれて1行文字列が取り出されるので、復元することができなくなります。
例を示します。
"abc\nxyz"
⇒Excel では、 abc\nxyz を1つのセル情報として取り込みますが、TextStream の readlineでは、abc\rxyz なのか、abc\r\nxyz なのか、abc\nxyz なのか分からないので、どれかに決め打ちするしかありません。
readall を用いれば、取りこぼしはなくなりますが、行分割を自身で行う必要が出てきますし、何よりファイルが大きい場合、内部メモリをこれらの展開の為に確保する必要が出てきます。
ADODB.Stream を利用した場合も、多少の違いはあるものの、行分割を自身で行わない限り、改行文字列の正確な復元はできないと考えられます。
VBS/VBA 速度改善の視点について
C,C++で作成されるパーサーは、一般的に文字を1文字ずつ読み込んで解析を行っているかと思います。VBS/VBA で、この一般的な手法を使うと大概遅くなってしまいます。1文字、1文字を独立に処理をする機能に乏しいこともありますし、いくら簡素な処理であっても、ソースコードの処理行数が増えると、著しく時間が掛かってしまう特徴があるため、設計方針として、できるだけ1文字単位の解析処理を避けて、全体の実行行数を減らす必要があると考えられます。
ということで、raplace, split を多用することで、これら文字単位の処理を回避してみようと考えました。クオートがなければ、1行分の文字列を読み出して、split(..., ",") だけで解くことができますが、このケースは改めてここで説明する必要はありませんね。
クオートされたカラムが存在する場合、1行分の文字列に対して split(..., ",") を行うとどうなるでしょうか?例を示します。
abc,"12"",""34",xyz
↓
abc
"12""
""34"
xyz
Splitした文字列要素の先頭文字が " の場合、その後の文字列要素と合体させてカラム文字列を作るようにします。上記の例では、2つ目、3つ目の文字列を合体させ、"12"",""34"とします。連続する2つのダブルクオート文字列は、1つのダブルクオート文字に変換させる必要がありますが、この操作をすると、クオート開始・終了を示すダブルクオート文字と区別ができなくなります。なので一旦 split の対象となる文字列に存在し得ない文字 \n に置換しておきます。※なぜ \n が存在し得ないかと云うと、1行分の文字列を読み出したとき、一般的に \r や \n が読み出した文字列末尾から取り除かれるためです。
そうすると、"12\n,\n34" となります。この文字列で、1文字目以外にダブルクオートが存在すれば、それがクオート終端になるので、ダブルクオートを取り除き、仮変換した \n を ダブルクオートに戻してカラム文字列の再合成が完成します。
"12""
""34"
↓
"12"",""34"
↓
"12\n,\n34"
↓
12\n,\n34
↓
12","34
クオート中に改行が含まれるケース、クオート終端後に文字列が続くケースなど、もう少し説明が必要な事柄もありますが、その辺はソースコードをご覧頂ければと思います。
速度計測について
こちらは掲示しようか、どうしようか迷ったのですが、データの構成に依って著しく結果が異なってまいります。つまり都合のよいデータで計測するんじゃね~よと叱られそうなので、辞めておきます。
あくまで一般的にということで申し上げますと、powershell の Import-Csv, ConvertFrom-Csv よりも、だいぶと速くなることが多かったです。
使い方
TextStream を利用する場合
filepath = "test.csv"
set objStream = createobject("Scripting.FileSystemObject").opentextfile(filepath)
do
record = readfields(objStream, ",") ' ファイルから1レコード分のデータを読み込む。
if isnull(record) then exit do
writefields(record) ' コンソールに1レコード分のデータを書き出す。
loop
objStream.close
set objStream = nothing
ADODB.Stream を利用する場合
※UTF-8 エンコードのファイルを対象にしたい場合
※或いは、行末尾の \r \n を正確に処理したい場合
set objCsv = new csv
with createobject("ADODB.Stream")
.charset = "utf-8"
.type = 1
.open
.loadfromfile filepath
.position = 0
.type = 2
objCsv.init .readtext, ","
.close
end with
do
record = objCsv.readfields() ' ファイルから1レコード分のデータを読み込む。
if isnull(record) then exit do
writefields(record) ' コンソールに1レコード分のデータを書き出す。
loop
set objCsv = nothing
最後に
せっかく頑張って作りましたので、本作に名前を付けておこうと思いまして、わたくしめの屋号 Biwaz Design のイニシャルを取って BD-CSV にさせて頂きました。もしかして、既に使ってらっしゃる方がいらっしゃったら、ごめんなさい。ご連絡頂けましたら、変更します。