Unicode から求めることができないレガシーエンコーディングのコードポイントを調べる


## 文字に関する誤解

この記事は Unicode からすべてのレガシーエンコーディングのコードポイントを求めることができるという誤解を解消するために書きました。この誤解は次のような内容を含みます。

  1. 1つの文字は1つのコードポイントだけであらわすことができる
  2. 1つの文字に対して割り当てられるコードポイントは1つだけである
  3. すべての文字はレガシーエンコーディングと Unicode のあいだで往復変換できる
  4. すべてのコードポイントに文字が割り当てられているもしくは割り当てられる予定である

事実は次の通りです。

  1. 漢字の異体字や国旗の絵文字などは複数のコードポイントを組み合わせる必要がある (拡張書記素クラスター)
  2. 古い文字エンコーディングとの互換性のために同じ文字に対して複数のコードポイントが割り当てられることがある
  3. Unicode とレガシーエンコーディングのあいだで往復変換をすると別のコードポイントに変わってしまう文字がある (NEC、IBM の機種依存文字)。
  4. ユーザー・ベンダー定義領域や Unicode の私用領域には文字が割り当てされないことが決められている。日本の携帯電話の絵文字や台湾や香港の繁体字のようなユーザー定義領域でさまざまな文字が割り当てされているものに関して、亜種の文字エンコーディングが定義されている。

## 不正なバイト列を生成する

Ruby の chr は無効な範囲の整数に対してエラーを投げます。

```ruby
> 0xF9FD.chr('cp932')
RangeError: invalid codepoint 0xF9FD in Windows-31J
```

制限を回避するには `pack` からバイト列をつくります。

```ruby
def chr_unsafe(cp, enc)
  [cp.to_s(16)].pack("H*").force_encoding(enc)
end
```

## 複数のコードポイントであらわされる文字 (拡張書記素クラスター)


Unicode では国旗は2つのコードポイントであらわれされます。日本の国旗 (JP) を調べてみましょう。

```ruby
> 0xF6A5.chr('sjis-kddi').encode('utf-8').length
=> 2
> 0xF6A5.chr('sjis-kddi').encode('utf-8').chars.map do |c| c.ord.to_s(16).upcase end
=> ["1F1EF", "1F1F5"]
```

記事の執筆時点では逆変換はエラーになりました。

```ruby
> "\u{1F1EF}\u{1F1F5}".encode('sjis-kddi')
Encoding::UndefinedConversionError: U+1F1EF to UTF8-KDDI in conversion from UTF-8 to UTF8-KDDI to SJIS-KDDI
```

キーキャップで囲まれた数字も2つのコードポイントであらわされます。

```ruby
> 0xF987.chr('sjis-docomo').encode('utf-8').length
=> 2
```

絵文字の具体例は Wikipedia の「Unicode6.0の携帯電話の絵文字の一覧」の[記事](https://ja.wikipedia.org/wiki/Unicode6.0%E3%81%AE%E6%90%BA%E5%B8%AF%E9%9B%BB%E8%A9%B1%E3%81%AE%E7%B5%B5%E6%96%87%E5%AD%97%E3%81%AE%E4%B8%80%E8%A6%A7)をご参照ください。


JIS X 0213 ([Wikipedia](https://ja.wikipedia.org/wiki/JIS_X_0213%E9%9D%9E%E6%BC%A2%E5%AD%97%E4%B8%80%E8%A6%A7)) の文字のなかにも拡張書記素クラスターに対応するものがありますが、記事の執筆時点で Ruby の EUC-JIS-2004 は対応していません ([バグ](https://bugs.ruby-lang.org/issues/6351))。

Python 3 の場合、SJIS-2004 と EUC-JIS-2004 の両方に対応します。

```python
>>> [format(ord(c), 'X') for c in b'\x83\x97'.decode('sjis_2004')]
['30AB', '309A']
```

## Unicode に未登録の絵文字

i モードの企業ロゴなどは Unicode に登録されていません。Unicode.org の Emoji Symbols: Background Data の[ページ](http://www.unicode.org/~scherer/emoji4unicode/snapshot/full.html)には登録されなかった絵文字も記載されています。

## レガシーエンコーディングから Unicode に変換できない文字を調べる

```ruby
def count_conversion_error(from, to, range=0x0..0xFFFF)
  count = 0
  range.each do |cp|
    begin
      hex = cp.to_s(16).upcase
      c = cp.chr(from).encode(to)
    rescue RangeError, Encoding::InvalidByteSequenceError => ex
      next
    rescue Encoding::UndefinedConversionError => ex
      print "#{hex} "
      count += 1
    end
  end
  puts
  puts "#{from} #{count}"
end

count_conversion_error('cp932', 'utf-8')
```

`0x0..0xFFFF` の範囲で CP932 から UTF-8 に変換した場合、約1600回のエラーが発生しました。文字が定義されていない範囲も含まれるので、大雑把なものです。


## ユーザー定義領域と Unicode の私用領域の対応関係

CP932 の 0xF040 - 0xF9FCは Unicode の U+E000 - U+E757 に関連づけされています。

```ruby
> 0xF040.chr('cp932').encode('utf-8').ord.to_s(16).upcase
=> "E000"
> 0xF9FC.chr('cp932').encode('utf-8').ord.to_s(16).upcase
=> "E757"
```

0xFC4C - 0xFCFC の範囲は Unicode との関連づけはされていません。

```ruby
def count_conversion_error(from, to, range=0x0..0xFFFF)
  count = 0
  range.each do |cp|
    begin
      hex = cp.to_s(16).upcase
      c = cp.chr(from).encode(to)
    rescue RangeError, Encoding::InvalidByteSequenceError => ex
      next
    rescue Encoding::UndefinedConversionError => ex
      print "#{hex} "
      count += 1
    end
  end
  puts
  puts "#{from} #{count}"
end

count_conversion_error('cp932', 'utf-8', 0xF000..0xFFFC)
```

逆に Unicode の私的領域の 0xE000 - 0xF8FF から CP932 に変換できないすべてのコードポイントは次のコードで調べることができます。結果は約4600です。

```ruby
def count_conversion_error(from, to, range=0x0..0xFFFF)
  count = 0
  range.each do |cp|
    begin
      hex = cp.to_s(16).upcase
      c = cp.chr(from).encode(to)
    rescue RangeError, Encoding::InvalidByteSequenceError => ex
      next
    rescue Encoding::UndefinedConversionError => ex
      print "#{hex} "
      count += 1
    end
  end
  puts
  puts "#{from} #{count}"
end

count_conversion_error('utf-8', 'cp932', 0xE000..0xF8FF)
```

Unicode 6.0 に絵文字が登録される以前に携帯キャリアの絵文字も私用領域にマッピングされていました。日本の国旗を変換してみましょう。


```ruby
> 0xF6A5.chr('sjis-kddi').encode('utf8-kddi')
=> "\uE4CC"
```

携帯キャリアの絵文字のマッピングルールは Windows とは異なります。

```ruby
> 0xF340.chr('cp932').encode('utf-8').ord.to_s(16)
=> "e234"
> 0xF340.chr('sjis-kddi').encode('utf8-kddi').ord.to_s(16)
=> "e5cd"

```

## 往復変換の安全性が保障されない文字

Unicode とレガシーエンコーディングの往復変換でコードポイントが変わってしまう文字があります。CP932 に関してマイクロソフトが[ページ](https://support.microsoft.com/ja-jp/help/170559/prb-conversion-problem-between-shift-jis-and-unicode)を公開しています。これらのコードポイントは重複登録されたものです。くわしくは Wikipedia の CP932 の[記事](https://ja.wikipedia.org/wiki/Microsoft%E3%82%B3%E3%83%BC%E3%83%89%E3%83%9A%E3%83%BC%E3%82%B8932)をご参照ください。

irb で変換の前後で別のコードポイントに変わってしまうことが確認できます。

```ruby
> 0x8792.chr('cp932').encode('utf-8').encode('cp932')
=> "\x{81E7}"
```

プログラミング言語やライブラリのなかには意図しない文字を変換してしまうバグをかかえているものがあります。

```ruby
> def str_upcase(str, enc) str.encode('utf-8').upcase.encode(enc) end
=> :str_upcase
> str_upcase(0x8792.chr('cp932'), 'cp932')
=> "\x{81E7}"
```

往復変換の安全性が保障されない文字をすべて数えてみましょう。

```ruby
def count_unsafe_chars(enc, range=0x0..0xFFFF)
  count = 0

  range.each do |cp|
    begin
      c = cp.chr(enc)
      ret = c.encode('utf-8').encode(enc)
    rescue RangeError => ex
      next
    rescue Encoding::UndefinedConversionError => ex
      next
    end

    if c != ret then
      print "[#{c.ord.to_s(16).upcase} #{ret.ord.to_s(16).upcase}] "
      count += 1
    end

  end

  puts 
  puts enc
  puts "unsafe characters: #{count}"
end

count_unsafe_chars('cp932')
```

CP932 の場合、398 の文字が往復変換に対して安全ではないことが示されます。