Compnet

仕事とか遊びとか、日々折々

2017-10-09(月)

Pelican の ReST 用に HTML タグを手軽に使うためのロール拡張を作った

Posted by Nakane, R. in technical   

本ブログは以前の記事で書いた通り Pelican という静的サイトジェネレーターを使って構築しています。 その中で Pelican を採用した理由として「reStructuredText で書ける」ということを挙げました。 実際に Pelican に移行してから今日までのほぼ一年に 21 本の記事を書き、それらはすべて reStructuredText で記述しています (平均して 2 週間に 1 本のペース、少ないですね)。 加えて、それ以前に Wordpress に書いた内の 11 本の記事を reStructuredText で書き直して、新しいブログの側に転載しました (まだ 200 本以上残っていますが)。

テキスト エディターだけ記事を書けるのが reStructuredText で書くことの手軽さです。 表題や箇条書きなども特別な表記法ではなく、誰もが思いつくような書き方なので、読み返したときに読みやすいという点でも手軽といえます。 もちろん reStructuredText といえども万能ではなく、使いにくい点もいくつかあります。 Pelican を使ってブログ記事を書く前提ですが、記事を reStructuredText から HTML に変換したときに、表題に ID 属性が付けられないというのも、そのひとつです。

reStructuredText で書かれた記事を HTML に変換しすると、表題は h1h2 といったタグを使った表記に変換され、その表題から始まる節が div タグで括られます。 そして表題を基にした ID が生成されて、div タグに ID 属性が付けられるます。 このため、一応は表題 (正しくは表題から始まる節) を参照するリンクが書けられそうに見えます。 しかし、表題に英数字以外が含まれていると、先頭の英数字部分だけが ID になったり、id1、id2 のような機械的に割り振られた ID になったりするため、どんな ID 属性が付くかは変換してみないと分からず、そうそう手軽だとはいえません。

また、打ち消し線を記述する方法がないというのもあります。 raw というロールを使って、reStructuredText の記述中に HTML による記述を埋め込むコトができなくはありませんが、見栄えの点からいってあまりいいとは思えません。

前者を解決するには reStructuredText を HTML などに変換する仕組み、つまり Docutils のソースコードに手を加えなくてはならないみたいなので、そうそう手を出せません。 しかし reStructuredTextdel タグ相当の記述方法を追加するだけなら、何とかなりそうです。 そこで、Pelican の拡張機能 (プラグイン) として、del タグ相当の記述方法を reStructuredText に追加しました。

とりあえず今回は、delins のふたつのタグを組み込みました。 今回の拡張機能を基に他のタグを追加するのも難しくはなさそうですが、当面はこのふたつのタグが使えれば事足りそうです。

使い方

基本的な使い方

reStructuredText で装飾などを記述するには「ディレクティブ」や「ロール」といった機能を使います。 標準の機能だけで打ち消し線を記述するには以下のようにします。

.. role:: inline-raw(raw)
    :format: html

ここに :inline-raw:`<del>打ち消し線</del>` を引きます。
ここに 打ち消し線 を引きます。
ここに <span class="inline-raw"><del>打ち消し線</del></span> を引きます。

実際には「打ち消し線」の前後の空白文字が出力されないように「(円マーク)」をつけて空白文字をエスケープしますが、ここでは省略します。

作成した拡張機能は、reStructuredTextdel タグに相当する del ロールを追加します (ついでに ins タグ相当の ins: ロールも追加します)。 これによって、以下のような記述ができるようになります。

ここに :del:`打ち消し線` を引きます。
ここに 打ち消し線 を引きます。
ここに <del>打ち消し線</del> を引きます。

比べれば分かるように、標準機能では余計な span タグが挿入されてます。 それに対して、作成した拡張機能では余計なタグが含まれたりはしせん。

タグに属性を付ける使い方

del タグには属性として citedatetime を付けられます。 class 属性を付けることも多いでしょう。 しかし残念なことに、reStructuredText の標準機能だけではこれらの属性は付けられません。 クラス属性だけは以下のようにすればできないこともありませんが、この場合でも del タグに直接は付きません。

.. role:: inline-raw-with-class(raw)
    :format: html
    :class: class-delete

ここに :inline-raw-with-class:`<del>クラス属性を付けた打ち消し線</del>` を引きます。
ここに クラス属性を付けた打ち消し線 を引きます。
ここに <span class="inline-raw-with-class class-delete"><del>クラス属性を付けた打ち消し線</del></span> を引きます。

作成した拡張機能では、以下のようにして citedatetimeclass の属性を記述できます。 自由な属性を付けられるようにはできませんが、これらの三つの属性があれば十分だと思います。 もちろん del ロールだけでなく ins ロールでもこれらの三つの属性が記述できます。

.. role:: del-with-class(del)
    :class: class-delete
    :cite: https://www.compnet.jp
    :datetime: 2017-10-09T13:31+09:00

ここに :del-with-class:`クラスなどの属性を付けた打ち消し線` を引きます。
ここに クラスなどの属性を付けた打ち消し線 を引きます。
ここに <del class="class-delete" cite="https://www.compnet.jp" datetime="2017-10-09T13:31+09:00">クラスなどの属性を付けた打ち消し線</del> を引きます。

reStructuredText の制限のため若干煩わしい点もありますが、作成した拡張機能の方が多少なりとも使い勝手がいいはずです。

拡張機能の中身は

この拡張機能はどのように作られているのでしょう。 この拡張機能は Pelican という静的サイトジェネレーター用に作ったものですが、内容的には reStructuredText にロールを追加しているだけのものです。 このため、Pelican でなくても、例えば Sphinx のようなドキュメント ジェネレータであっても reStructuredText、つまり Docutils を利用しているのならば、少し修正するだけで簡単に転用できます。

実際のソース コードは以下の通りです。 Pelican で構築する Web サイトの設定ファイルで指定しているプラグイン ディレクトリに、適当なディレクトリ (たとえば html-tags) を作り、そこに __init__.pyhtml-tags.py のふたつのファイルを置き、そのディレクトリをプラグインとして追加するだけです。

__init__.py ファイルの内容は以下の通りです。

1
2
3
4
# -*- coding: utf-8 -*-

from __future__ import absolute_import
from .html_tags import *

html-tags.py ファイルの内容は以下の通りです。

# -*- coding: utf-8 -*-
# :refs: Creating reStructuredText Interpreted Text Roles
#           http://docutils.sourceforge.net/docs/howto/rst-roles.html

from __future__ import division, print_function
import logging
from pelican import signals
from docutils import nodes
from docutils.parsers.rst import directives, roles

logger = logging.getLogger(__name__)

class html_tag:
    def __init__(self, tagname, option_list={}):
        self.tagname = tagname
        self.option_list = option_list

    def __call__(self, role, rawtext, text, lineno, inliner, options={}, content=[]):
        roles.set_classes()
        classes = options.get('classes', [])
        attrs = []
        if classes:
            attrs.append('class="{}"'.format(' '.join(classes)))
        for opt in self.option_list:
            if opt in options:
                attrs.append('{}="{}"'.format(opt, options[opt]))
        attrs.insert(0, self.tagname)
        starttag = '<{}>'.format(' '.join(attrs))
        endtag = '</{}>'.format(self.tagname)
        result = []
        result.append(nodes.raw('', starttag, format='html'))
        result.append(nodes.Text(text, rawtext))
        result.append(nodes.raw('', endtag, format='html'))
        return result, []

def del_role(role, rawtext, text, lineno, inliner, options={}, content=[]):
    tag = html_tag('del', ['cite', 'datetime'])
    return tag(role, rawtext, text, lineno, inliner, options, content)

def ins_role(role, rawtext, text, lineno, inliner, options={}, content=[]):
    tag = html_tag('ins', ['cite', 'datetime'])
    return tag(role, rawtext, text, lineno, inliner, options, content)

del_role.options = {'class': directives.class_option,
                    'cite': directives.uri,
                    'datetime': directives.unchanged,
                   }
ins_role.options = {'class': directives.class_option,
                    'cite': directives.uri,
                    'datetime': directives.unchanged,
                   }

def register():
    roles.register_local_role('del', del_role)
    roles.register_local_role('ins', ins_role)

なお、本ソース コードのライセンスは Docutilsライセンス の継承を明言しておきます。

蛇足

以下は蛇足です。

今回は reStructuredText のロールに関数を登録するような実装をしました。 Docutilsドキュメントで紹介されいる方法と、ほぼ変わらないかたちです。 なので特に問題は無いのですが、当初は docutils.nodes.Node クラスを継承した delete(docutils.nodes.Inline, docutils.nodes.TextElement) のようなクラスでの実装を考えていました。 docutils.nodes.Node クラスを継承できれば、docutils.nodes.raw クラスに HTML タグを直接 放り込むような実装をしなくても済むと考えたからです。

docutils.nodes.Node クラスを継承したクラスは Docutils における「ノード」として解釈されます。DocutilsreStructuredText (またはそれ以外の対応する書式) の文書を読み込むと、その文書はノードのツリーに変換されます。 そして docutils.writers を継承したクラスを使って、ノードのツリーを基に HTML やその他 (LaTex や PDF など) の形式の文書を出力します。 つまり、各ノードやノードのつながりを考慮して HTML などの形式に変換するのは、docutils.writers を継承したクラスの役割です。 Docutils で HTML への変換を担っている docutils.writers.html4css1.HTMLTranslator クラスには、visit_...()depart_...() のようにノードの名前が付いたメソッドが沢山定義されていて、それらのメソッドにそれぞれのノードをどのように HTML にすればいいのかが書かれています。 一例として以下に、「段落」を意味する「paragraph」ノードに対応するメソッドを挙げておきます。 これを見ると、paragraph ノードがどのように p タグに変換されるかが分かります。

class HTMLTranslator(writers._html_base.HTMLTranslator):
        :
    def visit_paragraph(self, node):
        if self.should_be_compact_paragraph(node):
            self.context.append('')
        else:
            self.body.append(self.starttag(node, 'p', ''))
            self.context.append('</p>\n')

    def depart_paragraph(self, node):
        self.body.append(self.context.pop())

        :

Pelican では HTML への変換に docutils.writers.html4css1.HTMLTranslator を継承した pelican.readers.PelicanHTMLTranslator クラスを使っています。 当初考えていたように docutils.nodes.Node を継承する delete(docutils.nodes.Inline, docutils.nodes.TextElement) のようなクラスで実装するのであれば、pelican.readers.PelicanHTMLTranslator クラスに visit_delete()depart_delete() メソッドを追加しなくてはなりません。 visit_delete()depart_delete() メソッドの自体はそう難しい内容にはなりません。 しかし残念なことに、pelican.readers.PelicanHTMLTranslator クラスに新たな追加したり、未定義のノードの処理をキャッチするフォールバック関数を定義する方法がありません。 ここまで調べた結果、今回のような実装に至ったのですが、実はそれが Docutilsドキュメントで紹介されいる方法だったというオチにもなっています。 回り道をした形にはなりましたが、多少なりとも Docutils の構造が分かったことでよしとします。

Comments