こんにちは、寒い日が続きますね。
久しぶりに更新担当が回ってきました。ふちがみです。

この仕事をしていると、よくあるのが既存システムの管理画面にCSVダウンロード機能を追加する案件です。
そんな時、目の前に表組がHTMLで表示されてるのに、もどかしい。。
という気分になりませんか?私はなります。というか、今現在なっております。

定時も回ったしそろそろ店じまいにしようと考えていたその時。

閃きました。私のゴーストがささやきました。

「そうだ、JavascriptでCSVに変換してファイル保存させればいいじゃない!」

仕様

そうと決まれば善はいそげ。さっそく、やってみましょう。
ざっくりと仕様を考えてみます。こんな感じでしょうか。

[変換]

  • Table要素を渡すとCSV文字列に変換する
    • TR単位で1行とする
    • 改行コードははCRLF
    • TR内のTD,TH単位で1列とする
    • 文字列はダブルクォートでくくる。
    • 文字列にダブルクォート(“)が含まれるときは、(“”)に変換する。
    • rowspan, colspan ⇒ 気づかなかったことにします。

[ファイル保存]

  • 変換した文字列をファイル保存できるようにする

[その他]

  • jQueryはつかわない(なんとなく)

この時点で素直にサーバサイドで実装したほうがいいと思い始めています。
(ほとんど、ビューを変えるだけですし。)

が、気にせず続けたいと思います。

なぜならば、原稿の締め切りが過ぎているからです。

出力対象のテーブル

こんな感じのテーブルをCSV化してみます。

<table id="tbl">
  <thead>
    <tr>
      <th><strong>店舗名</strong></th>
      <th>メールアドレス</th>
      <th>電話番号</th>
      <th>FAX番号</th>
      <th>所在地</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>A店</td>
      <td>a@example.com</td>
      <td>01-1111-1111</td>
      <td>01-1111-0000</td>
      <td>東京都新宿区</td>
    </tr>
    <tr>
      <td>"B"店</td>
      <td>a@example.com</td>
      <td>02-2222-2222,02-2222-2220(採用担当)</td>
      <td>02-2222-0000</td>
      <td>東京都中野区</td>
    </tr>
  </tbody>
</table>

実装

せっかちなかたはこちらをご覧下さい。

CSV変換機能の実装

まずはtable要素を渡して、CSV文字列に変換する処理を実装したいと思います。

まずはtableからtr,trからtd th を抽出したいですね。
特定要素の子孫要素から、特定要素を抜き出す処理を作ってみました。

jQueryがあれば、$(‘table’).find(‘tr’) とかで一発なのですが、
ちょっと面倒くさいですね。。

var Util = {
  getNodesByName: function(elm /*, string or array*/) {
    var children  = elm.childNodes;
    var nodeNames = ('string' === typeof arguments[1]) ? [arguments[1]] : arguments[1] ;
    nodeNames = nodeNames.map(function(str){ return str.toLowerCase() });

    var results  = [];

    for (var i = 0, max = children.length; i < max; i++ ) {
      if (nodeNames.indexOf(children[i].nodeName.toLowerCase()) !== -1)
      {
         results.push(children[i]);
      }
      else
      {
         results = results.concat(this.getNodesByName(children[i], nodeNames));
      }
    }

    return results;
  }
}

思案の結果、Util.getNodesByNameというメソッドが完成しました。
第1引数にHTMLElementを取り、第2引数に配列または文字列で要素名を取ります。
返ってくるのは、マッチした要素集合です。

入れ子構造が深い場合もあるので、再帰的に探すようにしてみました。
例えばこんな感じで使います。

var tableObj = document.getElementById('テーブルのid');
var rows = Util.getNodesByName(tableObj, 'tr');
var firstRowCols = Util.getNodesByName(rows[0], ['th', 'td']);

このメソッド、trにtrが入れ子になったりするとおそらく子孫をとりこぼすのですが、
その話は聞かなかったことにして下さい。

これを使って前述のtableをCSVに変換していきたいと思います。

var csv = tableToCSV.export(document.getElementById('tbl'))
console.log(csv);

でCSVに変換できるように、tableToCSVを定義しました。

var tableToCSV = {
  export: function(elm /*, delimiter */) {
    var table = elm;
    var rows  = this.getRows(table);
    var lines = [];
    var delimiter = delimiter || ',';

    for (var i = 0, numOfRows = rows.length; i < numOfRows; i++) {
      var cols    = this.getCols(rows[i]);
      var line = [];

      for (var j = 0, numOfCols = cols.length; j < numOfCols; j++) {
          var text = cols[j].textContent || cols[j].innerText;
          text = '"'+text.replace(/"/g, '""')+'"';

          line.push(text);
      }

      lines.push(line.join(delimiter));
    }
    return lines.join("\r\n");
  },

  getRows: function(elm){
    return Util.getNodesByName(elm, 'tr');
  },

  getCols: function(elm){
    return Util.getNodesByName(elm, ['td', 'th']);
  }
}

console.logの結果は↓のようになります。

"店舗名","メールアドレス","電話番号","FAX番号","所在地","A店"
"a@example.com","01-1111-1111","01-1111-0000","東京都新宿区"
"""B""店","a@example.com","02-2222-2222,02-2222-2220(採用担当)","02-2222-0000","東京都中野区"

ファイル保存機能の実装

無事、テーブルをカンマ区切りテキストにできたので、
次はファイルとして保存する処理を実装したいと思います。

Chromeでは、download属性にファイル名を指定したaタグで簡単にファイル保存できるそうです。
今回は、Chromeのみ動作するように実装します。

来週の記事担当も私ですので、
クロスブラウザの落としどころは、来週考えることにしたいと思います。

Chromeでは、下記の手順で実現できました。

  • 文字列をBlobに変換
  • BlobのURLを生成(URL.createObjectURL())
  • aタグを生成して生成したURLをhref属性にセット、download属性にファイル名指定

保存処理を追記したtableToCSVは、下記のようになりました。

var tableToCSV = {
  export: function(elm /*, delimiter */) {
    var table = elm;
    var rows  = this.getRows(table);
    var lines = [];
    var delimiter = delimiter || ',';

    for (var i = 0, numOfRows = rows.length; i < numOfRows; i++) {
      var cols    = this.getCols(rows[i]);
      var line = [];

      for (var j = 0, numOfCols = cols.length; j < numOfCols; j++) {
          var text = cols[j].textContent || cols[j].innerText;
          text = '"'+text.replace(/"/g, '""')+'"';
          line.push(text);
      }

      lines.push(line.join(delimiter));
    }

    this.saveAsFile(lines.join("\r\n"));
  },

  saveAsFile: function(csv) {
    var blob = new Blob([csv], {type: 'text/csv'});
    var url  = URL.createObjectURL(blob);

    var a = document.createElement("a");

    a.href = url;
    a.target = '_blank';
    a.download = 'table.csv';

    a.click();
  },

  getRows: function(elm){
    return Util.getNodesByName(elm, 'tr');
  },

  getCols: function(elm){
    return Util.getNodesByName(elm, ['td', 'th']);
  }
}

結果

今回は、こんなものが出来上がりました。
Chrome以外のブラウザ対応は、次週の記事で実施してみます。

あと、ExcelではUTF8が文字化けすることをすっかり忘れていました。。
文字コード変換ライブラリを提供している方がいるようなので、
次回はそのライブラリを使った文字コード変換も実施します。

最終的なコード(table_csv.html)

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<style>
table, th, td {
  border: 1px solid;
}
</style>
<script>
var tableToCSV = {
  export: function(elm /*, delimiter */) {
    var table = elm;
    var rows  = this.getRows(table);
    var lines = [];
    var delimiter = delimiter || ',';

    for (var i = 0, numOfRows = rows.length; i < numOfRows; i++) {
      var cols    = this.getCols(rows[i]);
      var line = [];

      for (var j = 0, numOfCols = cols.length; j < numOfCols; j++) {
          var text = cols[j].textContent || cols[j].innerText;
          text = '"'+text.replace(/"/g, '""')+'"';

          line.push(text);
      }

      lines.push(line.join(delimiter));
    }

    this.saveAsFile(lines.join("\r\n"));
  },

  saveAsFile: function(csv) {
    var blob = new Blob([csv], {type: 'text/csv'});
    var url  = URL.createObjectURL(blob);

    var a = document.createElement("a");

    a.href = url;
    a.target = '_blank';
    a.download = 'table.csv';

    a.click();
  },

  getRows: function(elm){
    return Util.getNodesByName(elm, 'tr');
  },

  getCols: function(elm){
    return Util.getNodesByName(elm, ['td', 'th']);
  }
}

var Util = {
  getNodesByName: function(elm /*, string or array*/) {
    var children  = elm.childNodes;
    var nodeNames = ('string' === typeof arguments[1]) ? [arguments[1]] : arguments[1] ;
    nodeNames = nodeNames.map(function(str){ return str.toLowerCase() });

    var results  = [];

    for (var i = 0, max = children.length; i < max; i++ ) {
      if (nodeNames.indexOf(children[i].nodeName.toLowerCase()) !== -1)
      {
         results.push(children[i]);
      }
      else
      {
         results = results.concat(this.getNodesByName(children[i], nodeNames));
      }
    }

    return results;
  }
}

window.onload = function(){
  document.getElementById('download').addEventListener('click', function (e){ e.preventDefault(); tableToCSV.export(document.getElementById('tbl')); });
}

</script>
</head>
<body>

<strong style="color:RED;">Google Chromeだけで動作します!!!</strong>  
<table id="tbl">
  <thead>
    <tr>
      <th><strong>店舗名</strong></th>
      <th>メールアドレス</th>
      <th>電話番号</th>
      <th>FAX番号</th>
      <th>所在地</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>A店</td>
      <td>a@example.com</td>
      <td>01-1111-1111</td>
      <td>01-1111-0000</td>
      <td>東京都新宿区</td>
    </tr>
    <tr>
      <td>"B"店</td>
      <td>a@example.com</td>
      <td>02-2222-2222,02-2222-2220(採用担当)</td>
      <td>02-2222-0000</td>
      <td>東京都中野区</td>
    </tr>
  </tbody>
</table>

<a href="#" id="download">CSVダウンロード</a>
</body>
</html>