golden-luckyの日記

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

もっとPandocフィルター

昨日の記事では、いわゆる行コメントっぽい振る舞いを例に、2種類のPandocフィルターについて説明しました。 今日は、もうちょっと非自明なPandocフィルターの例として、 昨日のPandocフィルターをもうちょっと進化させたバージョンと、コードブロックのLaTeX出力に非標準的なパッケージを使う事例を紹介します。 (ちなみにLuaフィルターはお仕事で使ったことがないので、いずれも古典的なPandocフィルターによる事例です)

行コメントPandocフィルター、バージョン2

機能の記事で例として挙げた行コメント用Pandocフィルターの実装は、段落の先頭がだった場合にはその段落を出力しない、という中途半端なものでした。 これをもうちょっと実用的に、Markdownで行の先頭がだった場合にはその行を出力しない、というふうにできないものでしょうか?

結論から言ってしまうと、これはPandocフィルターだけで完全にやるのは困難です。 たとえばMarkdownの原稿に次のような箇条書きがあったとします。

* temp1
* temp2
* temp3

これの2つめの項目の先頭にを付けてみましょう。

* temp1
★* temp2
* temp3

これをPandocの標準的なMarkdown Readerで読み込むと、次のようなPandoc構造が得られます(フィルターをかけてない素の状態です)。

[BulletList
 [[Plain [Str "temp1",SoftBreak,Str "\9733*",Space,Str "temp2"]]
 ,[Plain [Str "temp3"]]]]

上記のPandoc構造は「2項目からなる箇条書き」であることが見て取れます。 また、「先頭がの行を出力しない」という処理に期待される構造について想像してみると、やはり「2項目からなる箇条書き」のはずです。 両者の構造は同じであることから、この処理はPandoc構造に対するアクション、つまりフィルターとして無理なく実装できそうです。

一方、次のように1つめの箇条書き項目にをつけると、Markdown ReaderはどんなPandoc構造を読み取るでしょうか?

★* temp1
* temp2
* temp3

この場合には次のようなPandoc構造が得られます。

[Para [Str "\9733*",Space,Str "temp1",SoftBreak,Str "*",Space,Str "temp2",SoftBreak,Str "*",Space,Str "temp3"]]

これは、もはや箇条書きではありません。ただの段落です。この構造から箇条書きを復元するのは、不可能ではありませんが、かなり厄介な相談です。

この例からわかるように、元の原稿の記法をPandocフィルターで拡張しようと思ったら、それがReaderでどんなPandoc構造に押し込められるかを常に意識して考える必要があります。 場合によっては、Text.Pandoc以下のさまざまなモジュールを駆使することで、フィルターではなく「専用の変換器」をHaskellで書くほうが簡単かもしれません。

参考までに、上記のような「Readerで意図しない構造として読まれてしまう」場合やCodeBlock、それにテーブルなどを除いて、おおむね行コメントっぽく機能するように拡張したPandocフィルターの例を貼り付けておきます。 見てのとおりぜんぜん自明じゃないので、このような機能が本当にほしかったら、sedとかrubyワンライナーで先頭にがある行を削除してからPandocにかけるようにするほうが現実的かもしれません。というか、ふつうはそうすべきです。

import Text.Pandoc.JSON

main :: IO ()
main = toJSONFilter block

block :: Block -> Block
block (Para (Str s:xs)) = commentOut Para (T.head s) s xs
block (Plain (Str s:xs)) = commentOut Plain (T.head s) s xs
block bs = bs

commentOut t s str xs = 
  case s of
    '★' -> skipBlock t xs
    _ -> t $ Str str:(nextInline xs)

skipBlock :: ([Inline] -> Block) -> [Inline] -> Block
skipBlock t (SoftBreak:xs) = block $ t xs
skipBlock t (_:xs) = skipBlock t xs
skipBlock _ _ = Null

nextInline :: [Inline] -> [Inline]
nextInline (SoftBreak:(Str s:xs)) =
  case (T.head s) of
    '★' -> SoftBreak:(skipInline xs)
    _ -> SoftBreak:Str s:(nextInline xs)
nextInline (x:xs) = x:nextInline xs
nextInline _ = []

skipInline :: [Inline] -> [Inline]
skipInline (SoftBreak:xs) = xs
skipInline (_:xs) = skipInline xs
skipInline _ = []

そもそも、ある記法に対する行コメントというのはReaderで実現すべき機能なので、Pandocフィルターの例として使うのはアンチパターン的だったかもしれませんね。

コードブロックをLaTeXの好きな環境で出力する

もう1つだけ、非自明なPandocフィルターを紹介します。

PandocではLaTeXの出力に対応しています。 ただ残念なことに、標準ではコードブロックの出力にfancyvrbが使われます。 --listingsオプションを付けることでlistingsパッケージが使われるようにもできますが、LaTeXでいまいちばんナウいVerbatim系のパッケージはfvextraなので(異論は認める)、できればこれを使いたいものです。

こういう、特定の構造の出力方法をカスタマイズしたいときこそ、Pandocフィルターの出番でしょう。 もっとも単純なのは、こんな感じにCodeBlockRawBlockへと変換する方法です。

main :: IO ()
main = toJSONFilter block

block (CodeBlock _ cts) = 
  RawBlock (Format "tex") $ 
       "\\begin{Verbatim}[(fvextraのオプション)]\n"
    ++ cts
    ++ "\\end{Verbatim}\n"

fvextraパッケージのオプションは、\\begin{Verbatim}の後ろの部分に目的に合わせて追記してあげてください。

ただ、このナイーブな実装にはひとつ難点があって、コードハイライトが効かなくなります。 fvextramintedを併用してもいいのでしょうが、たぶんうまくいかなくて大変なので、そこはPandocのほうでやってもらいたいものです。

現在のPandocでは、コードハイライトの仕掛けが本体から切り離されており、Skylightingという別パッケージにまとめられています。 これはチュートリアル的なのはないので、Hackageのドキュメントとソースを見ながら使い方を解読していく感じになります。

最終的にはこんな感じのPandocフィルターになりました(最低限の部分だけを切り出してます)。

{-# LANGUAGE PackageImports #-}
{-# LANGUAGE OverloadedStrings #-}
import Text.Pandoc.JSON
import Text.Pandoc.Highlighting
import qualified Data.Text as T
import Data.Char (isSpace)
import Skylighting
import Skylighting.Format.LaTeX
import "regex-compat-tdfa" Text.Regex as R

main :: IO ()
main = toJSONFilter block

block (CodeBlock (i,c,attrs) cts) = 
  RawBlock (Format "tex") $ 
       "\\begin{Verbatim}[(fvextraのオプション)]\n"
    ++ (doCodeHighlight cts)
    ++ "\\end{Verbatim}"
  where doCodeHighlight s = 
          case highlight defaultSyntaxMap formatLaTeXBlock ("",c,attrs) s of
            Left msg -> s
            Right h -> h

        formatLaTeXBlock :: FormatOptions -> [SourceLine] -> String
        formatLaTeXBlock opts ls = T.unpack $ T.unlines [formatLaTeX False ls]

        formatLaTeX :: Bool -> [SourceLine] -> T.Text
        formatLaTeX inline = T.intercalate (T.singleton '\n')
                               . map (sourceLineToLaTeX inline)

        sourceLineToLaTeX :: Bool -> SourceLine -> T.Text
        sourceLineToLaTeX inline = mconcat . map (tokenToLaTeX inline)

        tokenToLaTeX :: Bool -> Token -> T.Text
        tokenToLaTeX inline (NormalTok, txt)
          | T.all isSpace txt = (T.pack . escapeTeX . T.unpack) txt
        tokenToLaTeX inline (toktype, txt)   = T.cons '\\'
          (T.pack (show toktype) <> "{" <> (T.pack . escapeTeX . T.unpack) txt <> "}")

block bs = bs

escapeTeX :: String -> String
escapeTeX s = 
  let re  = R.mkRegex "[(]symbol ([0-9]{1,3})[)]"
  in R.subRegex re (at $ sh $ bs $ lp $ rp $ us $ ps $ dl $ ad s) "{\\symbol{\\1}}"
  where bs s = R.subRegex (R.mkRegex "[\\]") s "(symbol 92)"
        lp s = R.subRegex (R.mkRegex "{") s "(symbol 123)"
        rp s = R.subRegex (R.mkRegex "}") s "(symbol 125)"
        at s = R.subRegex (R.mkRegex "[@]") s "(symbol 64)"
        sh s = R.subRegex (R.mkRegex "[#]") s "(symbol 35)"
        us s = R.subRegex (R.mkRegex "[_]") s "(symbol 95)"
        ps s = R.subRegex (R.mkRegex "[%]") s "(symbol 37)"
        dl s = R.subRegex (R.mkRegex "[$]") s "(symbol 36)"
        ad s = R.subRegex (R.mkRegex "[&]") s "(symbol 38)"

escapeTeXは、いつかの記事で解説したTeX用の特殊文字エスケープです。 なんとなくここで伏線を回収してみました。

 $ cat temp.md
 ```ruby
 if true p "pandoc" end
 ```

 $ pandoc --filter codeblock.hs -t latex temp.md
 \begin{Verbatim}[(fvextraのオプション)]
 \KeywordTok{if} \DecValTok{true}\NormalTok{ p }\StringTok{"pandoc"} \KeywordTok{end}
 \end{Verbatim}

コードブロックに与えた属性により、ブロック内のキーワードなどがコードハイライト用のコマンドで囲まれてますね。 あとはこれらに対する見せ方をLaTeXのスタイルで好きなように指定してあげるだけです。

明日は、Markdown原稿で索引のエントリを指定する記法について考えてみたいと思います。 そのうえでPandocフィルターを使ってその処理の実装例を紹介する予定です。