2009-12-04

2バイトエンティティじゃなくて、文字参照の変換

「す」のような文字列を見たことがあるだろうか? これまで、なんとなく「2バイトエンティティ」と呼んでいたんだけど、正しくは「文字参照」、それも「数値文字参照」と言うようだ。今回、これが混じったテキストから「文字参照」だけを一致する文字に変換する必要に迫られた。ブラウザに表示させれば元の文字列に戻るから(「す」は「す」)、テキストが少量なら手作業で変換したと思う。が、ちょっとサイズが大きかったのだ(4MBほど)。仕方がないのでプログラムを書いて対応することにした。

仕様の確認

まずは、仕様を見てみようとW3C を訪れた。該当する部分をちょこっと抜粋する。

(W3C: HTML 4.01 Specification: 5.3 Character References より) Character references are a character encoding-independent mechanism for entering any character from the document character set.

「文字参照 (character references)」 は、「数値文字参照 (numeric character references)」と「文字実体参照 (character entity references)」に分かれる、ともある。この数値文字参照の数値の部分は ISO 10646 の文字番号だということ。

ISO 10646 は符号化文字集合の国際規格のひとつ。誤解を恐れず、ものすごくおおざっぱに言うと Unicode と同じもの。正確なことは Wikipedia を始めとする資料にあたること。(→ 関連リンクを参照せよ)

Ruby で変換

CGI.unescapeHTML を使うと一発のようだけど、こいつは「数値文字参照」以外も変換してしまう。やりたいことは「数値文字参照」だけを文字に変換すること。もう少し手間をかける必要がある。どうせなら CGI.unescapeHTML を使わずにやってみよう。

「〹」の "12345" の部分が文字番号なのだから、文字列中から「&#?????;」(?は数字) というパターンを取り出して頭2文字と尾1文字を削れば良い。パターンを取り出すときは、正規表現を使えばコードは簡潔になるはず。

問題は ISO 10646 の文字番号から文字(列)を生成することだが、Ruby 1.9 ではこれが拍子抜けするほど簡単にできる。書式変換関数(C の printf/sprintf に相当)を使うだけだ。

プログラム全体はフィルタにする。標準入力から読んで加工して標準出力に書き出す。それを入力が終わるまでループする。こうなる(↓):

(convert_entity.rb)
 1: pattern = /&#[1-9][0-9]*;/
 2: 
 3: def convert(entity)
 4:   num = entity[2...-1].to_i     # entity must be "〹"
 5:   fs = "%c".encode("UTF-8")
 6:   fs % num
 7: end
 8: 
 9: STDIN.each { |line|
10:   while pattern === line
11:     entity = Regexp.last_match[0]
12:     line[entity] = convert(entity)
13:   end
14:   STDOUT.puts line
15: }

5 行目で、書式変換のテンプレート (fs) に対して encode("UTF-8") を呼び出しているのは、この文字列のエンコードを UTF-8 にするため。これがないと fs は US-ASCII になってしまい、書式変換関数の呼び出して例外が出てしまう。この他にソースファイルの先頭に「# coding: utf-8」という行を追加する方法もある。

肝になるのは前述の書式変換関数による文字番号から文字(列)への変換(convertメソッド)、および 12 行目の元の行に対する置換。正規表現とマッチした文字列をそのまま部分文字列置換の引数としている。

Ruby では Apple orange Grape という文字列(これを s とする)に対して、s["orange"] = "Peach" を実行すれば s = "Apple Peach Grape" となる。

この、「パターンマッチ」→「マッチした部分を対応する文字(列)」で置き換え → マッチに戻る、というサイクルを繰返すことで元の文字列中の数値文字参照が順次、置き換わっていく。長い行に文字参照がいくつもある場合は効率が悪いんだけど、その分、記述がすっきりしたものになった。

実行結果はこんな風(↓)。例によって長い行は "\" のところで折り返してある。

[mini:~] mnbi% cat .data.txt 
<p>&#12377;&#12409;&#12390;&#12398;&#22987;&#12414;&#12426; \
&#12539;&#12539;&#12539;&#12363;&#12394;(&#12539;&#969;&#12539;) \
&#65311;</p>
[mini:~] mnbi% cat data.txt | ruby1.9 convert_entity.rb
<p>すべての始まり・・・かな(・ω・)?</p>

ちなみに、上記のコードは Ruby 1.9 専用。これは String.encode を使っているからだが、そもそも文字列の扱いが 1.8 までと 1.9 とでは異なため、肝心の 6 行目が動かない。1.8 でやるなら CGI.unescapeHTML を使うことになる。また、インタープリタに -Ku を渡さないとダメ。

さて、これで一応、期待どおりに動くプログラムができた。あと気になるのは文字番号から文字(列)への変換の部分。これですべての文字を変換できるのだろうか? それとも、ある領域の番号は変換に失敗したりするのだろうか? この問いに答えるには、ISO 10646 や Unicode、そして UTF-8 のことをもっと調査する必要がある。それは別の機会に回そう。今日のところは(だいたい)動いていることで良しとする。

PHP で書くと・・・

ちょっと、たどたどしいんだが、Ruby で書く前に PHP でもちゃちゃっと書いてみた。それがこれ(↓):

(convert_entity.php)
 1: <?php
 2: $pattern = '/&#[1-9][0-9]*;/';
 3: 
 4: while (!feof(STDIN)) {
 5:   $raw = fgets(STDIN);
 6: 
 7:   $coocked = "";
 8:   $head = 0;
 9:   $tail = strlen($raw);
10:   $pos = 0;
11: 
12:   while ($head < $tail) {
13:     $count = preg_match($pattern, $raw, $matches,
14:                         PREG_OFFSET_CAPTURE, $head);
15:     if ($count > 0) {
16:       $entity = $matches[0][0];
17:       $pos = $matches[0][1];
18:       if ($head < $pos) {
19:         $normal = substr($raw, $head, $pos - $head);
20:         $coocked = $coocked . $normal;
21:       }
22:       $coocked = $coocked . 
23:                  html_entity_decode($entity, ENT_NOQUOTES, 'UTF-8');
24:       $head = $pos + strlen($entity);
25:     } else {
26:       $coocked = $coocked . substr($raw, $head);
27:       break;
28:     }
29:   }
30: 
31:   fwrite(STDOUT, $coocked);
32: }
33: ?>

これも先の Ruby のプログラムと同様、フィルタになっている。Ruby 版の方がぐっと短くなっているのは、わたしが Ruby の方を少し余計に知っているせいかも。PHP でも Ruby 版と同様の書き方にできるかもしれない。ただ、こちらの方が少し効率は良いはず。変換が進むにつれ探す部分が短くなるから。

関連リンク

0 件のコメント:

コメントを投稿