JavaScriptでカンマ区切りCSVとタブ区切りテキストの双方をパース

外部プラグイン利用不可、サーバサイド処理不可という条件で、JavaScriptのみでCSVのパースが必要だったので、実装した処理をメモです。

## 必要要件
- システム側から出力されるタブ区切りテキストの取り込み
- ユーザーがExcelから出力したCSVの取り込み
- セルの中にカンマが入る事もある
- Excel出力したファイルは、必要なセルしかダブルクォーテーションで囲まれない

以上の事から、単純にsplitできる物ではなかったため、実装処理を備忘録として残します。

実際には、さらにSJISとUTF-8(BOM付き)のどちらのパターンも対応する必要がありましたが、ここではそれは割愛しますが、データを読み込んで、先頭にBOMがついていれば、文字コード指定して再読み込みする処理をいれて対応しました。

## 実装
簡単に言えば、
1. 行末改行で区切ってループ
2. ダブルクォーテーションの数が奇数なら次の行と結合して行データにする
3. 行データごとにループ
4. 先頭からセルを切り出す

という流れで構築しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/591064/7d80735c-cadf-9db0-ddde-69b429761fd9.png)

```csv:Excelから出力したCSV(UTF-8)
id,"nam,e",price,about,,d
1,スイカ,1200,熊本産,a,e
2,"メロン
メロン2","2,500","マスクメロン
贈答品にお勧め",b,f
3,そら豆,500,塩茹でしてビールとともに,c,g
```

```tsv:タブ区切りテキスト
id	nam,e	price	about		d
1	スイカ	1200	熊本産	a	e
2	"メロン
メロン2"	2,500	"マスクメロン
贈答品にお勧め"	b	f
3	そら豆	500	塩茹でしてビールとともに	c	g
```

```HTML:index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8" />
    <link rel="stylesheet" href="style.css" />
</head>
<body>
    <form name="form">
        <input type="file" name="filechoose">
    </form>
    <table></table>
    <script src="script.js"></script>
</body>
</html>
```

```CSS:style.css
@charset "UTF-8";
table {
    background: #ccc;
    border-collapse: separate;
    border-spacing: 1px;
}
th, td {
    min-width: 80px;
    padding: 5px 10px;
}
th {
    background: #efefef;
}
td {
    background: #fff;
}
```

```JavaScript:script.js
const table = document.querySelector('table');
const input = document.querySelector('[name="filechoose"]');
const reader = new FileReader();

const make_col = (label, tag) => {
    const col = document.createElement(tag);
    if (label !== null)
        col.innerHTML = label.replace('\n', '<br />').replace('\x22\x22', '\x22');
    return col;
}
const make_row = (line, tag) => {
    const tr = document.createElement('tr');
    line.forEach(label => {
        tr.appendChild(make_col(label, tag));
    });
    return tr;
}

// 画面出力
const show = (header, items) => {
    table.innerHTML = '';
    table.appendChild(make_row(header, 'th'));
    
    // 値
    items.forEach(line => {
        table.appendChild(make_row(line, 'td'));
    });
}

// ファイル選択確認
const file_choosed = () => {
    if (input.files.length === 0) return;
    const file = input.files[0];
    reader.readAsText(file);
}

// ファイル読み込み
const file_load = () => {
    let csv = reader.result;
    // 空データなら処理しない
    if (csv.length === 0) return false;
    
    // Excel出力されたCSVの最終行の空行を無視
    // Excel出力されたセル中の改行は\n、行末の改行は\r\n(macは行末\nかも...)
    csv = csv.replace(/\r\n$/, '');
    console.log(csv);

    // 改行区切りしたデータ
    const parse = csv.split(/\r\n/m);
    let data = [];

    // 行データごとにまとめる
    let line = [];
    let quot = 0;
    for (const current of parse) {
        const this_quots = current.match(/\x22/g);
        if (this_quots == null && line.length == 0) {
            data.push(current);
        } else if (this_quots == null) {
            line.push(current);
        } else {
            quot += this_quots.length;
            line.push(current);
            if (quot % 2 == 0) {
                data.push(line.join('\r\n'));
                quot = 0;
                line = [];
            }
        }
    }

    /**
     * セル区切りパターン
     */
    // 空セル
    const pt_1 = new RegExp(/^\x22?[,\t]\x22?/, 'm');
    // ダブルクォーテーションつきのセルはダブルクォーテーション後カンマ、タブが出てくるまで
    const pt_2 = new RegExp(/^\x22([^\x22]+)\x22(\,|\t)/, 'm');
    // ダブルクォーテーションなしのタブ区切りでカンマが出てくるパターン
    const pt_3 = new RegExp(/^([^\t]+)(\t)/);
    // ダブルクォーテーションで囲まれていない場合はカンマかタブが出現するまで
    const pt_4 = new RegExp(/^([^\,\t]+)(\,|\t|$)/);

    // ヘッダ、内容出力データ用配列
    const header = [];
    const items = [];
    before = null;
    data.forEach((v, i) => {
        cell = null;
        while (v.length > 0) {
            if (pt_1.exec(v)) {
                cell = [pt_1.exec(v)[0], null, null];
            } else if (pt_2.exec(v)) {
                cell = pt_2.exec(v);
            } else if (pt_3.exec(v)) {
                cell = pt_3.exec(v);
            } else if (pt_4.exec(v)) {
                cell = pt_4.exec(v);
            } else {
                // 最終行
                cell = [v, v, v];
            }
            if (i === 0) {
                header.push(cell[1]);
            } else {
                if (typeof(items[i - 1]) === 'undefined') items[i - 1] = [];
                items[i - 1].push(cell[1]);
            }
            v = v.substring(cell[0].length, v.length);
        }
    });

    // 画面出力
    show(header, items);
}

// Events
input.addEventListener('change', file_choosed, false);
reader.addEventListener('load', file_load, false);
```

## 取り込み結果
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/591064/33f74289-1a62-436c-41cc-0c8cbb94cffe.png)

## あとがき
タブ区切りテキストで、ダブルクォーテーションで囲まれていないセルの中に、ダブルクォーテーションが入っている時の処理も入れたら、ほぼほぼカバーできます。この処理はさらに精度を高めた処理にしていきたいと思います。