青空文庫の外字をPythonでUnicodeに置換

# はじめに

[青空文庫](https://www.aozora.gr.jp/)のデータが [GitHub](https://github.com/aozorabunko/aozorabunko) に全部ある！ということで、Deep Learning の日本語データのネタとして使おうと思ったら、Shift JIS だったり、ルビが入ってたり、外字がコメントの形で入ってたりで、そのままでは普通の日本語テキストになってません。

この文書は、Python (Jupyter) で、青空文庫で使われている外字表記コメントを Unicode に置きかえよう、という話です。

先人の試みも[いくつかありましたが](#外字の変換)、一部リンク切れだったり、sed などによる実装だったので、ほぼ等価なものを Python で書きました。

やってることは、参考サイト（カビパン男と私： [青空文庫「外字」を utf8 に変換](http://www.kabipan.com/computer/mobi/aozora_kanji.html)）にある説明をベースに書いただけです。テストも十分ではないと思いますが、勝手に使ってください。

## 最初に使い方

気が短い人のために、まず最初に結果だけ。

まず JIS X0213 対応表をダウンロード　`!wget http://x0213.org/codetable/jisx0213-2004-std.txt` してから、以下を実行します。（コードは、コメントいただいた @shiracamus さんによる改訂版です。）

```python
import re

with open('jisx0213-2004-std.txt') as f:
    ms = (re.match(r'(\d-\w{4})\s+U\+(\w{4})', l) for l in f if l[0] != '#')
    gaiji_table = {m[1]: chr(int(m[2], 16)) for m in ms if m}

def get_gaiji(s):
    # ※［＃「弓＋椁のつくり」、第3水準1-84-22］
    m = re.search(r'第(\d)水準\d-(\d{1,2})-(\d{1,2})', s)
    if m:
        key = f'{m[1]}-{int(m[2])+32:2X}{int(m[3])+32:2X}'
        return gaiji_table.get(key, s)
    # ※［＃「身＋單」、U+8EC3、56-1］
    m = re.search(r'U\+(\w{4})', s)
    if m:
        return chr(int(m[1], 16))
    # unknown format
    return s

def sub_gaiji(text):
    return re.sub(r'※［＃.+?］', lambda m: get_gaiji(m[0]), text)
```

あとは、この `sub_gaiji()` に青空文庫のテキストを投げれば、

```
new_text = sub_gaiji(text)
```

外字が埋め込まれたテキストが返ってきます。

`Jupyter` じゃなく、普通にコマンドラインから叩きたい方はメイン関数を追加してファイルに書き出して実行してみてください。

```python
def main():
    if len(sys.argv) <= 1:
        print(f'usage: {sys.argv[0]} <file_name>')
        exit()
    with open(sys.argv[1]) as f:
        print(sub_gaiji(f.read()))

if __name__ == '__main__':
    main()
```

以上です。


# くわしい説明

## 青空文庫の外字コメント

[青空文庫](https://www.aozora.gr.jp/)のデータが [GitHub](https://github.com/aozorabunko/aozorabunko) の文書が（「[青空文庫の GitHub からデータの取得](#青空文庫の-github-からデータの取得)」などを参考に）すでにローカルに展開されているとします。

例えば `Data/Text/aozorabunko_work` の下に `akutagawa` フォルダを掘って、そこに `unzip` された目的のテキストファイルがあるとします。

このテキストを、例えば `Jupyter` で、以下のように開いてみます。

```python
from pathlib import Path
import codecs

PATH = Path('Data/Text/aozorabunko_work')
with codecs.open(PATH/'akutagawa/shujuno_kotoba.txt', 'r', 'shift_jis') as f:
    text = f.read()

print(text[:500])
```

最初の５００文字を表示してますが、

<pre>
侏儒の言葉
芥川龍之介

-------------------------------------------------------
【テキスト中に現れる記号について】

《》：ルビ
（例）侏儒《しゅじゅ》

｜：ルビの付く文字列の始まりを特定する記号
（例）時々｜窺《うかが》わせる

［＃］：入力者注　主に外字の説明や、傍点の位置の指定
　　　（数字は、JIS X 0213の面区点番号、または底本のページと行数）
（例）里見※［＃「弓＋椁のつくり」、第3水準1-84-22］

〔〕：アクセント分解された欧文をかこむ
（例）〔Abbe' Choisy〕 にこんなことを尋ねた。
アクセント分解についての詳細は下記URLを参照してください

-------------------------------------------------------

　　　「侏儒《しゅじゅ》の言葉」の序

「侏儒の言葉」は必《かならず》しもわたしの思想を伝えるものではな
</pre>

こういう感じで、青空文書の最初に【テキスト中に現れる記号について】という説明が書かれています。

この文書でやりたいことは、このように表記された外字が含まれる Python の文字列に対して、例えば

<pre>
里見※［＃「弓＋椁のつくり」、第3水準1-84-22］は、ああしてこうして
</pre>

という部分を

<pre>
里見弴は、ああしてこうして
</pre>

に変換したい、ということです。

## 「JIS X 0213とUnicodeの対応表」の取得

上の青空文庫での外字コメントには JIS 第３水準、第４水準のコードが指定されているので、それと Unicode の対応関係があれば、はなしはおしまいです。

で、この情報は、参考サイト「カビパン男と私： [青空文庫「外字」を utf8 に変換](http://www.kabipan.com/computer/mobi/aozora_kanji.html)」にある通り、「[Project X0213](http://x0213.org/)」により提供されている「[JIS X 0213のコード対応表](http://x0213.org/codetable/)」にあります。

これをローカルに（`Jupyter` から `wget` 叩いたりして）持ってきておきます。

```
!wget http://x0213.org/codetable/jisx0213-2004-std.txt
```

このファイルの中身は、例えば `Jupyter` から `!head -n 30 jisx0213-2004-std.txt` とかすると、以下のようなものであることが分かります。

```
## JIS X 0213:2004 vs Unicode mapping table
## 
## Date: 3 May 2009
## License:
## 	Copyright (C) 2001 earthian@tama.or.jp, All Rights Reserved.
## 	Copyright (C) 2001 I'O, All Rights Reserved.
## 	Copyright (C) 2006, 2009 Project X0213, All Rights Reserved.
## 	You can use, modify, distribute this table freely.
## Note:
## 	3-XXXX	JIS X 0213:2004 plane 1 (GL encoding)
## 	4-XXXX	JIS X 0213:2000 plane 2 (GL encoding)
## 	[1983]	JIS codepoint defined by JIS X 0208-1983
## 	[1990]	JIS codepoint defined by JIS X 0208-1990
## 	[2000]	JIS codepoint defined by JIS X 0213:2000
## 	[2004]	JIS codepoint defined by JIS X 0213:2004
## 	[Unicode3.1]	UCS codepoint defined by Unicode 3.1
## 	[Unicode3.2]	UCS codepoint defined by Unicode 3.2
## 	Fullwidth	UCS fullwidth form (U+Fxxx)
## 	Windows 	Windows (CP932) mapping
## 	Some 0213 character can't represent by one UCS character.
## 	In this table, such characters are described as 'U+xxxx+xxxx'.
## 
## JIS	Unicode	Name	Note
3-2121	U+3000	# IDEOGRAPHIC SPACE
3-2122	U+3001	# IDEOGRAPHIC COMMA
3-2123	U+3002	# IDEOGRAPHIC FULL STOP
3-2124	U+002C	# COMMA	Fullwidth: U+FF0C
3-2125	U+002E	# FULL STOP	Fullwidth: U+FF0E
3-2126	U+30FB	# KATAKANA MIDDLE DOT
3-2127	U+003A	# COLON	Fullwidth: U+FF1A
```

## Python の「JIS X 0213とUnicodeの対応」辞書

まず最初に、この表から ```gaiji_table``` という Python の辞書を構成することにします。

```python
import re

f = open ('jisx0213-2004-std.txt')

gaiji_table = {}
for l in f:
    if l[0] == '#': continue
    m = re.match(r'(\d-\w{4})\s+U\+(\w{4})', l)
    if m:
        k, v = m.groups()
        gaiji_table[k] = chr(int(v, 16))
    
f.close()
```

この辞書は、上記 JIS と書かれた１列目の文字列（例えば `'3-2127'`）をキーとして、対応する Unicode （今の場合 `chr(int('3-2127', 16))`）を返します。

### 改訂

@shiracamus さんによる改訂版です。

```python
import re

with open ('jisx0213-2004-std.txt') as f:
    ms = (re.match(r'(\d-\w{4})\s+U\+(\w{4})', l) for l in f if l[0] != '#')
    gaiji_table = {m[1]: chr(int(m[2], 16)) for m in ms if m}
```

* ファイルは with 節を使って開く（と close 忘れが防止できる）。
* ファイルオブジェクトはイテレータなので for 文を使って書ける。（行ごとに読みだしてくれる。）
* Python 的にはいわゆる[内包表記](https://docs.python.org/ja/3/tutorial/datastructures.html#list-comprehensions) ([list comprehensions](https://docs.python.org/3/tutorial/datastructures.html#list-comprehensions)) というものがあって、これを使うと普通の for 文をスッキリとまとめて書けたりする。
* 内包表記は、リスト、辞書の生成時に使えるものだが、今回のようにタプルも書けるんだな -- と思ったら、丸括弧だとイテレータを返してくれるのか。
* if 文も、三項演算子（１行で書く）タイプのものを内包表記につけると、場合分けもしてくれる。


## 青空文庫の外字コメントから JIS X 0213 コード

あと必要なのは、青空文庫に与えられたコード「`'第3水準1-84-22'`」から、上のテーブルのコード「`'3-7436'`」を導き出すことです。

ここに関しても、参考サイト「カビパン男と私： [青空文庫「外字」を utf8 に変換](http://www.kabipan.com/computer/mobi/aozora_kanji.html)」の記述
>JIS X 0213:2004 の1面 2面が、この表で 3- 4- によって表されている。JIS X 0213:2004 の区・点それぞれに10進で32(16進で20)を足して16進で表記してやれば、この表の XXXX という 4 桁の整数の上位 2 桁、下位 2 桁になる。

に書かれているように、そのまま書いてみたら、いいみたいです。

```python
def get_gaiji(s):
    # ※［＃「弓＋椁のつくり」、第3水準1-84-22］
    m = re.search(r'第(\d)水準\d-(\d{1,2})-(\d{1,2})', s)
    if m:
        a, b, c = m.groups()
        key = '%1d-%2X%2X' % (int(a), int(b)+32, int(c)+32)
        return gaiji_table[key] if key in gaiji_table else s
    else:
        # ※［＃「身＋單」、U+8EC3、56-1］
        m = re.search('U\+(\w{4})', s)
        return chr(int(m.groups()[0], 16)) if m else s
```

見て分かるように、実は青空文庫の外字表記は、上のパターン以外に `［＃「身＋單」、U+8EC3、56-1］` と Unicode を直接与えているものもありました。上のコードには、その対応も入れてあります。

この関数に、外字に関するコメントを含む文字列を投げると、対応する外字（だけ）を返してくれます。例えば

```
get_gaiji('里見※［＃「弓＋椁のつくり」、第3水準1-84-22］')
> '弴'

get_gaiji('なんたら※［＃「身＋單」、U+8EC3、56-1］かんたら')
> '軃'
```

### 改訂

@shiracamus さんによる改訂版です。

```python
def get_gaiji(s):
    # ※［＃「弓＋椁のつくり」、第3水準1-84-22］
    m = re.search(r'第(\d)水準\d-(\d{1,2})-(\d{1,2})', s)
    if m:
        key = f'{m[1]}-{int(m[2])+32:2X}{int(m[3])+32:2X}'
        return gaiji_table.get(key, s)
    # ※［＃「身＋單」、U+8EC3、56-1］
    m = re.search(r'U\+(\w{4})', s)
    if m:
        return chr(int(m[1], 16))
    # unknown format
    return s
```

* Python の正規表現の[マッチオブジェクト](https://docs.python.org/ja/3/library/re.html#match-objects) はイテレータもあるので、[] を使ってマッチ要素を取り出せる。
* Python の新しめな文字列のフォーマッティングは {} で変数を埋め込める（C# みたいな感じの奴）。（古いタイプのフォーマッティングも obsolete 扱いじゃないらしい、ということを、どっかでみたけど。）
* if 節で return してるので、複数のマッチングを順次処理する場合でも、こんな感じに同じ階層で書ける。（この方が分かりやすい。）
* Python の辞書から中身を取り出すとき、[get()](https://docs.python.org/3/library/stdtypes.html#dict.get) を使うと、 key がない場合に None か、指定したものを返してくれる。今の場合は、元の文字列 `s` なので、挙動は元のコードと同じ。


## 外字コメントの置換

青空文庫のテキスト全体に対して外字の置換を行うルーチンは、以下のようになります。

```python
def sub_gaiji(text):
    new_text = ''

    i = 0
    while 1:
        m = re.search(r'※［＃.+?］', text[i:])
        if m is None:
            new_text += text[i:]
            break
        j = i + m.start()
        k = i + m.end()
        s = text[j: k]
        g = get_gaiji(s)
        try:
            new_text += text[i:j] + g
        except:
            print('ERROR:', g)
            raise
        i += m.end()
    return new_text
```

これを使うと、例えば

```
sub_gaiji('里見※［＃「弓＋椁のつくり」、第3水準1-84-22］だよだよなんたら※［＃「身＋單」、U+8EC3、56-1］かんたら')
> '里見弴だよだよなんたら軃かんたら'
```

となります。よかったよかった。

### 改訂

@shiracamus さんによる改訂版です。

```python
def sub_gaiji(text):
    return re.sub(r'※［＃.+?］', lambda m: get_gaiji(m[0]), text)
```

* Python の正規表現の [re.sub()](https://docs.python.org/ja/3/library/re.html#re.sub) で、文字列全体に全部の置換を行ってくれるのですね。
* 正規表現とか時々しか使わないので、都度、ググってリファレンス読んでと初心者的にやってますが、「string 中に出現する一番左の重複しない pattern を置換 repl で置換することで得られる文字列を返します。」とか見て、あぁとなったんだな、確か今回は。続きの方に「オプション引数 count は出現したパターンを置換する最大の回数です。 count は非負整数です。省略されるか 0 なら、出現した全てが置換されます。」とかって書いてあった。
* あと、置換するときにマッチしたものを渡さないといけないの、どうするんだろうと思ったが、 lambda 使うといいんですね。
* リファレンスに「repl は文字列または関数です。」「repl が関数であれば、それは重複しない pattern が出現するたびに呼び出されます。この関数は一つの マッチオブジェクト 引数を取り、置換文字列を返します。」と書いてあった。


# 参考サイト

## 外字の変換

* INAP流： [青空文庫の第三第四標準文字を変換する (2015年04月30日)](https://www.internap.co.jp/internapsway/?p=72)
* カビパン男と私： [青空文庫「外字」を utf8 に変換](http://www.kabipan.com/computer/mobi/aozora_kanji.html)
* 青空文庫 FAQ: [Ｑ：作品ファイル内で注記や画像で表されている文字を、コード化したいのですが……](https://www.aozora.gr.jp/guide/aozora_bunko_faq.html#midashi1130) H.Nakanoさんが、青空文庫のテキストファイルを JIS 第三・第四水準に対応した UTF-8 テキストに変換する「青空文庫データ変換ツール」を用意してくださっています。（リンク切れ）

## 青空文庫

* [青空文庫](https://www.aozora.gr.jp/)
* https://github.com/aozorabunko/aozorabunko

## 青空文庫の GitHub からデータの取得

* SOFTANTENNA BLOG: [【Tips】GitHubで公開されている「青空文庫」の内容をまるごとローカルに保存する方法 (2018.08.06)](https://www.softantenna.com/wp/tips/howto-git-clone-aozora/)
* Qiita: [青空文庫の書籍をすべてローカルに保存 (@nekkoneko Edited at 2017-12-05)](https://qiita.com/nekkoneko/items/21202414798f8339ded6)

## JIS X 0213 の Unicode 対応表

* [Project X0213](http://x0213.org/) 
* [JIS X 0213のコード対応表](http://x0213.org/codetable/)