Markdown + XSL → PDF


動機

論文を Markdown で書きたい。Microsoft Word も LaTeX も嫌だ。もちろん PDF を出力したい。でも HTML も CSS も嫌だ。ところで CommonMark は XML 形式の AST が定められている (DTD もある)。XML から PDF といえば XSLT/XSL-FO がある。よしこれを使おう。

必要なもの

  • cmark : CommonMark のリファレンス実装。AST を XML で出力できる。
  • fop : Apache FOP, XSL-FO プロセッサ。XSLT プロセッサも内蔵している。
  • make : 便利なので。

Makefile

OUTPUT = thesis.pdf  # 出力する PDF ファイルの名前
STYLESHEET = stylesheet.xsl  # XSLT ファイルの名前
FOP_CONFIG = fop.conf  # Apache FOP の設定ファイルの名前

.PHONY: all
all: $(OUTPUT)

.PHONY: clean
clean:
    $(RM) *.pdf *.fo *.xml

%.pdf: %.fo $(FOP_CONFIG)
    fop -c $(FOP_CONFIG) -fo $< -pdf $@

.PRECIOUS: %.fo
%.fo: %.xml $(STYLESHEET)
    fop -xml $< -xsl $(STYLESHEET) -foout $@

.PRECIOUS: %.xml
%.xml: %.md
    cmark -t xml $^ | grep -v '^<!DOCTYPE' > $@

FOP は DOCTYPE 宣言のある XML を処理しようとすると怒るので cmark の出力から DOCTYPE 宣言をフィルタしている。

stylesheet.xslt

再利用しやすいように、おそらく変更したくなるであろう設定値は冒頭にまとめて定義しておいた。

<?xml version="1.1" encoding="UTF-8"?>
<xsl:stylesheet version="3.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:fo="http://www.w3.org/1999/XSL/Format" xmlns:md="http://commonmark.org/xml/1.0">

  <!-- ページ設定の名前 -->
  <xsl:variable name="master-name" select="'A4'" />

  <!-- 言語設定 -->
  <xsl:attribute-set name="locale">
    <xsl:attribute name="language">ja</xsl:attribute>
    <xsl:attribute name="country">jp</xsl:attribute>
  </xsl:attribute-set>

  <!-- ページ設定 -->
  <xsl:attribute-set name="page-settings">
    <xsl:attribute name="page-width">210mm</xsl:attribute>
    <xsl:attribute name="page-height">297mm</xsl:attribute>
    <xsl:attribute name="margin">1in</xsl:attribute>
  </xsl:attribute-set>

  <!-- 基本スタイル -->
  <xsl:attribute-set name="base-style">
    <xsl:attribute name="font-family">serif</xsl:attribute>
    <xsl:attribute name="font-size">10.5pt</xsl:attribute>
    <xsl:attribute name="line-height">1.5</xsl:attribute>
    <xsl:attribute name="margin-bottom">1em</xsl:attribute>
  </xsl:attribute-set>

  <!-- ブロック引用スタイル -->
  <xsl:attribute-set name="block-quote">
    <xsl:attribute name="start-indent">2em</xsl:attribute>
    <xsl:attribute name="margin">2em 0</xsl:attribute>
  </xsl:attribute-set>

  <!-- 普通のパラグラフのスタイル -->
  <xsl:attribute-set name="paragraph">
    <xsl:attribute name="text-indent">1em</xsl:attribute>
  </xsl:attribute-set>

  <!-- 見出しスタイル -->
  <xsl:attribute-set name="heading">
    <xsl:attribute name="font-family">sans-serif</xsl:attribute>
    <xsl:attribute name="keep-with-next">always</xsl:attribute>
  </xsl:attribute-set>

  <!-- 脚注のための右肩数字のスタイル -->
  <xsl:attribute-set name="footnote">
    <xsl:attribute name="font-size">0.8em</xsl:attribute>
  </xsl:attribute-set>

  <!-- setup -->

  <xsl:strip-space elements="*" />

  <xsl:template match="/">
    <fo:root>
      <fo:layout-master-set>
        <fo:simple-page-master xsl:use-attribute-sets="page-settings">
          <xsl:attribute name="master-name">
            <xsl:value-of select="$master-name" />
          </xsl:attribute>
          <fo:region-body region-name="xsl-region-body" />
        </fo:simple-page-master>
      </fo:layout-master-set>
      <xsl:apply-templates />
    </fo:root>
  </xsl:template>

  <xsl:template match="md:document">
    <fo:page-sequence xsl:use-attribute-sets="locale">
      <xsl:attribute name="master-reference">
        <xsl:value-of select="$master-name" />
      </xsl:attribute>
      <fo:flow flow-name="xsl-region-body">
          <xsl:apply-templates />
      </fo:flow>
    </fo:page-sequence>
  </xsl:template>

  <!-- block elements -->

  <xsl:template match="md:block_quote">
    <fo:block xsl:use-attribute-sets="base-style block-quote">
      <xsl:apply-templates />
    </fo:block>
  </xsl:template>

  <xsl:template match="md:paragraph">
    <fo:block xsl:use-attribute-sets="base-style paragraph">
      <xsl:if test="name(following-sibling::*[1]) = 'paragraph'">
        <xsl:attribute name="margin-bottom">0</xsl:attribute>
      </xsl:if>
      <xsl:apply-templates />
    </fo:block>
  </xsl:template>

  <xsl:template match="md:heading">
    <fo:block xsl:use-attribute-sets="base-style heading">
      <xsl:if test="@level = 1">
        <xsl:attribute name="text-align">center</xsl:attribute>
      </xsl:if>
      <xsl:value-of select="." />
    </fo:block>
  </xsl:template>

  <!-- inline elements -->

  <xsl:template match="md:text">
    <xsl:value-of select="." />
  </xsl:template>

  <xsl:template match="md:softbreak" />

  <xsl:template match="md:linebreak">
    <fo:block />
  </xsl:template>

  <!-- リンク記法を脚注に使う -->
  <xsl:template match="md:link">
    <fo:footnote>
      <fo:inline xsl:use-attribute-sets="footnote" baseline-shift="super">
        <xsl:number level="any" count="md:link" />
      </fo:inline>
      <fo:footnote-body>
        <fo:list-block start-indent="0">
          <fo:list-item>
            <fo:list-item-label end-indent="label-end()">
              <fo:block>
                <xsl:number level="any" count="md:link" />
              </fo:block>
            </fo:list-item-label>
            <fo:list-item-body start-indent="body-start()">
              <fo:block>
                <xsl:apply-templates />
              </fo:block>
            </fo:list-item-body>
          </fo:list-item>
        </fo:list-block>
      </fo:footnote-body>
    </fo:footnote>
  </xsl:template>
</xsl:stylesheet>

見出しの書式はもっと凝りたい人がいるかも知れないが、私はせいぜいレベル2までしか使わないので、レベル1を中央寄せにした以外は特に差がない。

fop.conf

FOP の設定ファイル。具体的なフォントはここで設定する。フォントファイルは絶対パスで指定すること。
また印刷すると美しい Noto / Adobe Source Han フォントは脚注の数字が上に吹っ飛ぶので使えない。めっちゃ残念。

めんどうだったら <font ... は消して <auto-detect /> とだけ書いても動きはする。

<fop>
  <renderers>
    <renderer mime="application/pdf">
      <fonts>
        <font embed-url="ipaexm.ttf">
          <font-triplet name="serif" style="normal" weight="normal" />
        </font>
        <font embed-url="ipaexg.ttf">
          <font-triplet name="sans-serif" style="normal" weight="normal" />
        </font>
      </fonts>
    </renderer>
  </renderers>
</fop>

出力サンプル

リストやコードブロックの変換は定義していないので適当に置きかえている。

既知の問題

  • text-align="justify" が効かないので右端がガタガタになる
  • CFF を使った最近の綺麗なフォントは PDF への出力が壊れる
  • リスト