✏️

SwiftでCP932(Windows-31J)を検知したい

2024/02/04に公開

今回やりたいこと

やりたいこととしては符号化文字集合JIS X 0208に存在せず、CP932にのみに存在している「①」や「髙」など一部の特殊文字をSwiftで検知することです。検知できればあとはよしなに弾くなりなんなりすればいいわけです。

用語

文字コード

実装の前に用語を整理しておきます。

文字をコンピュータで扱うためには「あ」や「い」などそのままの文字では扱えないため、0や1のバイナリで扱う必要がでてきます。簡単に言えばこの任意の文字がどうバイナリに対応するかという対応方法、変換方法が文字コードになります。

例えば文字コードにはUTF-8やShift_JISがあり、「あ」はUTF-8では「0xE381 0x82」、Shift_JISでは「0x82A0」となります。

同じ文字でもそれぞれ文字コードが異なると結果として処理されるバイナリも異なるため文字化けといった現象が発生します。

コードポイント

先にでてきた文字コードにおいて、文字と1:1関係で対応する固有の識別子をコードポイントと呼びます。

符号化

コードポイントを元に実際にバイナリに変換することを指します。
符号化自体は今回扱う文字コード特有の用語ではないですが、ここでは、という意味で雰囲気を捉えていただければと思います。
符号化した結果は0と1になりますが、通例0x82など16進数で表記することが多いです。

符号化文字集合

実際の文字とコードポイントの対応表(厳密には集合)のことを指します。

用語まとめ

文字コードとは符号化文字集合から対応するコードポイントを用いて文字を符号化する方法、その定義。

CP932、Windows31-Jとは

符号化文字集合JIS X 0208に対して「①」や「髙」などの特殊文字(IBM拡張文字やNEC拡張文字)を追加したものがCP932(Windows31-J)にあたります。この記事ではCP932とWindows31-Jはほとんど同義として扱っていますが、IANAでの登録上、正確にはWindows-31Jです。(CP932の方が打ちやすいので以下CP932)

符号化文字集合JIS X 0208を扱う文字コードとしてShift_JISが定義されています。つまりShift_JISはIBM拡張文字などは含まない、はず。
しかし、ここで少し厄介なのが、よくある変換サイトやSwiftのString.EncodingのShift_JISは「Shift_JIS」と言ってはいるものの、恐らく厳密にはWindows-31Jになっている点です。そのため特に意識しなくても「①」や「髙」をShift_JISとしてデコード、エンコードすることが可能です。

引用
https://weblabo.oscasierra.net/shift_jis-windows31j/

方針

今回このCP932だけにある特殊文字を検知したいのですが、上述の通り特殊文字もShift_JISとしてエンコード、デコードすることが可能なため、エンコード・デコードの失敗=拡張文字という判断ができません。

また、Swiftでは他のプラットフォームのように与えられた文字列のCharSetを返すようなメソッドもありません(調べた限り)。

CFStringConvertNSStringEncodingToEncoding(_:)というメソッドがありましたが、拡張文字以外の文字(「あ」など)でもcp932と帰ってきてしまったため、拡張文字かどうかの判断には使用できなさそうでした。

ということで愚直に、文字列をShift_JISで符号化→拡張文字のバイナリと照らし合わせて判断する、という方針でいこうと思います。

また、Shift_JIS、CP932のコードポイント、バイトコードは下記のサイトを参考にします。
http://charset.7jp.net/sjis.html
https://seiai.ed.jp/sys/text/java/shiftjis_table.html

NEC拡張文字などの範囲については下記サイトを参考にしました。
https://qiita.com/kasei-san/items/cfb993786153231e5413#cp932-って-shift_jis-とどう違うの

Shift_JISのルールにしたがってコードポイントからバイナリを計算(実装)しても良かったのですが、幸い上記のサイトには符号化したあとのバイナリが記載されています。
今回はコードポイントでの比較ではなくバイナリの比較で拡張文字か否かを判断していきます。

実装

Shift_JISの1文字のバイナリは1バイト、もしくは2バイトの可変長です。
2バイトというのは1バイト目が区の0x82、2バイト目が点の0xA0で合わせて0x82 0xA0といった具合になります。(1バイトの際は例外となりますが)

バイナリを比較する際に、1バイト目==0x82 && 2バイト目==0xA0という評価式を書くのが少し面倒です。DataはUInt8の配列になっていますが(概念的には)、扱いやすいように2バイトを合わせたUInt16にバイナリを格納し直すことにします。

UInt16をそのまま0と1で人間が読むのは厳しいです。最初にデバッグ用に16進数で出力するExtensionを切っておきます。

extension UInt16 {
    func toHexString() -> String {
        String(format:"0x%02X", self)
    }
}

次に文字をShift_JISで符号化したバイナリを格納しておくShiftJISという構造体を定義します。

initでの処理は下記の通りです。

  1. 文字を受け取り、shiftJISで符号化
  2. バイナリをUInt8からUInt16に変換
  3. 2バイト目が存在すれば、1バイト目を1バイト(8bit)ずらして2バイト目を足してbinaryに格納
  4. 2バイト目が存在しなければ1バイト目をそのままbinaryに格納
struct ShiftJIS {
    let character: Character
    let binary: UInt16

    init?(character: Character) {
        guard let data = String(character).data(using: .shiftJIS),
              let first = data.first else {
            return nil
        }

        var binary: UInt16 = UInt16(first)
        if data.count == 2 {
            binary <<= 8
            binary += UInt16(data[1])
        }

        self.character = character
        self.binary = binary
    }
}

一旦はNEC拡張文字だけを判定するプロパティを追加します。

extension ShiftJIS {
    /// NEC拡張文字: ①(0x8740) ~ ∪(0x879c)
    var isNECExtendedCharacter: Bool {
        switch binary {
        case 0x8740...0x879c:
            true
        default:
            false
        }
    }
}

実行

let string = "①Ⅰ㌍1a"
let shiftJISCharacters = string.compactMap { ShiftJIS(character: $0) }
shiftJISCharacters.forEach { character in
    print("\(character.character): \(character.binary.toHexString()), isNECExtnded?: \(character.isNECExtendedCharacter)")
}

実行結果

①: 0x8740, isNECExtnded?: true
Ⅰ: 0x8754, isNECExtnded?: true
㌍: 0x8769, isNECExtnded?: true
1: 0x31, isNECExtnded?: false
a: 0x61, isNECExtnded?: false

かなり愚直なやり方かつ、更に拡張文字が追加された場合に対応できないという大きな欠点を抱えてはいるものの、更にIBM拡張文字などの範囲も定義してあげればやりたいことは実現できそうです。

他にいいやり方を知っている方がいましたらご教示いただけますと幸いです。

Discussion