HEARTBEATS

文字エンコーディングの検出方法

   

こんにちは、技術開発室の滝澤です。

最近(2021年春)、Go言語でメールパーサーを書く機会があり、備忘録的な意味でも知見をまとめておこうかなと思い、この記事を書きました。

メールパーサーを書いていて考慮しないといけないことの一つは、文字エンコーディング(charset)が正しく指定されていないメールがときどきあることです。 MIME(Multipurpose Internet Mail Extensions)関連のインターネット標準であるRFCが公開された1990年代や世間一般にインターネットメールが利用され始めた2000年代初期ならともかくとして、2021年にもなってまだその点を考慮しないといけないのはなかなかつらいことです。 そのようなメールを取り扱うときには、文字エンコーディングの検出を行う必要があります。本記事ではその文字エンコーディングの検出方法について書いてみました。

なお、本記事では文字エンコーディングや文字コード関連の用語の説明は行っていませんのでご了承ください。 文字コード関連の知識があることが前提で記述しています。

背景(文字エンコーディングの指定が無いメール)

私たちが普段利用しているメーラーやウェブメールからメールを作成すると、文字エンコーディングの指定は間違いなく行われます。

しかし、メールについてのインターネット標準や文字エンコーディングに詳しくない人が開発したメールフォームやメール送信機能では、文字エンコーディングの指定が無かったり、指定された文字エンコーディングと実際の文字エンコーディングが異なっていたりすることがあります。また、MIMEに対応してないレガシーなメール送信コマンドを利用して、スクリプトからメールを送信している場合も同様に文字エンコーディングの指定が無い場合があります。

本記事では、このような文字エンコーディングの指定が無かったり誤っていたりする場合でも文字エンコーディングを検出する方法について紹介します。

文字エンコーディングの検出方法

今回作成したメールパーサーでは次の文字エンコーディングの検出方法をそれぞれ用いました。

  1. 文字エンコーディングの特徴で検出する方法
  2. 指定された文字エンコーディングで変換を試みる方法
  3. 文字エンコーディング検出器を利用する方法
  4. 候補リストにある文字エンコーディングから一つ一つUTF-8に変換できるかを試みる方法

3の文字エンコーディング検出器には1で行う「文字エンコーディングの特徴で検出する方法」の処理が含まれているので、1の処理は不要に思えます。

しかし、2の「指定された文字エンコーディングで変換を試みる方法」を行う前に、指定された文字エンコーディングと実際の文字エンコーディングが異なっている場合の処理を行うために、このような順番になりました。 特に、実際の文字エンコーディングがISO-2022-JPであり、指定した文字エンコーディングがISO-2022-JP以外の場合では、1の処理を行わずに2の処理を行うと、誤って変換に成功しまうからです。

4は無くてもかまいませんが、最後の手段といったところです。

文字エンコーディングの特徴で検出する方法

文字エンコーディングが指定されていれば、その文字エンコーディングをそのまま利用すればいいという話はあります。 しかし、先に述べたように指定された文字エンコーディングと実際の文字エンコーディングが異なっていることがあるため、文字エンコーディングの特徴で検出できる場合はその方法をまず用いてみます。

UTF-8, UTF-16, UTF-32 (BOMあり)

BOM(byte order mark)ありのUTF-8, UTF-16, UTF-32の場合は、先頭の数バイトで検出できます。

  1. "0x00 0x00 0xFE 0xFF" → UTF-32
  2. "0xFF 0xFE 0x00 0x00" → UTF-32
  3. "0xFE 0xFF" → UTF-16
  4. "0xFF 0xFE" → UTF-16
  5. "0xEF 0xBB 0xBF" → UTF-8

US-ASCII, ISO-2022-JP

文字列を構成するバイト列が7bitコード(0x00〜0x7F)のみの場合は、US-ASCIIか"ISO-2022-"で始まる文字エンコーディングかHZ(HZ-GB-2312)です。

"ISO-2022-"で始まる文字エンコーディングISO-2022-JP, ISO-2022-JP-2, ISO-2022-KR, ISO-2022-CN, ISO-2022-CN-EXTなどはESC(0x1B)で始まるエスケープシーケンスにより符号化文字集合を指示して切り替えることができます。そのため、そのようなエスケープシーケンスが文字列を構成するバイト列に登場すればその符号化文字集合を指示できる文字エンコーディングであると判断できます。

例えば、エスケープシーケンス"ESC $ B"は符号化文字集合JIS X 0208:1990への指示であるため、このエスケープシーケンスが含まれていれば、ISO-2022-JPかISO-2022-JP-2であるということになります。

  • "ESC $ B" → JIS X 0208:1990 → ISO-2022-JP or ISO-2022-JP-2

"ISO-2022-"で始まる文字エンコーディングには先に示した様々なものがありますが、実際に使われているのはISO-2022-JPのみであるため、ISO-2022-JPで利用するエスケープシーケンスだけを評価してもよいでしょう。

HZは中国の文字の文字エンコーディングであり、"~"で始まるエスケープシーケンスにより符号化文字集合を指示して切り替えることができます。現在では使われていないので考慮しなくてもよいでしょう。

文字列を構成するバイト列が7bitコード(0x00〜0x7F)のみの場合で、上述のようなエスケープシーケンスが含まれていなければUS-ASCIIであると判断できます。

文章中に制御コードであるESCが登場することは通常はないため、簡易的な判断としては、文字列を構成するバイト列が7bitコード(0x00〜0x7F)のみの場合で、ESC(0x1B)が含まれていなければUS-ASCIIと判断し、ESC(0x1B)が含まれていればISO-2022-JPであると判断してもほぼ問題ないでしょう。

UTF-16, UTF-32 (BOMなし)

BOMなしのUTF-16やUTF-32の場合は、文字列の始まりや終わりの文字で検出できることがあります。

通常使われる文字のほとんどはBMP(Basic Multilingual Plane, 基本多言語面)に収まっているため、UTF-32の場合は"0x00 0x00"が文字を構成するバイト列に多く含まれています。この特徴を簡易的に明確に判断できるのは始まりと終わりの文字です。

  1. 始まりや終わりのバイト列が"0x00 0x00 0xXX 0xXX"である → UTF-32BE
  2. 始まりや終わりのバイト列が"0xXX 0xXX 0x00 0x00"である → UTF-32LE

ここで、0xXXは任意の文字コードです。

UTF-16ではASCII文字やLatin-1 Supplement文字やCc(C0/C1)制御コードには"0x00"が文字を構成するバイト列に含まれています。この特徴が簡易的に明確に判断できるのは先頭の文字や終わりの文字です。 特にメールの本文の文字列は改行コードで終わることが多いため、終わりのバイト列で判断できることがあります。

  1. 始まりや終わりのバイト列が"0x00 0xXX"である → UTF-16BE
  2. 始まりや終わりのバイト列が"0xXX 0x00"である → UTF-16LE

ここで、0xXXは任意の文字コードです。

なお、メールでUTF-16やUTF-32が使われることはほぼ無いため、この検出処理自体を不要と判断してもよいです。

なお、筆者は残念なことに10年くらい前にBOMなしのUTF-16やUTF-32が使われたメールを見たことがあります。古い記憶なので正確なことは覚えていませんが、UPS(無停電電源装置)からのアラート通知メールだったかと思います。

指定された文字エンコーディングで変換を試みる方法

文字エンコーディングが指定されている場合は、その文字エンコーディングからUTF-8に変換を試みます。 このとき、すべての文字が問題なく変換に成功すれば、その文字エンコーディングは正しいということになります。

開発言語やライブラリの実装にもよりますが、変換できない文字が含まれる場合はエラーや例外を発生させたり、置換文字に置き換えたりすることができます。

しかし、レガシーな文字エンコーディングではOS毎にいわゆる機種依存文字と呼ばれる文字の独自追加が行われており、その機種依存文字が変換できないことがあるため、単純にエラーや例外を発生させると都合が悪い場合があります。例えば、「髙」(はしご高)は本来はISO-2022-JPでは扱えない文字ではありますが、この文字を含んだISO-2022-JPのメール本文を受け取った側が変換できないとしてエラーや例外を発生させてしまうと、メールの本文すべてが読めないということになります。これは望ましくはないため、変換できない文字は置換文字に置き換えるのが妥当です。

置換文字に置き換える方法では、指定した文字エンコーディングが間違っている場合でも置換文字を大量に含んだUTF-8の文字列に変換できてしまいます。そのため、変換後の検査として、置換文字の割合が多ければ、指定した文字エンコーディングが間違っていると判断します。

なお、開発言語やライブラリによっては、機種依存文字に対応しているものがあるため、その場合は機種依存文字でも変換できます。

また、文字エンコーディングが指定されていない場合は、UTF-8であると仮定して、UTF-8の文字列として有効であるか検証し、有効であればUTF-8であると判断します。

ちなみに、メールのデフォルトの文字エンコーディングはUS-ASCIIです。しかし、ヘッダーの国際化の仕様であるRFC 6532に対応した実装では、UTF-8の文字列をヘッダーに記述可能になるため、デフォルトの文字エンコーディングをUTF-8であるとして扱ってもよいでしょう。

Go言語での実装例として次のようになります。

func decode(charset string, content []byte) ([]byte, error) {
    enc, _ := Lookup(charset)
    if enc == nil {
        return nil, fmt.Errorf("convert: unknown charset: %s", charset)
    }

    b, err := enc.NewDecoder().Bytes(content)
    if err != nil {
        return nil, err
    }
    s := string(b)

    // replacement character (\uFFFD)が一定割合以上存在したら、charsetが正しくないと判断する。
    l := 0
    // 総文字数からASCII文字を除く
    for _, c := range s {
        if c >= '\u0080' {
            l++
        }
    }
    if l > 0 {
        ratio := strings.Count(s, "\ufffd") * 100 / l
        if ratio >= replacementCharacterRatioThreshold {
            return nil, fmt.Errorf("convert: too many replacement characters: %d %%", ratio)
        }
    }

    return b, nil
}

文字エンコーディング検出器を利用する方法

次のような文字エンコーディング検出器を利用して、文字エンコーディングを推定してもらうことができます。

Mozilla Universal Charset Detector

Mozilla Universal Charset Detector は単体で利用できるものではなくMozillaのライブラリとして開発されたものです。

単体のライブラリとしてフォークされたり、様々な開発言語用のライブラリとして移植されたり、バインディングが用意されたりしているので、それを使うことができます。次のはその一部です。

ICU Character Set Detection

ICU Character Set DetectionICUで提供されているものです。C/C++用とJava用のライブラリとして利用できます。

様々な開発言語用のライブラリとして移植されたり、バインディングが用意されたりしているので、それを利用することもできます。次のはその一部です。

Compact Encoding Detector

Compact Encoding DetectorはGoogle社により開発されたC++用のライブラリです。

利用例

今回開発したメールパーサーではICU Character Set DetectionをGo言語用に移植したchardetを利用しました。

次のようなコードを書きました。 chardet.NewTextDetector().DetectAll()が複数の候補を返してくれるため、それぞれをConfidenceとLanguageを込みで評価して、変換を試みています。

    if results, err := chardet.NewTextDetector().DetectAll(content); err == nil {
        for _, result := range results {
            // windows-*からの変換は誤って必ず成功してしまうため、跳ばす。
            if strings.HasPrefix(name, "windows") {
                continue
            }

            // Confidenceが低い場合は跳ばす。
            // その閾値は推定された言語がpreferedLanguagesと一致するかどうかで異なる。
            isPreferedLanguage := false
            for _, lang := range preferredLanguages {
                if result.Language == lang {
                    isPreferedLanguage = true
                }
            }
            if isPreferedLanguage {
                if result.Confidence < detectorConfidenceThreshold+detectorConfidenceThresholdRise {
                    continue
                }
            } else {
                if result.Confidence < detectorConfidenceThreshold {
                    continue
                }
            }

            // 推定されたcharsetで変換を試みる。
            if e, name = Lookup(result.Charset); e != nil {
                if decoded, err := decode(name, content); err == nil {
                    return e, name, decoded
                }
            }
        }
    }

候補リストにある文字エンコーディングから一つ一つUTF-8に変換できるかを試みる方法

文字エンコーディングの候補リストを用意して、それぞれをUTF-8に変換して試みる方法です。

日本語の場合は"EUC-JP"と"Shift_JIS"を候補として用意します。"ISO-2022-JP"も候補として用意してもよいのですが、上述した「文字エンコーディングの特徴で検出する方法」で検出できるので含めなくてもよいでしょう。

Go言語でのコードの例としては次のようになります。

    for _, cs := range assumedCharsets {
        if e, name = Lookup(cs); e != nil {
            if decoded, err := decode(name, content); err == nil {
                return e, name, decoded
            }
        }
    }

まとめ

開発したメールパーサーで利用している文字エンコーディングの検出方法について紹介しました。 詳しい説明は何もしていない記事ですが、興味がありましたら、参考にして調べてみてください。

株式会社ハートビーツの技術情報やイベント情報などをお届けする公式ブログです。