RssRollingを作っていていつも悩まされていたのが Perl における Unicode 文字列の文字化け。ちょっと調べてみました。
なお、Perl の Unicode サポートについては貞廣知行さんの'Perlのページ - PerlのUnicode support'にとても詳細に記述されており、とても助かりました。
さて、Perl はバージョン 5.6 以降、Unicode をサポートしています。「サポート」とは具体的にどういうことなんでしょうか。Unicode はマルチバイトの文字コードですが、そのマルチバイトを(利用者の意図する)「文字単位」で扱うことができたりするというのが簡単な説明になるかと思います。「あ」というマルチバイト文字があったときに、それを1文字として扱える、ということです。
#!/usr/local/bin/perl use utf8; my $string = "あいうえお"; print length($string), "\n";
このスクリプトの出力は「5」になります。(スクリプト自体は UTF-8 で記述します。) 文字がバイト単位ではなく、文字単位で数えられている証拠です。(utf8 プラグマは、スクリプトの文字コードを UTF-8 で記述するときに利用します。これによって、スクリプトに直接記述されたマルチバイト文字列に UTF8フラグが立ちます。UTF8フラグについては後述。)
場合によっては、文字単位ではなくバイト単位で扱いたいときもあります。そんなときのために、Perl は Unicode 文字列に内部的なフラグ - UTF8フラグを設けて、それを基準に対象の文字列データをUnicode文字単位で扱うか、バイト単位で扱うかを場合分けします。
例えば Perl の XML パーサの代表的な実装である XML::Parser は、入力された XML 文書を内部で UTF-8 にエンコードします (正確には UCS-2 に変換され、内部コードとして UTF-8 にエンコードされる、とのこと。'Manabu Higashida's Technical Laboratory Coverting UTF-8' より)。このときに UTF8フラグが立ちます。XML のパースに XML::Parser を使っているXML::RSS などで RSS 文書を処理すると、UTF8フラグが立った文字列として結果が返ってきます。
例えば
#!/usr/local/bin/perl
use strict;
use warnings;
use XML::RSS;
use LWP::Simple;
my $rss = XML::RSS->new;
$rss->parse( get('http://naoya.dyndns.org/~naoya/mt/index.rdf') );
my $title = $rss->{items}->[0]->{title};
printf("%s\n%d\n", $title, length($title));
utf8::encode($title);
printf("%d\n", length($title));
というスクリプトを実行してみます。
[naoya@mary Unicode]$ ./rssutf8.pl | nkf -e Wide character in print at ./rssutf8.pl line 11. 小沢健二『刹那』はまだ先のようです 17 51
RSSから一件目の記事のタイトルを抜き出して、その文字列の長さを数えます。(警告が出ていますが、これについては後に記述します。) 特に何も意識せずに数えた場合、17 という結果が出ます。出力された文字列の文字数に等しいことが分かります。一方、"utf8::encode($title);" とした後には、51 になります。utf8::encode() は Perl 5.8.0 から追加された utf8 パッケージに属する関数で、対象のバイト列のUTF8フラグを落とす(UTF8文字列を同じ内部表現のままバイト列に変換する)関数です。
XML::RSS に入力された RSS をパースして得られた記事タイトルのバイト列には、XML::Parser を通った際にUTF8フラグが立てられるため、length で数えた際に文字単位で処理され 17 文字という結果が出力されます。一方、utf8::encode() によりUTF8フラグを落とすと、バイト単位で扱われるため 51 バイトという結果になります。
utf8::encode() とは逆に、UTF8フラグを立てる(バイト文字列を、同じ内部表現のままUTF8文字列に変換する)には utf8::decode() が使えます。また、Perl 5.8.0 以降で追加された Encode モジュールにも同じ働きをする関数が用意されています。Encode::_utf8_on() や Encode::_utf8_off() がそうです。一応、perldoc には
The following API uses parts of Perl's internals in the current implementation. As such, they are efficient but may change.
と記述されています。"efficient but may change" だそう。
対象の文字列にUTF8フラグが立っているかどうかを調べるには、Perl 5.8.1 で utf8 パッケージに追加された、utf8::is_utf8() や Encoding モジュールの Encoding::is_utf8() を使うことができます。
さて、Unicode文字列にはUTF8フラグという内部的なフラグが存在し、それが文字列の扱いに影響を与えているということは分かりました。では、UTF8フラグが立っている文字列とUTF8フラグが立っていない文字列を連結した場合などの処理はどうなってしまうのでしょうか。貞廣さんのページにはこうあります。
問題になる場合は、Unicode文字列と非Unicode文字列(内部的にはバイト列)との混用でしょう。例えば、Unicode文字列と非Unicode文字列との連結では、非Unicode文字列がUnicode化(upgrade)されます。すなわち、Latin 1として解釈されます。例えば、UTF8フラグに無頓着な古い変換モジュールを使って得られたUTF-8文字列は、内部的にはUTF8フラグが付いていないので、Unicode文字列とはみなされません。そこで、UTF8フラグが付いたUnicode文字列と連結すると、文字化けします(ASCIIだけの場合を除く)。
どうやら僕が経験した文字化けの原因の多くはこの辺り、UTF8フラグの立っている文字列とそうでない文字列の混用にあったようです。
例えば、HTML::Template で UTF-8 な文書を出力することを考えてみます。以下のテンプレートを用意します。テンプレートは UTF-8 で記述します。
このテンプレートは Emacs + Mule-UCS を使って UTF-8 で記述しています。 <TMPL_VAR NAME="value"> どれどれ。
このテンプレートを使う Perl スクリプトを記述します。
#!/usr/local/bin/perl use strict; use warnings; use HTML::Template; use utf8; my $tmpl = HTML::Template->new( filename => './example.tmpl' ); $tmpl->param( value => "スクリプトに記述された文字列" ); print $tmpl->output;
このスクリプトにより期待される出力は
このテンプレートは Emacs + Mule-UCS を使って UTF-8 で記述しています。 スクリプトに記述された文字列 どれどれ。
というものですが、僕の環境では文字化けしました。文字化けの箇所は、1行目と3行目、(スクリプトの中ではなく)テンプレートに記述された行です。
HTML::Template はコンストラクタにテンプレートのファイル名を渡すとそのファイルを open() しますが、特に何もせずファイルを open() した場合、ファイルの中身は(UTF8フラグが立っていない)バイト列として扱われます。一方、スクリプトに記述した「スクリプトに〜」の文字列は、スクリプト先頭で utf8 プラグマを宣言しているため、UTF8フラグの立った文字列として扱われます。ここで、UTF8フラグの立った文字列とそうでない文字列が混用されるため、文字化けが発生します。
これを回避するには、そのファイルから読み取る文字列にUTF8フラグを立ててやれば良いということになります。Perl 5.8 でファイルの入出力に採用された PerlIO レイヤ を用いることでファイルハンドルに対してこの辺りの制御が可能です。阿辺川 武さんの 'perlのページ - perl5.8のUnicodeサポート' 辺りで、その使い方が簡潔にまとめられています。
PerlIO によりファイルハンドルに対してファイルをUTF-8エンコードされたファイルとして扱うには、open の引数を 3 つにして、2番目の引数 - レイヤでそれを指定します。
先ほどのスクリプトに適用すると、以下のようになります。HTML::Template はコンストラクタにファイル名ではなくファイルハンドルを渡すこともできるので、先に PerlIO レイヤを使ってテンプレートを open しておき、そのファイルハンドルを渡します。
#!/usr/local/bin/perl use strict; use warnings; use HTML::Template; use utf8; binmode STDOUT, ":utf8"; my $fh; open $fh, "<:utf8", './example.tmpl'; my $tmpl = HTML::Template->new( filehandle => $fh ); $tmpl->param( value => "スクリプトに記述された文字列" ); print $tmpl->output;
これで文字化けが回避できます。binmode STDOUT, ":utf8" は冒頭のスクリプトでも出ていた "Wide character in print at..." の警告に対処したものです。標準出力ファイルハンドルにも、Unicode 制御をさせているわけです。
RssRolling でも HTML::Template を用いているので、この辺りを意識して書き換えたら文字化けしなくなった...と思ったのですが、一部改善されたもののそれでも文字化けする箇所がちらほらありました。何でかなあと思い必死にデバッグしてみると、どうやら HTML::TokeParser に Unicode 文字列を渡すと化けることが分かりました。
RSS をパースした後の文字列に含まれている HTML タグを除去するために HTML::TokeParser を使っていて、Util.pm というユーティリティクラスに以下のような実装を施していました。
sub remove_html {
my ($str) = @_;
my $ret;
if (defined $str) {
my $p = HTML::TokeParser->new( \$str ) or die "$!";
do {
my $plain = $p->get_text;
$ret .= $plain;
} while (my $token = $p->get_tag);
}
$ret;
}
で、試行錯誤の末
sub remove_html {
my ($str) = @_;
my $ret;
if (defined $str) {
utf8::encode($str);
my $p = HTML::TokeParser->new( \$str ) or die "$!";
do {
my $plain = $p->get_text;
$ret .= $plain;
} while (my $token = $p->get_tag);
}
utf8::decode($ret);
$ret;
}
として、入力の前にUTF8フラグを落として、返す前に再度 UTF8フラグを立ててみたところ、文字化けしなくなりました。ううーん、と思っていたのですが HTML::TokeParser が HTML のパースに利用しているHTML::Parser に以下のような記述がありました。
Unicode strings are not parsed correctly. A workaround is to encode them as UTF-8 before passing them to the HTML::Parser. The Encode module can do that.
バグだったみたいです。対処方はこれで合っていた模様。ちなみに perldoc に書かれている、という話は宮川さんに教えてもらいました。毎度感謝です。
Perl 5.8 は内部処理によって Unicode文字列を適切に処理するようになったため、その辺りをあまり気にする必要はないとかどっかで聞いた気がしますが、個人的には、結構ちゃんと意識してないとはまってしまうように思います。
5.8 の場合どう対処するべきかっていうのはだんだん分かって来たのですが、じゃあ 5.6 系はどうしたらいいのってのがいまいち良くわかってません。
Perl 5.6 による Unicode 処理は、(少なくとも Perl 5.6.1 の段階では)まったくお奨めできません。バグが多く、機能も不充分であるからです。perlunicode.pod の WARNING を参照のこと。
と貞廣さんのページにはあったりもしますが、まだ 5.6 以前を無視するわけにはいかないわけで、悩ましいところです。