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(お勧めしない)の回を挟むつもりです。

Pandocで索引をどう作るか

Pandocフィルターの便利さと限界が見えてきたところで、最後に実用的かもしれない例を1つ紹介します。 Markdown原稿に索引のエントリを指定するための「記法」を考えてみる話です。

どういう記法にするか

そもそもMarkdownの方言で索引に対応してるものはないと思うので(まじめに先行事例を調べていないのであるかもしれない)、記法から考えないといけません。 どんな記法ならMarkdown原稿中に索引を挿入してもうざくならないでしょうか。 まあ、どんな記法であれテキスト原稿中に索引を挿入していくと、読みにくくなったりgrepしにくくなったりするのは必然なのですが、そのへんの工夫は過去にもいろいろやってきたので、この記事では「Markdown原稿にふさわしい索引の記法を何かしら考える」ことに集中します。

まっさきに思いつくのは、LaTeXの索引コマンドをそのままLaTeX原稿中に投入することでしょう。 これはこれで悪くない気がします。というか、だいたいこんな感じにやるような気がします。

# About foo

bar \index{baz!foo}**baz**

この方法は、LaTeXのみを出力にするのなら、おそらく何も問題ありません。 そのままLaTeXにおける索引のエコシステムを利用してPDFで索引が組めます。 ただ、たとえばHTMLでも索引を作りたいような場合にはちょっと大変そうなので、別の手を考えることにします。

ここでヒントになると思うのが、Pandoc界隈でMarkdown原稿における「アンカー」を設定するのによく使われる記法です。 たとえば、Pandocでheader_attributes拡張を使うと、ヘッダに後置した{#...}がIDとして扱われるようになります。

# About foo{#foo}

bar **baz**

この{#...}という記法は、図や数式に対する相互参照で広く利用されているpandoc-crossrefフィルターでも、アンカーを設定するのに使われています。

索引もアンカーみたいなものなので、この{#...}という記法を流用することにします。 さらに、今回はそこまでやりませんが、他のフィルターや拡張ではこの記法により属性を指定できるので、漢字に非標準的な読みを指定したい場合などにも悩むことが減りそうです。

また、索引の記法ではもう1つ、階層的に複数のエントリを指定できるようにしたいという要求もあります。 上記でLaTeXの生の索引記法を埋め込んだ\index{baz!foo}という例だと、この箇所には「bazの子要素foo」という索引エントリからのアンカーが設置されます。 このような「エントリの階層」を手軽に指定する方法が必要です。

いろいろ考えたのですが、階層の表現にはカンマ,を使うことにしました。 さらに1階層めの項目にもカンマ,を前置することにすれば、「先頭が{#ではなく{#,になってるアンカーっぽいやつは索引のエントリ」というふうに構文を決められます。 {#,であれば、他の主なフィルターや拡張で使われている記法とかちあうこともなさそうです。

以上のような考察をへて、Markdown原稿に次のような感じで索引のエントリを指定していくことにしました。

# About foo

bar {#,baz,foo}**baz**

実装

あとは、この記法をPandoc構造から抽出してRawInlineLaTeXやらHTMLやらの該当する記法として吐き出すようなフィルターを書けばいいのですが、 その前に決定が必要なことが2つあります。

1つは、「この記法をどこに設置できることにするか」です。 ここでは、「Pandoc構造でStrとして読み取られる部分」ということに決めます。 これは逆にいうと、原稿中で一続きのStrにならないような形では索引を指定しない、ということです。 どういうことかというと、たとえばこんなふうに索引のエントリにスペースを含めてはいけません。

foo {#,baz baz}**bar baz**

これはPandoc構造として読み取られると、Str Space Str Emphが順番に並んだような状態になります。 エントリに空白を含めたい場合には、次のようにエスケープすることで、Str Emphの並びとして読まれるようにする必要があります。

foo {#,baz\ baz}**bar baz**

もう1つ決めないといけないことは、LaTeXやHTMLにおける表現をどうするか、です。 LaTeXについては単純に\index{...}を使えばいいでしょう。 問題はHTMLですが、これは今回の記事では答えを出さないことにして、今後の課題ということにしておきます(本文での表現だけでなく索引ページのほうの出力も考えないといけないので単純にはいかない)。 ちなみにRawInlineに指定するフォーマットをスイッチするには、toJSONFilterが「Maybe Format型の引数をとれる関数」を引数にとれるようになっているので、それを利用します。

といわけで実装例です。 こういう場合にHaskellでは正規表現をがんばらずにパーサコンビネータがさくっと使えるのでとても生産性が高い。

{-# LANGUAGE OverloadedStrings #-}
import Text.Pandoc.JSON
import Text.Parsec
import Text.Parsec.String
import Data.List (intercalate)

main :: IO ()
main = toJSONFilter index

index :: Maybe Format -> Inline -> Inline
index f (Str s) = rawindex f $ Str s
index f (Span attr xs) = Span attr $ map (index f) xs
index _ x = x

parseIndex :: Maybe Format -> Parser Inline
parseIndex format = do
  string "{#"
  es <- manyTill indexentry (try $ string "}")
  return $ makeIndex format es
  where 
    makeIndex (Just f) ls
      | f == Format "latex" = RawInline f $ "\\index{" ++ (intercalate "!" ls) ++ "}"
      | f == Format "html" = Str "" -- ここは読者への宿題とします
      | otherwise = Str ""

indexentry :: Parser String
indexentry = do
  char ','
  e <- manyTill anyChar (try $ lookAhead $ oneOf ",}")
  return e

parseInline :: Parser Inline
parseInline = do
  s <- manyTill anyChar (try $ lookAhead $ string "{#,")
  return $ Str s

parseInline' :: Parser Inline
parseInline' = do
  s <- manyTill anyChar (try eof)
  return $ Str s

rawindex format (Str s) = 
  case parse (manyTill (choice [try $ parseIndex format, try parseInline, try parseInline']) eof) "" s of
    Right [(Link a txt x)] -> Link a txt x
    Right [(Str s)] -> Str s
    Right r -> Span nullAttr r
    Left err -> Str s

なお、この実装例は最新のPandoc型の定義ではなく、1つ古いバージョンを使って書きました。 Pandoc、2019年11月からStrStringではなくTextを取るようになったので、新しいバージョンだとParsecのモジュールにもText.Parsec.Textを使う必要があるでしょう。

もっと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フィルターを使ってその処理の実装例を紹介する予定です。

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フィルターの話をするかもしれないし、しないかもしれない。

PandocをreSTのリストテーブルに対応させる

Python界隈でよく見かける構造化文書のための記法として、reStructuredText(以降はreSTと書きます)があります。

軽量マークアップ言語などと呼ばれることもありますが、reSTはかなり高度な表現力がある記法です。 その記法をパースするために標準で使われているのはDocutilsという仕組みです。ただ、DocutilsはreST専用ではなく、他の記法のパーサを実装することもできるらしいです。 その意味でDocutilsは、Pandocと同じく、内部の抽象的なデータ構造へと記法を押し込めるツールだといえる気がします。

Docutilsについては『マスタリングDocutils』に詳しいので興味がある方は購入しましょう。

今日はDocutilsのことは忘れて、reSTの記法をPandocで読み込み、Pandocの抽象データ型(Pandoc構造)に押し込める話をします。

reSTのリストテーブル

reSTにはリストテーブル(List Table)という記法があります。 正確に言うとこれはreST本来の記法ではなく、reSTに備わっているディレクティブという仕組みで定義された拡張です。 ちなみにディレクティブとは、思いっきり単純にいうと、「ドキュメントの構造を作り出す局所ローカルな記法」を定義するためにreSTに用意されている仕掛けです。

リストテーブルは、表(テーブル)を2階層の箇条書きで書けるという便利な記法です。 上記の公式ドキュメントにはこんな例が載っています。

.. list-table:: Frozen Delights!
   :widths: 15 10 30
   :header-rows: 1

   * - Treat
     - Quantity
     - Description
   * - Albatross
     - 2.99
     - On a stick!
   * - Crunchy Frog
     - 1.49
     - If we took the bones out, it wouldn't be
       crunchy, now would it?
   * - Gannet Ripple
     - 1.99
     - On a stick!

上記のリストテーブルは、こんな感じのシンプルなテーブルとして最終的にレンダリングされることが想定されています。

Frozen Delights!
Treat Quantity Description
Albatross 2.99 On a stick!
Crunchy Frog 1.49 If we took the bones out, it wouldn't be crunchy, now would it?
Gannet Ripple 1.99 On a stick!

PandocのreST Readerでリストテーブルは読めるか

さて、PandocにはreSTのReaderもあります。 わりといろいろなディレクティブにも対応しているのですが、リストテーブルについては2017年5月ごろまで長らく未対応でした。 試しに当時のpandocコマンド(バージョン1.17.2がたまたま手元で利用可能でした)で上記の例を読み込んでみると、こんな感じに無視されてしまいます。 (pandocでは、-t nativeとすることで、Haskellの代数的データ型(をshowしたもの)の生の姿を見られます)

$ pandoc -f rst -t native temp.rst
pandoc: ignoring unknown directive: list-table "source" (line 19, column 1)
[]

一方、最近のpandocコマンドでは、こんなふうにPandoc構造として読み取ってくれます。

$ pandoc -f rst -t native temp.rst
[Table [Str "Frozen",Space,Str "Delights!"] [AlignDefault,AlignDefault,AlignDefault] [0.0,0.0,0.0]
 [[Plain [Str "Treat"]]
 ,[Plain [Str "Quantity"]]
 ,[Plain [Str "Description"]]]
 [[[Plain [Str "Albatross"]]
  ,[Plain [Str "2.99"]]
  ,[Plain [Str "On",Space,Str "a",Space,Str "stick!"]]]
 ,[[Plain [Str "Crunchy",Space,Str "Frog"]]
  ,[Plain [Str "1.49"]]
  ,[Plain [Str "If",Space,Str "we",Space,Str "took",Space,Str "the",Space,Str "bones",Space,Str "out,",Space,Str "it",Space,Str "wouldn't",Space,Str "be",SoftBreak,Str "crunchy,",Space,Str "now",Space,Str "would",Space,Str "it?"]]]
 ,[[Plain [Str "Gannet",Space,Str "Ripple"]]
  ,[Plain [Str "1.99"]]
  ,[Plain [Str "On",Space,Str "a",Space,Str "stick!"]]]]]

しかし、これには実は制限があります。 リストテーブルでは、テーブルのヘッダとなる行の数を:header-rows:で指定できるのですが、これを2にしても、常に最初の1行だけがヘッダになります。 たとえば次のようなリストテーブルをPandocでMarkdownに変換してみると…、

.. list-table:: 
   :widths: 15 10 30
   :header-rows: 2

   * - Treat
     - Quantity
     - Description
   * - Albatross
     - 2.99
     - On a stick!

こうにしかなりません。

$ pandoc -f rst -t markdown temp.rst
  Treat           Quantity   Description
  --------------- ---------- -----------------------------------------------------
  Albatross       2.99       On a stick!

Markdownにヘッダ行が複数のテーブルがないからだろ、と思うかもしれませんが、HTMLでも同じです。

$ pandoc -f rst -t html temp.rst
<table>
<thead>
<tr class="header">
<th>Treat</th>
<th>Quantity</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>Albatross</td>
<td>2.99</td>
<td>On a stick!</td>
</tr>
</tbody>
</table>

この連載をずっと読んでもらっていればわかると思いますが、これは「複数行ヘッダのテーブル」を表す型がPandoc構造にないからです。 どんなにがんばってReaderを実装しても、このリストテーブルをreSTの意図通りに読むこと、つまりDocutilsと同じように読むことはできないのです。

宣伝

最後に宣伝ですが、このPandocのreSTリストテーブル対応、ぼくがやりました。 何もコメントせずにいきなりPRを出したらjgmから瞬時に怒られが発生して青くなったのは懐かしい思い出です(最初はPRを出すつもりなかったけど操作ミスでこうなった)。

そもそもなんで実装したかといったら、『Goならわかるシステムプログラミング』の原稿がreSTで、アスキー.jp連載時はこれをPandocでHTMLにしてアスキーさんに入稿してたんですが、そのときにPandocがリストテーブル未対応で困ったからなのでした(なおSphinxを使わなかったのは、入稿仕様のHTMLにするPandocのテンプレートをすでに作っていて、それを使いまわしたかったからです)。

こんな感じに編集ツールを作るようなお仕事もできるので、そうしたお仕事があったらご連絡ください。

Pandocの抽象データ型

Pandocのいいところは、構造をさまざまな記法から暗黙に読み取ってくれる点です。 ただし、その構造はPandocの内部で定義された抽象データ型であり、利用者の目的に合わせて増改築することはできません。 XMLLaTeXでやるフルフルの構造化文書に比べると良い意味でも悪い意味でも制限があるので、昨日の記事ではPandocによる構造化文書の扱いを「ライトウェイト」と呼びました。

今日は、この「抽象データ型によるライトウェイト構造化文書」について、Pandocの抽象データ型を例にもうちょっと具体的に説明します。

Pandocの抽象データ型

なにはともあれ、Pandocの抽象データ型がどんなかを見てみましょう。

Pandocにおける文書とは、「ブロック要素」と「インライン要素」からなるデータです。 この概念の具体的な姿は、Haskellの代数的データ型という仕組みを使って、pandoc-typesというHaskellのパッケージで定義されています。

hackage.haskell.org

このパッケージで定義されているPandocの「型」を簡単に紹介します。 まず、これがPandocのインライン要素を表す代数的データ型です。

data Inline
    = Str Text              -- 単純な文字列
    | Emph [Inline]         -- 強調
    | Strong [Inline]       -- もっと強調
    | Strikeout [Inline]    -- 打ち消しの文字列
    | Superscript [Inline]  -- 上付き文字列
    | Subscript [Inline]    -- 下付き文字列
    | SmallCaps [Inline]    -- スモールキャプスの文字列
    | Quoted QuoteType [Inline] -- 引用の文字列
    | Cite [Citation]  [Inline] -- 文献参照
    | Code Attr Text        -- 等幅の文字列
    | Space                 -- 空白
    | SoftBreak             -- ソフト改行
    | LineBreak             -- ハード改行
    | Math MathType Text    -- TeX記法の数式
    | RawInline Format Text -- HTMLやLaTeXの生データ
    | Link Attr [Inline] Target  -- ハイパーリンク
    | Image Attr [Inline] Target -- 画像
    | Note [Block]          -- 脚注や後注
    | Span Attr [Inline]    -- 汎用のインライン要素

Pandocにおけるもっとも基本的な型のひとつは、素の文字列Textに対してStrという名前を付けたStr Textであり、これがインライン要素を表す型Inlineの代表選手です。 なお、Strのような名前のことを、Haskellでは「構成子(値構成子)」と呼びます。

複数のインライン要素Inlineを集めたものは[Inline]という型で表します。 上記の定義には、「[Inline]Emphなどの構成子を付けたインライン要素」もいくつかあることがわかります。

さらに、いくつかの構成子にはAttrという型の引数もあります。 これは、HTMLとかXMLにおける「属性」に相当する型です。 StrAttrを取らない構成子なので、素の文字列には属性が付けられません。 こうした要素に属性を付けたい場合や、いくつかのインライン要素を束ねて特に意味を持たないインライン要素にしたい場合には、Spanを使います。 これはHTMLの<span>に相当します(余談ですが古代のPandocにはSpanがなくてわりと苦労しました)。

次は、Pandocのブロック要素を表す代数的データ型です。

data Block
    = Plain [Inline]        -- 段落ではないブロック
    | Para [Inline]         -- 段落
    | LineBlock [[Inline]]  -- 複数行からなるブロック
    | CodeBlock Attr Text   -- コードブロック
    | RawBlock Format Text  -- HTMLやLaTeXの生データ
    | BlockQuote [Block]    -- 引用のブロック
    | OrderedList ListAttributes [[Block]]  -- 順序付き箇条書き
    | BulletList [[Block]]                  -- 箇条書き
    | DefinitionList [([Inline],[[Block]])] -- 見出し付き箇条書き
    | Header Int Attr [Inline] -- 見出し
    | HorizontalRule           -- 水平線
    | Table [Inline] [Alignment] [Double] [TableCell] [[TableCell]]  -- 表
    | Div Attr [Block]       -- 汎用のブロック要素
    | Null                   -- 何も含まないブロック

多くのブロック要素は、「インライン要素の集まり[Inline]に構成子を付けたもの」として定義されているのが見て取れると思います。 箇条書き(OrderdListとかBulletListとか)のように、他のブロック要素の集まり[Block]によって構成されているブロック要素もありますね。

以降では、このBlockInlineで定義されたPandocの抽象データ型のことを、「Pandoc構造」と呼びます。

Pandoc構造に当てはまらないものはどうなるか

原稿をPandocで読み込むと、その原稿に含まれている内容は、元の原稿の記法が何であれ、すべてPandoc構造として保持されます。

しかし、たとえば元の原稿がDocBookであったとして、DocBookのスキーマには、このPandocの代数的データ型では表現できないような構造もたくさんあります。 たとえば、初出の用語を表す目的で用意されている構造としてDocBookには<firstterm>というものがありますが、これに相当する構成子はPandocのInline型にはありません。 組版された表示結果で何らかの強調をするといった効果を期待したいだけならEmphマッピングできそうな気もしますが、元の構造は索引項目の抽象などにも使えるように意図されているので、どうやっても別ものにしかならないでしょう。

また、<sidebar>というブロック要素もPandoc構造には対応するものがありません。 これは、いわゆる「コラム」のように、本文とは切り離された余談などを記述するために使うXML要素です。

では、DocBookの原稿に出てくる<firstterm><sidebar>で囲まれた部分は、Pandocではどのように扱われるのでしょうか? 残念ながら、Pandocでは、そもそもこれらのタグを認識してくれません。 単純に要素が無視されるわけではなく、知らないタグがスルーされます。 つまり、その中に出てくる文字列や他のXML要素は読み取って、それをできるだけPandoc構造にマッピングしようと試みます。

なお、上記では未知のタグはスルーされると言いましたが、これはDocbookを読むときの話で、他の記法を読むときにもスルーされるとは限りません。 Pandocでは入力の記法をパースして代数的データ型として読み込む仕組みのことを「Reader」と呼んでおり、さまざまな記法ごとにReaderが用意されています。 そして、そのReaderの実装によって、記法からPandoc構造への対応も異なるからです。 DocBookのReaderの方針がたまたま「知らないタグはスルー」なのであって、他のReaderがどういう方針で実装されているかはまちまちです。 たとえばreSTのReaderは未知のディレクティブを無視します(記法で定まる構造を無視することと、記法をスルーすることは、違う)。

Readerの方針を知るには、実装を見るしかありません。 DocBookのReaderの実装は以下で参照できます。

DocBookの場合には、このReaderの冒頭に、対応するタグと対応しないタグを示したチェックシートが並んでいます。 それを見るとわかるように、ほとんどのタグは未対応のままです。 たぶん今後も完全に対応することはないでしょう。

PandocではDocBookに対応しているとされていますが、このReaderの実装を見ると、処理結果が元のDocBookの原稿で意図されていたものになるとは限らない(むしろならないことのほうが多い)と考えるほうがよさそうだとわかると思います。

構造化文書から記法を分離しないとPandocの話がしにくい

DocBookは、OASISという団体が管理しているガチガチの構造化文書の仕組みです。

昨日の話で構造化文書から記法を分離し、「Pandocは記法の変換」という話をしたのは、今日の話のような状況、つまりPandocが何らかの構造化文書に対応しているといってもそれは標準化されたフルフルの規格に対応しているという意味ではなく、あくまでも「記法からPandoc構造が得られるよ」、もしくは「Pandoc構造から記法が得られるよ」という意味であり、ライトウェイトに構造化文書をやる仕組みであることを強調したかったからです(余談ですが、プログラミング言語の型がライトウェイトな形式手法と呼ばれていることを意識してライトウェイトと言っています)。

ここで、昨日の最後の図を再掲します。 この図は、それぞれの構造化文書の標準的な利用方法を横方向の流れで示したとして、Pandocにおける「対応」はあくまでも「Pandoc構造という抽象データ型に照らして記法を読み書きできる」という意味だ、というふう読んでほしかったものです。

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

ところで、いままでWriterの話をしてませんが、実はWriterの実装について気にすることはあまりありません。 というのは、Pandocにはフィルターという仕組みがあり、これによって「いったんPandoc構造になったものを何らかの記法で出力する」際にはカスタマイズが可能だからです。 もちろん、Pandoc構造として読み取られていない元の原稿の記法にあった情報を復元することはできませんが、読み取られた範囲で好きなように出力を組み立てることは容易です。

逆に、ある記法の原稿が「Pandoc構造として読み取られる方法」を変えたい場合には、Readerを改造して対処するしかないとも言えます。 明日はそのような事例を紹介します。

ライトウェイト構造化文書

このアドベントカレンダーでは、先週まで、主にページメディアにおける「PDF」と「XML」の話をしてきました。 この2つ、それぞれ「Webブラウザでのレンダリング」と「HTML」に言い換えると、ウェブメディアの世界観と似ている気がしてこないでしょうか。

実際のところ、ウェブメディアとページメディアって互いに遠い存在ではなく、「平面に文字などを配置することを目的とした構造化文書」という視点に立てば、むしろ兄弟みたいなものです。

Webブラウザへの表示がHTMLだけで済むわけないだろ、CSSJavaScriptの役割を知らないのか」という指摘が聞こえてきそうですが、ページメディアにもそれらに相当するものは存在します。 というか、いろいろありすぎて、もはや収拾がついていません。 ページメディアでわりと標準っぽいのは、CSSの役割がXSL-FO、JavaScriptの役割がXSLTという、いわゆるXML組版的な世界観でしょう。

ちょっと話は逸れますが、個人的にこの世界観で面白いなと思うのは、「すべてを同じ構文で済ませよう」とする点です。 XSL-FOでも、XSLTでも、山かっこXML構文が採用されています。 特別なGUIアプリを使わない限り入力も山かっこXML構文なので、おはようからおやすみまですべてがXMLになります。

もっとも、TeXにしても入力から表示制御まですべてを同一の記法で完結させているので、むしろ面白いのは、HTMLとCSSJavaScriptという異なる記法の技術を当然のように組み合わせているウェブの世界観のほうなのかもしれません。 仕組みを考える側からしても、入力も変換も表示制御もすべてを同じ構文で済ませるほうが、周辺ツールなどを統一的に扱えるので理想的でしょう。 とくにXMLだと構文エラーが機械的に判別しやすく、さらにスキーマを適切に扱えば妥当性検証のようなご利益がもれなくついてくるので、そのような傾向が強いのかもしれません。

ただ、ぼくには、XML組版TeXのように単一の記法ですべてを完結させる世界観はちょっと馴染めませんでした。 馴染めなかったというよりは、そこまでの腕力がなかっただけともいえます。 そこでウェブメディアにおいてCSSが担っている役割はLaTeXJavaScriptが担っている役割はSchemeにして、商品としての本を作れていますよというのが、先週の話です。 「ふつうの書籍くらいの規模であればXML組版でなくても構造化文書からの自動組版は十分に可能である」という話だったと思ってもらってもかまいません。 (もちろんこれはXML組版の否定ではなく、たとえばDITAの導入が必要といった具体的な目的意識がある状況であれば、この連載のようなXML組版に対する態度が役に立たないのは言うまでもありません。)

意味の構造、見た目の構造、表示結果

前置きが長くなりましたが、「平面に文字などを配置することを目的とした構造化文書」から実際にコンテンツを平面に配置するまでの流れ的なものを整理しておきます。 いろんなモデル化が考えられると思いますが、ここではまず「意味の木構造」、「見た目の構造」、「表示結果」の3段階で考えます。 これはWebブラウザのモデルに沿ったモデルで、「意味の木構造」は入力されたHTMLもしくはJavaScriptでいじれるDOMツリー、「見た目の構造」はそれにCSSを適用したレンダーツリー、「表示結果」はWebブラウザが表示するレンダリング結果に相当します。 このブログ的には、「意味の木構造」はTeX記法やXML記法の原稿、「見た目の構造」はPDFのドキュメント構造、「表示結果」はPDFビューワーが表示するレンダリング結果です。

f:id:golden-lucky:20191214181923p:plain
3段階モデル

XML組版は、これの最初の2つの段階にかかわる要素技術をすべてXMLでやろうというアプローチだといえます。

f:id:golden-lucky:20191214181948p:plain
XML組版の世界観

LaTeX組版の場合、そこまでとんがった使い方をしたがる人は実際には少なく、見た目の構造にはDVIドライバの存在もあったりしてTeX構文でない世界になりますが、このモデルに当てはめるとだいたいXML組版と同じようなノリにはなります。

f:id:golden-lucky:20191214182012p:plain
LaTeXの世界観

構造は記法とは別

今日の本題はここからです。 先ほどの3段階モデルには、実際にはその前に「記法」があると考えるのが自然でしょう。

f:id:golden-lucky:20191214182031p:plain
記法を分離

XML組版LaTeX組版だと記法がXMLTeXそのものなので意識されにくいんですが、人間が読み書きする記法を「意味の木構造」の生の姿と同一視する必要はないので、ここは区別して考えられるはずです。

f:id:golden-lucky:20191214182103p:plain
XMLLaTeXだと記法と意味の木構造が同一視されがち

ここの同一視を明確に区別して考えると、いろいろ説明がしやすくなります。 たとえば、Re:VIEW(処理系)やSphinxは、Re:VIEW(記法)やreStructuredTextを「記法」としてそれぞれが内部で利用する「意味の木構造」を作り、RubyPythonで調整しつつLaTeXやHTML/CSSによる「見た目の構造」へと変換し、それらの仕組みによって表示結果を得る仕組み、と説明できます。 CSS組版なんかも同じように説明できるでしょう。

f:id:golden-lucky:20191214182142p:plain
ひろがる可能性

さらに、記法を意味の木構造と区別すれば、ある記法から非標準的な方法で表示結果を得ることもできます。 実際、当社の『プロフェッショナルSSL/TLS』の制作フローでは、記法としては原書と同じXMLを使っていながら、それを標準的なXML組版の流れでなく、LaTeXによる「見た目の構造」へとSchemeで変換することで最終的な表示結果を得ています。

Pandocの構造は構造化文書の構造ではない

「記法」と「構造」、とくにこのモデルでいう「意味の木構造」を明確に区別しようという話は、先週の記事でもときどき触れていました。 なんでこれをそんなに強調しているかというと、この区別をつけないと「構造化文書」の話が「記法」の好き嫌いの話になってしまいやすいからです。 XML組版TeXは「記法」と「構造」に見た目上の区別があんまりないので、「記法」の好き嫌いで選んでしまうと、他の部分の特性を見失います。 また、部分の特性ばかりを強調すると、「記法」がつらくても我慢して使えみたいな話になり、これはこれであまり健全ではないと思います。

さらに、「記法」と「構造」を混ぜて話すと、HTMLベースの「意味の木構造」に対する「記法」として生まれながら後で「記法」の部分だけが独り立ちしたMarkdownの可能性が見えにくいでしょう。

もうひとつ、この混同を放置したままだと可能性をうまく生かせなくなる技術があると思っていて、それがPandocです。 Pandocの価値の中核は「記法」レベルでの変換であり、変換対象の「記法」がもともと想定している「意味の木構造」より後ろの工程は、Pandocにとってはおまけでしかないからです。

f:id:golden-lucky:20191214182222p:plain
Pandocの核は記法の変換と抽象データ型の提供

各「記法」が想定している「意味の木構造」の代わりに、Pandocでは独自の抽象データ型を利用します。 その抽象データ型から、さまざまな「意味の木構造」のサブセットが作り出せるようになっています。 そして、この抽象データ型を暗黙に構成するための「記法」として、PandocのMarkdown記法があります。

ちなみに上の図にも示しましたが、このPandocと同じ世界観の仕組みとして、Sphinxがバックエンドで利用しているDocutilsがあります (Docutilsをこのようなモデルに合わせて使ったことがないのですが、『マスタリングDocutils』を読んだ限りでは、たぶんそう)。

もうひとつちなみに、このような「記法から何らかの抽象データ型を暗に読み取り、そこから後段の処理系が自由になるような意味の木構造を生成し、そっちの処理系で表示結果を得る」という戦略は、謎の記法の原稿に対峙するときの基本技でもあります。 そのための武器としてPandocやDocutilsを使ってもいいし、自分でパーサを書いてもいいでしょう。

そんなわけで、構造化文書だからといってXMLLaTeXのような「意味の木構造」のための従来のフレームに縛られる必要はなく、「記法とそれに対する抽象データ型」でライトウェイトに構造化文書をやっていくのがトレンドになるといいなと考えています。