「CSV でダウンロードできるようにして」は業務システムで最もよくある要望の1つで、リリース後に最もよく来る問い合わせが「Excel で開くと文字化けする」です。
よく知られた話ですが、実際に Excel で確かめてみると、文字化けだけでなく列の区切りまで消えることがわかったので、結果と対策をまとめます。後半では、ライブラリなしでブラウザから Shift_JIS の CSV を書き出す方法も紹介します。
この記事は、生成AI(Claude)を使って書いた下書きを、実機での動作確認とあわせて検証してから投稿しています。
実験: 同じ CSV を3つの文字コードで保存して Excel で開く
中身は「名前,x」「テスト,1000」の2行だけです。日本語版 Windows(システムの文字コード 932)の Excel 16.0 で Workbooks.Open で開き、セルの値を読み取りました(2026年10月22日)。
| ファイルの文字コード | A1 | A2 | B2 |
|---|---|---|---|
| UTF-8(BOM なし) | 蜷榊燕 |
繝・せ繝・1000 |
(空) |
| UTF-8(BOM 付き) | 名前 |
テスト |
1000 |
| Shift_JIS | 名前 |
テスト |
1000 |
BOM(先頭の EF BB BF)がない UTF-8 は、Shift_JIS として読まれて文字化けします。ここまでは有名な話です。
注目は A2 と B2 です。BOM なしでは 「1000」が B2 ではなく A2 に入っています。「テスト」の UTF-8 の最後のバイトと、続く , が Shift_JIS の2バイト文字として1文字に読まれてしまい、列の区切りが消えたためです。「文字化けするけど数字の列は読める」とは限らず、列ずれで別の列に値が入ることがあります。
どの文字コードで出すか
| 用途 | 文字コード |
|---|---|
| 利用者が Excel でダブルクリックして開く | UTF-8(BOM 付き) |
| 銀行・会計ソフト・古い基幹システムに取り込む | 取り込み先の仕様に合わせる(Shift_JIS が多い) |
| プログラムで読み込む | UTF-8(BOM なし) |
UTF-8(BOM 付き)は、こう作れます。
const body = new TextEncoder().encode(csvText);
const bytes = new Uint8Array(body.length + 3);
bytes.set([0xEF, 0xBB, 0xBF]); // BOM
bytes.set(body, 3);
const blob = new Blob([bytes], { type: 'text/csv' });
ブラウザで Shift_JIS を書き出すには
TextDecoder('shift_jis') で読むことはできても、TextEncoder は UTF-8 しか出力できません。そこで、TextDecoder で全バイト列をデコードして、逆引きの表を作る方法があります。
let SJIS = null;
function sjisTable() {
if (SJIS) return SJIS;
SJIS = new Map();
const dec = new TextDecoder('shift_jis');
for (let b = 0x00; b <= 0x80; b++) SJIS.set(dec.decode(new Uint8Array([b])), [b]);
for (let b = 0xA1; b <= 0xDF; b++) SJIS.set(dec.decode(new Uint8Array([b])), [b]); // 半角カナ
for (let hi = 0x81; hi <= 0xFC; hi++) {
// 半角カナの範囲、NEC選定IBM拡張(IBM拡張と重複)、ユーザー定義領域は飛ばす
if ((hi >= 0xA0 && hi <= 0xDF) || hi === 0xED || hi === 0xEE || (hi >= 0xF0 && hi <= 0xF9)) continue;
for (let lo = 0x40; lo <= 0xFC; lo++) {
if (lo === 0x7F) continue;
const ch = dec.decode(new Uint8Array([hi, lo]));
if (ch.length !== 1 || ch === '\uFFFD' || SJIS.has(ch)) continue;
SJIS.set(ch, [hi, lo]);
}
}
return SJIS;
}
先頭バイト 0xED・0xEE(NEC 選定 IBM 拡張文字)を飛ばしているのは、同じ文字が 0xFA〜0xFC(IBM 拡張文字)にもあり、Windows はそちらを使うためです。
本当に Windows と同じになっているか
この表(7,518文字)と、Windows の .NET の CP932 エンコーダー(Encoding.GetEncoding(932))が出すバイト列を、U+0000〜U+FFFF のすべての文字(サロゲートと私用領域を除く)で比べました。両方を「コードポイント:バイト列」の行にして SHA-256 を取ったところ、完全に一致しました(Chrome 152・Windows 11)。
「〜」が変換できない問題
Mac などで入力した「〜」は U+301C、Windows で入力した「~」は U+FF5E です。CP932 にあるのは U+FF5E だけなので、そのままだと「変換できない文字」になります。変換の前に置き換えます。
const TO_CP932 = { '\u301C': '\uFF5E', '\u2212': '\uFF0D', '\u2016': '\u2225', '\u00A2': '\uFFE0', '\u00A3': '\uFFE1', '\u00AC': '\uFFE2', '\u2014': '\u2015' };
function encodeSjis(text, replacement = '?') {
const table = sjisTable();
const out = [], bad = [];
for (const ch of text) {
const bytes = table.get(TO_CP932[ch] ?? ch);
if (bytes) out.push(...bytes);
else { bad.push(ch); out.push(...table.get(replacement)); }
}
return { bytes: new Uint8Array(out), bad };
}
encodeSjis('〜').bytes; // Uint8Array [0x81, 0x60]
encodeSjis('𠮷野家😀').bad; // ['𠮷', '😀']
絵文字や「𠮷」は CP932 に存在しないので、bad で呼び出し元に返し、黙って「?」にしないようにしています。
まとめ
- BOM なし UTF-8 の CSV は、Excel で文字化けするうえ、列の区切りが消えて値がずれることがある
- Excel 向けは UTF-8(BOM 付き)、取り込み先の指定があれば Shift_JIS
- ブラウザでも、
TextDecoderの逆引きで Windows と同じ Shift_JIS を書き出せる。「〜」などは事前に置き換える
この記事の内容に、CSV のクォート処理・CSV インジェクション対策・読み込み時の文字コード判定を加えたものを、Zenn の本の1章にまとめています。振込カナ・営業日・和暦・消費税の端数など、日本の業務データの落とし穴を、公式資料の出典とテストつきで扱った本です(最初の2章は無料)。