ブラウザでCSVの文字コードを変換するとき、TextDecoderとTextEncoderでできること、encoding-japaneseが必要な処理を紹介します。当サイトの文字化けを直すツールを例に、最小のコードと、実測で分かった注意点をまとめました。

使っているもの
| もの | 役割 | 出どころ |
|---|---|---|
| TextDecoder / TextEncoder | バイト列と文字列の変換(ブラウザ標準) | 標準API |
| encoding-japanese 2.2.0 | 文字コードの判定と、Shift_JIS・EUC-JPへの変換 | cdnjs |
<script src="https://cdnjs.cloudflare.com/ajax/libs/encoding-japanese/2.2.0/encoding.min.js"></script>
まず、ブラウザ標準でできること・できないこと
ここが出発点です。手元のChromeで確かめました。
- 読む(バイト→文字列)は、標準で足ります。
TextDecoderは'shift_jis'・'euc-jp'・'iso-2022-jp'・'windows-1252'のラベルを受け付けます。 - 書く(文字列→バイト)は、UTF-8しかできません。
TextEncoderは仕様でUTF-8固定です。new TextEncoder().encodingは常に'utf-8'を返します。 - 判定(このバイト列は何のコードか)は、標準にありません。
つまり「Shift_JISのファイルを読んでUTF-8で保存する」だけなら、ライブラリなしで書けます。ライブラリが要るのは、Shift_JISで書き出すときと、コードを自動判定したいときです。
読む:TextDecoderだけで済む
var bytes = new Uint8Array(await file.arrayBuffer());
var text = new TextDecoder('shift_jis', { fatal: false }).decode(bytes);
fatal: falseにしておくと、そのコードとして読めないバイトは例外にならず、置き換え文字「�」になります。ファイル変換のプレビューは、これで「読めているかどうか」を目で確かめる作りにしています。
判定:Encoding.detect で当たりをつける
encoding-japaneseのEncoding.detectにバイト列を渡すと、文字コードの名前が返ります。返り値はライブラリ独自の名前なので、TextDecoderのラベルに読み替える表が1つ要ります。この例で扱うのはUTF-8・Shift_JIS・EUC-JP・ISO-2022-JPの4つで、それ以外の判定結果は「決めつけずに人が選ぶ」に回します。
function pickLabel(bytes) {
var d = Encoding.detect(bytes); // 'SJIS' / 'EUCJP' / 'JIS' / 'UTF8' / 'UTF16' など
if (d === 'SJIS') return 'shift_jis';
if (d === 'EUCJP') return 'euc-jp';
if (d === 'JIS') return 'iso-2022-jp';
if (d === 'UTF8') return 'utf-8';
return null; // UTF16などは対象外。手動で選んでもらう
}
var label = pickLabel(bytes);
var text = label ? new TextDecoder(label).decode(bytes) : '';
手元で確かめると、「こんにちは」をShift_JISにしたバイト列(10バイト)は'SJIS'、UTF-8(15バイト)は'UTF8'、EUC-JPは'EUCJP'と返りました。一方、BOM付きのUTF-16(LE・BEとも)は'UTF16'が返ります。最初に書いた版のこの関数は「それ以外は全部'utf-8'」にしていたので、UTF-16のファイルをUTF-8として読んでしまう作りでした。判定は正しいのに、受け取る側で潰していたわけです。
判定はあくまで推定なので、ツールでは自動判定の結果を「[自動判定: shift_jis]」とプレビューに添えて、人が読めるかどうかで最終確認する形にしています。
書く:Shift_JISへの変換は Encoding.convert
ここがライブラリの出番です。文字列をいったんライブラリが扱う数値の配列にして、変換先を指定します。
function toSjis(text) {
var code = Encoding.stringToCode(text); // 文字列 → 数値の配列
var out = Encoding.convert(code, { to: 'SJIS', from: 'UNICODE' });
return new Uint8Array(out); // Blobにできる形へ
}
var blob = new Blob([toSjis(text)], { type: 'text/csv' });
stringToCodeが返すのはUTF-16のコード単位の配列で、Unicodeのコードポイントではありません。「𠮷」のようにサロゲートペアになる文字は、1文字で2つの数値になります(実測:[55362, 57271])。この配列はfrom: 'UNICODE'を指定してconvertにそのまま渡せます。ただし、変換先にない文字は保持できません(次の「はまりどころ その3」)。配列の要素数と文字数も、同じとは限りません。
UTF-8で保存したいときは標準のTextEncoderで足ります。ExcelにUTF-8として認識させるために、先頭にBOM(EF BB BF)を付けます。
var bom = new Uint8Array([0xEF, 0xBB, 0xBF]);
var body = new TextEncoder().encode(text);
var blob = new Blob([bom, body], { type: 'text/csv' }); // UTF-8(BOMあり・Excel向け)
応用:文字化けした文字列から、元の文章を逆算する
文字化けは「AというコードのバイトをBというコードで読んだ」ときに起きます。ということは、化けた文字列をBでバイトに戻して、Aで読み直せば元に戻るはずです。ただしこれが成り立つのは、途中で文字やバイトの情報が失われていない場合に限ります。読んだ時点で「�」に置き換わったり、コピーの途中で文字が落ちたりしていると戻せません。ツールの①「文字化けした文字を直す」は、この逆算を7通り試しています。
いちばん多い型で試してみます。「こんにちは」をUTF-8で書いたバイト列をShift_JISとして読むと、こうなります。
var u8 = new TextEncoder().encode('こんにちは'); // 15バイト
var garbled = new TextDecoder('shift_jis').decode(u8); // → 縺薙s縺ォ縺。縺ッ
この「縺薙s縺ォ縺。縺ッ」をShift_JISでバイトに戻し、UTF-8で読み直します。
var back = new TextDecoder('utf-8').decode(toSjis(garbled)); // → こんにちは
戻りました。ツールでは、この組み合わせを「UTF-8をShift_JISで読んだ」「UTF-8をEUC-JPで読んだ」「Shift_JISをUTF-8で読んだ」「EUC-JPをUTF-8で読んだ」「UTF-8を欧文コードで読んだ(ãやâが出る型)」「EUC-JPとShift_JISの取り違え」の7通り用意して、全部の結果を並べます。
並べる順番は、日本語らしさの採点で決めています。1文字ずつ、ひらがなは+3、カタカナは+2、漢字と英数字は+1、「�」は−2として平均を取る。5文字以上でひらがなが1つも無く漢字ばかりの候補は、偽の候補であることが多いので1点引きます。単純ですが、これで読める候補がだいたい上に来ます。
はまりどころ3つ
その1:BOMは、TextDecoderが既定で取り除く
new TextDecoder('utf-8')は、先頭のBOM(EF BB BF)を既定で取り除いて文字列にします(実測:BOM+「あ」の6バイトを読むと1文字)。この読み方なら、別途subarray(3)で外す必要はありません。残したいときだけ{ ignoreBOM: true }を渡します(名前と逆に見えますが、「BOMを無視して、そのまま文字として通す」という意味です)。
気をつけるのは、UTF-8のラベル以外で読んだときと、バイト列を直接加工するときです。BOM付きUTF-8を'shift_jis'で読むと、BOMも文字として解釈され、先頭に余分な文字が現れます(実測:BOM+「あ」は「�ソ縺�」の4文字、BOMなしの「あ」は「縺�」の2文字)。本文も文字化けするため、まず正しい文字コードで読むことが大切です。
Encoding.convertにBOM付きUTF-8のバイト列を渡す場合も注意が必要です。2.2.0では、UNICODEへの変換では先頭にU+FEFFが残り、SJISへの変換ではその部分が「?」になりました(UTF8→UTF8ならEF BB BFがそのまま残ります)。この経路では、変換前に先頭3バイトを見てUTF-8のBOMを取り除きます。
その2:判定は「読めるか」で最終確認する
Encoding.detectは短いファイルや英数字ばかりのファイルで外すことがあります。ツールでは自動判定を初期値にしつつ、人が「読み込む文字コード」を切り替えてプレビューを見られるようにしました。判定に頼りきらないのが安全です。
その3:変換先にない文字は「?」になって失われる
Shift_JISに変換するとき、変換先にない文字は既定では「?」(0x3F)に置き換わり、元の文字の情報は失われます。エラーは出ません。手元の2.2.0で試すと、①・Ⅰ・~(U+FF5E)・髙は変換して戻せましたが、〜(U+301C)・—(U+2014)・絵文字・𠮷は「?」になりました。このライブラリのSJISはCP932の拡張文字も扱うので、「丸数字だから変換できない」という単純な話ではなく、1文字ずつ結果が違います。
黙って失われるのが困る場面では、fallback: 'error'を付けると変換不能な文字で例外が出て止まります。fallback: 'html-entity'なら😀のような数値参照に置き換わります。ツールでは「大事な書類では、その部分だけ目で確かめてください」と案内しています。①が機種依存文字と呼ばれる由来は、別の記事「丸数字①が文字化けするのはなぜ?」に書きました。
// 変換できない文字があれば例外を出して止める
Encoding.convert(code, { to: 'SJIS', from: 'UNICODE', fallback: 'error' });

まとめ
ブラウザ標準のTextDecoderはShift_JISもEUC-JPも読めますが、TextEncoderはUTF-8しか書けず、判定の機能もありません。そこを埋めるのがencoding-japaneseで、Encoding.detectで判定し、Encoding.convertでShift_JISに書き出せます。判定結果のうち扱えないもの(UTF-16など)は人に選んでもらう、変換先にない文字は「?」で失われるので保存前に確かめる。この2つを押さえておけば、文字コード変換も、文字化けの逆算も、サーバーなしで動きます。
実際に動いているのが文字化けを直すツールです。化ける前に確かめたいときは機種依存文字チェッカーをどうぞ。
参考: encoding.js(GitHub・README。detect・fallback・UNICODEの意味)/cdnjs: encoding-japanese/MDN: TextDecoder/MDN: TextEncoder/WHATWG Encoding(ignoreBOM)


