0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

文字コードの自動判定はどう動くか — BOMとバイトパターンスコアリングでUTF-8/Shift_JIS/EUC-JPを見分ける

0
Posted at

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?