[Python] 結合文字を使用した濁点や半濁点を直前の仮名と結合させる方法(ウ゛→ ヴ)

# まえがき

人名検索が行えるデータベースを作る際、提供されたデータにこんな読み仮名が混在していた。

- ヤマグチ
- ヤマク゛チ

**ヤマグチ**と検索しても**ヤマク゛チ**さんがヒットしないので、「**ク゛**」を「**グ**」に寄せる形で**正規化**することにした。
適当なライブラリが見当たらなかったので、自前で実装することにした奮闘記。


# 実装方法

1. <del>文字列内の合成文字を基底文字と結合文字へ分解</del>
2. 1で分解した文字列をバイト列に変換
3. 2で変換したバイト列の濁点、半濁点をUnicode結合文字の濁点、半濁点に置換
4. 3で置換されたバイト列を文字列型へ戻す
5. (必要に応じて)合成文字に変換


## Unicodeの合成文字と結合文字

日本語の「**は**」「**ば**」「**ぱ**」のように符号の有無・種類で音を表現する文字に対し、Unicodeでは**合成文字**と**結合文字**を使用する二通りの表現方法がある。

「ば」の例では以下の二種類がある。

- `ば` `U+3070`
- `は` + 濁点 `U+306F` + `U+3099`


`ば`は**合成文字**、`は`は**基底文字**、濁点が**結合文字**である。


濁点`゛`や半濁点`゜`を**結合文字**へ置換してやればUnicode上、結合された1文字となる


Wikipediaにわかりやすい例があるので引用する。

> 例： â は U+00E2 (latin small letter a with circumflex) でも、U+0061 U+0302 (latin small letter a + combining circumflex accent) でも表すことができる。
[結合文字 - Wikipedia](https://ja.wikipedia.org/wiki/%E7%B5%90%E5%90%88%E6%96%87%E5%AD%97)


## UTF-8の合成文字と結合文字

Unicode上での合成文字と結合文字の動作がわかったところで、Unicodeに対応した文字符号化方式で一番メジャーであろう**UTF-8**で合成文字と結合文字の動きを見る。

### 合成文字

```python
import unicodedata

print(unicodedata.normalize("NFC", "ヤ マ グ チ").encode())
> b'\xe3\x83\xa4 \xe3\x83\x9e \xe3\x82\xb0 \xe3\x83\x81'
```

"ヤマグチ"の**グ**が、`\xe3\x82\xb0`で**符号化**されている


### 結合文字

```python
import unicodedata

print(unicodedata.normalize("NFD", "ヤ マ グ チ").encode())
> b'\xe3\x83\xa4 \xe3\x83\x9e \xe3\x82\xaf\xe3\x82\x99 \xe3\x83\x81'
```
"ヤマグチ"の**グ**が、`\xe3\x82\xaf`と`\xe3\x82\x99`の組み合わせで**符号化**されている


## 濁点・半濁点の文字符号

UnicodeとUTF-8でそれぞれ対応する符号を知る必要があるが、Wikipediaにあった。

[濁点 - Wikipedia](https://ja.wikipedia.org/wiki/%E6%BF%81%E7%82%B9)
[半濁点 - Wikipedia](https://ja.wikipedia.org/wiki/%E5%8D%8A%E6%BF%81%E7%82%B9)


| 記号 | Unicode | UTF-8符号 | 備考 |
|:--|:--|:--|:--|
| ゛ | U+309B | \xe3\x82\x9b | 全角濁点 |
| - | U+3099 | \xe3\x82\x99 | 濁点(結合文字) |
| ﾞ | U+FF9E | \xef\xbe\x9e | 半角濁点 |
| ゜ | U+309C | \xe3\x82\x9c | 全角半濁点 |
| - | U+309A | \xe3\x82\x9a | 半濁点(結合文字) |
| ﾟ | U+FF9F | \xef\xbe\x9f | 半角半濁点 |

**全角濁点**と**半角濁点**を**濁点(結合文字)**へ置換、
**全角半濁点**と**半角半濁点**を**半濁点(結合文字)**へ置換する

# コード

```python
#!/usr/bin/env python3

import re
import unicodedata

def join_diacritic(text, mode="NFC"):
    """
    基底文字と濁点・半濁点を結合
    """
    # str -> bytes
    bytes_text = text.encode()

    # 濁点Unicode結合文字置換
    bytes_text = re.sub(b"\xe3\x82\x9b", b'\xe3\x82\x99', bytes_text)
    bytes_text = re.sub(b"\xef\xbe\x9e", b'\xe3\x82\x99', bytes_text)

    # 半濁点Unicode結合文字置換
    bytes_text = re.sub(b"\xe3\x82\x9c", b'\xe3\x82\x9a', bytes_text)
    bytes_text = re.sub(b"\xef\xbe\x9f", b'\xe3\x82\x9a', bytes_text)

    # bytet -> str
    text = bytes_text.decode()

    # 正規化
    text = unicodedata.normalize(mode, text)

    return text

```

```python:実行
join_diacritic("ルイズ・フランソワーズ・ル・ブラン・ド・ラ・ウ゛ァリエール")
> ルイズ・フランソワーズ・ル・ブラン・ド・ラ・ヴァリエール
```


# 応用

ヴァリエールちゃんを**ウ゛ァリエール**ちゃんにしたい。

```python
    # 一度結合文字へ変換して
    text = unicodedata.normalize("NFD", text)

    # 結合文字を全角濁点・半濁点へ変換すればできる
    bytes_text = re.sub(b'\xe3\x82\x99', b"\xe3\x82\x9b", bytes_text)
    bytes_text = re.sub(b"\xe3\x82\x9a", b'\xe3\x82\x9c', bytes_text)
```

UTF-8以外の文字符号化方式に対応する

```python
    # 例えばこの様に実装にすれば、UTF-8以外にも対応できる
    bytes_text = re.sub("\u309B".encode(charset), "\u3099".encode(charset), bytes_text)
    bytes_text = re.sub("\uFF9E".encode(charset), "\u3099".encode(charset), bytes_text)
```

# おわりに

**Unicode結合文字**の仕組み使うので、**Shift_JIS**とかのお友達は一度Unicodeに準じた文字符号化方式にエンコードすることで使えるようになる(たぶん):tired_face: