Perlで日本語文字列が文字化けしてるかどうか推測する&修復する

ちょっと最近Buzzurlに自作スクリプトか何かで、大量の二重エンコード文字列を含むブックマークが投稿されたので対策のために調べてみたことのまとめ。<追記>id:miyagawaさんのブクマで Encode::DoubleEncodedUTF8 というモジュールを教えてもらいました。調べたら作者もid:miyagawaさん。二重エンコード是正にはこちらを使うようにしましょう。
でもこれ"二重エンコード perl utf8"とかでぐぐったけど見つからなかった…。id:miyagawaさんのブログとかもっと検索に引っかかるべきだと思うのだが。

PerlでUTF8文字列を使うときの原則

PerlでUTF8文字列を扱うならば、Encodeの神であるところのid:dankogaiが何度も何度も口をすっぱくして言っている次の原則に従わなければならない。そうしないとすごく不愉快な目にあう。


入り口で decode して、内部ではすべて flagged utf8 で扱い、出口で encode する。これがすべてです!とにかくこの基本方針をまもっていれば幸せになれます。

しかしそれでも文字化けを見る場合がある。CPANモジュールなどで、「入り口でdecode/出口でencode」原則に従っているのだろうと期待してencode済みのバイナリ列を渡してみたら文字化けて、なぜかと調べていたらモジュールは引数としてdecodeされたflagged utf8文字列を期待していて文字化けるとか。まあこれは普通にテストしていれば検出できるのであまり問題にならない。

あと昔は、flagged UTF8文字列に対してさらにdecode_utf8することにより文字化けが発生したような気がするが(試そうと思ったが今手元に環境がない)、2.13以降の新しいEncodeではflagged UTF8文字列にdecode_utf8しても何もしないようになった。

となると、現実に見る文字化けパターンは以下の2つであろう。

  1. flagged UTF8文字列とバイナリの文字列結合
  2. 二重エンコード

二重エンコード

文字列結合はいいとして、二重エンコードとは何か。これは、UTF8エンコーディングされているバイナリをさらにUTF8エンコーディングしたときに起きる不具合だ。
どういうことか。例えば"ECナビ"というキャラクタ列は、UTF8では[(0x45) (0x43) (0xE3 0x83 0x8A) (0xE3 0x83 0x93)]と表現される(※()は区切りのための表示で、もちろんバイナリ表現には存在しない)。何かの都合で(例えばEncode::encodeに対する理解不足とか)、このようなUTF8のバイナリ表現に対してさらにUTF8エンコーディングをしてしまうことがありうる。
するとどうなるか。UTF8というエンコーディングは論理的な意味はともかく物理的には21bit(または31bit)までの任意のビット列をエンコーディング可能なので、各バイトを7または8bitのビット列としてUTF8エンコードされることになる。UTF8では7bitのビット列は1バイトで表現され、0x00〜0x7Fの間であり、8〜11bitのビット列は2バイトで表現され、 0xC080 〜 0xDFBF の間である。"ECナビ"のUTF8表現 [0x45 0x43 0xE3 0x83 0x8A 0xE3 0x83 0x93] をこのように二重UTF8エンコードすると次のようになる:[(0x45) (0x43) (0xc3 0xa3) (0xc2 0x83) (0xc2 0x8a) (0xc3 0xa3) (0xc2 0x83) (0xc2 0x93)]

use strict;
use warnings;
use utf8;
use Encode;

my $utf8str = "ECナビ";
my $utf8bin = encode_utf8($utf8str);
my $fuckbin = encode_utf8($utf8bin);
print $utf8bin, "\n";
print $fuckbin, "\n";

自分が注意深ければこのような腐ったUTF8バイナリを作り出さなくて済むが、問題となるのが外部からこのような腐ったUTF8バイナリを流し込まれたときである。無意味な表現を含むかもしれないが、少なくとも物理的には"正しいUTF8フォーマット"なので、decode_utf8はあなたを救ってはくれない。

二重エンコードを検出

このような腐ったUTF8バイナリを検出できるだろうか? このようなUTF8はすべて1〜2バイト表現で構成されるが、幸いなことにほとんどの日本語キャラクタはUTF8では3バイトで表現されるため、主に日本語キャラクタだけを使っている場合は、完全とはいえないが二重エンコードっぽい文字列かどうかを判定することができる。

sub only2bytes {
    my $ascii = my $to07b = "[\x{00}-\x{7f}]";
    my $chr2b = my $to11b = "[\x{c0}-\x{df}][\x{80}-\x{bf}]";
    my $re = qr/^($ascii|$chr2b)+$/o;

    #Encode2.13以降ではdecode_utf8()は二重デコードの心配はないので、
    #安全にencodeするためにdecode_utf8()してからencode_utf8()
    my $bin = encode_utf8(decode_utf8(shift));
    $bin =~ /$re/
}

my $utf8bin = encode_utf8("ECナビ");
my $fuckbin = encode_utf8($utf8bin);
warn( (only2bytes($utf8bin)) ? "fuck" : "valid" );
warn( (only2bytes($fuckbin)) ? "fuck" : "valid" );

ウラジミールプーチン検出関数

さて、「ほとんどの」といったが、2バイト表現のみからなるような日本語文字列というのは存在しないのだろうか?(ユニコードにおいて"日本語"というのは微妙な問題ではある。しかしWebアプリなどを作るのであれば例えば「今はEUC-JPやCP932と相互変換できる範囲内についてテストする。アラビア文字サポートについてはイラン市場にサービス展開するときに予算を取る」などという現実路線は考えられる。)
結論からいくと、ある。完全なものかどうか分からないが、ググって出てきたコード対応表からgrepしたら、例えば"Владимир Путин"(ウラジミールプーチン)などはUTF8にすると2バイト表現のみからなる。一部の記号と、ギリシャ文字キリル文字などが該当する。
さすがにこういう文字列をサポートしないとなるとKGBに消されるεπιστημη(えぴすてーめー)氏に失礼なので、こういう場合は救うようにしてみよう。

sub is_putin {
    my $ascii = my $to07b = "[\x{00}-\x{7f}]";
    my $jpn2b = "[´ ¨ ± × ÷ ° ¢ £ § ¬ ¶Α-Ωα-ωА-Яа-я]";
    my $re = qr/^($ascii|$jpn2b)+$/o;

    my $str = decode_utf8(shift);
    $str =~ /$re/
}

my $putin = "Владимир Путин";
warn( (only2bytes($putin)) ? "fuck" : "valid" );
warn( (is_putin($putin)) ? "ウラーー!!" : "fuck" );

あとは組み合わせるだけ

日本語のみのサポートとはいえ、検出さえできれば、修復は簡単である。二重エンコードされているのだから、もう一度decodeしてやればよい。(decode_utf8はflagged UTF8に対して何もしないので、二度目のデコードはdecodeを呼ぶ必要がある点に注意)

sub smart_decode_utf8 {
    my $utf8bin = shift;
    my $tmp = decode_utf8($utf8bin);
    (is_dual_encode($tmp)) ? decode("utf8", $tmp) : $tmp;
}
sub is_dual_encode {
    my $text = shift;
    only2bytes($text) && !is_putin($text);
}

my $str = "ECナビ";
print smart_decode_utf8($str), "\n";
my $fuck = encode_utf8(encode_utf8($str));
print smart_decode_utf8($fuck), "\n";
my $name = "Владимир Путин";
print smart_decode_utf8($name), "\n";

以上です。間違っているところがあったらきっとid:dankogaiが何とかしてくれる。

二重エンコードに関する関連情報