HyperEstraier に non-BMP 文字を登録する

ではなぜ必死こいて non-BMP 文字の入力方法を整備するのかといえば、 non-BMP 文字を含んだテキストデータをつくっておけば、 機械的な検索の対象になるからである。

ところが全文検索ソフトのなかには、 non-BMP 文字を無視するような仕様のものがあって、 せっかくの努力が水泡に帰する。

たとえば長年愛用してきた HyperEstraier では、 UTF-8 から UTF-16BE に変換する est_uconv_in およびその逆変換の est_uconv_out という関数が、 4バイト以上の UTF-8 文字が出たときには処理しないようになっている。

たとえば BMP に含まれる「暵」(U+66b5) は3バイトのUTF-8文字列だが、 BMP に含まれない「𦟫」(U+267eb) は4バイトである。 これは python で

>>> u'暵'
u'\u66b5'
>>> u'暵'.encode('utf-8')
'\xe6\x9a\xb5'
>>> u'𦟫'
u'\U000267eb'
>>> u'𦟫'.encode('utf-8')
'\xf0\xa6\x9f\xab'

のようにして知ることができる。 man utf-8 に符号化の仕様が書いてあるが、 0x267eb (0 0010 0110 0111 1110 1011) の文字コードを UTF-8 文字列になおすと、 11110000 10100110 10011111 10101011 すなわち \xf0\xa6\x9f\xab の4バイトになる。 これを estraier.c は処理してくれないのであった。

これでは面白くないので non-bmp-hypes.patch.gz のように estraier.c に手をいれてコンパイルしなおし、 検索データベースをつくりなおす。

異体字検索のためのクエリ展開スクリプト

HyperEstraier に付属の CGI プログラム estseek.cgi には、 クエリ展開の機能が付属していて、 検索語の表記のヴァリアントを出力するプログラムを呼び出すようになっている。

まず設定ファイル estseek.confqxpndcmd: のところに、このプログラムの絶対パスを書いておく。 そうしてウェブ・ブラウザから estseek.cgi を呼び出すと、 expansion のチェック項目があるから、 これを指定して検索すると、 ページ右上に検索語が展開されるのが確認できる。

estseek.cgi が生成する html の画面でチェックする方法の他に、 URL に .../estseek.cgi?qxpnd=1&phrase=.... のように、 qxpnd=1 のパラメータ指定をしてウェブサーバに渡してやることによって、 最初からクエリ展開を指定することもできる。

で、ヴァリアントをクエリ展開するプログラムの簡単なものとしては、 たとえば Perl で

#!/usr/local/bin/perl
# -*- coding: utf-8 -*-
# 
#   estqrycmd-lite.pl
#

use strict;
use Env qw(ESTWORD);
use open IN => ":utf8";

my @a = (
    [ "蛸", "鮹", "鱆", "章魚", ],
    [ "乒乒乓乓", "𠁣𠁣𠃛𠃛", ],
    [ "百鬼園", "百閒", ],
    [ "間", "间", "閒", ],
    [ "涩", "渋", "澀", "澁", ],
    [ "斋", "斎", "齋", ],
    [ "函数", "関数", ],
    [ "关", "関", "關", "闗", ],
    [ "凾", "函", ],
    [ "数", "數", "數", ],
    [ "畑", "畠", "𤳉", ],
    [ "鸩", "鴆", "酖", "䲴", ],
    [ "欠", "歇", "歉", "缺", "缼", ],
);

my @e = ( $ESTWORD );

foreach my $j (0 .. $#a) {
    foreach my $i (0 .. $#{$a[$j]}) {
	my $d = 0;
	foreach my $k (@e) {
	    if ($k =~ /$a[$j][$i]/) {
		my @c = expand_est($k, $i, @{$a[$j]});
		push(@e, @c);
		$d = 1;
	    }
	}
	last if $d;
    }
}

printcand(@e);

sub expand_est {
    my ($e, $i, @a) = @_;
    my @b;
    foreach my $j (0 .. $#a) {
	my $t = $e;
	if($j != $i) {
	    $t =~ s/$a[$i]/$a[$j]/;
	    push(@b, $t); 
	}
    }
    return @b;
}

sub printcand {
    my (@c) = @_;
    @c = sort { $a cmp $b } @c;
    print ($c[0] . "\n");
    foreach my $i (1..$#c) {
	if($c[$i] !~ /$c[$i-1]/) {
	    print $c[$i] . "\n";
	}
    }
}

のようなスクリプト(estqrycmd-lite.pl)を書いてみて、 コマンドラインから

ESTWORD="澀江抽齋" estqrycmd-lite.pl

と実行すると、

涩江抽斋
涩江抽斎
涩江抽齋
渋江抽斋
渋江抽斎
渋江抽齋
澀江抽斋
澀江抽斎
澀江抽齋
澁江抽斋
澁江抽斎
澁江抽齋

と出力される。

このスクリプトは簡略のために、 同じ異体字のある文字が一度ずつしか出現しないことを前提としているから、

    [ "欠", "歇", "歉", "缺", "缼", ],

が二度適用されるはずの「欠缺」のような検索語では、 25通りではなく5通りの候補しか出さない。 これがいやならば

#!/usr/local/bin/python
# -*- coding: utf-8 -*-
#
#   estqrycmd.py

import os
import re

a = [
    [ "蛸", "鮹", "鱆", "章魚", ],
    [ "乒乒乓乓", "𠁣𠁣𠃛𠃛", ],
    [ "百鬼園", "百閒", ],
    [ "間", "间", "閒", ],
    [ "涩", "渋", "澀", "澁", ],
    [ "斋", "斎", "齋", ],
    [ "函数", "関数", ],
    [ "关", "関", "關", "闗", ],
    [ "凾", "函", ],
    [ "数", "數", "數", ],
    [ "畑", "畠", "𤳉", ],
    [ "鸩", "鴆", "酖", "䲴", ],
    [ "欠", "歇", "歉", "缺", "缼", ],
]

e = [ os.environ['ESTWORD'] ]

for aj in a:
    c = re.compile('|'.join(aj))
    f = []
    for k in e:
        n = c.split(k)
        if len(n) > 2:
            import itertools
            d = len(n) - 1
            b = itertools.product(aj, repeat=d)
            for i in b:
                t = ''
                for j in range(d):
                    t = t + n[j] + i[j]
                t = t + n[d]
                if t != k:
                    f.append(t)
        elif len(n) == 2:
            for m in aj:
                t = m.join(n)
                if t != k:
                    f.append(t)
    e = e + f

e.sort()
print e[0]
for i in range(1,len(e)):
    if e[i] != e[i-1]:
        print e[i]

のような Python スクリプトを使えばよい。 同じアルゴリズムならば Ruby で

#!/usr/local/bin/ruby
# coding: utf-8
# estqrycmd.rb

a = [
    [ "蛸", "鮹", "鱆", "章魚", ],
    [ "乒乒乓乓", "𠁣𠁣𠃛𠃛", ],
    [ "百鬼園", "百閒", ],
    [ "間", "间", "閒", ],
    [ "涩", "渋", "澀", "澁", ],
    [ "斋", "斎", "齋", ],
    [ "函数", "関数", ],
    [ "关", "関", "關", "闗", ],
    [ "凾", "函", ],
    [ "数", "數", "數", ],
    [ "畑", "畠", "𤳉", ],
    [ "鸩", "鴆", "酖", "䲴", ],
    [ "欠", "歇", "歉", "缺", "缼", ],
]

e = [ ('' + ENV['ESTWORD']).force_encoding('utf-8') ]

a.each { |aj|
  c = Regexp.new(aj.join('|'))
  f = []
  e.each { |k|
    n = k.split(c, -1)
    if n.length > 2 then
      d = n.length - 1
      b = aj.repeated_permutation(d)
      b.each { |i|
        t = ''
        d.times { |j| t = [ t, n[j], i[j] ].join }
        t = [ t, n[d] ].join
        if t != k then
          f.push(t)
        end
      } 
    elsif n.length == 2 then
      aj.each { |m|
        t = n.join(m)
        if t != k then
          f.push(t)
        end
      }
    end
  }
  f.each { |o| e.push(o) }
}

e = e.sort.uniq
e.each { |o| puts o }

と書いたほうが高速かもしれない。

Perl にしろ Python にしろ簡易なスクリプト言語だから、 必要に応じて異体字パターンを随時に追記添削することができる。

せっかく non-BMP の漢字も扱えるようにしたのだから、 最初の叩き台として、 出来合いの異体字辞書、 たとえば SKK の異体字辞書や Unihan Database にある Unihan_Variants.txt というデータから、 機械的に上記のスクリプトに加筆する異体字の組を書き出すこともできる。 その異体字セット生成スクリプトなどをこちらにまとめておいた。 もちろん機械的にまとめてしまうものなので、おかしな異体関係が登録されてしまうから、用途に応じて人力で添削する必要がある。

ギリシア文字などのフィルタ

で以下は蛇足。

HyperEstraier ではラテン文字の à, á, ã, â, äa と一緒くたに扱うが、 ギリシア文字は α とを区別する仕様になっている。 とくに古典ギリシア語のような気息記号も使うようなテキストでは

  [ "ἀ", "ἁ", "α", "ά", "ὰ", "ἄ",
    "ἂ", "ἅ", "ἃ", "ᾶ", "ἆ", "ἇ", ],

というような組(下書イヨタは省略)を上記のクエリ展開スクリプトに追記して、 ラテン文字に似たような取扱をすることも不可能ではない。

ところがクエリ展開を有効にしていると、 *[σξ]υγγραφη* のような正規表現による展開ができなくなる。 その他、下書のイヨタも含めれば一個の文字のヴァリアントの夥しいギリシア語では、美観上の問題もあるから、 クエリ展開よりも、 estraier.c のソースコードに手をいれて、 ラテン文字と同じように、最初から一緒くたにして登録する取扱にしたほうが簡単である。

そういうわけで、この改造パッチをつくったが、 これにはアラビヤ文字やヘブライ文字の母音記号などもスッとばして登録する修正も含まれている。 もちろんこのパッチをあててコンパイルしなおした HyperEstraier では、 データベースを作りなおす必要がある。

Linux メモ