Wordでのルビ振りを一括でできるようにした話2

# 概要
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/587871/5f6be9b6-6b0d-19b2-b8ed-1bdc0fc5c5f1.png)
Wordで教材を作るために、大量のテキストにルビを振りたい（ふりがなを設定したい）。しかし、Wordのルビ機能は貧弱なためほぼ手作業になり時間がかかる。そこで、Yahoo!のルビ振りAPIを使い、精度の高い高速なルビ振りの実現を目指した。


# ざっくり言うと
:::note info
* ChatGPTを利用したふりがな設定は自然だが、問題点があった
* Yahoo!のルビ振りAPIを使うことで、手作業を大きく減らすことができた
* Wordのルビ設定に近い使用感のプログラムができた
:::

# 前回までのあらすじ

https://qiita.com/enoz_jp/items/0a746cd1c0c021599a1d

Wordのルビ振り機能には次のような問題点があった。

:::note alert
**Wordのルビ振り機能の問題点**
* カタカナ語にルビが振られる
* 送り仮名にもルビが振られる
* 正確とは限らない
* 一度に100文字程度しか処理されない ←ゆるされない
<img width="320" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/587871/83ea6404-151c-4d00-e5b0-920795c8156c.png">
:::

ChatGPTを活用してそこそこの成果を得たが、課題が残った。

:::note warn
**ChatGPTを使った方法の課題**
* まれに本文が改変されることがある
* カタカナにルビが振られることがある
* 生成AIを利用している（私の所属では使用が面倒）
:::

特に本文が改変されるのは、私にとって大きな欠点だった。そんな中、私はYahoo!デベロッパーネットワークの提供するWeb API「ルビ振り（V2）」を発見した。このAPIを活用するため、我々調査隊はアマゾンの奥地へと向かった――。


# やったこと

## ルビ振りAPIを使用する準備

### Yahoo! JAPAN テキスト解析API ってなに？
Yahoo! JAPAN テキスト解析APIは、LINEヤフーが提供するWebサービスの一つで、次のような種類がある。

* 日本語形態素解析
* かな漢字変換
* ルビ振り
* 校正支援
* 日本語係り受け解析
* キーフレーズ抽出
* 自然言語理解
* 固有表現抽出

「Yahoo! JAPAN ID」と「アプリケーションID」を取得する必要があるが、無料で使うことができる。すごい！

https://developer.yahoo.co.jp/webapi/jlp/


### Yahoo! JAPAN IDの取得

テキスト解析APIを使うには、Yahoo! JAPAN IDが必要である。私はすでに持っていたのでそれを使った。

https://account.edit.yahoo.co.jp/registration


### アプリケーションIDの取得

テキスト解析APIを使うには、アプリケーション登録を行い、アプリケーションIDを取得する必要がある。私は次のように入力して登録した。

| 項目                     | 必須 | 内容               |
|:-------------------------|:----:|:-------------------|
| ID連携利用有無           | *    | ID連携を利用しない |
| 利用者情報               | *    | 個人               |
| 個人情報提供先 ...       | *    | 同意しない         |
| 契約者住所の国または地域 |      | 日本               |
| アプリケーション名       | *    | ルビルビ星人の侵略 |
| 利用規約、...            | *    | 同意する           |

登録するとClient ID（アプリケーションID）が発行されるので、これを控えておく。

https://e.developer.yahoo.co.jp/register


## VBA-JSONの導入

### APIとのやり取りはJSON形式

今回使うAPIは、ルビを振ってほしい文章を渡すと、ルビ情報を返してくれる。やり取りは行きも帰りもJSON形式で行われる。JSONとは、次のような { } で構造をあらわす書き方である。

```json:JSON形式の例
{
  "id": "1234-1",
  "jsonrpc": "2.0",
  "method": "jlp.furiganaservice.furigana",
  "params": {
    "q": "漢字かな交じり文にふりがなを振ること。",
    "grade": 1
  }
}
```

この、戻ってきたJSON形式のルビ情報を単純な文字列の検索で解釈するのはとても大変なので、JSON形式のデータを解析して、構造にアクセスしやすくするライブラリ「VBA-JSON」を導入する。

VBA-JSONではなく、ScriptControllを使おうとしてやめた話は後述。


### VBA-JSONの導入手順

#### 1. VBA-JSONをダウンロードする

https://github.com/VBA-tools/VBA-JSON

上記のGitからファイルをダウンロードした。

#### 2. モジュールをエクスポートする
解凍したファイルのspecsフォルダ内にある「VBA-JSON – Specs.xlsm」というファイルを開き、VBEを開いて次の2ファイルをエクスポートした。
* JsonConverter
* Dictionary

#### 3. モジュールをインポートする
エクスポートした2ファイルを、自分の環境でインポートした。私は、Wordでどのファイルを開いたときにも今回のプログラムを使いたかったので、Normalにインポートした。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/587871/809738e0-0568-b48a-8f53-0b2f349e862b.png)

これでVBA-JSONの導入は完了。

#### 参考サイト
今回のVBA-JSONの導入手順は、こちらの記事を参考にした。

https://vbaexcel.slavesystems.com/vba/?p=646


## APIでルビを取得して設定する
APIにリクエストを投げてルビ情報を取得するコードを、Word VBAで書いた。

### コード

```vb:RubyRuby.bas
Const APP_ID As String = "YOUR_API_KEY_HERE"

Const API_GRADE As Integer = 1 ' gradeパラメータの値

Sub ApplyRubyToSelection()
    Dim selectedText As String
    Dim jsonResponse As String
    Dim rubyData As Collection

    ' 選択されていないときは終了
    If Selection.Range.text = vbNullString Then
        MsgBox "テキストが選択されていません。", vbExclamation
        Exit Sub
    End If
    
    ' 選択されたテキストを取得
    selectedText = Selection.Range.text
    
    ' テキストのバイトサイズをチェック
    ' If LenB(EncodeUTF8(selectedText)) > 4000 - 400 Then ' 4KBに少し余裕を持たせる
    '     MsgBox "選択されたテキストのサイズが大きすぎます。" & vbCr & vbCr & _
    '            "選択サイズ: " & LenB(EncodeUTF8(selectedText)) & " B" & vbCr & _
    '            "上限サイズ: 3600 B", vbExclamation
    '     Exit Sub
    ' End If
    
    ' APIを呼び出してレスポンスを取得
    jsonResponse = GetRubyFromAPI(selectedText)
    
    ' レスポンスからルビ情報を解析
    Set rubyData = ParseAPIResponse(jsonResponse)
    
    ' ルビを適用
    ApplyRuby Selection.Range, rubyData
End Sub


Private Function GetRubyFromAPI(text As String) As String
    Dim httpRequest As Object
    Set httpRequest = CreateObject("MSXML2.XMLHTTP")
    Dim apiUrl As String
    apiUrl = "https://jlp.yahooapis.jp/FuriganaService/V2/furigana"
    
    ' APIリクエストのボディを構築
    Dim requestBody As String
    requestBody = "{""id"":""1234-1"",""jsonrpc"":""2.0"",""method"":""jlp.furiganaservice.furigana""," & _
                  """params"":{""q"":""" & text & """,""grade"":" & API_GRADE & "}}"

    ' HTTPリクエストを送信
    With httpRequest
        .Open "POST", apiUrl, False
        .SetRequestHeader "Content-Type", "application/json"
        .SetRequestHeader "User-Agent", "Yahoo AppID: " & APP_ID
        .Send requestBody
        GetRubyFromAPI = .ResponseText
    End With
    
End Function


' JSONデータを解析し、ふりがな情報を抽出する関数
Private Function ParseAPIResponse(jsonResponse As String) As Collection
    Dim rubyData As New Collection
    Dim json As Object
    Dim word As Object
    Dim subword As Object
    Dim surfaceText As String
    Dim furiganaText As String
    Dim isKanjiFlag As Boolean

    ' JSON文字列を解析してオブジェクトに変換
    Set json = JsonConverter.ParseJson(jsonResponse)

    ' "word"プロパティから単語のリストを取得
    For Each word In json("result")("word")
        surfaceText = word("surface")

        ' "furigana"プロパティが存在するかチェック
        If word.Exists("furigana") Then
            furiganaText = word("furigana")
            
            ' 漢字かどうかを判断
            isKanjiFlag = IsKanji(surfaceText)

            ' "subword"プロパティが存在する場合
            If word.Exists("subword") Then
                For Each subword In word("subword")
                    surfaceText = subword("surface")
                    If IsKanji(surfaceText) Then
                        furiganaText = subword("furigana")
                        rubyData.Add Array(surfaceText, furiganaText)
                    End If
                Next subword
            Else
                ' ルビ情報をコレクションに追加
                rubyData.Add Array(surfaceText, furiganaText)
            End If
        End If
    Next word

    ' 解析結果を返す
    Set ParseAPIResponse = rubyData
End Function


' 文字が漢字かどうかを判断する関数
Private Function IsKanji(text As String) As Boolean
    Dim charCode As Long
    charCode = AscW(text) ' 文字列の最初の文字の Unicode コードポイントを取得
    If charCode < 0 Then
        charCode = charCode + 65536
    End If
    IsKanji = (charCode >= 19968 And charCode <= 40959) ' Unicodeの範囲で漢字かどうかを判定(&H4E00-&H9FFF)
End Function


Private Sub ApplyRuby(rng As Range, rubyData As Collection)
    Dim Item As Variant
    
    Application.DisplayStatusBar = True
    
    ' 上向きに検索しながらルビを設定
    ' （ルビ設定で挿入されたフィールドが新たに検索対象になり検索位置がずれてしまうため、上向きにする必要がある）
    cnt = 0
    n = rubyData.Count
    For i = n To 1 Step -1
        prog = Int(cnt / n * 100 / 10)
        Application.StatusBar = "Processing...: " & String(prog, "■") & String(10 - prog, "□") & " (" & cnt & "/" & n & ")"
        Item = rubyData(i)
        With rng.Find
            .Execute FindText:=Item(0), Forward:=False, Wrap:=wdFindContinue
            If .Found Then
                rng.PhoneticGuide text:=Item(1)  ', Alignment:=wdPhoneticGuideAlignmentCenter, Raise:=10, FontSize:=5 ' ルビ部分
            End If
        End With
        cnt = cnt + 1
    Next i
    
    Application.StatusBar = False
End Sub


'Private Function EncodeUTF8(text As String) As String
'    Dim i As Long
'    For i = 1 To Len(text)
'        EncodeUTF8 = EncodeUTF8 & "%" & Hex(AscW(Mid(text, i, 1)))
'    Next i
'End Function


' Web Services by Yahoo! JAPAN （https://developer.yahoo.co.jp/sitemap/）
```


### 解説

#### 定数部分

```vb
Const APP_ID As String = "YOUR_API_KEY_HERE"

Const API_GRADE As Integer = 1 ' gradeパラメータの値
```
APP_IDには、事前に取得したアプリケーションIDを設定する。

API_GRADEは定数として書き出したが、後述する「余談」の理由により、今回は基本1に固定して使うことにした。

#### プロシージャ部分
大まかな構成は次の通り。
* ApplyRubyToSelection
* GetRubyFromAPI
* ParseAPIResponse
* IsKanji
* ApplyRuby
* EncodeUTF8

**ApplyRubyToSelection**
ApplyRubyToSelectionがメインのプロシージャである。今回は選択したテキストに対して処理を行うようにしたので、はじめに選択のチェックを行う。APIの仕様で「1リクエストの最大サイズを 4KB に制限」されているので、サイズをチェックしてからリクエストすべきだと思っていたが、1万字くらいで試しても大丈夫だったので、やっぱり気にしないことにした。

**GetRubyFromAPI**
実際にAPIを使ってリクエストしている関数です。GETじゃなくてPOST。

**ParseAPIResponse**
GetRubyFromAPIで返ってきたJSONデータを、VBA-JSONを使ってパースし、必要なルビ情報を取り出す。ルビ情報にはカタカナ部分や送り仮名部分も含まれるが、今回は漢字部分だけにルビを振りたいので、漢字部分の情報だけを取り出して返す。

**ApplyRuby**
ApplyRubyでルビ設定するときには、Findメソッドで対象の漢字を検索しながら進めていく。初めは下向きで検索していたが、同じ漢字が2連続で出てくるとPhoneticGuideで失敗してしまった。ルビの設定は内部ではフィールドとして扱われるが、Findメソッドで検索される対象にはこのフィールドも含まれてしまう。検索途中でフィールドが挿入されることで対象テキストの文字列が増え、保持していた検索位置がフィールドによってずれてしまい、2つ目の同じ漢字の検索でフィールド内の文字が検出されてしまうことで失敗していることが分かった。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/587871/b0fcf7aa-4b55-46f6-3db1-f84edd404bbf.png)
↓
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/587871/03a6f377-def0-2f9c-1a09-41d128eee196.png)

そこで、挿入したフィールドの分検索位置をずらすのも大変なので、面倒なので上向きの検索にして末尾から処理することにした。

また、ルビの数が増えたときに一番時間がかかる処理なので、画面下部のステータスバーに進捗を表示するようにした。


#### APIの仕様

https://developer.yahoo.co.jp/webapi/jlp/furigana/v2/furigana.html


#### クレジット表示
個人用ではあるが、念のためクレジット表示もしておく。

https://developer.yahoo.co.jp/attribution/

#### エラー処理
してない。リクエストによってはエラーが返ることもあると思うが、自分用のプログラムなので丁寧なエラー処理は省略した。


## リボンに登録
実行しやすくするために、組んだマクロをボタンとして登録して実行しやすくした。Wordのもともとのルビ機能と、前回組んだ青空文庫形式のルビを変換するマクロも一緒にまとめた。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/587871/efd7f78d-6fd5-e118-4bad-eefe7192685f.png)


## 実行

### 実験1
```text:テキスト1
あのイーハトーヴォのすきとおった風、夏でも底に冷たさをもつ青いそら、うつくしい森で飾られたモリーオ市、郊外のぎらぎらひかる草の波。
```

**結果1**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/587871/53032d04-67ff-2136-2385-2c5a19d79551.png)

カタカナはちゃんとスルーして、漢字のみに正しいルビを設定することができた。うつくしい。

### 実験2

```text:使用テキスト2
今日は一月一日の日曜日で日本では祝日、日向市は五日ぶりの小春日和です。
```

**結果2**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/587871/19b5cfe8-2152-960b-3ef0-acb523c99eaf.png)

「ついたち」、「いつか」はうまく設定できなかった。あれ、よく見たら漢数字はスルーされている？ 見なかったことにしよう。ルビの正確な解釈はかなり難しく、文脈によって変わる読み方や固有名詞はどうせ自分で設定せざるを得ない。また、どうせ自分で読んで点検するので、細かいことは気にしない。


# 余談

## 1. ScriptControlにハマった話

ChatGPTに今回のコードを書いてもらったときに、外部ライブラリの「VBA-JSON」を使うコードを提示された。できればそういうのなしで解決したかったが、VBA-JSONを使わない方法としてよくヒットするScriptControlを使う方法がうまくいかなかったため、最終的にVBA-JSONを使うことにした。

「VBA JSON」でググると、ScriptControlを使ってパースする方法がたくさんヒットする。しかし、どれを試しても「クラスが登録されていません」というエラーが出る。参照設定をしたり、レジストリをいじったりといろいろ試すが、どれもうまくいかない。調べていくと、64bit環境では動かないとのことだった。

私と同じハマり方をした方の記事があったので、大変助かった。大いに参考にさせていただいた。

https://vbaexcel.slavesystems.com/vba/?p=646



## 2. 学年別漢字配当表に基づくルビ振り
実はこのAPIにはもう一つすごい機能があって、学習指導要領の学年別漢字配当表に基づくルビ振りができる。リクエストのparams/gradeに定数を指定することで、例えば「小学校の1～3年生で習う漢字にはふりがなをつけない」といったことができる。

> 学年（注1）を指定します。
> 1: 小学1年生向け。漢字（注2）にふりがなを付けます。
> 2: 小学2年生向け。1年生で習う漢字にはふりがなを付けません。
> 3: 小学3年生向け。1～2年生で習う漢字にはふりがを付けません。
> 4: 小学4年生向け。1～3年生で習う漢字にはふりがなを付けません。
> 5: 小学5年生向け。1～4年生で習う漢字にはふりがなを付けません。
> 6: 小学6年生向け。1～5年生で習う漢字にはふりがなを付けません。
> 7: 中学生以上向け。小学校で習う漢字にはふりがなを付けません。
> 8: 一般向け。常用漢字にはふりがなを付けません。
> 無指定の場合、ひらがなを含むテキストにふりがなを付けます。
> 
> 注1：学年は「小学校学習指導要領」の付録「学年別漢字配当表」（1989年3月15日文部科学省告示。1992年4月施行）を参考に設定されています。
> 注2：JIS X 0208が定める漢字

ただし、注記を見るとわかるが、これは1989年の配当表に基づいている。小学校で習う学習漢字は2010年の常用漢字の改定に伴って追加されているので、2024年現在の最新の配当表とは違うことになる。したがって、この機能を丁寧に使用するような組み方をしても信頼に欠けると考えた。

また、私の用途ではあまりこういう使い方はしないので、今回は1に固定し、すべての漢字にふりがなをつける使い方にした。


# 最後に
:::note info
**成果**
* そこそこ実用的なレベルで、ルビの設定を一括で自動処理することができた
:::

AIを使ってさらに精度の高い自然なふりがなを得るとか、プログラムをアドイン化するとかまだできることはありそうだが、私の場合の用途と使用頻度では労力が見合わないので、この辺にしておこうと思う。もし精度の高い自然なルビ振りができるツールがあったら、ぜひコメントください。


# 参考サイト
記事中で紹介しなかったが参考にしたサイト。

https://news.mynavi.jp/techplus/article/excelvbaweb-6/

https://news.mynavi.jp/techplus/article/excelvbaweb-7/