Ruby と Hunspell でスペルチェック

# はじめに

与えられたテキストのスペルチェックがしたい。
Ruby ではどうやるのがいいのか。

# Hunspell

昔，そういうことを調べた人（私を含む）は [Ispell](https://ja.wikipedia.org/wiki/Ispell) とか [GNU Aspell](https://ja.wikipedia.org/wiki/GNU_Aspell) の名を知っているかもしれない。

これらの後継として，[Hunspell](http://hunspell.github.io/) が今は有力であるらしい。

Hunspell はブラウザーやメールソフト，ワープロ，組版ソフトなど，テキストを扱う多くのアプリケーションに組み込まれている。
また，コマンドラインツールもあり，単独で使うこともできる。

少なくとも百数十の言語の Hunspell 用辞書が配布されているようなので，対応言語の数もなかなかのものだ[^a]。

[^a]: とはいえ，正書法が確立している言語の総数から見ればわずかと言えるかもしれない。なお，百数十というのは言語の数であり，一つの言語に幾つかの辞書が存在している場合もあるので，辞書の総数はもっと多い。

Ruby では [ffi-hunspell](https://rubygems.org/gems/ffi-hunspell) という gem があるので，本記事ではこれを使うことにする。

# インストール

## Hunspell のインストール

ffi-hunspell gem には Hunspell そのものは入っていないので，別途インストールする必要がある。
Hunspell は macOS でも Windows でも Linux でも簡単にインストールできる。

Hunspell のインストール方法はここでは網羅的に書けないが，たとえば macOS で Homebrew を使っているなら

```terminal
brew install hunspell
```

でインストールできる。

## 辞書のインストール

Hunspell をインストールしただけでは辞書が付いてこないので，スペルチェックはできない。

スペルチェックしたい言語の辞書のファイルをインストールする必要がある。

いま「辞書のファイル」という言い方をしたが，どうやら

* `*.dic`　辞書ファイル（dictionary file）
* `*.aff`　接辞ファイル（affix file）

の二つがペアになっていて，言語ごとに二つ揃えて用意する必要があるようだ。
「接辞ファイル」は私が仮に直訳してみただけで，正しい訳語かどうかは分からない。

例として，米国英語用辞書は

* en_US.dic
* en_US.aff

というファイル名になっている。

おそらくファイル名が被らなければ一つの言語にいくつでも辞書が置けるので，「ぼくがかんがえたさいきょうの米国英語辞書」を作って，my_awesome_en_us.dic, my_awesome_en_us.aff みたいなファイル名で使用・配布してもいいんだと思う（知らんけど）。

### 辞書の一覧

試しにコマンドラインで

```terminal
hunspell -D
```

と打ってみよう。これは「今使える辞書の一覧」を表示するものだ。

結果は，まず冒頭で

> SEARCH PATH:

として，辞書ファイルの検索パスがドバーッと表示されるはずだ。
逆に言えば，この検索パスに出てくるディレクトリーのどこかに辞書のファイルを入れれば認識してくれる，ということでもある。

次に

> AVAILABLE DICTIONARIES (path is not mandatory for -d option):

などと表示されているはずだ。辞書が存在すれば，この行に続いて辞書の一覧が出るはずだ。

私の環境では，これに続いて，

> Can't open affix or dictionary files for dictionary named "ja_JP".

が表示された。この行が言わんとすることは，

> 「ja_JP」の接辞ファイルも辞書ファイルも開けないんだけど？　

というようなことだと思う（超訳）。
そんなファイルは存在しないので開けなくて当然。
「ja_JP」はたぶん環境のロケールを拾っているだけじゃないかな。ここではこれについて追及しない。

表示は以上だ。

### 辞書を探す

Hunspell 用の辞書は実にさまざまな場所で配布されている。

まずは，The Document Foundation のサイトの「Language/Support」のページ

https://wiki.documentfoundation.org/Language/Support

を見てみよう。この団体はオフィススイートである [LibreOffice](https://ja.libreoffice.org/) を提供しているところ。

ちょっとページがゴチャゴチャしているが，表形式になっている箇所の左上に「Language」という検索窓がある。
ここに「english」と入力してみよう。
インクリメンタルサーチになっていて，「engl」くらいまで入力すれば英語だけに絞られる。

いろんなことが書かれているが，我々の目的はスペルチェック辞書なので，いちばん右の列の「Spell check dictionaries」のところを見よう。

ここに「English Dictionaries」というリンクがある。これをクリック。
すると以下のページに飛ぶ。

https://extensions.libreoffice.org/en/extensions/show/english-dictionaries

何も考えずに最新版をダウンロードするなら，右上の「Download latest」ボタンを押せばいいし，リリースの履歴を確認したければ「Release List」の表を見る。
「Release List」の表のいちばん右の列の「Download」はボタンにもリンクにも見えないが，これを押せばそのバージョンのファイルがダウンロードできる。

最新版ダウンロードファイルは，今やってみたら dict-en-20231001_lo.oxt という名前だった。
この `.oxt` という拡張子は，LibreOffice やそのフォーク元である OpenOffice.org の拡張機能のファイル形式らしいがよくは知らない。

### .oxt をバラす

このファイルは，ただの ZIP ファイルなので，拡張子を `.oxt` から `.zip` に変えてやると解凍できる。
解凍するとたくさんのファイルが出てくるが，この中で拡張子が `.dic` のものと `.aff` のものを探す。
ただし，hyph_en_GB.dic とか hyph_en_US.dic のように，「hyph_」で始まるものはスペルチェック辞書ではなくハイフネーション辞書なので，今回の目的には使わない。

dict-en-20231001_lo.oxt の場合，拡張子の前の部分が `en_XX` という形のファイル計 10 個が見つかった。
`XX` の部分は `AU`, `CA`, `GB`, `US`, `ZA` で，それぞれオーストラリア，カナダ，イギリス，アメリカ，南アフリカであるようだ。

これらのうち，使いたいものの `.dic` と `.aff` を然るべきところ（後述）に置けばよい。

### その他の辞書

英語の場合，実は特殊用途の辞書もある。もう一度 Language/Support のページの英語のエントリーを見てみよう。

「Spell check dictionaries」のほかに「Specialized spell check dictionaries」というものもある。
この一覧には医学用語や化学用語といったものも挙げられている。
専門分野の仕事をするならこういう辞書も役立つだろう（というか必須だろう）。
私自身は試していないので，これ以上は言及できない。

次に，フランス語の辞書も探してみよう。
「Language」の検索窓に英語で「french」と入力するか，フランス語で「français」と入力すればいい。

すると出てくるフランス語の行の右端の列の「Spell check dictionaries」のところに [Dictionnaires Francais](https://extensions.libreoffice.org/en/extensions/show/dictionnaires-francais) というリンクがあるので，これをクリック。
おいおい，フランス語で書いてあるのに「Français」じゃなくて「Francais」なのはなんでやねん。べつに ASCII 縛りとか無いと思うが。

こちらも英語でやったのと同じ要領で中身を見ると，辞書のファイルは以下の四点があった。

* fr-classique
* fr-moderne
* fr-reforme1990
* fr-toutesvariantes

それぞれの辞書の特徴は README_dict_fr.txt に書いてあるのだが，フランス語なので読めん。
ただ，Classique が 「recommandé」（推奨）となっているので，試すだけならとりあえず fr-classique でいいのだと思う。

### その他の配布元

Hunspell 用辞書の配布元としては，ほかにも例えばメールクライアント Thunderbird のアドオン配布サイトがある：

https://addons.thunderbird.net/ja/thunderbird/

ここで言語名と「dictionary」などで AND 検索してみると，スペルチェック辞書のアドオンが見つかる。

アドオンをダウンロードすると，拡張子 `.xpi` のファイルになっているが，これもまた ZIP ファイルなので，拡張子を `.zip` に変えて解凍し，中から `*.dic` と `*.aff` のファイルを取り出すことができる。

### 辞書の配置

辞書のファイルをどこに置くか。
すでに述べたように，`hunspell -D` で出てくる「SEARCH PATH」のどこかに置けばよい。

たぶん

* 全ユーザーが使えるようにするか，特定のユーザー専用にするか
* 特定のアプリ用にするか否か
* Hunspell だけで使うか，同じフォーマットの辞書を利用する他のスペルチェックツールでも使えるようにするか
 
で違ってくるのだと思う。

私の場合（macOS），あまり何も考えずに `/Library/Spelling` に置くことにした。
そういうディレクトリーは存在してなかったので作った。

置く場所を決めたらそこに `*.dic` と `*.aff` をペアで置くだけ。

もちろん，ディレクトリーやファイルの読み取りパーミションは適切に設定する必要がある。

辞書のファイルを配置したら再び

```terminal
hunspell -D
```

してみよう。今度は

```terminal:（抜粋）
/Library/Spelling/en_US
```

みたいな感じで表示されるはずだ。
なお，これはファイルパスではない。これらに拡張子を付けたものがファイルパスになる。

ちなみに，macOS の持つスペルチェック機能はこの場所に置かれた辞書を認識するようだ。
CotEditor というテキストエディターを使っているが，メニューの［編集］→［スペルと文法］で出てくるスペルチェック機能（これは OS の機能を呼び出しているらしい）で，新しく追加した辞書が言語の選択肢に出てきた。

## ffi-hunspell のインストール

最後に gem のインストール。単に

```terminal
gem install ffi-hunspell
```

で OK。
私の経験の範囲ではとくに困ったことは起きなかった。

# 試用

ではさっそく試してみよう。
`en_US` の辞書は入っているとする。

```rb
require "ffi/hunspell"

dict = FFI::Hunspell.dict("en_US")

puts dict.check?("Ruby") # => true
puts dict.check?("Rubi") # => false

dict.close
```

コードを上から順に説明する。

gem 名は `ffi-hunspell` なのだが，Bundler を使わずに `require` する場合，`ffi/hunspell` を指定することに注意。
Bundler を使う場合は，もちろん Gemfile に `gem "ffi-hunspell"` と書く。

まず最初に `FFI::Hunspell.dict` で辞書オブジェクトを生成する必要がある。
引数に辞書名を指定する。これは辞書のファイル名の拡張子を除いた部分だ。

辞書オブジェクトに対し，単語の文字列を引数に `check?` メソッドを呼び出すと，スペルが OK なら `true` が，NG なら `false` が返る。それだけ。

辞書オブジェクトは使い終わったら `close` する必要がある。`File` オブジェクトなんかと同じ。
たぶんスクリプトが終わる時に勝手に `close` してくれるのだと思うが，それに任せるのはよい流儀ではないだろう。

実は `File.open` なんかと同じように，ブロックを与える用法もある。以下のように書く。

```rb
require "ffi/hunspell"

FFI::Hunspell.dict("en_US") do |dict|
  puts dict.check?("Ruby") # => true
  puts dict.check?("Rubi") # => false
end
```

これだとブロックの評価が終わったところで自動的に `close` してくれるはずだ。
こちらの書き方が良い。

なお，上記の `dict` は `FFI::Hunspell::Dictionary` というクラスのインスタンス。

# 機能

FFI::Hunspell::Dictionary の持つメソッドは，よく調べていないがたぶん以下の三つ。

* `check?`　単語の綴りが合っているかを判定
* `stem`　単語の語幹の候補を挙げる
* `suggest`　単語が誤っている場合に正しい語形の候補を挙げる

このうち `check?` は既に見たので，残りの二つを確認しよう。

以下の例では，ローカル変数 `dict` に `en_US` の FFI::Hunspell::Dictionary オブジェクトが入っているとする。

## `stem` 

例えば，notes という単語は，語幹 note に複数形語尾 s が付いた形をしている。
`stem` メソッドを使えば以下のように「notes」から「note」を得ることができる。

```rb
p dict.stem("notes") # => ["note"]
```

メソッド名の「stem」は日常語では「幹」という意味だが，文法用語では「語幹」のこと。

dictionaries という単語も dictionary に複数形語尾を付加したものだが，この場合，語尾は s でなく es だし，語幹の y は i に変化している。
このような単語についても，期待する結果が得られる：

```rb
p dict.stem("dictionaries") # => ["dictionary"]
```

素晴らしい！

名詞の変化だけではない。動詞 start の三人称単数現在形，現在進行形，過去形についてもやってみよう：

```rb
p dict.stem("starts")   # => ["start"]
p dict.stem("starting") # => ["start"]
p dict.stem("started")  # => ["start"]
```

すべて期待どおり！
（starts は名詞 start の複数形とも解釈できるが結果は同じ）

ちなみに，変化語尾が付いていない語で試すと，同じものが返る：

```rb
p dict.stem("make")   # => ["make"]
```

ところで，ここまでの例を見て分かるとおり，返り値は文字列ではなく文字列**の配列**になっている。
これがなぜなのかは，以下の例を見れば分かる。

```rb
p dict.stem("making") # => ["making", "make"]
```

making という単語は，making という名詞とも解釈できるし，make という動詞の現在進行形とも解釈できるのだ。「語幹」の候補が一般には複数あるので，配列が返る仕様になっているというわけだ。

### 単語の辞書形が得られる？

ここまで試してみて，「おお！　これで単語の辞書形（動詞なら原形，形容詞なら原級，など）が分かるぞ！」と期待したのだが，そういうわけではなかった。

以下の例を見て少々がっかりした：

```rb
p dict.stem("wrote") # => ["wrote"]
p dict.stem("better") # => ["better"]
```

wrote から write が得られるわけではないのだ。
better から good が得られるわけでもない。

wrote は write に語尾を付けたものではないし，better も同様[^better]。

[^better]: better は歴史的にはそもそも good の変化形ですらない。全く別の語彙だったものが，いつの間にか原級・比較級のペアになっただけである。

おそらく `stem` メソッドはあくまで「語幹に語尾を付加して出来た語から元の語幹を得る」だけのものなのだろう。

dictionaries から dictionary が得られる例を見ると，語幹の若干の変化には対応していそうだが，以下の例はやはりがっかりする：

```rb
p dict.stem("stopping") # => ["stopping"]
p dict.stem("stopped")  # => ["stopped"]
```

動詞 stop に -ing や -ed を付けるにあたって，語幹の最後の子音 p を重ねているだけなのだがこういうものには対応していないようだ。

このあたりは辞書によるのかもしれない。

### 本当に語幹なのか

英語以外の言語でいろいろ実験してみたところ，どうやら Hunspell でいうところの「語幹（stem）」は言語学的な語幹とは違うということが分かった。

エスペラントで「学生」は studento なのだが，「学生たちを」は studentojn となる。
-o は名詞の単数主格語尾で，-ojn は複数対格語尾だ。
語幹は student である。この student に -o や -ojn を付ければ上記の単語が出来上がる（語尾無しの student という単語は無い）。

ところが，エスペラントの辞書（eo-EO）で `stem` を使ってみると

```rb
p dict.stem("studento")   # => ["studento"]
p dict.stem("studentojn") # => ["studento"]
```

のように，語幹ではなく，単数主格形が出てきた。
単数主格形は名詞のいわば基本形であり，辞書にもこの形で載っている。

また別の実験では，接辞の付いた語の接辞が取れて別の語尾が付いた語が返ってきた。

というわけで，Hunspell のいうところの stem は，語幹だったり，（何らかの意味の）基本形だったり，接辞まで剥ぎ取ったりしたものだったり，さまざまだということが分かった。
単純に語幹と思ってはいけない。

## `suggest`

`suggest` は，誤った語形から正しい語形を推定して，その候補を挙げるメソッドだ。

候補はやはり複数ありうるので，返り値は文字列の配列になっている。

ほな，定番の誤り recieve（正しくは receive）行きまひょか：

```rb
p dict.suggest("recieve") # => ["receive", "relieve", "reverie"]
```

三つも候補が出てきた。
reverie はあんまり似てない気もするけど，どういう基準で似ていると判断しているのだろう？[^ed]

[^ed]: 文字列の類似度を図る尺度として編集距離というものがある（編集距離もいろいろある）。しかし，recieve と reverie では編集距離が近いようにも思えない。

`suggest` に正しい語形を与えたらどうなるのか？

```rb
p dict.suggest("receive")
# => ["receive", "receives", "receiver", "received", "deceive"]
```

空配列が返るわけではない。存在している単語であっても，他の語のスペルミスの可能性はあるわけだし。

# さいごに

Ruby で Hunspell を使ったスペルチェックをやってみた。
かなりお手軽にできるということが分かった。

応用としては，たとえば

* Rails などウェブアプリで，ユーザー入力に対してスペルチェック
* テキスト処理プログラムに付加機能としてスペルチェックをプラス
* 静的ウェブサイトの全 HTML を一括でスペルチェック

といったことが考えられるだろうか。

しかし，そのためにはまだまだ考えなければならないことがある。

ffi-hunspell は（たぶん）単語単位でしかチェックができない。だからテキスト中から単語を切り出す処理が必要になるが，これが意外と難しいんである。

また，実用的には辞書に単語を追加して使いたいこともあるだろう。ffi-hunspell にはそういう機能もある（らしい）。

それから，いまシステムにどんな Hunspell 辞書が入っているかを Ruby 側から知りたいこともあるはずだ。これはどう書くのか。

もし本記事に反響があるなら，そういったことも続編として書いてみたい。