Docutilsのディレクティブを自作する

現在、Djangoの勉強を兼ねて簡単なCMSを作成しています。CMSに必要な機能の1つに、Wiki記法やはてな記法のような軽量マークアップ言語のサポートがあります。DjangoではデフォルトでTextile、Markdown、reStructuredTextの3種類のマークアップ言語が利用可能です。しかし、せっかく自分のCMSを作るので、マークアップ言語も自分仕様にしたいものです。かといって、パーサーを一から自分で作るのは大変なので、ここではDocutilsを拡張するというアプローチを取ることにします。

DocutilsはPyhtonで書かれたテキスト処理用のツール群及びライブラリで、reStructuredTextという形式で書かれたテキストをHTMLやTeXに変換することができます。Docutils及びreStructuredTextについては以下のサイトを参考にしてください。

reStructuredTextにはディレクティブという汎用の拡張機能があり、一つの記法で様々な機能をサポートすることができます。ディレクティブは以下のシンタックスに従います。

+-------+-------------------------------+
| ".. " | directive type "::" directive |
+-------+ block                         |
        |                               |
        +-------------------------------+

例えば、imageディレクティブの場合、以下のように使用します。

.. image:: picture.jpeg
   :height: 100px
   :width: 200 px
   :scale: 50 %
   :alt: alternate text
   :align: right

また、ディレクティブをインライン要素として使いたい場合、以下のように代入文を使用します。

|biohazard| シンボルは、 医療廃棄物を収めたコンテナに 表示されるマークです。

.. |biohazard| image:: biohazard.png 

ここでは、はてな記法のようにリンク作成支援のためのディレクティブを作成することにします。これから作成するディレクティブを使うと、以下のような記述が可能になります。

* google記法    : |google記法|
* amazon記法    : |amazon記法|
* wikipedia記法 : |wikipedia記法|
* isbn記法      : |isbn記法|

.. |google記法| google:: はてな

.. |amazon記法| amazon:: はてな

.. |wikipedia記法| wikipedia:: はてな_(企業)

.. |isbn記法| isbn:: 4798110523 近藤(2006)

これをHTMLに変換すると以下のようになります。

<ul class="simple">
<li>google記法    : <a class="reference external" href="http://www.google.com/search?q=%E3%81%AF%E3%81%A6%E3%81%AA">google:はてな</a></li>
<li>amazon記法    : <a class="reference external" href="http://www.amazon.co.jp/exec/obidos/external-search?keywords=%E3%81%AF%E3%81%A6%E3%81%AA&amp;mode=blended">amazon:はてな</a></li>
<li>wikipedia記法 : <a class="reference external" href="http://ja.wikipedia.org/wiki/%E3%81%AF%E3%81%A6%E3%81%AA_%28%E4%BC%81%E6%A5%AD%29">wikipedia:はてな_(企業)</a></li>
<li>isbn記法      : <a class="reference external" href="http://www.amazon.co.jp/o/asin/4798110523/">近藤(2006)</a></li>
</ul>

Docutilsのディレクティブを作成する方法はこちらのページで紹介されています。
基本的には、docutils.parsers.rst.Directiveを継承したクラスでrunメソッドを作りそこでdocutils.nodes.Nodeのサブクラスのインスタンスのリストを返すようにプログラムを組むようです。

HTMLのリンクに相当するノードはdocutils.nodes.referenceです。これは以下のプログラムで実際にreSTテキストからそれに対応するノードを調べることで確認することができます。

from docutils.core import publish_doctree
doctree = publish_doctree("`Example <http://www.example.com>`_")
print doctree

実行結果(見やすいように整形しています)

<document source="<string>">
  <paragraph>
    <reference name="Example" refuri="http://www.example.com">Example</reference>
    <target ids="['example']" names="[u'example']" refuri="http://www.example.com"/>
  </paragraph>
</document>

また、この実行結果からreferenceノードはrefuriという属性で参照先のURLを保持していることがわかります。

以上の情報とDocutilsのソースファイルを元に試行錯誤をした結果、以下のようなプログラムを作成しました。このプログラムを使用することで、上記の例のような出力を作成することができます。

import sys
import urllib
from docutils.core import publish_parts, publish_doctree
from docutils import nodes
from docutils.parsers.rst import directives
from docutils.parsers.rst import Directive
from docutils.parsers.rst import states

class MyBaseDirective(Directive):
  def run(self):
    if not isinstance(self.state, states.SubstitutionDef):
      raise self.error(
        'Invalid context: the "%s" directive can only be used within '
        'a substitution definition.' % self.name)
    title, refuri = self.run_impl()
    self.options['refuri'] = refuri
    self.options['name']   = title
    ref_node = nodes.reference(**self.options)
    ref_node += nodes.Text(title)
    return [ref_node]    

class Google(MyBaseDirective):
  required_arguments = 1  
  optional_arguments = 0
  final_argument_whitespace = True
  has_content = False
  
  def run_impl(self):
    q = self.arguments[0]
    title = u"google:%s" % q
    refuri = "http://www.google.com/search?q=%s" % urllib.quote_plus(q.encode("utf-8"))
    return title, refuri

class Amazon(MyBaseDirective):
  required_arguments = 1  
  optional_arguments = 0
  final_argument_whitespace = True
  has_content = False
  
  def run_impl(self):
    keyword = self.arguments[0]
    query = {"mode" : "blended",
             "keywords" : keyword.encode("utf-8")
             }
    query_str = "&".join(("%s=%s" % (k, urllib.quote_plus(v)) for k, v in query.iteritems()))
    refuri = "http://www.amazon.co.jp/exec/obidos/external-search?%s" % query_str
    title = u"amazon:%s" % keyword
    return title, refuri

class Wikipedia(MyBaseDirective):
  required_arguments = 1  
  optional_arguments = 0
  final_argument_whitespace = True
  has_content = False
  
  def run_impl(self):
    wiki_title = self.arguments[0]
    title = u"wikipedia:%s" % wiki_title
    refuri = "http://ja.wikipedia.org/wiki/%s" % urllib.quote_plus(wiki_title.encode("utf-8"))
    return title, refuri
  
class Isbn(MyBaseDirective):
  required_arguments = 2
  optional_arguments = 0
  final_argument_whitespace = True   
  has_content = False
  
  def run_impl(self):
    asin  = self.arguments[0]
    title = self.arguments[1]
    refuri = u"http://www.amazon.co.jp/o/asin/%s/" % asin
    return title, refuri

directives.register_directive("google", Google)
directives.register_directive("amazon", Amazon)
directives.register_directive("wikipedia", Wikipedia)
directives.register_directive("isbn", Isbn)

if __name__ == '__main__':
  with open(sys.argv[1]) as io:
    value = io.read()
    parts = publish_parts(source=value, writer_name="html4css1")
    # html = parts["whole"]
    html = parts["fragment"]
    print html.encode("utf-8")