markdownをtextileに変換するスクリプトを組んだ話

# textileという記法

みなさんはtextileという記法はご存知でしょうか。redmineで使うことのできる文書形式です。
https://redmine.jp/tech_note/textile/
markdownのように見出しや箇条書きを記述できるのですが、markdownとは記法が違うため互換性はありません。

# 経緯: markdownからtextileに変換したい！

自分は作業記録の文書はObsidianを使ってmarkdownで記述しています。
Obsidianでは箇条書きや括弧の補完が効いたり、表示される見た目通りにmarkdown文書を編集できて便利です。
(この辺は文字だけだと説明がしづらいので、画像や動画付きの解説を見るなり、ご自身で使って頂くなりすると良いと思います。)

markdown形式で文書を記述するときはObsidianで書いた文章をそのままコピペするだけで良いのですが、自分の場合は、時々textileで文書を書く必要が出てくることがあります。
その時、markdownから手動でtextileに変換するのは面倒ですし、初めからtextileで書こうとすると

- textileの記法を覚えなければならない
- Obsidianにログを残せない

といったデメリットが出てきます。

そこで、標題の通りmarkdown記法で書かれた文書をtextile記法の文書に変換するスクリプトを組んでみました。

# 変換スクリプトの内容

## まずはpandoc

調べてみたところ、文書の変換ツールとして pandoc というものが既にあるとのことでした。
https://pandoc-doc-ja.readthedocs.io/ja/latest/users-guide.html#using-pandoc
これはmarkdownやtextileに限らず、例えばhtmlやpdfといった形式への変換もできる便利ツールらしいです。
早速これを使ってみました。markdownをtextileに変換する場合は以下のコマンドになります。
(※pandocのインストール方法については記載しません。Macだと確か`brew install pandoc`で行けた気がしますがうろ覚えです)

```bash
$ pandoc -s test.md -o test.textile
```

これを実行したところ、あっさりとtextile形式への文書に変換することができました。見出しや箇条書きといった基本的な構造は問題なく変換できていました。
これにちょっと手を加え、「クリップボードにmarkdown文書がある時に、その文書をtextileに変換するスクリプト」を組んでみました。つまり、「変換したいmarkdown文書をコピーした状態でこのスクリプトを実行して、ペーストするとtextileに変換された文書になっている」という挙動になります。
(※ちなみにMacでの使用を想定しています)

```shell
pbpaste > test.md
pandoc -s test.md -o test.textile
cat test.textile | pbcopy
```

Macは`pbpaste`や`pbcopy`を使うことで簡単にクリップボードにアクセスできます。この機能を使ってクリップボードにある文書を一旦test.mdに書き出して、pandocで変換した文書をtest.textileに出力し、その中身をpbcopyでクリップボードにコピーするといった寸法です。このような仕組みで動いているので、このスクリプトと同じディレクトリにtest.mdとtest.textileが存在することになります。

```
.
├── md2textile.sh
├── test.md
└── test.textile
```

## pandocを少し改良

ですが、上述のスクリプトには少し問題がありました。というのも、pandocによる変換結果が微妙に間違っている場合があったのです。
主に問題だったのは以下の2つ。

1. 記号がエスケープされる
1. コードブロックが正しく変換されない

### 記号のエスケープ

textileでは記号を表現する際に、エスケープを使うみたいです。chatGPTに聞いたところ、以下のようになっているらしいです。

- ハイフン（-）: `&#45;`
- アンパサンド（&）: `&amp;`
- 小なり（<）: `&lt;`
- 大なり（>）: `&gt;`
- ダブルクオーテーション（"）: `&quot;`
- シングルクオーテーション（'）: `&apos;`
- プラス（+）: `&#43;`
- 等号（=）: `&#61;`
- アスタリスク（*）: `&#42;`
- スラッシュ（/）: `&#47;`
- バックスラッシュ（\）: `&#92;`
- コロン（:）: `&#58;`
- セミコロン（;）: `&#59;`
- クエスチョンマーク（?）: `&#63;`
- 感嘆符（!）: `&#33;`
- ドル記号（$）: `&#36;`
- パーセント記号（%）: `&#37;`
- パウンド記号（#）: `&#35;`
- アットマーク（@）: `&#64;`
- キャレット（^）: `&#94;`
- アンダースコア（_）: `&#95;`
- 波線（~）: `&#126;`
- 垂直棒（|）: `&#124;`

pandocでも上記エスケープに従って変換がなされます。エスケープのお陰で環境に依存せず同一の記号を表現できるというメリットはありそうですが、このエスケープが却って邪魔になる場合があります。

それが、**URLを表示する時** です。
URLには時折 `-` (ハイフン) が含まれることがありますが、pandocはURL中のハイフンであろうと全部 `&#45;` に変換してしまいます。これが地の文なら問題ないのですが、URL中の文字だと表示時にはエスケープが適用されませんでした(少なくとも自分の環境では)。
結果、 `https://hoge-hoge.com`のようなURLが `https://hoge&#45;hoge.com` となってしまい、無効なURLになってしまいました。これは大幅に利便性を損なう上に、毎回直すのはあまりにも手間です。
そのため、上記のスクリプトに以下のような変換ステップを噛ませることにしました。

```shell
pbpaste > test.md
pandoc -s test.md -o test.textile
python3 unescape_textile.py
cat test.textile | pbcopy
```

3行目の `python3 unescape_textile.py` が追加された処理です。この行では記号のエスケープを元に戻すための処理を行なっています。その処理をやってくれるスクリプトが以下。

```python
# unescape_textile.py

# エスケープされた記号と元の記号のマッピング
escape_mapping = {
    "&#45;": "-",
    "&amp;": "&",
    "&lt;": "<",
    "&gt;": ">",
    "&quot;": "\"",
    "&apos;": "'",
    "&#43;": "+",
    "&#61;": "=",
    "&#42;": "*",
    "&#47;": "/",
    "&#92;": "\\",
    "&#58": ":",
    "&#59;": ";",
    "&#63;": "?",
    "&#33;": "!",
    "&#36;": "$",
    "&#37;": "%",
    "&#35;": "#",
    "&#64;": "@",
    "&#94;": "^",
    "&#95;": "_",
    "&#126;": "~",
    "&#124;": "|"
}

def unescape_textile_file(file_path):
    with open(file_path, 'r', encoding='utf-8') as file:
        content = file.read()

    for escaped, original in escape_mapping.items():
        content = content.replace(escaped, original)

    with open(file_path, 'w', encoding='utf-8') as file:
        file.write(content)

if __name__ == "__main__":
    # 上書きするファイルの指定
    file_path = 'test.textile'
    
    unescape_textile_file(file_path)
```

ちなみに、このスクリプトはchatGPTに書いてもらったものです。エスケープ記号の一覧を聞いた時に、ついでにこのスクリプトを生成してもらいました。pythonで書かなければいけない理由は特にないので、シェルスクリプトとかでもよかったかもしれません。

### コードブロックの変換

上述のスクリプトにあったもう1つの問題点は、コードブロックの変換が適切に行われないというものでした。markdownでコードブロックを表現する際は以下のようにしますが、

````
```python
print("codeblock")
```
````

これがpandocをかますと何故か以下のようになってしまう場合がありました。

```
bc(python). print("codeblock")
```

ですが、(少なくとも自分の環境では)textile記法でコードブロックを表現する時は以下のように記載します。

```
<pre><code class = "python">
print("codeblock")
</code></pre>
```

なので、既存の状態ではコードブロックが正しく変換されていない状態となっています。
この問題に対処するために、pandocのフィルター機能というものを使いました。pandocにはユーザーが自由に文書の変換スタイルを決められるフィルターという機能があり、pythonなどの言語でそれを指定できます。
百聞は一見にしかず。今回のように正しいコードブロック変換を実現するためのpythonスクリプトは以下です。

```python
# markdown_to_textile_codeblock.py

import panflute as pf

def action(elem, doc):
    if isinstance(elem, pf.CodeBlock):
        # 言語指定がある場合は取得し、classとして追加
        language = elem.classes[0] if elem.classes else ''
        # Textile形式の<pre><code>ブロックに変換
        return pf.RawBlock(f'<pre><code class="{language}">\n{elem.text}\n</code></pre>', format='textile')

def main(doc=None):
    return pf.run_filter(action, doc=doc)

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

このコードブロックも、例の如くchatGPTに書いてもらいました。
なお、見ての通りこのスクリプトを使うには `panflute` をインストールする必要があります。 `pip install panflute` などでどうぞ。
そして、pandocでの変換時にフィルターを嚙ますコマンドは以下の通りです。

```bash
$ pandoc -F markdown_to_textile_codeblock.py -t textile test.md -o test.textile
```


# 終わりに

以上のようにして、markdownをtextileに変換する簡単なスクリプトを組みました。少なくとも筆者の環境では快適に動いています。ですが、markdownやtextileのあらゆる記法の変換が実現できているかテストしているわけではありません。また、textileを使用する環境(redmineのバージョン等)によっても動作状況は変わるかもしれません。ですので、

- 本記事のスクリプトは環境によって正しく動作しない可能性があります
- 本記事のスクリプトを使用して何らかの損害を被った場合も、筆者は責任を取りかねます

以上の2点についてご留意ください。
ちなみに、本記事で紹介したスクリプトは github (https://github.com/ryota37/markdown_to_textile) にて公開しています。ご自由にお使いください。