golden-luckyの日記

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

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を使う必要があるでしょう。