UTF-8 のサニタイズ

UTF-8 文字列中に UTF-8 として正しくないコードが入っていた場合に、その文字を「?」などに置き換えたいことがあります。

たとえば MySQL に登録するときは不正な文字を消しとかないと、その文字以降すべて消えてしまいます。

mysql> insert into t (c) values (0x414243FF58595A);
Query OK, 1 row affected, 1 warning (0.06 sec)

Warning (Code 1366): Incorrect string value: '\xFFXYZ' for column 'c' at row 1
mysql> select * from t;
+------+
| c    |
+------+
| ABC  |
+------+
1 row in set (0.00 sec)

ということで、Ruby では Iconv を使ってこんな感じで対処してます。

require 'iconv'
def sanitize_utf8(str)
  ret = ''
  i = Iconv.open('UTF-8', 'UTF-8')
  begin
    ret << i.iconv(str)
  rescue Iconv::Failure => e
    ret << e.success << '?'
    str = e.failed[1..-1]
    retry
  end
  ret << i.iconv(nil)
  ret
end

sanitize_utf8("\xff") #=> "ほ?げ"

Iconv が UTF-8 として不正な文字をエラーにしているのを利用しています。

ですが、次のようなバイト列ではうまくいかないことがわかりました。

sanitize_utf8("\xf8\x90\x90\x90\x90") #=> "ほ\xf8\x90\x90\x90\x90げ"

この5バイトは実は UTF-8 としては正しいバイト列なのです。ですが今は UTF-8 は 4バイトまでしか使われないことになってますし、当然 MySQL の utf8mb4 charset のカラムにも入りません。

Iconv はこの文字を UTF-8 として正しい文字として扱っているので UTF-8 から UTF-8 への変換では対処できません。4バイト以下の UTF-8 のみが有効な文字である UTF-16 を一旦経由することでうまくいきました。

require 'iconv'
def sanitize_utf8(str)
  ret = []
  i = Iconv.open('UTF-16BE', 'UTF-8')
  unknown_char = "\x00?"
  begin
    ret << i.iconv(str)
  rescue Iconv::Failure => e
    ret << e.success << unknown_char
    str = e.failed[1..-1]
    retry
  end
  ret << i.iconv(nil)
  Iconv.iconv('UTF-8', 'UTF-16BE', *ret).join
end

sanitize_utf8("\xf8\x90\x90\x90\x90") #=> "ほ?????げ"

他にもっといい方法を知ってる人は教えて下さい。

なお、3バイト以下の UTF-8 しか有効でない MySQL の utf8 charset には対応できてません。