はじめに
前回の記事では、ブラウザでCSVファイルを読み込む実装を紹介しました。
文字コードの検出・変換には encoding-japanese ライブラリを使用していましたが、文字化けが発生する問題がありました😔
この記事では、文字化けの原因と対応について紹介します。
💡 この記事でわかること
- encoding-japaneseでCP932の拡張文字が文字化けする理由
- Shift_JISとCP932(Windows-31J)の違い
-
TextDecoderを使った文字コード変換の実装
発生した問題
「﨑」などの文字を含むCSVファイルを読み込むと、文字コードの検出・変換に失敗して、文字化けする
前回の実装では、encoding-japaneseの detect で文字コードを判定し、convert でUnicodeに変換していました。
import Encoding from 'encoding-japanese';
const detectedEncoding = Encoding.detect(uint8Array);
const unicodeArray = Encoding.convert(uint8Array, {
to: 'UNICODE',
from: detectedEncoding,
});
const unicodeStr = Encoding.codeToString(unicodeArray);
detect → convert → codeToString で文字コードの判定と変換を行っています。
原因
encoding-japanese が CP932 の拡張文字に対応していない
原因は、Encoding.detect() がCP932拡張文字のバイト列を不正とみなし、文字コードを正しく判定できないことでした。
encoding-japaneseのGitHubリポジトリにもこの問題についてIssueが報告されています。
CP932形式とは、MicrosoftによるShift_JISの独自拡張です。特殊文字用に0xFA40以降を拡張して使用するため、Shift_JISとしてdetectしようとした場合検出に失敗することがあります。— encoding.js Issue #54
今回のケースでは、Encoding.detect() が 'UNICODE' を返していたため、Shift_JISのバイト列がUnicodeとして解釈され、文字化けが発生しました。
具体的には、CP932でエンコードされた「宮﨑」のバイト列を変換すると、以下のような結果になります。
Encoding.convert(from: 'SJIS') → 宮?(「﨑」が ? になる)
TextDecoder('shift_jis') → 宮﨑(正しくデコード)
「﨑」はCP932の拡張領域に含まれる文字であり、Shift_JIS(JIS X 0208)の範囲外にあたるため、encoding-japaneseでは変換できません。
⚠️ 注意
仮に検出を修正して from: 'SJIS' を明示的に指定しても、Encoding.convert() もCP932拡張文字の変換に対応していないため、文字化けの問題は解消されません。
Shift_JISとCP932の違い
Shift_JISは JIS X 0201 と JIS X 0208 の文字集合をサポートする文字コードです。
一方、日本語Windows環境で使用される「Shift_JIS」は、MicrosoftによるShift_JISの拡張であり、正式名称は Microsoft Windows Codepage : 932(CP932)です。
CP932はShift_JISの文字集合に加えて、NEC特殊文字、NEC選定IBM拡張文字、IBM拡張文字をサポートしています。
Shift_JIS : JIS X 0201 + JIS X 0208
CP932 : JIS X 0201 + JIS X 0208 + NEC特殊文字 + NEC選定IBM拡張文字 + IBM拡張文字
CP932で追加された拡張文字の例
| カテゴリ | 文字例 |
|---|---|
| NEC特殊文字 | ①、②、Ⅰ、Ⅱ、㍉、㌔ など |
| NEC選定IBM拡張文字 | 纊、褜、鍈、彅 など |
| IBM拡張文字 | 﨑、髙、德、彅 など |
WindowsやExcelで「Shift_JIS」と呼ばれているものは、実際はCP932のことです。
今回文字化けした「﨑」はIBM拡張文字に属しており、JIS X 0208には含まれていません。encoding-japaneseはShift_JIS(JIS X 0208)の範囲で処理を行うため、これらの拡張文字を正しく扱えませんでした。
対応方法
encoding-japaneseの detect + convert を、ブラウザ標準APIの TextDecoder に置き換えました。
TextDecoder
ブラウザの TextDecoder の仕様では、shift_jis と windows-31j は同一のエンコーディングとして扱われます。
shift_jis や windows-31j など、オプションとして異なるラベルを指定しても、ブラウザ内部では同一のデコーダが使われます。
new TextDecoder('shift_jis').encoding; // 'shift-jis'
new TextDecoder('windows-31j').encoding; // 'shift-jis'
TextDecoder にはエンコーディングの自動検出機能がありません。
今回の実装では、UTF-8かShift_JIS(CP932)かを判定することにしました。
fatalオプションによる文字コード判定
fatal: true
不正なバイト列をデコードした時、TypeError がスローされます。
const decoder = new TextDecoder('utf-8', { fatal: true });
decoder.decode(new Uint8Array([0x82, 0xa0])); // TypeError(Shift_JISの「あ」)
fatal: false(デフォルト)
不正なバイト列は U+FFFD(�)に置換され、エラーになりません。
const decoder = new TextDecoder('utf-8');
decoder.decode(new Uint8Array([0x82, 0xa0])); // '��'
この挙動を利用して、UTF-8でのデコードに成功すればUTF-8、TypeError がスローされればShift_JIS(CP932)としてデコードし直します。
try {
const decoder = new TextDecoder('utf-8', { fatal: true });
return decoder.decode(uint8Array);
} catch {
const decoder = new TextDecoder('shift_jis');
return decoder.decode(uint8Array);
}
ignoreBOMオプションによるBOM除去
前回は、BOMを考慮する場合、削除するコードを実装する必要がありました。
TextDecoder は ignoreBOM オプションでBOMを無視するかどうかを設定できます。
ignoreBOM の値 |
動作 |
|---|---|
false(デフォルト) |
BOMを出力から除去する |
true |
BOMを出力に含める |
今回の実装では、BOM付きUTF-8ファイルにも対応したいので、デフォルトを採用しています。
実装コード
ここまでの内容を踏まえた最終的な実装コードです。
/** CSVファイルを読み込んで文字列に変換 */
export const readCsvFile = async (file: File): Promise<string> => {
const arrayBuffer = await file.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
try {
const decoder = new TextDecoder('utf-8', { fatal: true });
return decoder.decode(uint8Array);
} catch {
const decoder = new TextDecoder('shift_jis');
return decoder.decode(uint8Array);
}
};
⚠️ 注意
この実装はUTF-8とShift_JIS(CP932)の2つのみを判定対象としています。
他の文字コードを対象とする場合は別途対応が必要です。
まとめ
- encoding-japaneseはCP932拡張文字の検出・変換に失敗する
- Shift_JISとCP932は別物で、CP932はShift_JISの拡張版
-
TextDecoder('shift_jis')はCP932相当のデコードを行う
参考