🎄

Python で Markdown を Html に変換する(発展編)

2022/01/15に公開

これまでの記事:


発展編として、生成された Html の DOM を操作してみましょう。

やること:

  • 特定の要素に対する属性やクラスの追加
  • 目次(Table of Contents)の追加

コード

DOM 操作といえば JavaScript。
ということで、生成した Html に自作の JavaScript を仕込んだこともありましたが、できれば静的な文書として完結させたかったので Markdown からの変換時に諸々操作できる lxml を使ってみました。

https://lxml.de/

ネット上の記事ではスクレイピングのパース目的で使われることが多いようです。
とはいえ DOM 構造をパースしてゴニョゴニョすることもかなり簡単にできる模様。

導入は pip で一発です。

pip install lxml

クラス作成

lxml を使って DOM を操作するクラスを作って domtree.py という名前で保存しておきます。

クラス全体
domtree.py
import re
import lxml.html

class DomTree:
    def __init__(self, markup:str="") -> None:
        self._text = markup

    @staticmethod
    def decode(elem:lxml.html.HtmlElement) -> str:
        return lxml.html.tostring(elem, encoding="unicode")

    @staticmethod
    def get_inner_html(elem:lxml.html.HtmlElement) -> str:
        children = []
        children.append((elem.text or ""))
        for c in list(elem):
            children.append(DomTree.decode(c))
        return "".join(children)

    def get_content(self) -> str:
        root = lxml.html.fromstring(self._text)
        root.classes.add("main")
        return self.decode(root)

    def insert_heading_id(self) -> None:
        root = lxml.html.fromstring(self._text)
        headers = root.xpath("h2 | h3 | h4 | h5 | h6")
        for i, hd in enumerate(headers):
            hd.set("id", "section-{}".format(i))
        self._text = self.decode(root)

    def add_class_to_img(self) -> None:
        root = lxml.html.fromstring(self._text)
        for elem in root.xpath("//p/img"):
            elem.getparent().classes.add("img-container")
        self._text = self.decode(root)

    def insert_code_label(self) -> None:
        root = lxml.html.fromstring(self._text)
        for elem in root.xpath("//code[contains(@class, 'language-')]"):
            code_info = str(elem.get("class"))
            new_label = code_info.replace("language-", "")
            elem.classes.remove(code_info)
            elem.getparent().classes.add("codeblock-header")
            elem.getparent().set("data-lang", new_label)
        self._text = self.decode(root)

    def adjust_index(self, x_path:str) -> None:
        root = lxml.html.fromstring(self._text)
        for elem in root.xpath(x_path):
            start_idx = elem.attrib["start"] or 1
            counter = int(start_idx)
            for ol in elem.xpath("ol"):
                ol.set("start", str(counter))
                counter += len(ol.xpath("li"))
        self._text = self.decode(root)

    def get_toc(self) -> str:
        root = lxml.html.fromstring(self._text)
        headers = root.xpath("h2 | h3 | h4 | h5 | h6")
        if len(headers) > 0:
            ul = lxml.html.Element("ul")
            for hd in headers:
                a = lxml.html.Element("a")
                a.set("target", "_self")
                a.set("href", "#{}".format(hd.attrib["id"]))
                a.text = hd.text_content()
                li = lxml.html.Element("li")
                li.classes.add("toc-{}".format(hd.tag))
                li.append(a)
                ul.append(li)
            return self.decode(ul)
        return ""

    def get_title(self) -> str:
        root = lxml.html.fromstring(self._text)
        headers = root.xpath("h1 | h2")
        if len(headers) > 0:
            t = self.get_inner_html(headers[0])
            return re.sub(r"\s|<.+?>", "_", t)
        return ""

以下、細部の解説です。

コンストラクタ

def __init__(self, markup:str="") -> None:
    self._text = markup

インスタンス変数をアンダースコアで始める書き方を見たことがあったので _text としてみました。

静的メソッド

@staticmethod
def decode(elem:lxml.html.HtmlElement) -> str:
    return lxml.html.tostring(elem, encoding="unicode")

lxml で生成される要素を文字列に変換するために作りました。
encoding を unicode に指定しないと出力が実体参照になります。

@staticmethod
def get_inner_html(elem:lxml.html.HtmlElement) -> str:
    children = []
    children.append((elem.text or ""))
    for c in list(elem):
        children.append(DomTree.decode(c))
    return "".join(children)

JavaScript の innerHTML 的なことをする目的のメソッドです。
要素の text プロパティでアクセスできるのが最初の子要素までにあるテキスト要素( <p>この部分<i>~~</i></p> すなわちテキストノード)というのが少し罠ですね。

Html を文字列として出力するメソッド

def get_content(self) -> str:
    root = lxml.html.fromstring(self._text)
    root.classes.add("main")
    return self.decode(root)

lxml.html.fromstring() の出力は、渡す Html が複数の要素を持っていると一番外側を <div></div> で囲む仕様になっているようです。後で判別できるようにこの一番外側のタグに main というクラスを指定してみました。

見出しに id を割り当てるメソッド

def insert_heading_id(self) -> None:
    root = lxml.html.fromstring(self._text)
    headers = root.xpath("h2 | h3 | h4 | h5 | h6")
    for i, hd in enumerate(headers):
        hd.set("id", "section-{}".format(i))
    self._text = self.decode(root)

h2 以下のタグに section-0section-1 、…… という id を割り振ります。

以下、 lxml.html.fromstring() で取得した内容に対して xpath() で要素を指定し、その各要素をメソッドで操作するという流れです。

最初は headers などの変数に格納したあとに各要素に操作を加えると元の root の内容が更新される点に違和感を覚えていましたが、参照渡しというものなのですね。

img 要素を直下に持つ p 要素にクラスを追加するメソッド

def add_class_to_img(self) -> None:
    root = lxml.html.fromstring(self._text)
    for elem in root.xpath("//p/img"):
        elem.getparent().classes.add("img-container")
    self._text = self.decode(root)

もうすでに登場しているようにクラス関連は classes からアクセスできます。

コードブロックの親要素を操作するメソッド

def insert_code_label(self) -> None:
    root = lxml.html.fromstring(self._text)
    for elem in root.xpath("//code[contains(@class, 'language-')]"):
        code_info = str(elem.get("class"))
        new_label = code_info.replace("language-", "")
        elem.classes.remove(code_info)
        elem.getparent().classes.add("codeblock-header")
        elem.getparent().set("data-lang", new_label)
    self._text = self.decode(root)

Markdown のコードブロックで、

    ```hoge
    ほげほげ
    ```

のように書くと、

<pre><code class="language-hoge">ほげほげ</code></pre>

のようになります。

こういったコードブロックを、

<pre class="codeblock-header" data-lang="hoge"><code>ほげほげ</code></pre>

のように変換して CSS でコードブロックの上にファイル名を表示できるようにします。

リストの start 属性を指定するメソッド

def adjust_index(self, x_path:str) -> None:
    root = lxml.html.fromstring(self._text)
    for elem in root.xpath(x_path):
        start_idx = elem.attrib["start"] or 1
        counter = int(start_idx)
        for ol in elem.xpath("ol"):
            ol.set("start", str(counter))
            counter += len(ol.xpath("li"))
    self._text = self.decode(root)

この記事 の内容です。

目次を取得するメソッド

def get_toc(self) -> str:
    root = lxml.html.fromstring(self._text)
    headers = root.xpath("h2 | h3 | h4 | h5 | h6")
    if len(headers) > 0:
        ul = lxml.html.Element("ul")
        for hd in headers:
            a = lxml.html.Element("a")
            a.set("target", "_self")
            a.set("href", "#{}".format(hd.attrib["id"]))
            a.text = hd.text_content()
            li = lxml.html.Element("li")
            li.classes.add("toc-{}".format(hd.tag))
            li.append(a)
            ul.append(li)
        return self.decode(ul)
    return ""

h2~h6タグの内容を読み取って、そこへ飛ぶ <a> タグのリストを生成します。

<ul>
    <li class="toc-h2"><a href="#section-0" target="_self">見出し2</a></li>
    <li class="toc-h3"><a href="#section-1" target="_self">見出し3</a></li>
    <li class="toc-h4"><a href="#section-2" target="_self">見出し4</a></li>
</ul>

toc-h● というクラスを割り振っているのは見出しのレベルに応じて CSS でインデントを調整できるようにです。

文書のタイトルを取得するメソッド

def get_title(self) -> str:
    root = lxml.html.fromstring(self._text)
    headers = root.xpath("h1 | h2")
    if len(headers) > 0:
        t = self.get_inner_html(headers[0])
        return re.sub(r"\s|<.+?>", "_", t)
    return ""

h1 もしくは h2 タグの内容をタイトルにします。
タグなしのテキスト内容を取得するだけなら text_content() も使えますが、要素内に <br> などの改行があるとそこで出力が途切れてしまうという問題があります。

呼び出し側

呼び出し側コード全体
convert.py

import argparse
import datetime
from pathlib import Path
import re
import webbrowser


from domtree import DomTree

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

################################
# mistletoe renderer のカスタム
################################

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 = ""


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)

    def render_list_item(self, token):
        if len(token.children) == 0:
            return '<li></li>'
        inner = '\n'.join([self.render(child) for child in token.children])
        inner_template = '\n{}\n'
        if self._suppress_ptag_stack[-1]:
            if token.children[0].__class__.__name__ == 'Paragraph':
                inner_template = inner_template[1:]
            if token.children[-1].__class__.__name__ == 'Paragraph':
                inner_template = inner_template[:-1]
        return '<li><div class="wrapper-li">{}</div></li>'.format(inner_template.format(inner))


################################
# メイン処理
################################

class Markup:
    pass

def md_to_html(md_str:str) -> Markup:
    markup = mistletoe.markdown(md_str, CustomRenderer)
    dom = DomTree(markup)
    dom.insert_heading_id()
    dom.add_class_to_img()
    dom.insert_code_label()
    dom.adjust_index("//*[contains(@class, 'force-order')]")
    m = Markup()
    m.content = dom.get_content()
    m.title = dom.get_title()
    m.toc = dom.get_toc()
    return m

def get_timestamp(path:Path) -> str:
    date_fmt = r"%Y-%m-%d"
    file_epoch_time = path.stat().st_mtime
    last_modified = datetime.datetime.fromtimestamp(file_epoch_time).strftime(date_fmt)
    today = datetime.datetime.today().strftime(date_fmt)
    if last_modified == today:
        return "update: {}".format(last_modified)
    return "contents updated: {} / document generated: {}".format(last_modified, today)

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", newline=None) 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

    md_str = read_file(file_path)
    html = md_to_html(md_str)

    title = html.title or 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"))

    body = [
        '<body>',
        '<div class="container">',
        '<div class="timestamp">{}</div>'.format(get_timestamp(md_path)),
        '<div class="toc">{}</div>'.format(html.toc),
        html.content,
        '</div>',
        '</body>',
    ]

    markup = "\n".join([
        '<!DOCTYPE html>',
        '<html lang="ja">',
	'<head>',
        "\n".join(head),
	'</head>',
        "\n".join(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)

応用編+α までに作った内容を拡張していきます。

mistletoe の renderer をカスタムする部分までは過去の記事で書いてきたことと変わりありません。
ここでは # メイン処理 のコメント以下を解説していきます。

Html 整形部分

from domtree import DomTree として同ディレクトリの自作クラスを読み込んで、これまでに書いてきたクラスを使えるようにします。

class Markup:
    pass

def md_to_html(md_str:str) -> Markup:
    markup = mistletoe.markdown(md_str, CustomRenderer)
    dom = DomTree(markup)
    dom.insert_heading_id()
    dom.add_class_to_img()
    dom.insert_code_label()
    dom.adjust_index("//*[contains(@class, 'force-order')]")
    m = Markup()
    m.content = dom.get_content()
    m.title = dom.get_title()
    m.toc = dom.get_toc()
    return m

Markup という空のクラスを作成し、DOM 操作の結果を格納しています。
内容、タイトル、目次はそれぞれ contenttitletoc というプロパティでアクセスできるようになります。
空のクラスは即時オブジェクト的な使い方ができて便利ですね。

main() 内部

    body = [
        '<body>',
        '<div class="container">',
        '<div class="timestamp">{}</div>'.format(get_timestamp(md_path)),
        '<div class="toc">{}</div>'.format(html.toc),
        html.content,
        '</div>',
        '</body>',
    ]

Body の中身は、

<div class="container">
    <div class="timestamp">(タイムスタンプ)</div>
    <div class="toc">(目次)</div>
    <div class="main">(Markdown から変換したメインの Html)</div>
</div>

という構造にして、あとは CSS で煮るなり焼くなり好きにできます。


数回に分けて Markdown → Html 変換をまとめてみました。
備忘録のつもりで書き始めたものが結構なボリュームに膨らんで自分でもびっくりです。

Discussion