Pandoc の Lua フィルターを書く:Word から変換した見出しにカスタムマークを付ける
Word 文書が Markdown になるとき、スタイルは構造として引き継がれます。「見出し 1」の段落は最上位のヘッダーに、「見出し 2」は第 2 レベルのヘッダーになります。しかし、それだけでは足りない場合もあります。Word から取り込んだ見出しに、サイトのテンプレートやビルドパイプラインが認識できる目印を付けたいことがあり、何百もの見出しを手作業で直すのは現実的ではありません。
Pandoc には組み込みの答えがあります。それが Lua フィルター、ドキュメントがコンバーターを通過する間に変換を加える小さなスクリプトです。このガイドでは、その仕組みを説明し、このマーク付けの課題にぴったりのフィルターを一歩ずつ作り、実行とデバッグの方法までカバーします。
Lua フィルターとは何か、pandoc が Lua を選んだ理由
Pandoc は、出力を書き出す前に、あらゆる入力を共通の抽象構文木(AST)、つまりヘッダー、段落、表などの要素の内部表現に解析します。フィルターとは、その木をたどって書き換えるコードです。フィルターは正規化された木に対して動作するため、入力が docx、HTML、Markdown のどれであっても同じように機能します。
Pandoc には Lua インタープリター(現在は Lua 5.4)が組み込まれているため、フィルターは別のランタイムやコンパイル済み拡張なしに pandoc のプロセス内で実行されます。テキストファイルを書き、コマンドラインフラグで pandoc に渡せば、変換の途中で変換処理が行われます。この方式のフィルターは、外部フィルターが必要とする JSON の往復も不要で、オーバーヘッドを小さく保てます。
フィルターの構造
フィルターファイルには、AST の要素型にちなんで名付けた普通の関数を定義します。Header 型にちなんだ関数はドキュメント内の各見出しごとに一度実行され、Para 型にちなんだ関数は各段落ごとに実行されます。ファイルが明示的なフィルターテーブルを返さない場合、pandoc はこれらのトップレベル関数を名前で集めて自動的に使用します。
戻り値が、木の中で要素を何に置き換えるかを決めます。nil を返す場合(何も返し忘れた場合も含む)、要素は変更されないままなので、忘れっぽい関数でも害はありません。要素を返す場合(変更の有無にかかわらず)、新しいバージョンで置き換えられます。リストを返すと元の要素の位置にその要素群が挿入され、空のリストを返すのがドキュメントから要素を削除する方法です。
Header 要素とその属性
pandoc の AST のすべての Header は 3 つのものを持ちます。数値としてのレベル、インラインコンテンツのリスト、そして属性セットです。属性セットは、アンカーに使われる識別子、クラス名のリスト、任意のキーと値の属性の 3 つの部分で構成されます。これは、出力で HTML の見出しタグの id と class として現れるのと同じ 3 つ組です。
これらの属性がどこに出力されるかは、出力フォーマットによって異なります。pandoc の Markdown では見出しテキストの後に波括弧の注釈として表現でき、HTML 出力ではタグ自体に付きます。プレーンな GFM のエクスポートには属性の構文がないため、クラスは失われてしまいます。出力フォーマットを選ぶ前に知っておく価値のある点です。
課題:Word のすべての見出しにタグを付ける
目標は、各 Header を検査し、条件に合致したときにカスタムクラスを追加するフィルターです。たとえばすべての見出し、あるいは第 2 レベルのものだけが対象です。ロジックは level フィールドを読んで判定し、リストの insert メソッドか素朴な table.insert で classes リストに文字列を追加します。それ以外は何も変わりません。
条件分岐があると実用的です。最上位の見出しには 1 つのクラスを、より深い見出しには別のクラスを与えたり、テキストが特定の単語で始まる見出しだけに絞ったり、すでにそのクラスを持つ見出しをスキップしたりできます。こうすればフィルターは冪等性を保ち、2 回実行してもマークが重複しません。
コードのロジックをたどる
書き出すと、フィルターは 4 つの短いステップです。pandoc がすべての見出しをそこに渡すよう、Header 型にちなんだ関数を 1 つ定義します。その中で、level フィールドを気になるしきい値と比較します。一致したら、クラス名を classes リストに追加し、必要に応じてキーと値の属性も設定します。最後に要素を返します。
最後の return は注目に値します。要素をその場で変更して返すのがドキュメントに記載されたパターンですが、return がないと pandoc は変更されていない元の要素を保持するため、変更は計算されてから静かに捨てられます。return があれば、見出しは新しいクラスを伴って出力され、HTML タグや pandoc の Markdown の波括弧属性で確認できます。
フィルターの実行
コマンドラインでは、pandoc はファイルを指す --lua-filter フラグを受け付けます。このフラグは繰り返し指定でき、複数のフィルターを順番に連結できます。フィルターは読み込みと書き出しの間に実行されるため、どの出力フォーマットを選んでも変換が適用されます。
このサイトでは、Pro オプションに Lua フィルターのアップロードがあります。Word 文書と一緒に同じ .lua ファイルを渡すと、ブラウザーのワーカーが同じ pandoc エンジンに渡します。クラスはローカルとまったく同じように出力に反映され、サーバーには何も送信されません。
デバッグのテクニック
最も手軽なツールは print 関数です。フィルターからの出力は stderr に送られるため、変換後のドキュメントを汚すことはなく、見出しのレベル、クラス、テキストをダンプして、フィルターが実際に何を受け取ったかを確認できます。Pandoc にはロギングモジュール pandoc.log も同梱されており、info 関数と warning 関数はメッセージに重要度を付けます。
良い習慣は、ごく小さなファイルで開発することです。各レベルに 1 つずつ見出しを持つ docx を作り、それに対してフィルターを実行し、本物のアーカイブに向ける前に出力を検査します。失敗のほとんどはフィールド名のタイプミスで、エラーチャンネルがすぐにそれを明らかにします。
拡張とパフォーマンス
同じパターンは見出しをはるかに超えて応用できます。テキストを含まない段落を削除したり、見出しテキストから識別子を生成して安定したアンカーにしたり、表を Div で囲んでサイトのテンプレートがグループとしてスタイルを当てられるようにしたりできます。これらはすべて、同じファイル内で要素型ごとに 1 つの関数を書くだけです。
パフォーマンスが問題になることはめったにありません。フィルターは変換プロセス内で要素ごとに実行され、非常に大きなドキュメントでも走査は高速です。避けるべきことは 1 つだけ、関数内でファイルを読むような要素ごとに繰り返される重い処理です。それはスクリプトの先頭で 1 回だけ実行すべきものです。