🎄

Python で Markdown を Html に変換する(応用編)

2022/01/09に公開

これまでの記事:


基本編 では Markdown から変換した Html を <body> タグ内に入れるだけの最低限の実装を紹介しました。

応用編として、

  • mistletoe の機能でカスタム書式を定義する
  • 自作の CSS を読み込む
  • 処理が終わったらブラウザで Html を開いてプレビューする

など色々やってみましょう。

コード全体

クリックで展開(長いので注意)
convert.py
import argparse
import datetime
from pathlib import Path
import re
import webbrowser

import mistletoe
from mistletoe.span_token import SpanToken
from mistletoe.html_renderer import HTMLRenderer


# カスタム書式を定義
class Underline(SpanToken):
    pattern = re.compile(r"\[_(.+?)_\]")

class CheckBox(SpanToken):
    parse_group = 0
    pattern = re.compile(r"\[ \]|\[x\]")
    def __init__(self, mo:re.Match):
        filled = mo.group(0) == "[x]"
        if filled:
            self.stat = "checked"
        else:
            self.stat = ""

# Renderer 定義
class CustomRenderer(HTMLRenderer):
    def __init__(self):
        super().__init__(CheckBox, Underline)

    def render_underline(self, token):
        template = "<u>{}</u>"
        return template.format(self.render_inner(token))

    def render_check_box(self, token):
        template = '<input type="checkbox" disabled {stat}>'
        return template.format(stat=token.stat)



# Html のメイン部分を作成する
def md_to_html(md_str:str) -> str:
    markup = mistletoe.markdown(md_str, CustomRenderer)
    return "\n".join([
        '<body>',
        '<div class="main">',
        markup,
        '</div>',
        '</body>'
    ])

# テキストファイルから要素を作成する
def get_element(src_path:str, tag_name:str) -> str:
    src = read_file(src_path)
    return "\n".join([
        "<{}>".format(tag_name),
        src,
        "</{}>".format(tag_name)
    ])

# ファイルを開く
def read_file(file_path:str) -> str:
    with open(file_path, "r", encoding="utf-8") as input_file:
        return input_file.read()

# ファイルに書き込む
def write_file(file_path:str, html_str:str) -> None:
    with open(file_path, "w", encoding="utf-8") as output_file:
        output_file.write(html_str)


# メイン処理
def main(file_path:str, css_path:str, silent:bool=False) -> None:

    md_path = Path(file_path)
    if md_path.suffix != ".md":
        return

    title = md_path.stem
    favicon = "&#x1F4DD;"
    head = [
        r'<base target="_blank">',
        r'<meta charset="utf-8">',
        r'<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">',
        r'<title>{}</title>'.format(title),
        r'<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text x=%2250%%22 y=%2250%%22 style=%22dominant-baseline:central;text-anchor:middle;font-size:90px;%22>{}</text></svg>">'.format(favicon)
    ]

    if css_path:
        head.append(get_element(css_path, "style"))

    extra_css_files = list(Path(md_path.parent).glob("*.css"))
    for file in extra_css_files:
        print("  + external style sheet: '{}'".format(file.name))
        head.append("<!-- from additional style sheet: \"{}\" -->".format(file.name))
        head.append(get_element(str(file), "style"))


    md_str = read_file(file_path)
    body = md_to_html(md_str)

    markup = "\n".join([
        '<!DOCTYPE html>',
        '<html lang="ja">',
        "\n".join(head),
        body,
        '</html>'
    ])

    ts = datetime.datetime.today().strftime(r"%Y%m%d")
    out_path = md_path.with_name(md_path.stem + "_" + ts + ".html")
    write_file(out_path, markup)

    if not silent:
        webbrowser.open(out_path)


if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument("filePath", type=str)
    parser.add_argument("--noDefaultCss", action="store_true")
    parser.add_argument("--silent", action="store_true")
    args = parser.parse_args()

    wd = Path(__file__).parent
    css_path = ""
    if not args.noDefaultCss:
        if (p := Path(wd, "markdown.css")).exists():
            css_path = p

    main(args.filePath, css_path, args.silent)

基本的には 公式のガイド で紹介されているとおりに組んで行けば OK ですが、いくつかハマりどころがありました。
以下、細部を解説していきます。

カスタム書式の定義

今回は [_下線_] のように書くと <u>下線</u> のように解釈するカスタム書式と、チェックボックスを定義してみました。

どちらもインライン要素なので from mistletoe.span_token import SpanToken として SpanToken を読み込んでおきます。

ブロックレベル要素のカスタム書式は 定義が大変そうなので また今度。

基本形

クラスを継承して SpanToken から派生クラスを作ります。

文字列にマッチさせるための正規表現をコンパイルして pattern という名前でクラス変数に格納しておけばいいそうです。

class Underline(SpanToken):
    pattern = re.compile(r"\[_(.+?)_\]")

このとき、() で囲んでグループ化した部分が変換後の innerHTML に対応します。

応用形

SpanToken から派生させる点では同じですが、正規表現でのマッチングを複雑にすることもできます。

class CheckBox(SpanToken):
    parse_group = 0
    pattern = re.compile(r"\[ \]|\[x\]")
    def __init__(self, mo:re.Match):
        filled = mo.group(0) == "[x]"
        if filled:
            self.stat = "checked"
        else:
            self.stat = ""

ここでは [ ] を空のチェックボックス、[x] を完了済のチェックボックスと解釈するようにしました。
継承元の SpanTokenparse_group というクラス変数を持っていて、これはマッチした正規表現オブジェクトのどのグループを対象とするかを決めています。
(Python の正規表現グループと対応していて0がマッチした箇所全体、1以降は各括弧に対応)

今回はマッチした文字列の全体を差し替えたいので、この値を0でオーバーライドします。

文字列がマッチした場合、 __init__ の第2引数にマッチオブジェクトが渡されます。
__init__ 内でインスタンス変数を指定してやると、以降のコンパイル処理で各トークンのプロパティとしてアクセスできるようです。

公式のサンプルを見ると、後から target というプロパティでアクセスするために2番めのマッチグループをインスタンス変数に格納していますね。

sample
class GithubWiki(SpanToken):
    pattern = re.compile(r"\[\[ *(.+?) *\| *(.+?) *\]\]")
    def __init__(self, match_obj):
        self.target = match_obj.group(2)

Renderer の定義

from mistletoe.html_renderer import HTMLRenderer として読み込んだ HTMLRenderer を継承して派生クラスを作ります。

__init__ のなかで super().__init__() に上記の自作クラスを渡してやると、自動的に HTMLRenderer にカスタム書式が組み込まれて変換時に各トークンに対して関数が実行されるようになります。

class CustomRenderer(HTMLRenderer):
    def __init__(self):
        super().__init__(CheckBox, Underline)

    def render_underline(self, token):
        template = "<u>{}</u>"
        return template.format(self.render_inner(token))

    def render_check_box(self, token):
        template = '<input type="checkbox" disabled {stat}>'
        return template.format(stat=token.stat)

なお、関数名は render_ に続けてクラス名をスネークケースにしてやる必要があるので要注意です。この規則に従わないとエラーにはなりませんが関数が実行されなくなります。

メイン処理部分

Head のカスタマイズ

ファビコンを絵文字で指定する方法は こちら の記事を参考にしています。


    title = md_path.stem
    favicon = "&#x1F4DD;"
    head = [
        r'<base target="_blank">',
        r'<meta charset="utf-8">',
        r'<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">',
        r'<title>{}</title>'.format(title),
        r'<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text x=%2250%%22 y=%2250%%22 style=%22dominant-baseline:central;text-anchor:middle;font-size:90px;%22>{}</text></svg>">'.format(favicon)
    ]

    if css_path:
        head.append(get_element(css_path, "style"))

外部 CSS の読み込み

単純に文字列として要素を作成する関数を作ってみました。

def get_element(src_path:str, tag_name:str) -> str:
    src = read_file(src_path)
    return "\n".join([
        "<{}>".format(tag_name),
        src,
        "</{}>".format(tag_name)
    ])

これを使って変換対象の Markdown と同じディレクトリにある CSS ファイルを自動的に読み込むようにしています。pathlib 便利!

    extra_css_files = list(Path(md_path.parent).glob("*.css"))
    for file in extra_css_files:
        print("  + external style sheet: '{}'".format(file.name))
        head.append("<!-- from additional style sheet: \"{}\" -->".format(file.name))
        head.append(get_element(str(file), "style"))

セイウチ演算子

main() の呼び出し部分で Python3.8 から導入されたセイウチ演算子 := を使ってみました。英語だと Walrus operator で牙を生やしたセイウチの絵文字に見えるからだそうです。

if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument("filePath", type=str)
    parser.add_argument("--noDefaultCss", action="store_true")
    parser.add_argument("--silent", action="store_true")
    args = parser.parse_args()

    wd = Path(__file__).parent
    css_path = ""
    if not args.noDefaultCss:
        if (p := Path(wd, "markdown.css")).exists():
            css_path = p

    main(args.filePath, css_path, args.silent)

括弧内の処理結果を保持してそれ以降も使い回せるようにします。下記のような書き方をしなくて済むので幾分スッキリしますね。

    p = Path(wd, "markdown.css")
    if p.exists():
        css_path = p

かなりカスタマイズできるようになりました。 次回の発展編では生成したHtml の DOM を操作してみようと思います。

ブロック要素を部分的にカスタムできるようになったので次回はその記事にします。
応用編+α

Discussion