0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SheetJSでExcel↔CSVをブラウザ変換 — Shift_JIS・BOM・先頭ゼロ消失の落とし穴

0
Posted at

CSV と Excel の相互変換、Web ツール化しようとすると Excel 側で起きる Excel 独特の「親切すぎる」データ書き換えに何度もハマる。先頭ゼロが消える、日付が勝手に変換される、Shift_JIS が文字化けする。

ぱんだツールズに「Excel→CSV一括変換」と「CSV↔Excel変換」を作ったので、SheetJS(xlsx)でブラウザ完結変換をするときに踏むであろう落とし穴と、実装でやった対策を整理する。すべてブラウザ内処理なので、社内データやID入りのCSVも安全に扱える。

SheetJSはブラウザでExcelを読み書きできる事実上の標準

xlsx(SheetJS)はブラウザでもNodeでも動く Excel 操作の事実上の標準ライブラリ。.xlsx (OpenXML) と .xls(バイナリBIFF)両方読める。

最低限の読み込み+CSV化はこれだけ:

import * as XLSX from 'xlsx'

const buffer = await file.arrayBuffer()
const workbook = XLSX.read(buffer, { type: 'array' })
const worksheet = workbook.Sheets[workbook.SheetNames[0]]
const csvText = XLSX.utils.sheet_to_csv(worksheet)

逆方向(CSV→Excel)も書ける:

const workbook = XLSX.read(buffer, { type: 'array' })
const excelBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' })
const blob = new Blob([excelBuffer], {
  type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})

これで動く。ただしここからが落とし穴の集合

落とし穴1. CSV → Excel で Shift_JIS が文字化けする

Windows で作られた古い CSV はだいたい Shift_JIS。それを XLSX.read(buffer, { type: 'array' }) だけで読むと デフォルトで UTF-8 として解釈するので、日本語が全部バケる。

回避策はオプションで codepage: 932 を指定すること:

const workbook = XLSX.read(buffer, { type: 'array', codepage: 932 })

932 は Shift_JIS の Microsoft コードページ番号。これでCSVを正しく日本語として取り込んだ上で .xlsx に書き出せる。

「自動判定すればいいじゃん」と思うかもしれないが、CSV の文字コード判定は確実じゃないので、UI で Shift_JIS / UTF-8 を選ばせる or 経験則で 932 をデフォルトにする方が事故が少ない。ぱんだツールズの実装では「日本語環境からのインポート」を想定して 932 をデフォルトにしている。

落とし穴2. Excel → CSV で UTF-8 BOM を付けないと Excel が文字化けする

逆方向、Excel→CSV も罠。デフォルトで XLSX.utils.sheet_to_csv() が返す文字列は BOM なし UTF-8。これを Blob にしてダウンロードさせると、ユーザーが Windows 版 Excel で開いた瞬間に日本語が化ける。

これは SheetJS の問題ではなく、Windows 版 Excel が CSV を Shift_JIS と前提して開く仕様が悪い。BOM (0xEF 0xBB 0xBF) を先頭に付ければ Excel は UTF-8 として開いてくれる:

function encodeToUtf8Bom(text: string): ArrayBuffer {
  const utf8 = new TextEncoder().encode(text)
  const withBom = new Uint8Array(3 + utf8.length)
  withBom.set([0xef, 0xbb, 0xbf], 0)
  withBom.set(utf8, 3)
  return withBom.buffer as ArrayBuffer
}

ぱんだツールズでは UTF-8 BOM 付きをデフォルトにしている。Mac でしか使わない人なら BOM はゴミでしかないので、選択肢として「BOM なし UTF-8 / BOM 付き UTF-8 / Shift_JIS」の3択を出す設計もアリ。

落とし穴3. Shift_JIS で書き出すなら encoding-japanese

完全な Shift_JIS バイト列でダウンロードしたい場合、TextEncoder では UTF-8 しか作れないので、別ライブラリ encoding-japanese を使う:

import Encoding from 'encoding-japanese'

function encodeToShiftJis(text: string): ArrayBuffer {
  const unicodeArray = Encoding.stringToCode(text)
  const sjisArray = Encoding.convert(unicodeArray, { to: 'SJIS', from: 'UNICODE' })
  return new Uint8Array(sjisArray).buffer as ArrayBuffer
}

encoding-japanese は依存ゼロで、Shift_JIS の他にも EUC-JP・JIS など主要な日本語エンコーディングをサポートする軽量ライブラリ。Shift_JIS で表現できない文字(絵文字や一部の Unicode 拡張漢字)は ? に置換されることだけ注意。

落とし穴4. ExcelがCSV開くとデータが壊れる現象(先頭ゼロ問題)

これはSheetJSの問題ではなく Excel の挙動だが、変換ツールを使うユーザーがハマる現象なので対策まで書いておく。

CSV に 00123 というデータがあるとき、Excel は数値として解釈して 123 に変換する。社員番号・郵便番号・商品コード・電話番号などはほぼ全部これで死ぬ。「Excel で開いて保存」しただけでデータが壊れる

回避策の選択肢:

方法 説明
Excel側で「テキスト/CSVから」インポート 各列の型をテキストに固定できる。手間はあるが確実
CSVではなく.xlsxで渡す テキスト型を保持して書き出せばExcelも書き換えない
値の前に '(シングルクオート)を付ける Excelが文字列として扱う。ただし元データを書き換える破壊的手法

ぱんだツールズの「CSV↔Excel変換」では CSV → .xlsx 変換を提供しているので、ID 系のデータが入ったCSVは一度 .xlsx に変換してから Excel で開くのが一番安全になる。

ただし XLSX.read のデフォルトは「数値っぽい文字列を数値として読む」挙動。全フィールドをテキストとして扱いたい場合は cell の型情報(t: 's')を後段で書き換える、もしくは sheet_to_json 側で raw: false を指定するなどの追加処理が要る。SheetJS のオプションは Read/Write/JSON 変換の各層に分散していて挙動が把握しづらいので、深掘りは別記事に回す。

落とし穴5. 日付の自動変換と数式の消失

1-2-3 のような文字列を Excel が日付(2003年2月1日相当)に変換する事故、2024/01 が「2024年1月」になって日が抜けるなど、Excel は文字列を日付と判断するヒューリスティックがやたら強い。

CSV → Excel 変換で「絶対に文字列のままにしたい」場合は、SheetJS 側で 各セルに t: 's'(string型)を明示する必要がある。XLSX.utils.aoa_to_sheetjson_to_sheet で組み立てた後、戻り値の各セル参照に対して worksheet[ref].t = 's'; worksheet[ref].v = String(...) のように直接書き換える。json_to_sheet 単体には「全セルを string 型として強制する」オプションは無い。

数式に関しては、CSV はプレーンテキストなので数式そのものは保持できない。Excel で数式入りファイルを開いて CSV 保存すると、SheetJS の sheet_to_csv も Excel 自身も「計算結果の値」だけを書き出す。これは仕様なので回避不能。

複数シートはJSZipでまとめてダウンロード

Excel の .xlsx は1ファイルに複数シートを持てる。これを「シート別CSV」に変換する場合、シート数だけCSVファイルが出来上がる。一度にダウンロードさせるには JSZip でアーカイブにする:

import JSZip from 'jszip'

const zip = new JSZip()
for (const sheet of selectedSheets) {
  const csvText = XLSX.utils.sheet_to_csv(workbook.Sheets[sheet.name])
  const encoded = encodeToUtf8Bom(csvText)
  zip.file(`${sheet.name}.csv`, encoded)
}
const zipBlob = await zip.generateAsync({ type: 'blob' })

ZIP 内でも UTF-8 BOM 付きエンコードでファイル単位に書き出している。ZIP 解凍後にそのまま Excel で開いても化けない。

ファイル名にシート名をそのまま使っているが、シート名に \ / : * ? " < > | が含まれる可能性があるので、本番では sanitize した方が安全。

TSV対応も同じインターフェースで

カンマ区切りじゃなくタブ区切りで欲しい場合、sheet_to_csv のフィールド区切りを変えるだけ:

const tsvText = XLSX.utils.sheet_to_csv(worksheet, { FS: '\t' })

FS (Field Separator) を \t にするだけで TSV 出力になる。データに , が含まれて引用符地獄になりがちなケースでは TSV の方が扱いやすい。

サイズ制限はメモリ次第

ブラウザ内処理なので、Excel ファイルのサイズはユーザーのメモリに依存する。SheetJS は DOM ライクなオブジェクトを丸ごとメモリに展開するので、10万行クラスの Excel は素のロードでも数百MBメモリを食う。

実用的には 2-5MB の Excel ファイルまでを想定するのが現実的。それ以上はサーバー側で処理した方が早いし安定する。とはいえブラウザ完結を売りにするツールでは「制限なし」と書いて、実際のユーザーがメモリで詰まったら案内する戦略を取っている。

まとめ

SheetJS でブラウザ完結 Excel↔CSV 変換を組むときに最低限押さえるところ:

  • CSV→Excel: codepage: 932 で Shift_JIS CSV を正しく取り込み、bookType: 'xlsx' で書き出し
  • Excel→CSV: 出力に UTF-8 BOM を付与(0xEF 0xBB 0xBF)か、encoding-japanese で Shift_JIS バイト列に変換
  • 複数シート: JSZip で ZIP 化、ファイル名は sanitize
  • TSV 出力は FS: '\t' だけ
  • 「先頭ゼロ消失」「日付暴走」は Excel 側の問題なので、CSV→.xlsx 変換を提供することで利用者を守れる

文字コード周りは特に「日本市場特化」で差別化できるポイント。海外製の同種ツールはだいたい UTF-8 前提で組まれていて Shift_JIS を考慮していないので、レガシーな日本のシステム連携に困っている人がそこそこ流入してくる。

ぱんだツールズ ではこの他にも CSV文字コード変換・PDF処理・画像処理などブラウザ完結ツールを80本以上公開している。全部無料・登録不要・サーバー送信なし。
https://sakutto-panda.com


この記事は Zenn にも同じ内容を投稿しています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?