ファイルを超えてリンクを貼る (domain#resolve_xref() のすゝめ)

昨日の記事で、どの拡張も HTML に変換した時に他のページにリンクできないという体たらくなので、
Sphinx においけるリンクの解決方法をまとめておく。

解決方法: domain#resolve_xref() を使う

以上。


見出しで言い切ったのでこれ以上ネタも無いのだが、コードを使って補足しておく。

Sphinx のベースになっている docutils では、単体のファイルしか扱わないため
ラベルを意味する reftarget ノードとリンク(参照)を表す reference ノードのふたつで用が足りていて、
リンクの解決はドキュメントの先頭から travrese() をして、該当のラベルを見つけるだけでよかった。

しかし、Sphinx では複数のファイルを組み合わせて文書を構築するため、
ファイル A からファイル B へリンクすることもある。
だが、ファイル A にあるリンクを解決しようとしても、
そのときにファイル B を処理し終わっているとは限らない。
まだ参照しているラベルが登場していない可能性も十分にある。

そのため、Sphinx ではリンクには pending_xref というノードを用い、
すべてのファイルの読み込みがひと通り終わった後にリンクの解決を行っていく*1
その時に利用されるのが domain 機構とそのメソッドである domain#resolve_xref() である。

domain#resolve_xref() を使う

サンプルとして、カスタムロールである :myref: を定義する。
:myref: は :ref: と同じ動きとし、ラベルを受け取ってリンクを作るものとする。

:myref:`a_subsection`

Sphinx には XRefRole というクロスリファレンス用のロールジェネレータが用意されているので
これを使うと簡単に :myref: ロールを定義することができる。

from docutils import nodes
from sphinx.roles import XRefRole


class myref(nodes.reference):
    pass


def setup(app):
    app.add_node(myref)
    app.add_role('myref', XRefRole(nodeclass=myref))

myref ノード用のクラスを定義して、XRefRole を使いながら Sphinx に定義を登録するだけである。

もちろんこのままでは未知のノードとして扱われるため、どの形式にも変換することはできない。
参照を解決しつつリンクを生成する処理を追加する必要がある。

ここでは Sphinx のリンク解決フェーズである doctree-resolved イベントで処理をする。

def on_doctree_resolved(app, doctree, docname):
    domain = app.builder.env.domains['std']
    for node in doctree.traverse(myref):
        xref = domain.resolve_xref(app.builder.env, docname, app.builder,
                                   'ref', node['reftarget'], node, node)
        if xref:
            node.replace_self(xref)
        else:
            app.builder.warn('unknown label: %s' % node['reftarget'])
            node.parent.remove(node)


def setup(app):
    app.connect('doctree-resolved', on_doctree_resolved)

ラベルの参照を解決する場合は app.builder.env.domains['std'] にある resolve_xref() を呼び出すとよい。
言語ドメインの参照(関数やモジュールなど)を解決する場合は domains['cpp'] や domains['python'] などを使うことになる。
domain#resolve_xref() は参照の解決ができた場合は reference ノードを、
参照が解決できなかった(ラベルが見つからなかったなど)場合は None を返す。

ここでは node.replace_self() で reference ノードに置き換えてリンクを生成しているが、
開発しているツールの都合に合わせてうまく変換して使うと良いだろう。
HTML 用途であれば、リンク先の URI 情報が reference ノードに収められている。
同一ファイル内へのリンクであれば xref['refid'] が、別ファイルへのリンクであれば xref['refuri'] を参照すると良い。

blockdiag でもこの仕組みを使って clickable map を実装している。

参照先のノードを知りたい

domain#resolve_xref() では参照先の URI とキャプション情報を得ることができるが、
ツールによってはもっと突っ込んでノードそのものを得たいこともあるだろう。

その場合は Sphinx の内部データ構造に手を突っ込むことになる。
ここに記載する情報は Sphinx 1.2.2 で確認したものなので、将来的に構造が変わる可能性もあるのでそのつもりで読んで欲しい。


さすがに domain 内部にも参照先のノードそのものの情報は入っていないので、
記録さている docname, labelid から該当のノードを復元する必要がある。

def on_doctree_resolved(app, doctree, docname):
    domain = app.builder.env.domains['std']
    for node in doctree.traverse(myref):
        target_docname, target_labelid, _ = domain.data['labels'].get(node['reftarget'])
        target_doctree = app.builder.env.get_doctree(target_docname)
        for target in target_doctree.traverse(nodes.Element):
            if target_labelid in target['names']:
                // here!

かいつまんで説明すると、

  • domain.data['labels'] から docname, labelid の情報を得る*2
  • app.builder.env.get_doctree() を使って該当ファイルの doctree を復元する
  • node.traverse() を使って該当のラベルが付いている(= names 属性に含まれている)ノードを見つけ出す

あとはここで得た情報を元にうまくやると良いだろう。
注意点として、リンク先のノードは一時的に復元したものであって、
これを書き換えても生成されたドキュメントには反映されないことが挙げられる*3

まとめ

お前らリンクを作るときはちゃんと domain#resolve_xref() を使え。

ちなみにこういう情報はリファレンスに載ってないのでひたすらコードを読むと良い。
訂正 (7/30 23:30): id:shimizukawa の指摘によると domain#resolve_xref() は最新のドキュメントに記載があるとのこと。1.2 系のリリース以降、開発者向けドキュメントも強化しているらしい。

*1:ノードの名前どおり、未解決のクロスリファレンス(ファイルをまたがるリファレンス)を表しているのだと思う。

*2:読み飛ばしている最後の情報にはキャプションが入っている。

*3:そういったことがやりたい場合は Transform を使うとよさそうだが、ファイルをまたがる場合はちょっと複雑になると予想される