golden-luckyの日記

ツイッターより長くなるやつ

Pandocフィルター101

昨日の記事では、PandocのReaderを自分で作り直す話をしました。 いうまでもありませんが、ReaderはPandocの一部なので、改造Readerを使うためにはPandocをソースから自分でビルドする必要があります。 ところがPandocというのは、Haskellで書かれているうえに、かなり巨大で依存関係がめんどくさいソフトウェアです。 GitHubからソースをcloneしてくれば誰でもビルドできるとはいえ、Haskellの開発経験がまったくないと、ビルドできる環境を整えるだけでもなかなか大変でしょう。

Readerを改造するしか手のうちようがない機能追加や修正については何ともなりませんが、Pandoc構造に押し込まれたコンテンツを他の記法として書き出すときに標準とは違うことをしたいだけなら、Pandocをソースからビルドしなくても済むような裏口が昔から用意されています。 それが今日の話、Pandocフィルターです。

古典的なPandocフィルター

Pandoc構造の実体は、Haskellの代数的データ型です。 これをHaskellで直接操作する感覚でフィルターを書けるように、pandoc-typesパッケージにはtoJSONFilterという関数が用意されています。 この関数に「Pandoc構造からPandoc構造への関数」を渡すと、Pandocフィルターになります。

「関数に関数を渡す」と言われてもぴんこないかもしれないので、とりあえず例を見てください。 次のHaskellプログラムは、原稿で先頭が「★」になっている段落をコメントとみなす(つまり出力しないようにする)ためのフィルターです。

import Text.Pandoc.JSON

main :: IO ()
main = toJSONFilter block

block (Para p@((Str (s:_)):_)) =
  case s of
    '★' -> Null
    _ -> Para p

block bs = bs

このコードでは、一昨日の記事で紹介したHaskellの代数的データ型に対する直観的なパターンマッチとして、Pandocフィルターを定義しています。 具体的には、Para Str ...というパターンの構造で...の1文字めがの場合はNullに置き換え、それ以外のパターンは元のParaの構造をそのまま返す、というふうに定義しています。 Para ...以外も、すべてそのままです。 HaskellのようなML系の構文を持つ言語のパターンマッチによる関数定義になじみがない方は、『n月刊ラムダノート Vol.1, No.3』を読みましょう。 そもそもHaskellを学びたい人には『プログラミングHaskell 第2版』がおすすめです。

上記のHaskellプログラムをcomment.hsのような名前で保存し、次のように--filterオプションで指定してpandocを実行すると、 Markdownで書いた原稿中でで始まる段落の構造がNullに置き換わります。 (結果からtest1.5の行も消えているのは、PandocのMarkdown Readerにとってこの行は独立した段落ではなく、「その上の段落の続き」だからです。)

$ cat temp.md
test1

★下の行も消える
test1.5

test2

$ pandoc --filter comment.hs -t native temp.md
[Para [Str "test1"]
,Null
,Para [Str "test2"]]

なお、toJSONFilterという名前の関数を使っていることから想像できるように、このHaskellで書いたフィルターは、Pandoc構造そのもの(つまりHaskellの代数的データ型)を変更するわけではありません。 Pandocフィルターは、実際には、Pandoc構造の「JSON表現」を操作するしくみなのです。 pandocコマンドでPandocフィルターを使うと、いったんJSONとして表現された状態でPandoc構造が変更され、それがまたPandocに読み込まれて再びPandoc構造になり、そこから目的の形式で出力されるという、かなり回りくどいことが起きます。

f:id:golden-lucky:20191216212130p:plain

HaskellでPandocフィルターを書いていると、いかにもPandocの内部構造であるHaskellの代数的データ型を直接操作しているような気持ちになりますが、その実体は「Haskellのデータ構造をいったんJSON化したものを変更する処理をHaskellの代数的データ型に対するパターンマッチとして書く」という、だいぶもどかしいことをしているといえます。

とはいえ、このようにいったんJSON化したものをいじる機構になっていることから、Haskell以外の好きな言語、たとえばPythonRubyでもPandocフィルターを書けるようになっているという効能があります。 もちろん、Haskell以外の言語を使う場合には、Haskellの代数的データ型に対するパターンマッチほど直観的ではない方法でPandocフィルターを書くことになります。

Luaフィルター

前節では、Writerが使うPandoc構造を好きなようにいじるための手段として、JSON表現に対する変換器を書く方法を紹介しました。 Pandoc界隈では長らくこの方法が便利に利用されていたのですが、Pandoc 2.0で新しい手法のフィルターが導入されました。 それがLuaフィルターです。

Luaフィルターが従来のPandocフィルターと異なるのは、JSON表現を介さず、直接Pandocの内部でPandoc構造をいじれる点です。 それを実現するためにLuaの処理系がPandocに内蔵されています。 LuaTeXなんかと同じノリですね。

f:id:golden-lucky:20191216212143p:plain

いったんJSON表現を作りだす手間がないので、そのぶんのオーバーヘッドがなく、とても高速です。 また、JSON表現では難しかったと思うのですが、木構造ではなくリストを返せます。 そのためインラインの要素をSpanでくるむような必要がないはず。

「はず」というのは、ぼくは実をいうとまだLuaフィルターをちゃんと使ったことがなくて、あまり複雑な実例が手持ちにないからです。 とりあえず前節と同じ「から始まる段落をコメントアウト」する例をLuaフィルターでも書いてみました。

function Pandoc(doc)
    local result = {}
    for i, el in ipairs(doc.blocks) do
        if (el.t == "Para" and
            el.c[1].t == "Str" and 
            string.match(el.c[1].text, '^★')) then
        else
            table.insert(result, el)
        end
    end
    return pandoc.Pandoc(result, doc.meta)
end

パターンマッチできないので個人的には書きづらいですが、LuaのほうがHaskellよりもうれしい人は多そうですね。

Luaフィルターの適用には--filterではなく--lua-filterを使います。

$ time pandoc --lua-filter comment.lua -t native temp.md
[Para [Str "test1"]
,Para [Str "test2"]]

入力は先ほどの例と同じ原稿ですが、LuaではHaskellと違ってifの型があってなくてもいいので、Nullを使う必要がありませんでした。 Haskellによる古典的なフィルターしか書いてこなかった自分にとってはかなり新鮮です。

明日はもう少し古典的なPandocフィルターの話をするかもしれないし、しないかもしれない。