ブラウザと JavaScript だけで任意のテキストをスペルチェックするWebアプリを作る

# はじめに

ブラウザと JavaScript だけで任意のテキストをスペルチェックする方法について紹介します。

ブラウザにもよるかもしれませんが、input や textarea 内の文字列であれば標準機能でスペルチェックしてくれます。

ただ、リッチテキストエディターのような HTML タグの構成が特殊なエディター内のテキストをブラウザはチェックしてくれませんので、チェック機能を独自に作りこむ必要があります。

想像よりも簡単に実装できましたので、紹介したい思います。

# JavaScript でスペルチェックできることを確認

ここでは必要最低限の手順で 1 つの文字列をスペルチェックするための手順を記載します。
webpack の利用を前提としていますが、その辺りの情報は省略します。

まずは、スペルチェックに必要なモジュールをインストールします。

```
npm install nspell dictionary-en
```

次に、以下のようなファイルを作成します。

辞書モジュールである dictionary-en はブラウザ用の JavaScript モジュールではないため、ブラウザで読み込めるように加工しています。
後述しますが、この加工処理は webpack の raw-loader を利用して、より最適な方法に実装しなおします。

```javascript
// my-dictionary-en.js
export default {
  // node_modules/dictionary-en/index.aff の中身をコピペ
  aff: `SET UTF-8
・・・
REP size cise
`,
  // node_modules/dictionary-en/index.dic の中身をコピペ
  dic: `49524
・・・
zymurgy/M
`
```

最後に、スペルチェックのモジュールに辞書を読み込ませてスペルチェックを実行します。

以下の通り、スペルチェックできることが確認できます。

```javascript
import nspell from 'nspell';
import dictionary from './my-dictionary-en';

const spell = nspell(dictionary);

console.log(spell.correct('colour')); // => false
console.log(spell.suggest('colour')); // => ['color']
console.log(spell.correct('color')); // => true
console.log(spell.correct('npm')); // => false
spell.add('npm');
console.log(spell.correct('npm')); // => true
```

簡単にスペルチェックができました。

とりあえずスペルチェックするだけならこれだけになりますが、ここからはより詳細に確認しながら対応していきます。

# 利用モジュールについて

スペルチェックで利用するモジュールは以下の 2 つになります。

- [nspell](https://github.com/wooorm/nspell)
- [dictionary-en](https://github.com/wooorm/dictionaries/tree/main/dictionaries/en)

nspell は、Hunspell と互換のある JavaScript のスペルチェッカーです。
Hunspell はいろんなアプリで使われているらしいスペルチェッカーです。

> Hunspell compatible spell-checker in plain-vanilla JavaScript.

dictionary-en は Hunspell にインストール可能な辞書ということで nspell でも利用可能です。

> Collection of normalized and easily installable hunspell dictionaries. Useful with nodehun, nspell, and others.

再掲ですが、インストールコマンドは以下の通りです。
TypeScript を利用しているのであれば nspell の型定義もインストールするとよいです。

```
npm install nspell dictionary-en
npm install @types/nspell --save-dev
```

## 利用モジュールのライセンスについて

ライセンスについてはあまり言及したのくないので、詳細はオフィシャルサイトを確認してください。

本ブログ投稿時点では、nspell は MIT License で、dictionary-en は MIT AND BSD とオフィシャルサイトに書いてありました。

- nspell: https://github.com/wooorm/nspell/blob/main/license
- dictionaries: https://github.com/wooorm/dictionaries
- dictionary-en: https://github.com/wooorm/dictionaries/blob/main/dictionaries/en/license

辞書を公開しているリポジトリは、英語以外にも多くの言語の辞書を公開しています。
このプロジェクト自体は MIT ですが、辞書そのものはオリジナルのライセンスを持っているので注意が必要です。

> Important: this project itself is MIT, but each index.dic and index.aff file still has its original license!

# 辞書ファイルの読み込み

前述の手順では辞書ファイルのテキストをコピペして利用しましたが、このままだとリポジトリが更新されたときの反映が手間なので、正しい方法で読み込む手順を記載します。

オフィシャルサイトでは、辞書ファイルの読み込み方法は以下のように記載されてますが、これをブラウザでそのまま利用することはできません。node.js での動作が前提の実装となっているためです。

```javascript
var en = require('dictionary-en')

en(function (err, result) {
  console.log(err || result)
})
```

dictionary-en の [index.js](https://github.com/wooorm/dictionaries/blob/main/dictionaries/en/index.js) を確認すると、**.aff** ファイルと **.dic** ファイルを読み込んで `{aff: .affファイル, dic: .dicファイル}` というオブジェクトを作るだけでよいということが分かります。

テキストファイルを文字列として読み込めばいいだけなので、webpack の [raw-loader](https://webpack.js.org/loaders/raw-loader/) をインストールします。

```
npm install raw-loader --save-dev
```

webpack のオフィシャルサイトを参考に、以下のような設定をします。

```javascript
// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.(aff|dic)$/i,
        use: 'raw-loader',
      },
    ],
  },
};
```

今回は vue-cli を利用していますので、vue.config.js を以下のように修正しました。

```javascript
// vue.config.js
module.exports = {
  configureWebpack: {
    module: {
      rules: [
        {
          test: /\.(aff|dic)$/i,
          use: 'raw-loader'
        }
      ]
    }
  },
};
```

前の手順にて、辞書ファイルをコピペで作ったファイルは、以下のように変更すればよいです。

```javascript
// my-dictionary-en.js
import aff from '{node_modulesへのパス}/dictionary-en/index.aff';
import dic from '{node_modulesへのパス}/dictionary-en/index.dic';

export default {
  aff,
  dic
};
```

辞書ファイルの読み込み方法を変更しただけですので、スペルチェック処理は特に変更することなく動作します。

```javascript
import nspell from 'nspell';
import dictionary from './my-dictionary-en';

const spell = nspell(dictionary);

console.log(spell.correct('colour')); // => false
console.log(spell.suggest('colour')); // => ['color']
console.log(spell.correct('color')); // => true
console.log(spell.correct('npm')); // => false
spell.add('npm');
console.log(spell.correct('npm')); // => true
```

# 任意のテキストをスペルチェックする

nspell は単語のチェックはできますが文章のスペルチェックができないため、任意のテキストをスペルチェックするには独自に実装する必要があります。

任意のテキストから単語を取り出すには、例えば、以下のような正規表現で取り出します。
アルファベット 2 文字を取り出していますが、もっといい方法があるような気もします。

```javascript
const spell = nspell(dictionary);
const re = /[a-z]{2,}/gi;
const text = '任意のテキスト';
let arr;
while ((arr = re.exec(text))) {
  console.log(arr[0], spell.suggest(arr[0]));
}
```

このロジックで以下のサイト(一般的にミスしやすい英単語のまとめ Wiki)の全文をスペルチェックすると、約 1400 単語が抽出され 8 秒くらいかかりました。
ブラウザでスペルチェックしているので利用 PC の性能に応じて処理速度は増減します。

https://en.wikipedia.org/wiki/Commonly_misspelled_English_words

テキスト入力ごとにスペルチェックすると、入力ごとに数秒間ブラウザが固まりますので対応が必要です。

## テキスト入力中も快適にスペルチェックする

テキスト入力中も快適にスペルチェックするには以下のような対応を行います。

- テキスト入力中でないときにスペルチェックする
- バックグラウンドでスペルチェックする
- 1 回のスペルチェックの範囲を狭める

### テキスト入力中でないときにスペルチェックする

テキスト入力中は何もしないで、一定期間何も入力されなかったらスペルチェックする、という対応を行います。

独自に実装してもいいですが、 [\_.debounce](https://lodash.com/docs/#debounce) の利用するのが簡単でよいです。
ネットで検索すればいくらでも情報は出てきますので、このメソッドの詳細については省略します。

この対応だけでは不十分で、スペルチェック中はブラウザが固まることに変わりはありません。

### バックグラウンドでスペルチェックする

スペルチェック中はブラウザが固まらないようにバックグラウンドで処理をします。

JavaScript でバックグラウンド処理するには ajax を利用するか Web Worker を利用すると思います。

サーバ上のスペルチェック API を呼び出すのであれば ajax の利用となりますが、今回はブラウザの JavaScript のみで完結させるため Web Worker を利用します。

Web Worker を webpack で利用するための便利なライブラリがありましたので利用させてもらいます。
[worker-loader](https://github.com/webpack-contrib/worker-loader) と [worker-plugin](https://github.com/GoogleChromeLabs/worker-plugin) というモジュールが見つかりましたが、今回は worker-plugin を採用します。

本ブログ投稿時点では、worker-plugin のほうがダウンロード数が多いですし、worker-plugin のほうが標準の Web Worker の記述で実装できるメリットがあるようです。

https://www.npmtrends.com/worker-loader-vs-worker-plugin

インストールするには以下を実行します。

```
npm install worker-plugin --save-dev
```

webpack の設定はオフィシャルサイトには以下のように記載されています。

```diff
+const WorkerPlugin = require('worker-plugin');

module.exports = {
  <...>
  plugins: [
+    new WorkerPlugin()
  ]
  <...>
}
```

前述と同様で vue-cli を利用していますので vue.config.js を以下のように変更しました。

```diff
+const WorkerPlugin = require('worker-plugin');

module.exports = {
  configureWebpack: {
+    plugins: [new WorkerPlugin()],
  },
};
```

Web Worker によるスペルチェック処理は以下のようになります。

```javascript
// spell.worker.js
import nspell from 'nspell';
import dictionary from './my-dictionary-en';

const spell = nspell(dictionary);

addEventListener('message', event => {
  const results = [];
  const re = /[a-z]{2,}/gi;
  const text = event.data;
  let arr;
  while ((arr = re.exec(text))) {
    results.push({ word: arr[0], suggestions: spell.suggest(arr[0]) });
  }
  postMessage(results);
});
```

呼び出し側は以下のようになります。

```javascript
const worker = new Worker('./workers/spell.worker.js', { type: 'module' });
worker.onmessage = (event: MessageEvent) => {
  console.log(event.data); // => [{word: 'tihs', suggestions: [ ... ]}, ... ]
};
worker.postMessage('tihs is a pen.');
```

ここまでで、スペルチェック中もブラウザが固まらないように対応できました。

#### worker-plugin 利用時の注意事項

ESLint と相性が悪いのかよくわかりませんが、Web Worker 側の実装が ESLint エラーとなりました。
エラーとならないように eslint-disable のコメントを入れたり .eslintignore を指定しても解消しませんでした。

以下のように Web Worker の実装だけ ESLint でチェックしないように設定することで解消できましたので記載しておきます。

```javascript
// .eslintrc.js
module.exports = {
  rules: {}
  overrides: [
    {
      files: ['**/workers/spell.worker.js'],
      rules: {
        'prettier/prettier': 'off'
        // 必要に応じて追加する
      }
    }
  ]
};
```

### 1 回のスペルチェックでの範囲を狭める

ここまでの対応で、スペルチェック中もブラウザが固まることなく快適に入力できる状態となりましたが、英単語が多いテキストの場合はスペルチェックの処理時間が長くなり、現在入力中のテキストをなかなかチェックしてくれないのでストレスとなります。

ほんとにスペルチェックしてほしい箇所は現在入力中のカーソル付近のテキストだと思います。
ただし、必ずしもカーソル付近のテキストをスペルチェックすればいいとは限りません。
テキスト全量をコピペするケースもあるでしょうし、テキスト入力後に高速にカーソルを移動するケースもあるでしょうから。

というわけで、1 回のスペルチェックイベントで、現在入力中のカーソル付近のテキストと全文のテキストと 2 回のスペルチェック処理を行うこととします。

現在入力中の位置を取得するには、HTML 標準の textarea を利用しているのか、それ以外を利用しているのかで実装方法が異なるでしょうから、これについては省略します。

実装におけるポイントは、範囲を狭めたスペルチェックはブラウザのメインスレッドで行うことです。
バックグラウンドでのチェック中に、別のバックグラウンド処理をしようとしても待機させられます。
そこで、範囲を狭めたチェックが先に処理されるようにブラウザのメインスレッドでチェックします。
ブラウザが固まることが懸念されますが、少ない単語数であれば高速に処理されるため、違和感はほとんどありません。

# まとめ

ここまでの内容をもとに出来上がったアプリは以下になります。
動作検証にでもご利用ください。

https://markdown.aprifield.com/

## 補足 1

[nspell-browser](https://github.com/rksm/nspell-browser) というモジュールもありましたが、ずいぶん更新されていないので今回は未検証です。おそらく、nspell をブラウザでも利用できるようにしたものと思われます。

## 補足 2

nspell を利用する前に、Azure の Bing Spell Check を利用したり、textlint で Web API を構築したりしてみました。
これについては満足のいく出来ではなかったので個人ブログに記載しています。
https://aprifield.hatenablog.com/entry/2020/08/05/191035

今回は満足のいく出来だと思ったのでこちらに投稿しました。

## 補足 3

ビルド後のファイルサイズについてですが思ったほどの増加ではありませんでした。
64KB ほど増加しまして、ほぼ辞書ファイルのサイズがそのまま増加した感じです。