ExcelのCSV問題の対策補助ツール「jsCsvViewer」を作ってみた

# はじめに
ここ最近、はてなブックマーク-人気エントリーにてCSVファイルに関する記事がエントリーされていました。

* [ExcelでCSVファイルを「0落ち」や「文字化け」なしで開く方法](https://qiita.com/loannp2609/items/3d24e797abbc922f38e6)
* [人生3回目のCSVエディタを作っている話](https://zenn.dev/kohii/articles/079c73ab14856f)

ExcelのCSVファイルの「0落ち」等の自動変換は昔から問題があり、皆が関心を持っていることが分かります。

```text:検証.csv
前0,12桁以上,日付1,番地,括弧数値,日付2,日付3,正常
00001,123456789012,2024-9-17,1-2-3,(1),"2024-9-17",2024/9,1
00002,1234567890123,2024-8-31,2-3-4,(2000),"2024-8-31",2024/8,2
```

Excel 2019 for Mac では下図のように変換されて表示される。
![スクリーンショット 2024-09-17 1.31.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14053/575419b8-a951-0330-0684-09f687c0d9f6.png)

## 履歴
|Version|内容|
|:--|:--|
|1.0.2|読み込みモジュールをJspreadsheet CE v4用に変更|
|1.0.1|サイズが大きいCSVファイルでも表示されるように遅延読込みとページネーションを追加|
|1.0.0|新規公開|

## Excelの自動データ変換の抑制機能
Microsoft 365のアップデートにより下記の自動データ変換は抑制されます。また買い切り版だと今年後半に発売予定のExcel 2024に搭載されるはず。
[先頭のゼロを勝手に消すな ～「Excel」のお節介な自動データ変換を抑制する機能が一般提供開始](https://forest.watch.impress.co.jp/docs/news/1538341.html)

* 先頭のゼロを削除して数値に変換する
* ロング数値の最初の 15 桁を保持し、科学的記数法で表示する
* 文字“E”を囲む数字を科学的記数法に基づく数値に変換する
* 連続する文字と数字を日付に変換する

しかしながら次の3点については、まだ対処できていません。他もあるはず
* 24:00以上の時間数の形式が意図せず変換される
* 日付「〇〇/〇〇」が「〇〇月〇〇日」の形式に変換される
* カッコつきの数「例 (1)」がマイナスの数に変換される

# jsCsvViewerの作成
今回作成した「jsCsvViewer」の画面
![スクリーンショット 2024-09-17 1.43.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14053/594725bb-fa76-4076-d64c-4745e6b2901f.png)

CSVファイルを編集したいためにExcelを開きたいわけではなく単に中身をちょっとだけ確認したい、その際に出来れば見やすく表形式で見たいわけです。
CSV専用エディタや[秀丸エディタのCSVモード](https://hide.maruo.co.jp/software/hidemaru8/new7.html)や[サクラエディタのCSVを見やすく設定](https://kakepura.com/sakura-editor0005/)などありますが、セキュリティが厳しい会社などはツールのインストール自体の許可が難しかったりします。

そこで、HTML5 + JavaScript + CSS のみで作成すればブラウザを開くだけで使え、かつWeb標準機能のみなので OS 関係なく使用することができます。

:::note info
ライブラリは外部サーバーから読み込んでいますが、CSVファイルの読み込み処理はクライアント側のJavaScriptのみで行われているため、CSVファイルの内容が外部サーバーに送信されることはありません。
:::

CSVファイルの編集はExcelやテキストエディタに任せ、jsCsvViewerはその名の通り表示専用ツールとして機能します。保存機能は後述するように限定的なもので、あくまで補助的な機能とお考えください。

## 使用ライブラリ
|ライブラリ|内容|ライセンス|
|:--|:--|:--|
|[Jspreadsheet CE v4](https://bossanova.uk/jspreadsheet/v4/)|JavaScript で Excel 風スプレッドシートを実現するライブラリ|MIT|
|[jsuites](https://jsuites.net/docs)|Jspreadsheetに付随するライブラリ|MIT|
|[papaparse](https://www.papaparse.com/)|JavaScript用の高速なCSVパーサー|MIT|

筆者は[Handsontableの記事](https://qiita.com/yaju/items/c0307fa9ea69ad02c189)をよく書いていますが、Handsontableの最新バージョンは14.5でMITライセンス版は6.2.2と差が開いてしまったため、今回は初期ごろのHandsontableをフォークしたJspreadsheet(旧名 jExcel)を数式やツールバーを標準搭載していることやCE版でもメンテナンスを継続していることもあり採用しました。

papaparseライブラリ側で区切り文字の自動検出や改行と引用符を正しく処理してくれます。

## ソースコード
```html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>jsCsvViewer Ver.1.0.2</title>
  <style type="text/css">
    #drop {
      position: absolute;
      top: 10; left: 10; width: 98%; height: 98%;
      z-index: -1;
    }
    #fileName {
      padding-left: 10px;
    }
    #grid {
      padding-top: 10px;
    }
    .jexcel thead tr td {
        text-align: center !important;
    }
  </style>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jspreadsheet-ce@4/dist/jspreadsheet.min.css" media="screen">
  <link rel="stylesheet" href="https://jsuites.net/v4/jsuites.css" type="text/css" />
  <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Material+Icons" type="text/css" />
</head>
<body>
  <div id="drop" ondragover="onDragOver(event)">
    CSVファイルをドラッグ&ドロップするかファイルの選択をしてください。<br>
    <input type="file" id="fileBox" accept=".csv, .tsv, .txt" style="display:none">
    <button id="fileSelect" type="button">ファイルを選択</button><span id="fileName"></span><br>
    <span style="flex:1;">文字コード
      <select id="encode-type">
        <option value="shift-jis">shift-jis</option>
        <option value="utf-8">utf-8</option>
      </select>
    </span>
    <input type="checkbox" id="isHeader" /><label for="isHeader">ヘッダー</label>
    <button type="button" onClick="reload()">再読み込み</button>
    <button type="button" onClick="copyAllToClipboard()" >全選択コピー</button>
    <input type="checkbox" id="isLazyLoading" onclick="validateCheckBox(event)"/><label for="isLazyLoading">遅延読込み</label>
    <input type="checkbox" id="isPagination" onclick="validateCheckBox(event)"/><label for="isPagination">ページネーション</label>
    <br>
    <div id="grid"></div>
  </div>
  <script src="https://cdn.jsdelivr.net/npm/jspreadsheet-ce@4/dist/index.min.js"></script>
  <script src="https://jsuites.net/v4/jsuites.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/papaparse@5.4.1/papaparse.min.js"></script>
  <script type="text/javascript">
    let spreadsheet;  // jSpreadsheetのインスタンス変数
    let savePath;     // CSVパス退避変数

    let emptyData = Array.from({ length: 10 }, () => Array(10).fill(''));
    createGrid(emptyData, true);

    const fileSelect = document.getElementById("fileSelect");
    const fileBox = document.getElementById("fileBox");
    fileBox.addEventListener("change", inputChange);
    fileSelect.addEventListener("click", () => { fileBox.click(); }, false);

    if (window.File) {
      document.getElementById("drop").addEventListener("drop", onDrop, false);
    } else {
      window.alert("本ブラウザではFile APIが使えません");
    }

    // ファイル選択のファイルのプロパティ情報読み取り処理
    function inputChange(event) {
      let files = event.target.files;
      readCSV(files[0]);
    }

    // Drop領域にドロップした際のファイルのプロパティ情報読み取り処理
    function onDrop(event) {
      let files = event.dataTransfer.files;
      readCSV(files[0]);
    }

    // ドラッグオーバー処理
    function onDragOver(event){ 
      event.preventDefault(); 
    }

    // 再読み込みボタンのクリック処理
    function reload() {
      if (savePath)
        readCSV(savePath);
    }

    // 全選択コピーボタンのクリック処理
    function copyAllToClipboard() {
      spreadsheet.selectAll();
      spreadsheet.copy();
    }

    // 遅延読込みとページネーションチェック処理
    function validateCheckBox(event) {
      if (document.getElementById("isLazyLoading").checked &&
          document.getElementById("isPagination").checked) {
          alert("遅延読込みかページネーションのどちらかを選択して下さい。");
          event.target.checked = false;
      }
    }

    // CSVファイルの読み取り処理
    function readCSV(csvpath){
      jspreadsheet.destroy(document.getElementById('grid'));

      let element = document.getElementById("fileName");
      element.innerText = csvpath.name;

      let disp = document.getElementById("disp");
      let encode_type = document.getElementById("encode-type").value;

      let f = csvpath;
      savePath = f
      let reader = new FileReader();
      reader.onerror = function () {
        disp.innerHTML = "読み取り時にエラーが発生しました。";
      }

      if (f.type.match('text.*') || f.name.match(/csv$/m)) {
        // ファイル読取が完了した際に呼ばれる処理
        reader.onload = function () {
          // FileReaderが取得したテキストをそのままdivタグに出力
          let isHeader = document.getElementById("isHeader").checked;
          Papa.parse(reader.result, {
            header: isHeader,
            worker: true,
            complete: function(results) {
              createGrid(results.data);
            }
          });
        }
        // readAsTextメソッドでファイルの内容を取得
        reader.readAsText(f, encode_type);
      }

      event.preventDefault();
    }

    // グリッド生成処理
    function createGrid(data, isNew=false) {
      let isHeader = document.getElementById("isHeader").checked;
      let grid = document.getElementById('grid');
      let rect = grid.getBoundingClientRect();
      let isLazyLoading = document.getElementById("isLazyLoading").checked;
      let isPagination = document.getElementById("isPagination").checked;

      const text = {
        insertANewRowBefore: '上に行を挿入',
        insertANewRowAfter: '下に行を挿入',
        deleteSelectedRows: '選択行の削除',
        copy: 'コピー',
        paste: '貼り付け'
      };

      // 保存ファイル名 jsCsv_yyyyMMddHHmm.csv
      let baseName = 'jsCsv';
      let dt = new Date(Date.now() + 3600000 * 9).toISOString().replace(/[^0-9]/g, '').slice(0, -5);
      if (!isNew) {
        let filename = savePath.name.replace(/^.*[\\\/]/, '');
        baseName = filename.split('.').slice(0, -1).join('.');
      }

      spreadsheet = new jspreadsheet(grid, {
        data: data,
        text: text,
        lazyLoading: isLazyLoading,
        tableOverflow: isLazyLoading,
        loadingSpin: isLazyLoading,
        search: isPagination,
        pagination: isPagination ? 10 : undefined,
        paginationOptions: [10, 25, 50, 75, 100],
        tableHeight: (window.innerHeight - rect.top) * 0.9 + 'px',
        colHeaders: isHeader ? Object.keys(data[0]) : [],
        defaultColWidth: 100,    // デフォルトの幅を設定
        defaultColAlign: 'left', // デフォルトの配置を左寄せに設定
        allowManualInsertColumn: isNew,
        allowManualInsertRow: isNew,
        wordWrap: true,
        search: isPagination,
        pagination: isPagination ? 10 : undefined,
        csvFileName: baseName + '_' + dt, 
        toolbar: [
          { type: 'i', content: 'undo', onclick: () => spreadsheet.undo(), tooltip: '元に戻す' },
          { type: 'i', content: 'redo', onclick: () => spreadsheet.redo(), tooltip: 'やり直し' },
          { type: 'i', content: 'save', onclick: () => spreadsheet.download(), tooltip: '保存' },
          { type: 'i', content: 'format_align_left', k: 'text-align', v: 'left', tooltip: '左寄せ' },
          { type: 'i', content:'format_align_center', k:'text-align', v:'center', tooltip: '中央寄せ' },
          { type: 'i', content: 'format_align_right', k: 'text-align', v: 'right', tooltip: '右寄せ' },
          { type: 'i', content: 'format_bold', k: 'font-weight', v: 'bold', tooltip: '太字' },
          { type: 'color', content: 'format_color_text', k: 'color', tooltip: '文字色' },
          { type: 'color', content: 'format_color_fill', k: 'background-color', tooltip: '背景色' },
          { type: 'i', content: 'add', onclick: () => addOrRemoveColumn(true), tooltip: '列を追加'},
          { type: 'i', content: 'remove', onclick: () => addOrRemoveColumn(false), tooltip: '列を削除' },
        ],
        // 読み込み完了後のイベントリスナー
        onload: (event, instance) => { 
          // A1にフォーカス
          instance.updateSelectionFromCoords(0, 0);
          // 末尾空行を削除
          if (!isNew) removeEmptyLastRow(instance);
          // 遅延読込み時のヘッダー位置不具合対応
          if (isLazyLoading) {
            const elements = document.querySelectorAll('.jexcel_container.with-toolbar .jexcel > thead > tr > td');
            elements.forEach(element => {
              element.style.top = '0px';
            });
          }
        },
      });
    }

    // 列追加/削除処理
    function addOrRemoveColumn(isAdd) {
      let selectedColumns = spreadsheet.getSelectedColumns();
      if (selectedColumns.length > 0) {
        if (isAdd)
          spreadsheet.insertColumn(1, selectedColumns[selectedColumns.length - 1] + 1);
        else
          spreadsheet.deleteColumn(selectedColumns[0]);
      }
    }

    // 最終行が空行なら削除する関数
    function removeEmptyLastRow(instance) {
        let data = instance.getData();
        let lastRowIndex = data.length - 1;
        let lastRow = data[lastRowIndex];
        let isEmptyRow = lastRow.every(function(cell) {
            return cell === '';
        });
        if (isEmptyRow)
          instance.deleteRow(lastRowIndex);
    }
</script>
</body>
</html>
```

# 使用方法
## Webサイト
オンライン上で使用する場合、下記サイトにアクセスして下さい。

https://yaju.sakura.ne.jp/tool/jsCsvViewer.html

## jsCsvViewer.htmlの作成
### Qiitaから取得
1.メモ帳などのテキストエディタを開く
2.ソースコードの部分をコピーして貼り付ける
3.ファイル名「jsCsvViewer.html」をUTF-8形式で保存

### GitHubから取得
GitHubからダウンロードできます。

https://github.com/yaju/jsCsvViewer

## 画面説明
jsCsvViewer.htmlを開くと次図が表示されます。
![スクリーンショット 2024-10-14 23.19.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14053/6661e4e5-3f9a-c195-6d6d-aec2d1272fdf.png)

### 画面上部
* CSVファイルをドラッグ＆ドロップするか「ファイルを選択」ボタンをクリックするとファイル選択ダイアログが表示される
* 読込み完了時に「ファイルを選択」ボタンの右側にファイル名が表示される
* 文字コードには「shift-jis」と「utf-8」が選択できる。※UTF-8 BOM付きは自動判断される
* 「ヘッダー」チェックボックスはオフなら1行目にヘッダーを表示、オンの場合は列ヘッダーに表示します。※Excel貼り付けた際にヘッダーまでコピーされるように初期値はオフにしています
* 「再読み込み」ボタンは同じファイルを読み込み直します。文字コードや「ヘッダー」チェックボックスの変更後などに使用する
* 「全選択コピー」ボタンは表全体をクリップボードにコピーします。但し、サイズが大きい場合はメモリエラーになってしまいます
* 「遅延読込み」チェックボックスは表示領域分データのみ表示し、スクロールした際に追加のデータを動的に読込んで表示します
* 「ページネーション」チェックボックスは、指定行数(初期は10行)ごとにページを分割し、ページを切り替えながらデータを表示します。行数は10,25,50,75,100が選択できます

※「遅延読込み」と「ページネーション」はどちらかを選択してください。

:::note warn
Excelへの貼り付けについて
「全選択コピー」ボタンによりクリップボードにコピーされますが、それをそのままExcelへ貼り付けても自動変換されてしまいます。
Excelへ貼り付けする前に、Excelのコーナーヘッドで全選択して書式を「文字列」にした上で貼り付けを行なってください。
その後に指定列を他の書式に変換したい場合、次記事を参考にしてください。
[【Excel】文字列で入力された数字を「標準」や「数値」に直したい](https://www.anadigilife.com/excel-mojiretu-formatchange/)
:::
Excel 2019 for Mac にて表全体の書式を「文字列」にして貼り付け
![スクリーンショット 2024-09-17 1.55.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14053/184cf404-748c-4f2d-1931-adf3f3d7b768.png)

### アイコン
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14053/6b22d77e-eb6a-b4c9-8815-15bd38fef688.png)

* 「元に戻す」ボタンは編集した内容を戻します
* 「やり直し」ボタンは元に戻した内容を取り消します
* 「保存」ボタンはカンマ区切りで表示内容を保存します
* 「右寄せ」ボタンは指定セルや選択範囲を右寄せにします
* 「中央寄せ」ボタンは指定セルや選択範囲を中央寄せにします
* 「左寄せ」ボタンは指定セルや選択範囲を左寄せにします
* 「太字」ボタンは指定セルや選択範囲を太文字にします
* 「文字色」ボタンは指定セルや選択範囲の文字をカラーピッカーの選択色にします
* 「背景色」ボタンは指定セルや選択範囲の背景をカラーピッカーの選択色にします
* 「列追加」ボタンは指定セルの右側に列を追加します
* 「列削除」ボタンは指定セルの列を削除します

### コンテキストメニュー
セルの右クリックメニューとなります。日本語化したところのみ説明します。
![スクリーンショット 2024-09-17 20.44.20 (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14053/fe096881-0aa0-e923-f767-3e46cdc6a639.png)

* 「上に行を挿入」メニューは指定セルの上側に空行を追加します
* 「下に行を挿入」メニューは指定セルの下側に空行を追加します
* 「選択行の削除」メニューは指定セルの行を削除します
* 「コピー」メニューは指定セルや選択範囲をクリップボードにコピーします
* 「貼り付け」メニューは指定セルにクリップボードから貼り付けします

## クリアー処理
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14053/3cea6c3f-4211-8716-93f9-153007b6890e.png)

ブラウザのChrome「このページを再読み込みします」またはEdge「最新の情報に更新」を使用してください。

## 検索機能
ブラウザの検索(Ctrl+F)を使用してください。

「ページネーション」チェックボックスをオンにして読込みした場合のみ、Searchテキストボックスが表示され、入力した値が見つかった行を抽出して表示します。

![スクリーンショット 2024-10-15 0.30.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14053/4b72575d-8b53-1f4a-c124-66ebe8037bec.png)

## ソート機能
列ヘッダーをダブルクリックすると数値のみでも「文字列」扱いでソートされます。
ソートする場合は、ヘッダーチェックボックスをオンにしておかないとヘッダーもソート対象になってしまいます。

:::note alert
Excelだと列幅を自動調整する際にダブルクリックしますが、この機能はないためダブルクリックするとソートされてしまいます。
:::

## 数式機能
Jspreadsheetライブラリには標準で数式機能が備わっています。
![スクリーンショット 2024-09-17 22.01.41 (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14053/59962714-4f1f-ca4f-0f10-eeb42240edd7.png)

金額は単価$\times$数量で`=C2*D2`や`=C3*D3`、合計は`=SUM(E2:E3)`のようにセルに埋め込み計算させることが可能です。
使用可能な関数一覧(formulajs ライブラリに依存)

https://formulajs.info/functions/

## 保存機能
:::note warn
Jspreadsheetライブラリの保存機能をそのまま使用しています。
読込みのファイル形式を維持できない(例 二重引用符の囲みはなくなる、タブ区切りでもカンマ区切りになるなど)ため、限定的な機能になります。

読込みのファイル名(新規 jsCsv)_年月日時分.csvでダウンロードフォルダに保存されます。
ファイル形式は、UTF-8 BOM付きカンマ区切りの改行CRLFとなります。
:::

## 読み込みサイズ
サイズが大きい場合はエラーになり読み込めません。
郵便局のCSVの全県データ「utf_ken_all.csv」18.3MiB(124390行)はメモリ不足エラーになりましたが、北海道データ「01HOKKAI.csv」860KiB(8201行)は問題なく読み込めました。

【2024/10/15追記】
通常ではサイズが大きいCSVファイルでも表示されるように「遅延読込み」と「ページネーション」の機能を追加しました。この機能を使うと郵便局のCSVの全県データ「utf_ken_all.csv」18.3MiB(124390行)が表示されます。

## カスタム
jsCsvViewerで修正して欲しいところがあれば、ソースコードもそんなに長くないので修正してみてください。

### デフォルトの幅
初期の幅を広げたい場合、下記の値を変更してみて下さい。

```js
defaultColWidth: 100,    // デフォルトの幅を設定
```

### ヘッダーチェックボックスの初期値
ヘッダーチェックボックスを最初からオンにしたい場合、`checked`属性を追加して下さい。
```js
<input type="checkbox" id="isHeader" name="header" checked />
```

### Jspreadsheetの変更
ChatGPTなどの生成AIで「Jspreadsheetで何々したい」と要望をかくとコードを提示してくれます。但しバージョンの違いによって動作しないコードが生成されます。

https://www.tohoho-web.com/ex/jspreadsheet.html

# 最後に
今回は枯れた技術を使ったツールではありますが、世の中的には大きな貢献ができるのではないかと期待しています。

ExcelでWebDriverを使用しないでWebスクレイピングさせる紹介記事を書いています。良かったら読んでみてください。

https://qiita.com/yaju/items/0807cc762af4a0568806