Pandoc の filter として panflute を使ってみる

# Pandoc の filter として panflute を使ってみる

この記事に関係する範囲で自己紹介すると、

* 最近、[Pandoc を利用して Markdown を RedMine の Textile に変換する VS Code Extension](https://marketplace.visualstudio.com/items?itemName=irisTa56.markdown2textile) をつくった

という感じです。

## はじめに

ここでは以下の 2 点に取り組んでみようと思います。

* pandocfilters から panflute へ乗り換える
* Markdown から Textile への変換を改善する

## pandocfilters から panflute へ乗り換える

Pandoc はドキュメントのフォーマットを変換してくれます。その変換ロジックをカスタマイズする仕組みとして filter というものがあります。Filter は何らかのプログラミング言語で記述でき、Python であれば [pandocfilters](https://github.com/jgm/pandocfilters) や [panflute](https://github.com/sergiocorreia/panflute) を使うことができます。Pandoc の製作者の方が直々につくったという理由で pandocfilters を使っていましたが、panflute のほうが _Pythonic_ らしいので、乗り換えてみたいと思います。

今までの filter は下記のようになっていました。この filter をつくるときには[この記事](https://qiita.com/ichimonji_haji/items/9bd22690b1a4d6c2f5a6)を参考にさせていただきました。

```python
from pandocfilters import toJSONFilter, RawBlock

def code_block(key, value, _format, _meta):
  if key == "CodeBlock":
    ((_, languages, _), code) = value
    syntax = 'class="{}"'.format(languages[0] if languages else "text")
    body = "\n".join([
      "<pre><code {}>".format(syntax),
      code,
      "</code></pre>\n",
    ])
    return RawBlock("html", body)

if __name__ == "__main__":
  toJSONFilter(code_block)
```

panflute を使うと下記のようになります。Pandoc の要素に対応するクラスが定義されているため、`isinstance` で要素の判別ができます。

```python
import panflute as pf

def code_block(elem, _doc):
  if isinstance(elem, pf.CodeBlock):
    languages = elem.classes
    syntax = 'class="{}"'.format(languages[0] if languages else "text")
    body = "\n".join([
      "<pre><code {}>".format(syntax),
      elem.text,
      "</code></pre>\n",
    ])
    return pf.RawBlock(body)

if __name__ == "__main__":
  pf.run_filter(code_block)
```

panflute の公式ドキュメントは[こちら](http://scorreia.com/software/panflute/index.html)です。

## Markdown から Textile への変換を改善する

### 文字化け？

例えば、`hyphen-separated` が `hyphen&#45;separated` に変換されていまします。[Pandoc のソース](https://github.com/jgm/pandoc/blob/master/src/Text/Pandoc/Writers/Textile.hs)を見てみると下記のような記述がありました（ちなみに Pandoc は Haskell で書かれています）。Textile に変換する際に行われる下記のようなエスケープ処理が原因のようです。

```haskell
-- | Escape one character as needed for Textile.
escapeCharForTextile :: Char -> String
escapeCharForTextile x = case x of
                         '&'      -> "&amp;"
                         '<'      -> "&lt;"
                         '>'      -> "&gt;"
                         '"'      -> "&quot;"
                         '*'      -> "&#42;"
                         '_'      -> "&#95;"
                         '@'      -> "&#64;"
                         '+'      -> "&#43;"
                         '-'      -> "&#45;"
                         '|'      -> "&#124;"
                         '\x2014' -> " -- "
                         '\x2013' -> " - "
                         '\x2019' -> "'"
                         '\x2026' -> "..."
                         c        -> [c]
```

~~RedMine に書く場合には、このエスケープ処理は（たぶん）必要ないので、~~filter を使ってうまく処理できないか考えてみます。panflute では、特別な意味を持たない文字列は `Str` というクラスで扱っているようです。`Str` の中身から `RawInline` というクラスのオブジェクトをつくります。`Str` だとエスケープ処理の対象になってしまいますが、`RawInline` は文字通り _Raw_ なのでそのままになります（ということは、filter を通過した後にエスケープなどの元々の処理が行われるようです）。

```python
def raw_string(elem, _doc):
  if isinstance(elem, pf.Str):
    return pf.RawInline(elem.text)

if __name__ == "__main__":
  pf.run_filters([code_block, raw_string])
```

### 空行なしの改行

```
This is a pen.
My name is Pen.
```

上記が下記のように変換されます。

```
This is a pen. My name is Pen.
```

ケータイ・スマホ世代の人は意図的に改行していると個人的に思っていて、その場合、改行は維持されていて欲しいのではないでしょうか？

原因は *hard* line break のつもりの改行が *soft* line break として認識されるためでした。両者を入れ替える filter を追加して対処しました。

```python
def line_break(elem, _doc):
  if isinstance(elem, pf.SoftBreak):
    return pf.LineBreak()

if __name__ == "__main__":
  pf.run_filters([code_block, raw_string, line_break])
```

### 水平線

`---` が `<hr />` に変換されてしまいますが、RedMine では `---` のままで大丈夫です。対処方法は下記のコードの通りです。Filter の順番について、block 要素を返すものを前に、inline 用を返すものを後に置いていますが、特に意図はありません。

```python
def horizontal_rule(elem, _doc):
  if isinstance(elem, pf.HorizontalRule):
    return pf.RawBlock("---\n")

if __name__ == "__main__":
  pf.run_filters([code_block, horizontal_rule, raw_string, line_break])
```

### 引用のあとに空行

```
> 2019/06/23
```

上記が下記のように変換されます。空行が追加されてしまっています。

```
bq. 2019/06/23


```

引用は `BlockQuote` というクラスで表現されているのですが、中に子要素を持っています。構造が不明だったので、下記の filter を使って調べます。

```python
def investigate_block_quote(elem, _doc):
  if isinstance(elem, pf.BlockQuote):
    child_elems = []
    def append(child, _doc):
      if child is not elem:
        child_elems.append(repr(child))
    elem.walk(append)
    return pf.RawBlock("{}\n".format(child_elems))
```

元の Markdown と変換後の出力を示します。

```markdown
> 2019/06/23

> 2019/06/23
> 2019/06/32

> 2019/06/23
>
> 2019/06/32
```

```
['Str(2019/06/23)', 'Para(Str(2019/06/23))']

['Str(2019/06/23)', 'SoftBreak', 'Str(2019/06/32)', 'Para(Str(2019/06/23) SoftBreak Str(2019/06/32))']

['Str(2019/06/23)', 'Para(Str(2019/06/23))', 'Str(2019/06/32)', 'Para(Str(2019/06/32))']
```

この構造、および RedMine では行頭の `>` で引用が表現できることを踏まえると、下記の filter を使えば良いです。

```python
def stripped_quote(elem, _doc):
  if isinstance(elem, pf.BlockQuote):
    child_elems = [pf.Str(">"), pf.Space()]
    after_paragraph = False
    def append(child, _doc):
      nonlocal child_elems
      nonlocal after_paragraph
      if isinstance(child, pf.Inline):
        if after_paragraph:
          child_elems += [pf.SoftBreak(), pf.Str(">")] * 2 + [pf.Space()]
        child_elems.append(child)
        if isinstance(child, pf.SoftBreak):
          child_elems += [pf.Str(">"), pf.Space()]
      after_paragraph = isinstance(child, pf.Para)
    elem.walk(append)
    return pf.Para(*child_elems)

if __name__ == "__main__":
  pf.run_filters(
    [code_block, horizontal_rule, stripped_quote, line_break, raw_string])
```

Global scope で定義した変数を `global` で束縛できることは知っていましたが、関数内で定義した変数をさらにその内側の関数で使う場合には `nonlocal` を使うんですね。初めて知りました。

## おわりに

panflute のほうが pandocfilters と比べてドキュメントが充実していて良いと思いました。Pythonic かどうかは、そもそも Pythonic って何なのかを自分がちゃんと理解していなかったので何とも言えません。。。

VS Code Extension の [Markdown2Textile](https://marketplace.visualstudio.com/items?itemName=irisTa56.markdown2textile) も無事に更新できました。

## 追記

コメントでご指摘いただいた通り、文字列に対するエスケープ処理をスキップしてしまうと問題が起こります。
ひとまず、先頭が `\w+://` にマッチする文字列のみスキップの対象とすることにしました。

```python
import re
re_uri = re.compile(r"\w+://")

def raw_string(elem, _doc):
  if isinstance(elem, Str) and re_uri.match(elem.text):
    return RawInline(elem.text)

if __name__ == "__main__":
  run_filters([
    code_block, horizontal_rule, stripped_quote, line_break, raw_string])
```

なお、この変更により `stripped_quote` の中の `pf.Str(">")` を `pf.RawInline(">")` に変える必要があります。