Home Reference Source

src/NTCsv.js

/**
 * CSV形式のテキストなどを扱うユーティリティクラス
 *
 * 主な特徴:
 * - 区切り文字のカスタマイズ(デフォルトは `,`)
 * - ダブルクォートで囲まれたフィールドや改行を含むフィールドのパースに対応
 * - 2次元配列 ⇄ CSVテキスト、CSV配列 ⇄ JSON配列 の相互変換
 *
 * @class NTCsv
 * @module NTCsv
 * @author natade (https://github.com/natade-jp)
 * @license MIT
 */
export default class NTCsv {
	/**
	 * CSVテキストから2次元配列を作成します。
	 *
	 * - 改行コードは `\r\n?|\n` を `\n` に正規化してから解析します。
	 * - ダブルクォートで囲まれたフィールド内では、`""` は文字としての `"` として扱います。
	 * - ダブルクォートで囲まれたフィールドは複数行にまたがっても1フィールドとして扱います。
	 *
	 * @param {string} text - CSV形式のテキスト
	 * @param {string} [separator=","] - 区切り文字(例: `","`、`"\t"`、`";"` など)
	 * @returns {Array<Array<string>>} CSVの各行を配列として持つ2次元配列
	 *
	 * @example
	 * // 基本: カンマ区切り
	 * const csvText = "name,age\nAlice,30\nBob,25";
	 * const rows = NTCsv.parse(csvText);
	 * // rows => [["name","age"],["Alice","30"],["Bob","25"]]
	 *
	 * @example
	 * // セミコロン区切り
	 * const text = "id;memo\n1;hello;world"; // ← 'hello;world' を一つのセルにしたい場合はクォートが必要
	 * const rows = NTCsv.parse("id;memo\n1;\"hello;world\"", ";");
	 * // rows => [["id","memo"],["1","hello;world"]]
	 *
	 * @example
	 * // 改行を含むフィールド(ダブルクォートで囲む)
	 * const t = "id,comment\n1,\"line1\nline2\"";
	 * const r = NTCsv.parse(t);
	 * // r => [["id","comment"],["1","line1\nline2"]]
	 */
	static parse(text, separator) {
		const iseparator = separator === undefined ? "," : separator;
		// 改行コードの正規化
		const itext = text.replace(/\r\n?|\n/g, "\n");
		const CODE_SEPARATOR = iseparator.charCodeAt(0);
		const CODE_CR = 0x0d;
		const CODE_LF = 0x0a;
		const CODE_DOUBLEQUOTES = 0x22;
		const out = [];
		const length = itext.length;
		let element = "";
		let count_rows = 0;
		let count_columns = 0;
		let isnextelement = false;
		let isnextline = false;
		for (let i = 0; i < length; i++) {
			let code = itext.charCodeAt(i);
			// 複数行なら一気に全て読み込んでしまう(1文字目がダブルクォーテーションかどうか)
			if (code === CODE_DOUBLEQUOTES && element.length === 0) {
				i++;
				for (; i < length; i++) {
					code = itext.charCodeAt(i);
					if (code === CODE_DOUBLEQUOTES) {
						// フィールドの終了か?
						// 文字としてのダブルクォーテーションなのか
						if (i + 1 !== length - 1) {
							if (itext.charCodeAt(i + 1) === CODE_DOUBLEQUOTES) {
								i++;
								element += '"';
							} else {
								break;
							}
						} else {
							break;
						}
					} else {
						element += itext.charAt(i);
					}
				}
			}
			// 複数行以外なら1文字ずつ解析
			else {
				switch (code) {
					case CODE_SEPARATOR:
						isnextelement = true;
						break;
					case CODE_CR:
					case CODE_LF:
						isnextline = true;
						break;
					default:
						break;
				}
				if (isnextelement) {
					isnextelement = false;
					if (out[count_rows] === undefined) {
						out[count_rows] = [];
					}
					out[count_rows][count_columns] = element;
					element = "";
					count_columns += 1;
				} else if (isnextline) {
					isnextline = false;
					//文字があったり、改行がある場合は処理
					//例えば CR+LF や 最後のフィールド で改行しているだけなどは無視できる
					if (element !== "" || count_columns !== 0) {
						if (out[count_rows] === undefined) {
							out[count_rows] = [];
						}
						out[count_rows][count_columns] = element;
						element = "";
						count_rows += 1;
						count_columns = 0;
					}
				} else {
					element += itext.charAt(i);
				}
			}
			// 最終行に改行がない場合
			if (i === length - 1) {
				if (count_columns !== 0) {
					out[count_rows][count_columns] = element;
				}
			}
		}
		return out;
	}

	/**
	 * 2次元配列からCSVテキストを作成します。
	 *
	 * - フィールド内に `"` または 改行(`\r`/`\n`) または 区切り(`,` デフォルト)またはタブが含まれる場合、
	 *   当該フィールドをダブルクォートで囲み、内部の `"` は `""` にエスケープします。
	 * - 改行コードは `newline` 引数で指定できます(デフォルトは `"\r\n"`)。
	 *
	 * @param {Array<Array<string>>} csv_array - CSVの各行を要素に持つ2次元配列
	 * @param {string} [separator=","] - 区切り文字
	 * @param {string} [newline="\r\n"] - 出力時の改行コード
	 * @returns {string} CSVテキスト
	 *
	 * @example
	 * const rows = [["name","note"],["Alice","He said \"Hello\""]];
	 * const csv = NTCsv.create(rows);
	 * // csv => "name,note\r\n\"Alice\",\"He said \"\"Hello\"\"\"\r\n"
	 *
	 * @example
	 * // セミコロン区切り & LF改行
	 * const rows = [["id","memo"],["1","hello;world"]];
	 * const csv = NTCsv.create(rows, ";", "\n");
	 * // csv => "id;memo\n\"1\";\"hello;world\"\n" (必要箇所がクォートされる)
	 */
	static create(csv_array, separator, newline) {
		const iseparator = separator === undefined ? "," : separator;
		const inewline = newline === undefined ? "\r\n" : newline;
		let out = "";
		const escape = /["\r\n,\t]/;
		if (csv_array !== undefined) {
			for (let i = 0; i < csv_array.length; i++) {
				if (csv_array[i] !== undefined) {
					for (let j = 0; j < csv_array[i].length; j++) {
						let element = csv_array[i][j];
						if (escape.test(element)) {
							element = element.replace(/"/g, '""');
							element = '"' + element + '"';
						}
						out += element;
						if (j !== csv_array[i].length - 1) {
							out += iseparator;
						}
					}
				}
				out += inewline;
			}
		}
		return out;
	}

	/**
	 * 1行目に列名があるCSV配列を、オブジェクトの配列(JSON配列)に変換します。
	 *
	 * - `csv_array[0]` をキー配列として使用します。
	 * - 2行目以降は、対応するキー名を持つオブジェクトにマッピングされます。
	 * - 列数が合わない場合は、存在するキーに対応する範囲でプロパティが作成されます(不足分は `undefined`)。
	 *
	 * @param {Array<Array<string>>} csv_array - 先頭行がヘッダーのCSV配列
	 * @returns {Array<Object<string, string>>} キー:ヘッダー名、値:セル文字列のオブジェクト配列
	 *
	 * @example
	 * const rows = [["name","age"],["Alice","30"],["Bob","25"]];
	 * const json = NTCsv.toJSONArrayFromCSVArray(rows);
	 * // json => [{ name: "Alice", age: "30" }, { name: "Bob", age: "25" }]
	 *
	 * @example
	 * // 列が足りない行がある場合
	 * const rows2 = [["a","b","c"],["1","2"],["3","4","5"]];
	 * const json2 = NTCsv.toJSONArrayFromCSVArray(rows2);
	 * // json2 => [{a:"1", b:"2", c:undefined}, {a:"3", b:"4", c:"5"}]
	 */
	static toJSONArrayFromCSVArray(csv_array) {
		const title_line = csv_array[0];
		const key_name = [];
		for (let i = 0; i < title_line.length; i++) {
			key_name.push(title_line[i]);
		}
		const json_array = [];
		for (let i = 1; i < csv_array.length; i++) {
			const line = csv_array[i];
			/**
			 * @type {Object<string, string>}
			 * @private
			 */
			const json_data = {};
			for (let j = 0; j < line.length; j++) {
				json_data[key_name[j]] = line[j];
			}
			json_array.push(json_data);
		}
		return json_array;
	}

	/**
	 * 共通のキー構造を持つJSON配列を、CSV配列へ変換します。
	 *
	 * - `title_array` を指定しない場合は、`json_array[0]` のキー列挙順をタイトルとします。
	 * - 各オブジェクトの値は `title_array`(または自動抽出したキーリスト)の順に並べます。
	 * - オブジェクトに存在しないキーの値は `undefined`(結果のセルは `undefined`)になります。
	 *
	 * @param {Array<Object<string, string>>} json_array - 同一キー構造のオブジェクト配列
	 * @param {Array<string>} [title_array] - ヘッダーに使用するキー名の配列(順序固定に便利)
	 * @returns {Array<Array<string>>} 先頭行がヘッダーのCSV配列
	 *
	 * @example
	 * const json = [{ name: "Alice", age: "30" }, { name: "Bob", age: "25" }];
	 * const rows = NTCsv.toCSVArrayFromJSONArray(json);
	 * // rows => [["name","age"],["Alice","30"],["Bob","25"]]
	 *
	 * @example
	 * // 列順を指定したい場合
	 * const json2 = [{ name: "Alice", age: "30" }, { name: "Bob", age: "25" }];
	 * const rows2 = NTCsv.toCSVArrayFromJSONArray(json2, ["age","name"]);
	 * // rows2 => [["age","name"],["30","Alice"],["25","Bob"]]
	 */
	static toCSVArrayFromJSONArray(json_array, title_array) {
		const csv_array = [];
		let title_list = null;
		if (title_array === undefined) {
			title_list = [];
			for (const key in json_array[0]) {
				title_list.push(key);
			}
		} else {
			title_list = title_array;
		}
		csv_array.push(title_list);
		for (let i = 0; i < json_array.length; i++) {
			const line = json_array[i];
			const data_list = [];
			for (let j = 0; j < title_list.length; j++) {
				data_list.push(line[title_list[j]]);
			}
			csv_array.push(data_list);
		}
		return csv_array;
	}
}