自動タグ付け機能でも作ってみる - 9: Markdownをパースする

MarkdownParser を作る

MarkdownParser については、MarkdownをAST的に扱えるライブラリとして、 mistletoe を使う。

mistletoe は、Markdownのレンダリングライブラリだけれど、レンダリングには各種シンタックスでの出力をサポートしている。 BaseRenderer を自前でオーバーライドすれば、AST(のようなもの)を得ることができる。

BaseRendererの実装を見ると、どうやら文書のトークン種別毎に renderer_(トークン種別名) を定義して、引数でトークンを受け取れるらしい。

どんなトークン種別があるかは、ソースコードを見れば一目瞭然だった。

    def __init__(self, *extras):
        self.render_map = {
            'Strong':         self.render_strong,
            'Emphasis':       self.render_emphasis,
            'InlineCode':     self.render_inline_code,
            'RawText':        self.render_raw_text,
            'Strikethrough':  self.render_strikethrough,
            'Image':          self.render_image,
            'FootnoteImage':  self.render_footnote_image,
            'Link':           self.render_link,
            'FootnoteLink':   self.render_footnote_link,
            'AutoLink':       self.render_auto_link,
            'EscapeSequence': self.render_escape_sequence,
            'Heading':        self.render_heading,
            'SetextHeading':  self.render_heading,
            'Quote':          self.render_quote,
            'Paragraph':      self.render_paragraph,
            'CodeFence':      self.render_block_code,
            'BlockCode':      self.render_block_code,
            'List':           self.render_list,
            'ListItem':       self.render_list_item,
            'Table':          self.render_table,
            'TableRow':       self.render_table_row,
            'TableCell':      self.render_table_cell,
            'Separator':      self.render_separator,
            'Document':       self.render_document,
            }

そして、同ライブラリの HTMLRenderer を見る感じ、入れ子に対応するため、BaseRendererの render_inner(self, token) を呼んで中身のレンダリングをして、適当なFormatをして返すというのが流儀らしい。

    def render_strong(self, token):
        template = '<strong>{}</strong>'
        return template.format(self.render_inner(token))

    def render_emphasis(self, token):
        template = '<em>{}</em>'
        return template.format(self.render_inner(token))
......

今回はレンダリングが目的ではないので、

  • 入れ子になりうるトークン種別では render_inner した結果の文字列を返すだけ
    • 太字(Strong)
    • 斜体(Emphasis)
    • インラインコード(InlineCode)
    • 画像(Image)
    • リンク(Link)
    • 脚注画像(FootnoteImage)
    • 脚注リンク(FootnoteLink)
    • エスケープシーケンス(EscapeSequence)
    • リスト(List)
    • テーブル(Table)
    • テーブル行(TableRow)
  • 入れ子にはならないブロック系のトークン種別では render_inner した結果の文字列を TextStorage に流し込んでから返す (render_store)
    • 見出し(Heading)
    • 引用(Quote)
    • 段落(Paragraph)
    • リスト項目(ListItem)
  • 不要なトークンは空文字列を返す (後述の render_ignroe)
    • コードブロック(BlockCode)
    • テーブルのセルの中身(TableCell)
    • 水平線(Separator)
    • URLリンク(AutoLink)
    • 取り消し線(Strikethrough)

という構成で良さそう。

BaseRendererで構築しているrender_mapに手を入れて、上記のリストごとに呼び出すメソッドをマッピングしてやればいいだろう。

from typing import TextIO, Any

import frontmatter
from mistletoe.base_renderer import BaseRenderer
from mistletoe.span_token import RawText
from mistletoe.block_token import Document

from .text_storage import TextStorage
from .parser import Parser


class MarkdownParser(BaseRenderer, Parser):
    def __init__(self, path: str, storage: TextStorage) -> None:
        super().__init__()
        self.path = path
        self.storage = storage

        for plain in ["Strong", "Emphasis", "InlineCode", "Image", "Link", 
                      "FootnoteImage","FootnoteLink", "EscapeSequence",
                      "List", "Table", "TableRow"]:
            self.render_map[plain] = self.render_inner

        for store in ["Heading", "Quote", "Paragraph", "ListItem"]:
            self.render_map[store] = self.render_store

        for ignore in ["BlockCode", "TableCell", "Separator",
                       "AutoLink", "Strikethrough"]:
            self.render_map[ignore] = self.render_ignore

    def parse(self, stream: TextIO) -> None:
        post = frontmatter.load(stream)
        self.render(Document(post.content))

    def render_store(self, token: Any) -> Any:
        inner = self.render_inner(token)
        self.storage.push(self.path, inner)
        return inner

    @staticmethod
    def render_ignore(token: Any) -> Any:
        return ''

    @staticmethod
    def render_raw_text(token: RawText) -> Any:
        return token.content

    def render_document(self, token: Document) -> Any:
        self.footnotes.update(token.footnotes)
        return self.render_inner(token)


if __name__ == '__main__':
    from io import StringIO

    class TextPrinter(TextStorage):
        def push(self, path: str, text: str) -> None:
            print(text)

    stream = StringIO('''# test `code` in the H1

paragraph contains **`strong code`**, [link](//example.com) and <//example.com/autolink>''')
    MarkdownParser("", TextPrinter()).parse(stream)

呼び出してみる。

$ python -m gen_tags.markdown_parser
test code in the H1
paragraph contains strong code, link and

できてる感じ。