カクヨム記法のルビをWordのルビにするツールをリリースしたので色々書く

# 紹介

https://github.com/msattova/conv-ruby-on-docx

Wordでルビ振りするのが面倒くさいという人向けに、こういうのを作っています。

一言で言えば、docxファイル中にカクヨム記法で示されたルビを、Wordのルビに変換するやつです。

（傍点を振る記法にも対応する予定ですが、現状ではルビ振りにのみ対応しています。）

（「カクヨム記法ってなに？」という方は[こちら](https://kakuyomu.jp/help/entry/notation)を見てください）

このツールを使うと、下の画像のbeforeのところをafterのようにすることが可能です。

![sample.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2521499/1bbd6d68-e490-c441-96ee-982226dd8d9c.png)
（文章は拙作[『誰が為の歴史改変』](https://note.com/msattova/n/n5981d867920d?magazine_key=mb9a82abba305)より）



[こちら](https://github.com/msattova/conv-ruby-on-docx/releases)からexe化した実行ファイルをダウンロードすることができます。以下のスクリーンショットみたいなウインドウが出てきます。

![スクリーンショット 2022-03-01 035857.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2521499/c89cf519-e6d7-e1ce-de5f-8e3b33aa1bda.png)

（Mac版、Linux版の実行ファイルは作成していません。製作者がWindowsにしか制作環境用意してないので…………）（また、同様の理由でMacやLinuxでの動作について動作テストが行えていません）

# どういう仕組み？

まず、前提知識として知っておいてほしいことが、**docxファイルの実態はzipファイル**ということです。

拡張子を`.zip`に変更して解凍すると、（環境等によって違いがあるかもしれませんが）以下のようなファイルおよびディレクトリが出てくるかと思います。

```
├── [Content_Types].xml
├── _rels
├── customXml
│   ├── _rels
│   │   └── item1.xml.rels
│   ├── item1.xml
│   └── itemProps1.xml
├── docProps
│   ├── app.xml
│   └── core.xml
└── word
    ├── _rels
    │   └── document.xml.rels
    ├── document.xml
    ├── endnotes.xml
    ├── fontTable.xml
    ├── footer1.xml
    ├── footnotes.xml
    ├── settings.xml
    ├── styles.xml
    ├── theme
    │   └── theme1.xml
    └── webSettings.xml
```

今回重要なのは`word/document.xml`です。

この`document.xml`にはdocx文書のテキストについての情報——段落や改行などの情報——が含まれています。

なので、`document.xml`を読み込んで「漢字《かんじ》」を「<ruby>漢字<rt>かんじ</rt></ruby>」と表示されるようにいい感じに置換してやれば目的は達成できるということになります。

処理の順序としてはだいたい次のようになっています。

1. 入力として受け取ったdocxファイルを展開する
1. 展開されたフォルダの中にあるdocument.xmlを開いて中身を文字列として取得
1. 取得した文字列からカクヨム記法で示されたルビを見つけてwordのルビ形式に置換
1. 置換処理をほどこした文字列でdocument.xmlを上書き
1. 展開されたファイル・フォルダをzipアーカイブして、新しいdocxファイルを作る

## 「Wordのルビ」ってなに？

> 一言で言えば、docxファイル中にカクヨム記法で示されたルビを、Wordのルビに変換するやつです。

と、この記事の最初の方に書きました。ここでは「Wordのルビ」について、少し詳しく説明します。

まず、document.xmlについて説明したいところなのですが………………ぶっちゃけdocument.xmlについては下の記事にわかりやすくまとまっているので、こちらを読んで下さい。

https://qiita.com/sky_y/items/2ab80adfb7a998e47239

https://jp.community.intersystems.com/post/xslt-%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%9F-docx-%E3%81%AE%E8%A7%A3%E6%9E%90

さて、document.xmlの中身についてざっくり把握しているという前提で進めます。

1段落分のxmlコードを以下に示します。

```xml
<w:p w14:paraId="4763609E" w14:textId="77777777" w:rsidP="005306E8" w:rsidR="005306E8" w:rsidRDefault="005306E8">
    <w:pPr>
        <w:rPr>
            <w:rFonts w:hint="eastAsia"/>
        </w:rPr>
    </w:pPr>
    <w:r>
        <w:rPr>
            <w:rFonts w:hint="eastAsia"/>
        </w:rPr>
        <w:t>
            これは
        </w:t>
    </w:r>
    <w:r>
        <w:ruby>
            <w:rubyPr>
                <w:rubyAlign w:val="distributeSpace"/>
                <w:hps w:val="10"/>
                <w:hpsRaise w:val="18"/>
                <w:hpsBaseText w:val="21"/>
                <w:lid w:val="ja-JP"/>
            </w:rubyPr>
            <w:rt>
                <w:r w:rsidR="00E95970" w:rsidRPr="00E95970">
                    <w:rPr>
                        <w:rFonts w:ascii="ＭＳ 明朝" w:eastAsia="ＭＳ 明朝" w:hAnsi="ＭＳ 明朝" w:hint="eastAsia"/>
                        <w:sz w:val="10"/>
                    </w:rPr>
                    <w:t>
                        まんねんひつ
                    </w:t>
                </w:r>
            </w:rt>
            <w:rubyBase>
                <w:r w:rsidR="00E95970">
                    <w:rPr>
                        <w:rFonts w:hint="eastAsia"/>
                    </w:rPr>
                    <w:t>
                        万年筆
                    </w:t>
                </w:r>
            </w:rubyBase>
        </w:ruby>
    </w:r>
    <w:r>
        <w:rPr>
            <w:rFonts w:hint="eastAsia"/>
        </w:rPr>
        <w:t>
            です。
        </w:t>
    </w:r>
</w:p>
```

このコードの、`<w:r><w:ruby>`～`</w:r></w:ruby>`で囲まれた箇所がルビ関連の部分です。

`<w:ruby>`の中身は（ざっくり言えば）ルビの文字列を表す`<w:rt>`タグの部分とルビの対象文字列（親文字）を表す`<w:rubyBase>`タグの部分とに分けられます。

つまり、私が作ったプログラムは、たとえば`万年筆《まんねんひつ》`という文字列を見付けたら`<w:ruby>`中の`<w:rt>`タグ内に`まんねんひつ`を、`<w:rubyBase>`タグ内に`万年筆`を入れて、作った`<w:r><w:ruby>`～`</w:r></w:ruby>`で`万年筆《まんねんひつ》`を置換している、というわけです。

## 置き換え処理について

実際の置き換え処理を行っている部分のコードは次のようになっています。

引数としてとっている`rubysets`は`([<w:r>タグの開始位置, <w:r>タグの終了位置, RubyType型の値], ...)`といった中身のイテラブルオブジェクトです。

`repl_strs`は置き換える文字列（あらかじめ作っておいた`<w:r><w:ruby>`～`</w:r></w:ruby>`）が出てくるイテレータです。

`each_lines`はdocument.xmlを一行ずつにした文字列リストです。

RubyType型はルビ振りの対象であるかどうか、パイプ（`|`←これのこと）を伴って示されたルビかどうかを表します。

```python:txt2docx.py
class RubyType(Enum):
    NOTHING = auto()  # ルビも傍点も振らない
    HASPIPE = auto()  # パイプ有りルビ
    NONPIPE = auto()  # パイプなしルビ
    BOUTEN = auto()   # 傍点

# （中略）

def repl_ruby(rubysets: Iterable,
                repl_strs: Iterator,
                each_lines: list[str]) -> list[str]:
    """ルビ振り置換"""
    replaced_lines = each_lines
    for rs in rubysets:
        if rs[0] is RubyType.NOTHING:
            continue
        else:
            tmp = list()
            start = rs[1]
            t = next(repl_strs)
            tmp.append(t)  # 要素数1のリストを作成するための措置
            if rs[0] is RubyType.HASPIPE:
                # `|`（パイプ）の次の文字列がルビ振り対象文字列なのでnext()を使う
                _, _, end = next(rubysets)
            elif rs[0] is RubyType.NONPIPE:
                end = rs[2]
            replaced_lines[start:(end+1)] = (['']*(end-start)+tmp)
    return replaced_lines
```



なぜ`RubyType.HASPIPE`の場合はnext(rubysets)しているのかというと、`|`の部分だけ他の文字列とは別個で`<w:r>`タグで囲われるためです。なので、ルビ振りの対象文字列の`<w:r>`開始、終了タグの位置を得るためにnext(rubysets)しています。

（こういう処理を行なっているため、カクヨムのように`|《`で`《`をそのまま表示することは現状（v0.1時点）できません。カクヨムにアップロードした小説をWordにコピペして利用できるようにするのを最終目標としているため、今後改善する予定です）

また、あらかじめ調べておいたeach_linesの位置を参照しているため、置き換える際は

```python
replaced_lines[start:(end+1)] = (['']*(end-start)+tmp)
```

としています。ここで`+tmp`とするためにやや苦しいですが、`tmp.append(t)`で要素数1のリストを作成しています。

色々とスマートじゃないのでこの辺の処理も改善したいですね。

## なぜ「あらかじめ`<w:r>`タグの開始、終了位置を調べる」などといったまどろっこしい真似をしているのか

このプログラムが、「元からあるdocument.xmlの一部を置換する」という処理を行っているためです。元からあるdocument.xmlに対して、必要な箇所以外は影響の出ないようにした結果、こうなりました。（例によってもっとスマートなやり方があるのかもしれませんが、今のところ思いついていません）

pandocのように、テンプレートとなるdocxファイルとdocxファイルの中身にしたい文字列が入ったtxtファイルを受け取って新しいdocxファイルを生成する……というやり方なら既存のdocument.xmlを気にしなくていい分、こういうことはしなくて済むのではないか、と考えています。

（将来的に、このpandocみたいな利用法もできるようにする予定です）

# まとめ

GUIからも動かせるようにしてみたのでソフトの宣伝がてら記事を書いてみたんですが、自分の未熟さばかりが目についてしまいました……。

今後も継続的に機能の追加、ソースコードの改善を行っていく予定なのでよろしくお願いします。


# 参考

https://qiita.com/sky_y/items/2ab80adfb7a998e47239

https://jp.community.intersystems.com/post/xslt-%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%9F-docx-%E3%81%AE%E8%A7%A3%E6%9E%90

https://qiita.com/macinjoke/items/13aa9ba64cf9b688e74a

https://note.nkmk.me/python-re-regex-character-type/

https://note.nkmk.me/file/

https://qiita.com/phyblas/items/d56003904c83938823f2

(実際に使用したのはPySide2ですが、途中までPyQt6で実装を進めていたため)