3行まとめ
- テキストファイルの文字コード(UTF-8 / Shift_JIS / EUC-JP / UTF-16)をブラウザ内で自動判定するツールを作った。ライブラリ不使用、
Uint8Arrayの走査だけで実装している - ブラウザの
TextDecoderは「指定したコードでのデコード」はできるが判定はしてくれない。だから判定は自前で書くしかない - 実装は3段構え。① BOM チェック(確実)→ ② ASCII のみなら判定不能と正直に返す → ③ 各エンコード特有のバイトパターンをスコアリングして多数決。スコア比率から信頼度(高・中・低)も出す
Excel で開いた CSV が文字化けした。古いシステムから来た .log が読めない。このとき最初に知りたいのは「このファイル、何の文字コードで保存されてるのか」だ。変換はその後の話で、まず判定ができないと始まらない。
ぱんだツールズの文字コードチェッカーは、ファイルをドロップするだけで UTF-8(BOM あり/なし)・Shift_JIS・EUC-JP・UTF-16(BOM あり)を判定して、信頼度とデコードプレビューまで表示する。処理はすべてブラウザ内で完結し、ファイルはサーバーに送られない。
判定ロジックは外部ライブラリなしの自前実装で、Uint8Array を先頭から走査するだけ。この記事ではその中身を解説する。
前提: ブラウザに「文字コード判定API」は存在しない
意外と知られていないが、ブラウザの TextDecoder は Shift_JIS も EUC-JP もデコードできる。
const decoder = new TextDecoder('shift_jis', { fatal: false })
const text = decoder.decode(buffer) // Shift_JISとしてデコードされる
Encoding Standard で定義されたラベル(shift_jis / euc-jp / utf-16le など)はモダンブラウザなら一通り通る。ただし**「このバイト列は何のコードか」を教えてくれる API はない**。TextDecoder は「言われたとおりにデコードする」だけで、間違ったコードを指定すれば黙って文字化けした文字列を返す。
つまり役割分担はこうなる。判定は自前のバイト解析、デコードは TextDecoder。判定さえできれば、あとはブラウザ標準機能に丸投げできる。
第1段: BOMチェック — あれば「確実」
ファイル先頭の BOM(Byte Order Mark)は、いわば文字コードの自己申告。あればそれで確定なので最初に見る。
function detectCharset(buffer: ArrayBuffer): DetectionResult {
const bytes = new Uint8Array(buffer)
const len = bytes.length
// BOMチェック
if (len >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
const preview = decodeWithCharset(buffer, 'utf-8', 3)
return { charset: 'UTF-8 (BOMあり)', confidence: '確実', preview }
}
if (len >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) {
const preview = decodeWithCharset(buffer, 'utf-16le', 2)
return { charset: 'UTF-16 (BOMあり)', confidence: '確実', preview }
}
if (len >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) {
const preview = decodeWithCharset(buffer, 'utf-16be', 2)
return { charset: 'UTF-16 (BOMあり)', confidence: '確実', preview }
}
// ... BOMがなければバイトパターン解析へ
}
-
EF BB BF→ UTF-8 BOM -
FF FE→ UTF-16 リトルエンディアン -
FE FF→ UTF-16 ビッグエンディアン
BOM 付きかどうかは実務でも重要な情報で、たとえば Excel は BOM なし UTF-8 の CSV を化かすことがある(だから「UTF-8」と「UTF-8 (BOMあり)」は区別して表示する)。プレビュー生成時は BOM のバイト数分だけオフセットを進めてデコードする。
第2段: ASCIIのみは「判定不能」と正直に返す
見落としがちだが原理的に重要なのがここ。ASCII 文字(0x00〜0x7F)だけで構成されたファイルは、文字コードを判定できない。UTF-8 も Shift_JIS も EUC-JP も、ASCII 域のバイト表現は完全に共通だからだ。abc,123 だけの CSV は、どのコードで保存しても1バイトも変わらない。
if (isAsciiOnly) {
const preview = decodeWithCharset(buffer, 'utf-8', 0)
return { charset: '不明(ASCIIのみ)', confidence: '-', preview }
}
ここで無理に「UTF-8です」と返すこともできる(実害もほぼない)が、このツールでは「不明(ASCIIのみ)」と正直に返す設計にした。判定ツールが当てずっぽうを言わないことは、信頼度表示と並んで「結果をどこまで信じていいか」をユーザーに渡すための一貫した方針になっている。
第3段: バイトパターンスコアリング
BOM がなく ASCII 以外のバイトが含まれる場合が本番。UTF-8・Shift_JIS・EUC-JP それぞれに特有のバイト配置があるので、先頭から走査しながら「らしさ」をスコアに積んでいく。
UTF-8: マルチバイト構造の厳格さを使う
UTF-8 のマルチバイト文字は構造が厳密に決まっている。先頭バイトの範囲でシーケンス長がわかり、後続バイトは必ず 0x80〜0xBF。
// 2バイトシーケンス: 0xC0-0xDF + 後続1バイト
if (b >= 0xc0 && b <= 0xdf && i + 1 < len) {
const b2 = bytes[i + 1]
if (b2 >= 0x80 && b2 <= 0xbf) {
utf8Score += 2
i += 2
continue
}
}
// 3バイト(0xE0-0xEF + 後続2バイト)、4バイト(0xF0-0xF7 + 後続3バイト)も同様
ひらがな・カタカナ・漢字は UTF-8 だと3バイトなので、日本語テキストなら3バイトシーケンスがひたすらヒットして utf8Score が伸びる。後続バイトの範囲チェックが1つでも外れたらシーケンス不成立でスコアなし——この「構造に合わないと一切加点されない」厳格さが、UTF-8 判定の精度を支えている。
Shift_JIS: 先行バイトと後続バイトの範囲
const isSjisLead = (b >= 0x81 && b <= 0x9f) || (b >= 0xe0 && b <= 0xfc)
const isSjisTrail = (b2 >= 0x40 && b2 <= 0x7e) || (b2 >= 0x80 && b2 <= 0xfc)
if (isSjisLead && isSjisTrail) {
sjisScore += 2
i += 2
continue
}
Shift_JIS の2バイト文字は、1バイト目が 0x81〜0x9F または 0xE0〜0xFC、2バイト目が 0x40〜0x7E または 0x80〜0xFC。2バイト目が ASCII 域(0x40〜0x7E)に食い込んでいるのが Shift_JIS の特徴で、これが世に言う「ダメ文字」問題(2バイト目が 0x5C = \ になる「表」「能」などがエスケープ文字と誤認される)の原因でもある。
EUC-JP: 3パターンを拾う
// 通常の2バイト文字
if (b >= 0xa1 && b <= 0xfe && b2 >= 0xa1 && b2 <= 0xfe) { eucjpScore += 2; ... }
// 半角カナ: 0x8E + 0xA1-0xDF
if (b === 0x8e && b2 >= 0xa1 && b2 <= 0xdf) { eucjpScore += 2; ... }
// 補助漢字: 0x8F + 2バイト
if (b === 0x8f && b2 >= 0xa1 && b2 <= 0xfe && b3 >= 0xa1 && b3 <= 0xfe) { eucjpScore += 3; ... }
EUC-JP は両バイトとも 0xA1〜0xFE が基本形。半角カナ(0x8E プレフィックス)と3バイトの補助漢字(0x8F プレフィックス)も別パターンとして拾う。
なぜ「多数決」が要るのか — 範囲が重なっているから
3エンコードのバイト範囲を見比べるとわかるが、Shift_JIS と EUC-JP の範囲はかなり重なっている。EUC-JP の2バイト文字(A1-FE + A1-FE)のうち1バイト目が E0-FC に入るもの(漢字域の一部)は、Shift_JIS の先行バイト(E0-FC)+ 後続バイト(80-FC)のパターンにそのまま合致してしまう。つまり EUC-JP のファイルを走査すると、sjisScore もそれなりに積み上がる。
だから単発のパターンマッチでは決められず、ファイル全体を走査してスコアの多数決を取る。走査は1つのループで行い、マッチしたシーケンスはそのバイト数分だけ読み進める(continue)。判定は最終スコアの比較で行う。
if (utf8Score >= sjisScore && utf8Score >= eucjpScore) {
charset = 'UTF-8'
} else if (sjisScore >= eucjpScore) {
charset = 'Shift_JIS'
} else {
charset = 'EUC-JP'
}
同点の場合は UTF-8 → Shift_JIS → EUC-JP の優先順で倒す。現代のファイルは UTF-8 である事前確率が圧倒的に高いので、迷ったら UTF-8 に寄せるのが実用的な判断になる。なお、非 ASCII バイトはあるのにどのパターンにも1つも合致しなかった場合(バイナリファイルを食わせたときなど)は、スコア比較には入らず「不明」を返す。
信頼度 = スコアの独走度合い
判定結果だけ返しても、それが「僅差の勝ち」なのか「圧勝」なのかで意味が違う。そこで最大スコアが全体に占める比率を信頼度として出す。
const total = utf8Score + sjisScore + eucjpScore
const ratio = maxScore / total
const confidence = ratio >= 0.8 ? '高' : ratio >= 0.5 ? '中' : '低'
日本語がたっぷり入った UTF-8 ファイルなら utf8Score が独走して「高」になる。短いテキストや、たまたま複数エンコードのパターンに合致するバイト列が多いファイルではスコアが割れて「中」「低」に落ちる。判定を断言せず、確からしさごと渡すのがこの手のヒューリスティック判定では誠実な設計だと思っている。
最後に、判定したコードで TextDecoder を使って先頭500文字をプレビュー表示する。判定が仮に外れていても、プレビューが化けていれば人間の目で即わかる——アルゴリズムの判定と目視確認の二段構えになっている。
function decodeWithCharset(buffer: ArrayBuffer, charset: string, offset: number): string {
const decoder = new TextDecoder(charset, { fatal: false })
return decoder.decode(buffer.slice(offset)).slice(0, 500)
}
まとめ
ブラウザ完結の文字コード判定で押さえるべきポイント。
-
TextDecoderはデコード専用。判定 API はブラウザにないので、バイト走査は自前で書く - 判定は3段構え: BOM(確実)→ ASCII のみ(判定不能と正直に返す)→ バイトパターンスコアリング
- UTF-8 はマルチバイト構造の厳格さ(先頭バイト範囲 + 後続
0x80-0xBF)で精度が出る - Shift_JIS と EUC-JP はバイト範囲が重なるので、単発マッチではなくファイル全体のスコア多数決で決める
- スコア比率を信頼度として表示し、
TextDecoderのプレビューで目視確認もできるようにする
文字コード判定は「かならず正解できる問題」ではない。だからこそ、確実に決まるケース(BOM)と原理的に決められないケース(ASCII のみ)を分離して、残りをスコアリングで埋める構成にすると、実装も挙動の説明もすっきりする。
ぱんだツールズ では他にも CSV 文字コード変換・改行コード変換・テキスト整形・文字数カウントなど、開発者向けのブラウザ完結ツールを多数公開中。全部無料・登録不要・ファイルはサーバーに送られない。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。