golden-luckyの日記

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

抽象データ型を自作する

昨日の記事では「書籍のマクロな構造」について話しました。 このマクロ構造はPandoc構造には組み込まれていません。 そのため、Pandocで書籍を作ろうと思うと、どうしたってPandoc構造にない部分を扱う別の仕組みが必要になります。 素のPandocでは、「書籍のマクロな構造を扱える外部の仕組み」を託す先として、主にLaTeXを利用しています。

裏を返すと、LaTeXは、書籍のマクロな構造を扱える仕組みです。 それなら最初からPandocではなく、LaTeXで本を作ればいいのではないでしょうか?

この反論はもっともです。 実際、本を作るプロは黙ってLaTeXであったり、あるいはInDesignであったり、あるいはFrameMakerであったりを使います。 組版のプロの要求を実現するためには、これらのツールが持つ表現への自由度が必要だからです。

しかし原稿をもらう立場からすると、この高い自由度がかえって仇になるケースのほうが多い。 原稿の肝の部分はPandoc構造くらいに制限されたセマンティクスで書いてもらえるほうがよい。 そのようなわけで当社では、「Pandocで処理をすることを前提としたMarkdown」を、原稿の執筆のための最初の記法として選択することにしました。 Pandoc構造は、最大公約数的な意味で、とてもうまくできていると思います。

なお、LaTeXに関していうともうひとつ難点があって、それは「LaTeX原稿は再利用が容易とはいえない」ことです。 これは、「Markdownのほうが再利用しやすい」という意味ではないので注意してください。 再利用しやすいものがあるとしたら、Markdownのような記法ではなく、そのセマンティックな構造を直接表した抽象データ型のほうです。 Pandocが多様な形式のドキュメントに対応できているのも、この抽象データ型の再利用の容易さの表れだといえるでしょう。

そんなわけで、「これから書く」ものに関してはPandoc構造を前提とした記法でいいと思います。 しかし、世の中には、「すでに書かれたものでPandoc構造では不足がある」ような原稿も当然あります。 標準的な処理系が利用できるLaTeXなどの原稿であればいいんですが、そうではなかったり、そもそも原稿の記法が未知であるようなケースもあります。 そうした原稿を手にした場合に、どうやってそれを手懐けるかというのが今日の話です。

結論からいうと、Pandocが採用しているアプローチ、つまり「記法から代数的データ型を読み取って、それを目的に合わせて出力する」という方針で挑みます。 具体的には、次の3つの仕組みを実装します。

  • その記法が表す構造を押し込めるのに都合がよさそうな抽象データ型の定義
  • それに合わせて記法から構造を読み取るReaderの実装
  • 抽象データ型に対する出力を定義したWriterの実装

Pandocの場合には、最初に意識されていた記法が「Markdownのそれ」だったといえるでしょう。 その実装であるPandoc構造よりもリッチな構造を読み取れそうな記法で書かれた原稿があったなら、その原稿を眺めながら自分で代数的データ型とそれをターゲットにしたReaderを実装し、自分が扱いやすいLaTeXやHTMLのような記法へと変換するWriterを書く、すなわち、自分専用のPandocを実装すればいい、というわけです。

未知の記法に向き合う

処理系が手に入らない未知の記法で書かれた原稿を手にする機会がある人は少ないと思うので、人口的ですが説明のための小さな例を用意します。

\chapter{Lorem Ipsum}

The quick \xdefn{brown fox}\emph{brown fox} jumps over the lazy dog (\ref{fig:fox}).

\bottomfloat \float{figure}
\include{fig1}
\caption{The fox}
\tag{fig:fox}
\endfloat

\section{Dolor sit amet}

Our \mono{etaoin shrdlu}.

\sidebar{consectetur adipiscing elit}
Ut enim ad minim veniam, quis nostrud exercitation.

Ullamco laboris nisi ut aliquip ex ea commodo consequat.
\endsidebar

\section{Excepteur sint occaecat cupidatat non proident, \titlenl; sunt in culpa qui officia}

\list{bullet}
\item Li Europan lingues

\item Lorem Ipsum
\endlist

これ、LaTeXに見えるかもしれませんが、LaTeXではありません。 ただ、TeXのアプリケーションではあります。 TeXというのは、いわば自分自身のオリジナルなドキュメントシステムを作るためのフレームワークで、その実装のひとつがLaTeXであり、LaTeX以外にもさまざまなフレームワークが存在します。 上記は、そのような「どこかのオリジナルなTeXベースのドキュメントシステム」の原稿のつもりです。 (なお、ConTeXtというLaTeXとは別のフレームワークかな、と思った人もいるかもしれませんが、それも違います。)

ここで選択肢は大きく2つあるでしょう。

  1. このTeXアプリケーションをTeX言語で実装する
  2. この原稿の記法をLaTeXに書き換える

具体的な仕様が公開されているTeXアプリケーションであれば、1もありえると思います。しかし、その望みが薄い場合、2のほうが無難でしょう。 とはいえ、この記法をどうやってLaTeXに書き換えるかは、それほど自明な問題ではありません。

ちなみに、この原稿に見えるTeXっぽいけどLaTeXにはないもの、たとえば\sidebarとか\list{bullet}TeXLaTeXの命令として定義してlatexコマンドで実行するという方針は、最悪です。 上記くらい単純だとそれも可能かもしれませんが、未定義のコントロールシーケンスっぽいやつを\defとかでアドホックに定義しても、全体の整合性はまずとれません。エラーを回避しながら何とかPDFにできたとしても、まともに組版できるとは限りません。

これはLaTeXに書き換えるという方針を採用する場合でも同じです。 たとえば、エディタの正規表現で原稿中のコントロールシーケンスっぽいやつをLaTeXのそれっぽいコマンドに変換していくだけでは、事態を悪化させる可能性が高いです。

そこで、Pandocのアプローチに倣って、この未知の記法から構造を取り出すことにします。 構造さえ取れれば、そこから先の工程をある程度は形式的に進められます。 何回か前の記事でPandocのアプローチのことを「ライトウェイト構造化文書」と表現しましたが、そこで含意していたのは、Pandocのアプローチにはこういう性質があるからだったのでした。

抽象データ型の定義

上記の記法を眺めつつ、必要そうなデータ型をHaskellで定義しましょう。 なんどかやっていると勘所がつかめてきます。 ブロックとインラインに分けるみたいな常套手段はあるんですが、結局は勘と経験だと思います。

この例の場合は、たぶんこんな感じに落ち着くと思います。

data MyBlock = Para [MyInline]
             | Header Int [MyInline]
             | FloatElem FloatType FloatPosition [MyBlock]
               Tag Caption [OuterResource]
             | ListItems ListType [([MyInline], [MyBlock])]
             | Sidebar [MyInline] [MyInline]
             | MyEmpty

data MyInline = TextString DT.Text
              | EmphString [MyInline]
              | TtString [MyInline]
              | Ref RefType Tag
              | Label Tag
              | Command DT.Text
              | CaptionCmd Caption

一発で決まることはまずないので、とりあえずデータ型を定義してみる→ReaderとWriterを作って結果と記法を見比べる→定義を追加したり修正したりする、の繰り返しになります。 というわけで、ReaderとWriterを作る話に移りましょう。

ReaderとWriterを作る

Readerは、ここまでの記事でなんどか登場しているParsecで作ります。 Parsecにおける「パーサ」とは、「局所的なパターンを見つけ、必要なデータ型としてreturnする機能をもった関数」のことです。 そういうパーサをいくつか組み合わせることで多機能なパーサを作り、「テキスト全体から構造を見つけてきてデータ型として吐き出す」というのがParsecのノリです。

たとえば、上記のうちブロック要素の構造を見つけてきてMyBlockを吐き出すようなパーサ(関数)は、こんな感じに複数の単機能のパーサの選択として定義できるでしょう。

myBlock :: Parser MyBlock
myBlock = choice
          [ try emptyLine
          , try myInclude
          , try myHeader
          , try myFloat
          , try myList
          , try mySidebar
          , myPara
          ]

emptyLine とか myPara みたいなのが単機能のパーサです。 先に定義した代数的データ型の各部に、各関数がそのまま対応していることが見て取れると思います。 このように代数的データ型との直観的な対応でパーサを定義できるのがParsec(というかパーサコンビネータ全般)の便利なところだと思います。

単機能のパーサは、原稿で記法がどう使われてるかを見て、それをそのまま定義として書き下します。 詳細は省きますが、 mySidebar であれば、「"\sidebar"という文字列に続いて波カッコで括られた文字列があり、そのあとにブロック要素が "\\endsidebar" という文字列が出てくるまで続く」ようなパターンの部分を見つけ出してきて、それを「Sidebar 型として return する」ような関数として定義する、という感じです。

mySidebar :: Parser MyBlock
mySidebar = do
  title <- string "\\sidebar" *> bracedMyInline <* spaces
  contents <- manyTill
              myBlock
              (try $ string "\\endsidebar")
  return $ Sidebar title contents

bracedMyInline = 
  string "{" *> manyTill 
    (try myInline <|> (TextString "\n" <$ (many (string " ") >> newline)))
    (try $ string "}")

Parsec、まじで強いので、テキストを処理するお仕事の人はこのためだけにHaskellを勉強しても損はしないと思います。

Writerを作る

WriterはReaderに比べると簡単で、構造に対する show メソッドを「LaTeXのソースを出力するもの」として定義してあげるだけです。

writeLatex :: [MyBlock] -> DT.Text
writeLatex myAST = DT.concat $ concatMap blockToText myAST

blockToText :: MyBlock -> [DT.Text]
blockToText (Para is) = "\n" : (map inlineToText is)
〈その他いろいろなブロック要素の出力方法をパターンマッチで定義〉

inlineToText :: MyInline -> DT.Text
inlineToText (TextString txt) = txt
〈その他いろいろなブロック要素の出力方法をパターンマッチで定義〉

実際には細かいところはいろいろ考えて実装しないといけないのですが、ノリとしてはこんな感じになるはずです。 時間と体力の都合により全体の実装は省略します。

まとめ

冒頭に提示した例は、実際に過去に自分が出会った「処理系が手に入らないTeXアプリケーション」の原稿を基にしています。 そのTeXアプリケーションはZzTeXというもので、英語圏の専門書の出版社でときどき採用されているようです。 調べたら、TUGboatに紹介記事がありました。

このときZzTeXの原稿をLaTeXへ変換するのに書いたプログラムでは、抽象データ型の定義だけで、最終的に全部で100行くらいの規模になりました。 今回の記事で例としてでっちあげた程度のドキュメントであれば、Pandoc構造に合わせてReaderを書くので十分かもしれません(足りない部分はDivとSpanでやる)。 しかし、これくらいの規模の構造を掘り起こさないといけない場合には、今回のような戦略で変換器を書くのがいちばん近道に思えます。

なお、この記事で「TeXアプリケーション」と呼んだものは、「TeXのフォーマット」とか「TeXのマクロパッケージ」とかって呼ぶほうが正確です。 マクロとかフォーマットというと、LaTeXをなんとなく使ったことがある人が知っているような気がする別のものが想起されやすいので、ここではTeX言語によるアプリケーションと表現することにしました。

明日はEPUBライターの話を予定していて、ぜんぜん話が変わるようですが、またHaskellを使います。

Markdownで書籍を作るとは

昨日まで何回かにわたり、多様なドキュメント形式の変換アプリケーションであるPandocのコアとなる仕組みを説明してきました。 特に、Pandoc構造とそれを生成するReader、生成されたPandoc構造を変換するPandocフィルターについて、少し時間をかけて紹介しました。

では、PandocのReaderとフィルターについて理解したところで、Pandocを使って本は作れるでしょうか?

いままでの説明には登場しませんでしたが、Pandocの出力側を担うWriterには、「PDF生成のためのLaTeX Writer」や「EPUB Writer」など、「本」を作るのに使えそうなものがあります。 それらWriterを制御するためのコマンドラインオプションはいろいろ用意されており、独自のテンプレートを指定することも可能です。

ただ正直なところ、これらのWriterは、吊るしの状態では売り物の本を作れるほどには洗練されていません。 本にはPandoc構造では表現できないより大きな構造があり、コマンドラインオプションやテンプレートでそれを完全に制御するのは難しいからです。 今日は、そのような書籍のマクロ構造を手なずける必要がある例として、書籍の前付けと本文とを区別する話をします。

Pandocで書籍のマクロ構造に対処する3つの方針

「ドキュメント」と一口にいっても、その用途に応じていろいろ形が決まっています。 その形を定める国際的な規格が成立しているものもあれば、長年の慣習でなんとなく定型ができているものもあります。 いわゆる書籍については、これといった公的な規格はありませんが、かなり長い年月にわたって受け継がれてきた形があり、それがマクロな構造を形成しているといえるでしょう。

ほとんどの日本の書籍は先頭付近に扉絵やクレジットがあり、序文などに続いて目次、それから本文、巻末にあとがきや参考文献、索引、奥付で終わるという構造になっています。 一方、Pandoc構造で与えられるのは、HeaderParaのようなブロック構造、あるいはそれらを構成するインライン構造だけです。 Pandocで本を作るときには、どうやってマクロな構造を組み立てればいいでしょうか?

さまざまな方針はあるでしょうが、おおまかには次の3つに区分できると思います。

  1. PandocのWriterもしくはフィルターでやる
  2. Pandocのメタデータとテンプレートでやる
  3. Pandocでやらない

1つめの方針は、昨日までの記事でだいたい想像がつくと思います。 マクロな構造をある程度決め打ちにし、必要があればフィルターで整えて、Writerで一気に出力するというものです。 EPUB Writerはこの方針だといえるでしょう。

2つめの方針は、LaTeXやConTeXtを介したPDF出力が該当します。

想像に難くないと思いますが、ぼくが採用しているのは3つめの方針です。 全体のワークフローを示すとこんな感じになります。

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

マクロな構造は完全にLaTeXで用意しておき、PandocはあくまでもコンテンツをMarkdownからLaTeXの章や節に変換するのに使っています。 この方法の利点は、書籍のマクロ構造に関して完全に枯れた技術であるLaTeXのパワーを余すところなく利用できることです。 欠点は、Pandocで完結しないのでMakefileのような仕組みが必要になることです。 もっとも、この欠点は高い柔軟性という利点でもあります。

前付けの\section\section*にする3つの方針

書籍のマクロ構造についてPandocの側では気にせず、LaTeX側で面倒を見るという方針を取るのはいいとして、PandocでLaTeXへと変換したコンテンツが、そのままの形でLaTeX側で要求する書籍のマクロ構造の要件に合致するとは限りません。 たとえば、書籍では通常は章や節に連番を振りますが、前付けの「序文」などでは連番を振りたくありません。 この両者を区別するのに、LaTeXでは \section{...} コマンドと \section*{...} コマンドを使い分けます。 LaTeXで書籍を作るとき、つまり書籍のマクロ構造を組み立てるときには、「前付けでは見出しに \section*{...} を使い、本文では見出しに\section{...}を使う」という使い分けを、人間がやっています。 これをPandocの出力で切り替えることは、標準ではできないはずです(それともできたっけ?)。

なお、もし書籍のマクロ構造についてもPandocで組み立てる、という方針にしたならば、連番そのものをPandocに作らせるという手はあります。 その場合には、pandocコマンドに--number-sectionsを付けたり付けなかったり、あるいはヘッダに{.unnumbered}という属性を付けたり付けなかったりすることで、前付けと本文での \section の使い分けが可能です(実際には全部が \section のままで、unnumberedの場合はテンプレートで「\setcounter{secnumdepth}{0}」になる)。 しかし、こうしたマクロな構造は、LaTeXの側で対処したいものです。Pandocの標準的な挙動に頼らない別の方法を考える必要があるでしょう。

別の方法としては次の3つの方針がありえると思います。

  1. Pandocフィルターでなんとかする
  2. 出力された.texファイルに対してsedrubyの変換スクリプトを走らせる
  3. TeX言語でやる

投げやりですが、好きな方法で対応すればいいと思います。

まとめ

今日の記事で言いたかったことは、「Pandocは書籍を作るためのツールではないので書籍が簡単に作れるわけではない」ということの再確認です。 特に、書籍のマクロ構造については、そのためにPandocが裏で使っているツール(TeX/LaTeXなど)まで下りてくる必要があります。 Pandocのメタ情報などを駆使してある程度まではパッケージ化できると思いますが、完全な柔軟性がほしい場合もあるでしょう。

なお、Re:VIEWとかSphinxのような「本を作ることをもともと考えている仕組み」には、書籍のマクロ構造を扱う標準的な方法が用意されています。 それらをMarkdown原稿から利用するのもいいでしょう。 ただ、Re:VIEWにしろSphinxにしろ、その書籍のマクロ構造を扱う部分のデフォルトはLaTeXのエコシステムに対するラッパーなので、そのラッパーではカバーしきれない範囲については、やはりLaTeXに下りてくるしかありません。 さらに、LaTeXTeXのアプリケーションのひとつなので、LaTeXで不満な部分についてはTeX言語まで下りてくるしかない、というのはPandocと事情は同じです。

一口に「Markdownで本を書く」といっても、Pandocで可能な範囲でやる、LaTeXなどの仕組みまで下りてくる、TeX言語まで下りてくる、というふうに「自分がどこで何をやっているのか」を意識すると、どこでどういう対策が可能かの選択肢も見えてくるだろう、というのが今日の話のひとつのテーマでした。

明日は「未知の記法を前にして人はどうするか」という話をします。 以降はすべて余談です。

前付けの\section\section*相当にする(TeXで)

そろそろPandocフィルターの話も飽きてきたと思うので、最後におまけとしてTeXでやる方法を考えてみます。 最終的なコードは示しますが、これで常に使える出力が安全に得られるとは限らないと思うので、注意してください。

さて、こういう要求が生じた場合、まずはlatex.ltxを見ます。 そこでは\sectionがこんなふうに定義されています。

\def\section{\secdef\@section\@ssection}

番号あり見出しが\@section、番号なしの見出しが\@ssectionで定義されており、これをスター*の有無で切り替えるのが\secdefというマクロです。 ということは、\secdefの2つの引数の順番を逆にすれば、\section{...}\section*{...}の機能を切り替えられそうだな、とわかります。 いま組版してるのが本文なのかそれ以外なのかは\if@mainmatterで判別できるので、こんな感じに書けばよさそうですね。

% うごかない
\def\section{%
  \if@mainmatter
    \secdef\@ssubsection\@subsection
  \else
    \secdef\@subsection\@ssubsection
  \fi}

もちろんこれは動きません。それがなぜかを知るには、もう少しまじめにTeX言語を読むことになります。

とりあえず関連するマクロの定義をlatex.ltxから書き出してみます。

\def\secdef#1#2{\@ifstar{#2}{\@dblarg{#1}}}
\def\@ifstar#1{\@ifnextchar *{\@firstoftwo{#1}}}
\long\def\@ifnextchar#1#2#3{%
  \let\reserved@d=#1%
  \def\reserved@a{#2}%
  \def\reserved@b{#3}%
  \futurelet\@let@token\@ifnch}
\let\kernel@ifnextchar\@ifnextchar
\def\@ifnch{%
  \ifx\@let@token\@sptoken
    \let\reserved@c\@xifnch
  \else
    \ifx\@let@token\reserved@d
      \let\reserved@c\reserved@a
    \else
      \let\reserved@c\reserved@b
    \fi
  \fi
  \reserved@c}
\long\def\@firstoftwo#1#2{#1}

これらの定義に従って、\section*{...}の展開がどう進行するかを確認してみましょう。

\section*{}\secdef{\@section}{\@ssection}*{}
    ↓
\@ifstar{\@ssection}{\@dblarg{\@section}}*{}
    ↓
\@ifnextchar *{\@firstoftwo{\@ssection}}{\@dblarg{\@section}}*{}

ここまでは単純な定義による置き換えだけです。 ここで\@ifnextcharの定義により、次のような代入が起こります。

  • \reserved@d*
  • \reserved@a{\@firstoftwo{\@ssection}}
  • \reserved@b{\@dblarg{\@section}}
  • \@let@token*

最後のは\futureletによる代入で、これにより残されるのは\@ifnch*{あ}です。 そこで次は\@ifnch*{あ}の展開を考えます。

\@ifnchの定義をみると、\@let@tokenの値に応じた条件分岐になっています。 いま、\@let@tokenは「*」になっています。 一方、\@ifnextcharの定義による代入で、\reserved@d*になっています。 そのため、この条件分岐の結果として残るのは、\let\reserved@c\reserved@aの部分です。 これは次のような代入です。

  • \reserved@c\reserved@a

そのうえで\reserved@cが展開されます。 先の代入により、\reserved@a\@firstoftwo{\@ssubsection}になっているので、結果的に\reserved@cもそうなっています。

\@firstoftwo{\@ssection}*{}\@ssection{}

これでようやく、「 \section*{...}という命令で\@ssectionが選択されてるまでには、こんな具合に展開が起こる」ということがわかりました。

TeX言語のつらさは、ここで話が終わらないことです。 ここまでの流れを注意して読むと、\@ifstarの挙動を逆にするためには、\@ifstarに渡される1つめの引数の展開を先に済ませておく必要がある、ということに気付きます。 したがって、こんな感じに \section を定義し直してあげれば、スターの有無の挙動を本文以外では反転できることになるでしょう。

\def\section{%
  \edef\stared@section{%
    \if@mainmatter\noexpand\@dblarg{\noexpand\@section}\else
    \noexpand\@ssection\fi}
  \@ifstar{\@dblarg{\@section}}{\stared@section}}

繰り返しになりますが、これをコピペして使うことは推奨しないので、この話はこれで終わりです。

なお、こういう感じに「LaTeXレベルの挙動を変えるのにTeX言語をもってする」ことは、「TeX on LaTeX」と呼ばれています。 これは「Ruby on Rails」のもじりですが、単なるもじりでそう言っているわけではなく、実際にこのRuby on RailsにおけるRubyRailsの役割が、TeXおよびLaTeXにそっくりそのまま対応していることによります。

どういうことかというと、RailsRubyで作られたアプリケーションのひとつであり、そのRailsでしか通用しない話を、まるでRubyの機能のように説明することはできません。 逆に、せっかくRailsを採用したのに、そのやり方に逆らって素のRubyだけでWebアプリケーションを作ろうとするのは悪手です。 TeXLaTeXにも、それらの関係に近い、いやむしろほとんど同じ関係を言うことができます。 「TeX on LaTeX」という表現には、「LaTeXの文脈で済む話をわざわざTeXでやり直すのは悪手である」という気持ちがあります。

あるフレームワークでドキュメントを作るということは、そのフレームワークに備わっている機能の範囲でドキュメントを作るということです。 Pandocを使うということはPandocが想定している使い方を受け入れるということであり、LaTeXを使うということはLaTeXが想定している使い方を受け入れるということです。 その範囲に収まらないことをしたい場合にはHaskellなりTeX言語なりまで下りてくるしかない、というのが今日までの話のひとつの結論だったような気がします。

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のテンプレートをすでに作っていて、それを使いまわしたかったからです)。

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