paizaの問題文用に作成した文章校正ツールProofLeaderの仕組み

<!-- @format -->

# はじめに

[paiza](https://paiza.jp/)アドベントカレンダー 2021 7 日目を担当する xryuseix です．paiza では[paiza ラーニング](https://paiza.jp/works/mypage)の学生アルバイトをしています．
今までのアドベントカレンダーは[こちら(Adventar)](https://adventar.org/calendars/6343)．昨日のアドベントカレンダーは[もじゃさんの「みて うちのかわいい魚たちを」](https://note.com/s10akir/n/n766b26f90d96)です．

さて，今日は文章構成ツールについてお話しします．社内ドキュメントも社外用公開ドキュメントも，複数人で書く場合はフォーマットを統一する必要があります[^1]．例えば以下の二つの文章をみてみましょう．

```txt
文章を綺麗に書く事はとても美しい．何故なら，世界で一番文章が麗しくなるからだ．
```

```txt
文章を綺麗に書くことはとても美しい。なぜなら、世界で 1 番文章がうるわしくなるからだ。
```

文章の意味は置いといて，読んだ時の印象が多かれ少なかれ異なるとおもいます(どちらがいいとかはまた別の話)．これが文章ごとに色々入ってたら違和感で文の内容が頭に入ってこないかもしれません．

本記事ではそのような**_表記ゆれ_**を訂正するための自動化ツールについてお話しします．

|                                                                                                                             |
| :-------------------------------------------------------------------------------------------------------------------------: |
| [![Qiita](https://raw.githubusercontent.com/xryuseix/ProofLeader/master/logo.png)](https://github.com/xryuseix/ProofLeader) |

(↑ 作った OSS はこれです．クリックすると GitHub へ飛びます．)

## 対象となる文章表現

具体的には人はどのような文章が*表記ゆれ*しやすいのでしょうか．例としてこのようなものが挙げられます．

|  内容  |            変更前             |        (良さそうな)変更後         |
| :----: | :---------------------------: | :-------------------------------: |
| 句読点 |            `， ．`            |              `、 。`              |
|  漢字  |   `よろしくお願い致します`    |    `よろしくお願いいたします`     |
| 計算式 |            `1+1=2`            |            `1 + 1 = 2`            |
| 英単語 | `平和とはpeaceということです` |  `平和とは peace ということです`  |
| 数値 1 | `全体の50%が中央値以下でした` |  `全体の 50% が中央値以下でした`  |
| 数値 2 | `5000000000000000円欲しい！`  | `5,000,000,000,000,000円欲しい！` |

他にもありますがざっとこんなもんでしょう．ここからはこれらの変更をどのように実現しているのかについて考えていきます．基本的にすべて正規表現でどうにかします(ここから出てくる言語は全部 Python です)．

### 句読点 ( `．，`→`、。` )

これは一番簡単です．正規表現モジュールを用いて，`re.sub`を 2 回やればよいです．

```py:converter.py
# ．，を、。に変換
def dot_to_comma(text):
    replacedText = re.sub("，", r"、", text)
    return re.sub("．", r"。", replacedText)
```

### 漢字 ( `致します`→`いたします` )

まずはワードリストを作る必要があります．なぜなら，漢字のルール一般的なものがあるわけではなく(ほんと?)，基本的に会社やプロジェクト単位で定めるからです．

ワードリストの例を下記に示します．

```txt:word_list.csv
致します,いたします
わたし,私,わたくし,俺,わい,イッチ
```

一般的にはこのように記述するように定義しました．

```txt:word_list.csv
After1,Before1
After2,Before2_1,Before2_2,Before2_3
After3,Before3_1,Before3_2
```

すると以下のように Before が文章に入っていた場合 After にした方がいいと警告します．

```sh
WARNING: ファイル名:行数:行頭から何文字目: (致します) => (いたします)
```

さて，実装について話を進めていきます．このような「大きな文章の中から特定の部分文字列を探索する」という処理は Rolling Hash を用いたりしますが，今回はユースケースを考えて，あまり大きな文章に対して実行しない(文書はいくつか分けるだろう)ので，単純にワードリストの単語の回数分`re.search`で探索しています．

```py:converter.py
for word in word_list:  # 文字列警告
    re_obj = re.search(word[0], text)
    if re_obj:
        warning_list.append([i + 1, re_obj.start(), re_obj.group(), word[1]])

for c in warning_list:
    print("\033[33mWARNING\033[0m: %s:%s:%s: (%s) => (%s)" % (file, c[0], c[1], c[2], c[3]))
```

### 計算式 ( `1+1`→`1 + 1` )

ここから少しずつ難しくなっていきます．計算式の記号の前後に空白を入れる際は`(何か)(記号)(何か)`を`(何か)(スペース)(記号)(スペース)(何か)`に置換しています．割り算がここに入っていないのはタグ`<a></a>`が`<a>< / a>`にならないようにするためです．ProofLeader は英字+一部の記号列の前後にスペースを入れる機能があり，その機能で別途`1/1`を`1 / 1`にするようにしています．

```py:converter.py
# 記号の前後に空白
op = r"\+\-\*"
text = re.sub(r"([^%s\n ])([%s]+)" % (op, op), r"\1 \2", text)
text = re.sub(r"([%s]+)([^%s ])" % (op, op), r"\1 \2", text)
```

### 数値 1 ( `トリオは3人`→`トリオは 3 人` )

難易度高めの正規表現が出てきました．もう一生書きたくありません(実は間違ってる説まである)．

```py:converter.py
# 数値の前に空白
text = re.sub(r"([^\n\d, \.])((?:\d+\.?\d*|\.\d+))", r"\1 \2", text)
# 数値の後ろに空白
text = re.sub(r"([\+\-]?(?:\d+\.?\d*|\.\d+))([^\n\d, \.])", r"\1 \2", text)
```

要は下記のような置換ができるような正規表現を書く必要がありました．

|     置換前     |     置換後      | 説明                                 |
| :------------: | :-------------: | :----------------------------------- |
|    `あ1い`     |    `あ 1 い`    | 単純にスペースを入れる               |
|    `あ+1い`    |   `あ +1 い`    | 数値の前に`+`が入っている場合        |
|    `あ-1い`    |   `あ -1 い`    | 数値の前に`-`が入っている場合        |
|  `(行頭)1い`   |  `(行頭)1 い`   | 行頭にはスペースを入れたくありません |
|   `あ3.5い`    |   `あ 3.5 い`   | 小数にも対応したいです               |
| `(行頭)-3.5い` | `(行頭)-3.5 い` | 全部のせ                             |

それがいい感じになるようなんとなく正規表現を書くとあんな感じになりました(?)

### 数値 2 ( `1000`→`1,000` )

これもなかなか難しいです．まずは数値の箇所を切り抜きます．

```py:converter.py
# textから数値の場所のみを切り出す
def cut_out(self):
    return re.sub(r"\d+[.,\d]*\d+", self.__digit_comma, self.text)
```

ここからは小数の可能性もある(先頭に+-はない,カンマが含まれる可能性はある)，数値を受け取り，いい感じにカンマを足します．数値を整数部と小数部に分け，整数部にだけ`re.sub("(\d)(?=(\d\d\d)+(?!\d))", r"\1,", ここに整数部の文字列)`を適用させています．

```py:converter.py
# 数字を三桁ごとに区切ってカンマ
def __digit_comma(self, num: str):
    num = num.group()
    integer_decimal = num.split(".")
    commad_num = re.sub(
        "(\d)(?=(\d\d\d)+(?!\d))", r"\1,", integer_decimal[0]
    )  # 整数部
    if len(integer_decimal) > 1:
        commad_num += "." + integer_decimal[1]  # 小数部
    return commad_num
```

急に出てきた`(\d)(?=(\d\d\d)+(?!\d))`って正規表現こそが三桁ごとにカンマを入れる正規表現です(正確には入れるべきカンマの直前にある数値を抽出する)．正規表現の肯定先読みで後続の文字列の数値が 3 の倍数個であればマッチします．3 の倍数個かどうかの判定は少なくとも 3 の倍数個ある(`(\d\d\d)+`)かつ 3 の倍数個の数値の後ろに数値がない(否定先読み`(?!\d))`で実装)しています．

## その他の機能

実はまあまあ**あくまで自分が**使いやすいよう色々やっています．例えばコードブロック(```` ``` ````)内や code タグ(`<code></code>`)内では置換しないだとか，一部のファイルを置換対象から除外する設定ができたりだとか，ワードリストは`わたし,(私|わたくし|俺|わい|イッチ)`と書けるよう正規表現を採用したりだとか，まあそんなところです．

## さいごに

ここまで雑な文章を読んでいただきありがとうございました．もし良ければ[GitHub レポジトリ](https://github.com/xryuseix/ProofLeader)にスターを押していただけるとうれしいです．押すとたぶん来年のおみくじは大吉になります．たぶん．
明日は[toshiki imai さんの「友達の研究室にArduinoで動くカードキーを設置した話」](https://qiita.com/imaimai17468/items/8d982b91f99a698a8b6c)です．お楽しみに！


[^1]: とはいえ，これは社内ツールではなく個人的に使ってるだけのただの自分の文章を綺麗に見せたいだけのツールです．