golden-luckyの日記

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

TeXの脚注をなんとかする

この記事はTeX & LaTeX Advent Calendar 2019の24日目の記事です。23日めはwtsnさんの記事でした。25日めは☃さんの記事です。

今年は3年ぶりにTUGに参加してきました。 TUGというのはTeX User Groupのことであり、TeX界隈の開発者とアドバンストな利用者からなる世界的なコミュニティです。 年に1回、地球のどこかで集まっていて、それもまたTUGと呼ばれています。 2013年には東京でも開かれました。

なお、今年はKnuthも参加しています。前回の参加は10年くらい前のことで、そのときはiTeXというネタ発表をしたことでも話題になりました。 今年は完全に聴衆として参加していたのですが、それでも圧倒的な存在感が圧倒的でした。

日本からはわたしを含めて5人が参加し、それぞれ思い思いのKnuth体験をしたようです。 わたしは圧倒的に圧倒されて距離をとっていたのですが、気が付いたら寺田さんたちが一緒に写真を撮っており、それに混ざっていたら、ちょっとだけお話をする機会が得られました。

実は、以前からKnuth本人に直接尋ねてみたかったことがあったのです。 それは、「Knuthの奥様は本当に脚注が嫌いなんだろうか?」ということでした。 というのも、"The TeXbook"の15章の末尾には次のような引用があって、これがずっと気になっていたからです。

Don't use footnotes in your books, Don.
--- JILL KNUTH (1962)

わたしは、一読者として脚注が好きです。 脚注が多い本はよくない、という一般論は理解しているのですが、これまで自分が本を読みながら、その内容を理解するにあたっては、脚注の存在に何度も助けられてきました。 リニアに読んで理解できるように編集されているのがベストというのは、ほんとその通りだと思います。 が、そもそも世の中はリニアに説明しやすいことばかりではなく、それで脚注をなくすことに腐心するくらいなら、むしろ脚注を濫用するくらいのほうがまし、とさえ思うのです。 本文で説明を完結させるのが前提として、すっきり書き上げた本文に脚注が追加されて解説に厚みが出ることは、読者としては歓迎すべきことではないだろうかと。

で、せっかくなのでKnuthに「奥様はほんとに脚注が嫌いだったの?」と質問してみました。 いま考えると唐突な質問でしたが、すぐに15章の引用のことを思い出してもらえて、「あの引用は本当に彼女が言ったことなんだ。けど、もちろん冗談としてね」と教えてもらえました。 まあ、おおむね予想の範囲内の回答だったといえますが、冗談であるという言質を得ることはできて安心しました。

さらに、「脚注をTeXに実装したということは、自分でも脚注を使うつもりはあったんですよね?」とも聞いてみました。 すると、「いままで4000ページくらい書いてきて、4つだけ脚注を使ったよ」と自慢されました。 どの本で使ったのかは聞き損ねたのですが、日本に帰ってから持っている本をぱらぱら見たところ、TAOCP 2edのVol.2に1つだけ見つけました。 ほかにKnuthの脚注を見つけた人がいたら教えてください(TeXブックで脚注の例として紹介されてるやつらは除く)。

TeXの脚注は出力されない場合がありますね

さて、その脚注なんですが、TeXには確かに脚注のための仕組みが実装されています。 しかし、この仕組みではあらゆるところに脚注を配置できません。 典型的に問題になるのは次のようなケースです。

\vbox{abc\footnote*{def}.} % この脚注は消える
\bye

LaTeXでも同様です。

\documentclass{article}
\begin{document}
\vbox{abc\footnote{def}.} % この脚注は消える
\end{document}

消えないまでも、脚注として期待される場所、つまりページの最下部に脚注が出ないという制限がある場合もあります。 「 minipage で箱の最下部に出てしまう脚注をページの最下部に出したい」というのはLaTeX初心者あるあるですね。 最近だと、 tcolorbox で同様の問題に苦労している人もいると思います。 対症療法として「\footnotemark\footnotetext を使う」というテクニックを知っている人は少なくないと思いますが、そもそもなんでこんな仕様になっているのでしょうか?

TeXはなぜ脚注を期待どおりに扱えないのだろう

TeXの脚注がいろいろアレなのは、TeXがページ組み立ての際に脚注を「特別な行」として扱うことが根本的な原因です。 大事なところなので、少しだけだけ詳しく説明します。

TeXは本文を組むとき、まず段落をいい感じの見た目になるように行分割します。 それから、各行を「メイン垂直リスト」(MVL)という場所にいったん集めます。 このMVLに行を集める処理のことを「ページビルダ」と呼びます。

いま、ある行に \footnote{...} が出てきたとしましょう。 ページビルダは、その脚注の中身を「特別な行」とみなし、MVLでは該当する行の直下に追加します。

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

ちょっと紛らわしいのですが、ページビルダは最終的なページを作るわけではありません。 最終的なページは、ページビルダがMVLに集めた行からさらに1ページ分の材料を配置することにより作り上げられます。 この作り上げられた最終的なページは \box255 という箱に入れられます。 また、その際に「特別な行」は「insert」という特別な箱に入れられます。 もともと脚注だったやつであれば、この特別な箱は \box\footins です。 最後にこれらの箱をDVIなりPDFなりの1ページとして出力するのが「出力ルーチン」(OTR)という仕組みです。

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

ここでポイントになるのは、 \vbox の中に \footnote が出てきてもページビルダがそれを「特別な行」として扱えない、という事実です。 それどころか、ページビルダは \vbox の中を覗きません。 つまり、そもそも脚注があることに気付かないのです。 というわけで、 \vbox の中に出てくる \footnote は、かなり早い段階でTeXからは「不可視」の存在になっているのです。

脚注をMVL上で可視にすればよい

原因が見えれば対処の方法も考えられるというものです。 具体的には、不可視の脚注をページビルダから見えるようにしてMVL上に載せる方法を考えます。

とはいえ、ページビルダはどうやっても \vbox の中は見ません。 なんとかして \footnote の中身を \vbox の外に出して上げる必要があります。

それを手動でやる方法が \footnotetext テクニックです。 このテクニックで実際にやっていることは、「その \vbox が配置されるページ」と同じページの材料としてMVLに載る場所(つまり本文のどこか)を人間が判断し、そこに脚注の中身を置く、という手作業だったわけです。

同じことを自動的にやる方法もあります。 いったん \vbox の中身をトークンリストという場所に逃がしておき、それを \vbox の直下で復元する、というマクロを設置するのです。 具体的にはこういう仕掛けを書きます。

\newtoks\mftn
\def\mfootnote#1{%
  \footnotemark
  \edef\@tempa{\the\mftn\noexpand\footnotetext[\the\c@footnote]}%
  \global\mftn\expandafter{\@tempa{#1}}}%
\def\mfootnoteout{%
  \the\mftn
  \global\mftn{}}

\begin{...}
  \let\footnote\mfootnote
  ...
\end{...}
\mfootnoteout

ページ分割する箱にも脚注を入れたい

トークンリストによる方法は、tcolorboxminipage など、 \vbox と同等な他の箱の中に脚注を配置するときの手法としてもだいたいうまくいきます。 しかし、breakable オプション付きの tcolorboxframed などで、箱の途中にページ分割が起きる場合にはうまくいきません。 箱が複数のページに分かれてしまうので、「その箱が配置されるページ」と同じページの材料としてMVLに載せる、という戦略がそもそも成り立たないからです。

筆者は昨年のTeX & LaTeXアドベントカレンダーにおいて、この問題に対する解決のアプローチがLuaTeXで得られることを示しました。

ただ、昨年の時点では脚注の高さを計算する部分の作りこみが甘く、ページの高さが紙面のサイズを越えてしまう場合がかなりありました。 今年はそれを作り込んだので、それをTeX Conf 2019で発表することを予定していました。 また、昨年のアドベントカレンダーでは時間切れになって説明をまったく書かなかったので、今日の記事で書いたような話をひととおり話すつもりでいました。

以下の図は改良版の yafootnote による tcolorbox への脚注の実例です。入れ子を含むかなり複雑な状況にも対応できているのがわかると思います(ちなみに入れ子の内側を breakable にすることは tcolorbox の制限でできないはず)。

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

yafootnoteの実装について(読まなくてもよい)

というわけで、最後にこの yafootnote が何をしているのかを雑に説明しておきます。ほとんど開発メモです。

yafootnote は、LuaTeXのいくつかのコールバックを利用することで「ページビルダがやっているTeXの脚注のメカニズムをMVL以外でも模倣する」ものです。 具体的には、下記のように各種のコールバックを利用します。

  • post_linebreak_filter コールバックを利用して、「脚注の中身を特別な行として該当する行の下に移動する」
  • vpack_filter コールバックを利用して、「MVLに載らない特別な行の高さをゼロにする」
  • buildpage_filter コールバックを利用して、 「tcolorbox で分割される場合にページの高さを必要なだけ減らす」
  • pre_output_filter コールバックを利用して、「MVLの特別な行を \box\footins に移動する」

post_linebreak_filter コールバックで脚注を「特別な行」としてMVLに載せる際には、LuaTeXの「属性」の仕組みを使っています。 TeXマクロで脚注のコマンドを定義するとき、脚注の中身を個別のボックスに入れて、そのボックスに対して特別な属性を設定するという使い方です。 この属性を他のコールバックでも引っ掛けることで、ページビルダに任せずに脚注の材料を \box\footins に移動するようにしています。

buildpage_filter コールバックの使い道がちょっとわかりにくいんですが、これは、 tcolorbox が「ボックスの材料を \vsplit する際の高さ」を計算する際に、それを「脚注の高さ」分だけ減らすという処理に利用しています。 これを計算するためには tcolorbox/tcbbreakable.code.tex の一部にも改造が必要でした。 この「脚注の高さ」は、 tcolorbox による分割ではない通常のページ分割でも考慮する必要があり、それは出力ルーチンでやるしかないので、出力ルーチンにも改造が必要になります。

さらに、ページビルダの動作には、TeXの処理のさまざまなタイミングで非同期的に発生するという罠があります。 そのため、 tcolorbox による分割まで考慮した「脚注の高さ」がbuildpage_filter コールバック経由で計算され尽くすのは、実際にはページが完成した後になってしまいます。 これはいかんともしようがないので、実際の組版ではLuaTeXを2回実行することにしました。 1回めの実行では、各ページで最終的に必要になる「脚注の高さ」をすべて計算し、それを外部ファイルにいったん記録します。 そして2回めの実行で、その情報を使って実際のページを組み立てます。

なお、この手法はFrank MittelbachによるLuaTeXによるフロートの最適配置の研究からヒントを得ました。

まとめ

脚注大好き。

XMLからEPUBを作る

昨日までの話をふりかえってみます。

  • 構造化文書というと、どうしてもXMLタグの書式で構造を示すあの世界観が想起されやすい
  • しかし、書式はあくまでも記法にすぎないと思うことにして、構造のほうだけ抽象データ型でかっちり用意するという世界観もありうる。これはPandocやDocutilsでいちおう成功を見ている

このアドベントカレンダーでは、後者の世界観を「ライトウェイト構造化文書」と呼んでみました。 特にPandocの成功については、代数的データ型、パーサコンビネータ、パターンマッチを兼ね備えたHaskellに依拠する部分が大きかったのではないかなと個人的には思っています。

今日は、そんなHaskellEPUBを作ろう、という話です。 PandocにはEPUB Writerもありますが、痒い所に手が届く感じではなかったので、自分で生成器を作ることにしました。

HaskellXMLを扱う

EPUBの中身はXMLです。したがって、EPUBを作るにはXMLを扱う仕組みが必要です。

ここで、Pandoc構造のような抽象データ型とXMLの関係にちょっと思いを馳せてみます。 抽象データ型をXML的に観れば、あるセマンティクスをひとつ固定してその構造を代数的データ型で表したもの、といえます。 ということは、XMLを抽象データ型の観点で見れば、抽象データ型をさらに抽象したもの、といえるでしょう。 そのような対象を扱う手段として、HaskellにはArrowという仕組みがあります。 そして、このArrowを利用してXMLを扱うライブラリとして、HXTというものが使えます。

HXTそのものについては、時間の都合上、この記事では説明を省略します。 かなり昔に書いた記事がいまでも役に立つと思うので、使ってみようと思う人はそちらを参考にしてください。

HXTを使ってXMLからEPUBを作るアプリケーションを作りました

PandocにはEPUB Writerがあるので、たとえばMarkdownの原稿からEPUBを作るなら、素直に pandoc コマンドを使えば十分です。 しかし、野良XMLからEPUBへの変換でPandocを使うのは、個人的にはあまりメリットがないと考えています。 入力もXMLであるような場面で、わざわざその構造をPandoc構造にいったん潰してしまうのは、あまりうまくないからです。 原稿がXMLっぽいもの(HTMLを含む)なら、その構造を目いっぱい使ってEPUBを作りたいものです。

っていうふうに書くと、「EPUBの中身はXMLなのだから、元のXMLをそのまま使うだけではないのか」と思う人がいるかもしれません。 しかし、それだけだとふつうはEPUBにはなりません。 確かに、データとして見れば、EPUBは「XMLをZIPしたもの」です。 しかし、コンテンツとして見ると、Webブラウザなどで閲覧する前提で書かれたXML(あるいはHTML)と書籍として公開する前提のEPUBとでは、いろいろ異なる点があります。 そのためちょこちょこ細工が必要になるのです。

実際にEPUBを自分で作ってみると、面倒なのはこの辺の細工だなというのがわかると思います。

  1. ヘッダとか図のキャプションに文字列や連番を追加する
  2. そのヘッダに付加した連番で、本文から章や図を参照する
  3. 論理目次と物理目次をはじめとするメタ情報を作成する

1つめの話は、たとえば<h1>要素の頭には「第1章」、その下の<h2>要素の頭には「1.1」、図には「図1.1」などの文字列を連接するということです。 そんなのCSSでやればいいじゃん、と思うかもしれませんが、CSScontentプロパティが使える広義のEPUBリーダー(Kindleも含む)は一部なので、これはコンテンツのHTMLのほうに埋め込む必要がある、というのがEPUB作成者の間では一般的な認識だと思います。 また、そうやって生成した連番のテキストは、場合によってはそれを参照している側にも付加する必要があり、これが上記でいうと2つめの項目です。 これらの作業には、当然、(テキストではなく)XMLに対する操作が必要になります。

3つめの話は、読者が目に見える部分だけじゃなくて、そもそもEPUBの仕様的に必要なメタ情報がいっぱいあります。 詳しくは他の資料などを参照してもらうとして、とくにめんどくさいのはOPFファイルと呼ばれるものの準備です。 このファイルに、中で使われている画像から何からすべての情報をきちんと登録しなければ、正しいEPUBファイルになりません。 そのための情報は元のXMLソースから抽出してくることになり、これにもXMLの操作が必要になります。

先にもちらっとふれましたが、元のXMLをいったんPandoc構造に落としてもかまわなければ、Pandocがこのへんの面倒をすべてみてくれます。 Pandoc構造にはHTMLを生で保持するデータ構造もあるので、それで十分なことも多いでしょう。

が、せっかくなのでその辺の処理もいちど自分で経験してみるかと思って、HXTでXMLからEPUBを生成するアプリケーションを開発しました。 仕事でEPUBを生成する必要があるとき、いまでは基本的にこれを使っています(中身がわかっているので手をいれやすい)。

ちなみに「qnda」という名前の由来は画面をひっくり返すとわかると思います。

このqndaを使って、「WikipediaのエントリをEPUB化するWebサーバ」をScottyというHaskellの軽量Webフレームワークを使って書いたこともあります。 HTMLではなくMediaWikiをソースにしているので、qndaのもともとのコンセプトは完全に失われていますが…

複数のエントリ(デフォルトでは3つまで)をフォームに指定してSubmitすると、それぞれを章とするEPUBファイルが返ってくるような仕掛けです。

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

生成されたEPUBはリーダーでこんな感じに読めます。

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

EPUB、売ってないじゃん

というわけでHTMLに変換可能な原稿からは技術的にEPUBを自力で生成できる当社ですが、いまのところEPUBの商品は販売していません。 Amazon Kindleでも販売していないんですが、これには次のような理由(というか言い訳)があります。

現状、EPUBが真につらいのは、生成することよりも、リーダーごとの動作検証だと思います。 PDFでさえ特定のビューワーが思いもよらない動作をする場合があるのですが(とくにApple系)、これにEPUBリーダーでの動作検証をして販売できるモノを準備する余裕がありません。 また、もしモノは用意できたとしても、管理する商品形態が1つ増えるのは当社の体力的につらく、そのために残念ながら見送っているのが実情です。 EPUBも欲しいという声があるのは承知しているのですが…。(そもそもEPUBに未来はあるのでしょうか…)

ちなみにKindleも事情は同じで、生成はできても、販売するために解決しないといけないバックオフィス上の課題がいくつかあります。

以上、ドキュメント屋さん的にはいろいろ面白いEPUBだけど出版社的にはちょっぴり難しい判断がある、という話でした。

明日の記事の予定はまだ決まっていません。

抽象データ型を自作する

昨日の記事では「書籍のマクロな構造」について話しました。 このマクロ構造は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フィルターを使ってその処理の実装例を紹介する予定です。