Pandoc の filter として panflute を使ってみる
この記事に関係する範囲で自己紹介すると、
という感じです。
はじめに
ここでは以下の 2 点に取り組んでみようと思います。
- pandocfilters から panflute へ乗り換える
- Markdown から Textile への変換を改善する
pandocfilters から panflute へ乗り換える
Pandoc はドキュメントのフォーマットを変換してくれます。その変換ロジックをカスタマイズする仕組みとして filter というものがあります。Filter は何らかのプログラミング言語で記述でき、Python であれば pandocfilters や panflute を使うことができます。Pandoc の製作者の方が直々につくったという理由で pandocfilters を使っていましたが、panflute のほうが Pythonic らしいので、乗り換えてみたいと思います。
今までの filter は下記のようになっていました。この filter をつくるときにはこの記事を参考にさせていただきました。
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
で要素の判別ができます。
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 の公式ドキュメントはこちらです。
Markdown から Textile への変換を改善する
文字化け?
例えば、hyphen-separated
が hyphen-separated
に変換されていまします。Pandoc のソースを見てみると下記のような記述がありました(ちなみに Pandoc は Haskell で書かれています)。Textile に変換する際に行われる下記のようなエスケープ処理が原因のようです。
-- | Escape one character as needed for Textile.
escapeCharForTextile :: Char -> String
escapeCharForTextile x = case x of
'&' -> "&"
'<' -> "<"
'>' -> ">"
'"' -> """
'*' -> "*"
'_' -> "_"
'@' -> "@"
'+' -> "+"
'-' -> "-"
'|' -> "|"
'\x2014' -> " -- "
'\x2013' -> " - "
'\x2019' -> "'"
'\x2026' -> "..."
c -> [c]
~~RedMine に書く場合には、このエスケープ処理は(たぶん)必要ないので、~~filter を使ってうまく処理できないか考えてみます。panflute では、特別な意味を持たない文字列は Str
というクラスで扱っているようです。Str
の中身から RawInline
というクラスのオブジェクトをつくります。Str
だとエスケープ処理の対象になってしまいますが、RawInline
は文字通り Raw なのでそのままになります(ということは、filter を通過した後にエスケープなどの元々の処理が行われるようです)。
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 を追加して対処しました。
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 用を返すものを後に置いていますが、特に意図はありません。
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 を使って調べます。
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 と変換後の出力を示します。
> 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 を使えば良いです。
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 も無事に更新できました。
追記
コメントでご指摘いただいた通り、文字列に対するエスケープ処理をスキップしてしまうと問題が起こります。
ひとまず、先頭が \w+://
にマッチする文字列のみスキップの対象とすることにしました。
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(">")
に変える必要があります。