golden-luckyの日記

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

Pandocをコマンドでなくライブラリとして使う

昨日までの記事では、Pandocフィルターの基本と少しだけ実用味がありそうな例を紹介しました。 Pandocフィルターは、Pandoc本体の開発言語と同じくHaskellで書けますが、Pandocの内部動作を変えられるわけではなく、pandocコマンドによってJSONとして出力したデータを操作する仕組みです。 内部に組み込まれたLua処理系で実行できる新しいフィルターの仕組みもありますが、いずれにしてもpandocというコマンドに対する補助的な機構です。

一方で、Haskellというプログラミング言語から見ると、Pandocはライブラリでもあります。 つまりpandocコマンドとしてでなく、自分が書くHaskellのプログラムで読み込んでそのMarkdownのパーサだけを使う、といったことも可能です。 今日はそのような事例を紹介します。

XML原稿にあるMarkdownの島

先週、このアドベントカレンダーでは、原稿にXMLを利用した組版の世界観について何回かに分けて紹介しました。 当社がやっているのはレイアウトの層にLaTeXを使う非標準的なXML組版ですが、XMLを原稿のフォーマットに使うことにはそれなりに意義があるのは伝わったと思います。

で、XMLが原稿の形式として至高であることはまあ認めるとして、専用のエディタなしに書いたり編集したりするのはやはり大変です。 そこで多くの人は、「<markdown>みたいな要素を作って、そのなかではMarkdownを直接書けるようにすればいいんじゃない?」みたいな暗黒面に堕ちるわけです。 もちろん、暗黒面というのはXML組版ジェダイ教とみなした場合の話であって、XML組版のほうをシスとみなすなら、原稿にMarkdown島を入れ込むことは真のフォースを挫く愚行に見えるに違いありません。 いずれにせよ悪い文明だな。

<?xml version="1.0" encoding="UTF-8"?>
<chapter>
  <title>chapter title</title>
<markdown>
Foo bar...

## Intro to Bar ## {#sec.foo}

bar baz...

### About Baz  ###

baz baz...
</markdown>
</chapter>

どうやらDocbookっぽいスキーマXMLの中に、<markdown>という独自のタグを使って生のMarkdownを入れられるようにしたようですね。 Madkdownの中ではヘッダの属性記法が使われていることが見て取れます。

Pandocをライブラリとして使う手順

この原稿を手にしたときは絶望しましたが、Pandocライブラリを使ってMarkdown島の中身を正規のDocbookへと戻してあげれば、ふつうにDocbookのようなXMLとして扱えそうですね。 島の周囲については、XMLを扱うライブラリでまじめに処理してもいいのですが、ここでは手を抜いてテキストフィルター的に済ませましょう。

つまり、次のような戦略です。

  1. パーサコンビネータ<markdown>から</markdown>の部分を探す
  2. その中身をPandocのMarkdownパーサに渡してDocbookへと変換し、同じ場所に書き戻す

まずはMarkdownをDocbookへ変換する部分から考えましょう。 Text.Pandoc.Readers.Markdownモジュールを読み込むと、テキストをMarkdownとして読むreadMarkdownという関数が使えるようになります。 これはpandocコマンドでMarkdownを読み込むときに使われるReaderそのものです。

Writerのほうは、この場合に読み込むべきはText.Pandoc.Writers.Docbookです。 この中にwriteDocbook5という関数があります。

これらを単純に使えれば楽なのですが、pandocコマンドに無数のオプションを指定できるようになっていることからわかるように、ライブラリ関数にもオプションを指定しなければなりません。 ここではヘッダ属性を扱いたいので、標準の設定にExt_header_attributesを追加したものとしてオプションを用意します。

また、Readerの関数が返すPandoc構造はPandocMonadでくるまれているので、実行には専用の仕掛けが必要です。 この場合にはText.Pandoc.ClassrunPureを使えばいいでしょう。 結局、だいたいこんな感じに書けるはずです。 大げさに見えるかもですが、mdToDocbook関数でやっていることを読み取るのはそんなに難しくないでしょう。

import Text.Pandoc.Writers.Docbook
import Text.Pandoc.Readers.Markdown
import Text.Pandoc.Extensions
import Text.Pandoc.Options
import Text.Pandoc.Class (runPure)

mdToDocbook b = 
  case runPure $ readMarkdown readOptions $ T.pack b of
    Right p -> 
       case runPure $ writeDocbook5 writeOptions p of
         Right s -> T.unpack s
         Left err -> ""
    Left err -> ""

readOptions = def{readerExtensions = extensionsFromList
                    [ Ext_header_attributes]}

writeOptions = def{writerExtensions = extensionsFromList
                    [Ext_header_attributes]}

次は、全体からMarkdown島を探すパーサが必要です。 これはParsecで簡単に書けて、上記のmdToDocbookを組み込めばこんな感じになります。

mdParser :: Parser String
mdParser = spaces >> string "<markdown" >>
  conc
  <$> (manyTill (noneOf "/>") (try $ string ">"))
  <*> (manyTill anyChar (try $ string "</markdown>"))
  where
    conc _ = mdToDocbook

残るはファイル全体に対するパーサーです。 Markdown島以外はスルーするように定義します(定義が簡単になるように、ファイルの末尾に必ず空行があることを想定しました)。

fixDocbook :: String -> IO String
fixDocbook filename = do
  h <- IO.openFile filename IO.ReadMode
  b <- IO.hGetContents h
  return $ fixer b

fixer body = case parse (concat <$> manyTill block eof) "" body of
  Right str -> str
  Left  err -> ""
  where block = choice [try $ mdParser, otherlines]

otherlines :: Parser String
otherlines = (++"\n") <$> manyTill anyChar (try newline)

以上を全部まとめてmainをつけたものを以下に示します。

module Main where

import System.Environment (getArgs)
import qualified System.IO as IO
import qualified Data.Text as T

import Text.Pandoc.Writers.Docbook
import Text.Pandoc.Readers.Markdown
import Text.Pandoc.Extensions
import Text.Pandoc.Options
import Text.Pandoc.Class (runPure)

import Text.ParserCombinators.Parsec hiding (many, optional, (<|>))
import Control.Applicative

main :: IO ()
main = do
  srcfile:_ <- getArgs
  db <- fixDocbook srcfile
  putStrLn db
  return ()

fixDocbook :: String -> IO String
fixDocbook filename = do
  h <- IO.openFile filename IO.ReadMode
  b <- IO.hGetContents h
  return $ fixer b

fixer body = case parse (concat <$> manyTill block eof) "" body of
  Right str -> str
  Left  err -> ""
  where block = choice [try $ mdParser, otherlines]

otherlines :: Parser String
otherlines = (++"\n") <$> manyTill anyChar (try newline)

mdParser :: Parser String
mdParser = spaces >> string "<markdown" >>
  conc
  <$> (manyTill (noneOf "/>") (try $ string ">"))
  <*> (manyTill anyChar (try $ string "</markdown>"))
  where
    conc _ = mdToDocbook

mdToDocbook b = 
  case runPure $ readMarkdown readOptions $ T.pack b of
    Right p -> 
       case runPure $ writeDocbook5 writeOptions p of
         Right s -> T.unpack s
         Left err -> ""
    Left err -> ""

readOptions = def{readerExtensions = extensionsFromList
                    [ Ext_header_attributes]}

writeOptions = def{writerExtensions = extensionsFromList
                    [Ext_header_attributes]}

これをコンパイルして先のXMLっぽいファイルを処理してみると、こうなりました。

$ ghc fixer.hs
[1 of 1] Compiling Main             ( fixer.hs, fixer.o )
Linking fixer ...
$ ./fixer input.xml
<?xml version="1.0" encoding="UTF-8"?>
<chapter>
  <title>chapter title</title>
<para>
  Foo bar...
</para>
<section xml:id="sec.foo">
  <title>Intro to Bar</title>
  <para>
    bar baz...
  </para>
  <section>
    <title>About Baz</title>
    <para>
      baz baz...
    </para>
  </section>
</section>
</chapter>

Pandocの豊富な機能をライブラリとして使うことで、わりとこざっぱりしたコードでMarkdown島を含むXMLを処理することができました。

ところで今回の話の影の主役は、実はPandocライブラリではなくParsecです。 Parsecを駆使することにより、未知の記法で書かれたドキュメントを自分が好きなHaskellの抽象データ型へと押し込めて、そこから他の扱いやすい形式へと変換するという道が切り拓けます。

Parsecを使って未知の記法から都合がいい構造を取り出す話をする前に、明日はちょっとTeX on LaTeX(お勧めしない)の回を挟むつもりです。