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_sheet や json_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 にも同じ内容を投稿しています。