はじめに
このブログでは、普段ブログ記事を書く際、Markdown形式で書いたテキストをPythonスクリプトでHTML形式に変換するという方法を取っています。
しかし、先日、記事を書いている際に、引用(blockquote要素)を二つ続けて並べると一つの引用ブロックとして扱われてしまうという問題が発生しました。
間に改行を入れたり、空白を入れたりと色々試行錯誤してみたのですが、どれも思うような結果にならず、かなり手こずりました。
結論から言うと、使用ライブラリをPython-MarkdownからMistuneに変更することによって問題が解決できたので、その辺りの詳細を共有していこうと思います。
問題の原因
これまで、MarkdownからHTMLへの変換ライブラリにはPython-Markdownを使用していました。PythonでMarkdownを変換しようとする際、最初に検索でヒットする、かなり一般的なライブラリだと思います。pip install markdownでインストールした後に、import markdownでインポートすると簡単に使えます。
Python-Markdown/markdown - GitHub
しかし、このライブラリは公式ドキュメントにもある通り、ジョン・グルーバー(John Gruber, Markdownの開発者)のオリジナルのMarkdownをベースにしています。
元々、Markdownは結構曖昧な仕様の言語だったようです。(そして開発者のジョン・グルーバーもそちらを好んでいたっぽい?)その結果、様々な方言や実装が生まれ、同じ書き方でもシステムによって出力が違うという問題が引き起こされました。
今回の問題も、Python-Markdownの仕様では、間に改行を入れても2つの引用ブロックとして解釈してくれないというのが問題の原因のようでした。
解決策
Stack Overflowで全く同じ内容の質問がありました。↓
How can I write two separate blockquotes in sequence using markdown? - Stack Overflow
質問の回答を見る限り、Python-Markdownのような仕様に従う場合、二つの引用ブロックの間に何か文字(段落)を挟まないと分離するように解釈してくれないようです。しかし、さすがに間に無駄な文字を入れるというのは、あまり解決策として美しくないような気がしたので却下です。
一方、CommonMarkという仕様に準拠していれば、間に改行を入れることによって二つの引用ブロックを分離できるという回答もありました。
CommonMarkは、2014年に策定されたMarkdown仕様の一つで、Markdownの曖昧さを解消するためにより厳密に定義された仕様だそうです。GitHubや上記のStackOverflowといったサイトもCommonMarkを採用しているため、かなり標準的な仕様だと思われます。
今回は解決策として、思い切ってCommonMarkに準拠したPythonライブラリに移行することに決めました。
ライブラリの選定
CommonMark準拠のライブラリとして、GitHubのスター数などを考慮に入れると以下の3つが候補に挙がりました。
今回は、ドキュメントが充実しており、実行速度も速いというMistuneを選択しました。
※ちなみにMistuneは、正確にはCommonMarkに完全には準拠していません。CommonMarkで用意されているテストケースには普通ではあり得ない(insane)ようなものが含まれているため、それらは意図的に無視するという設計思想のようです。↓
Introduction - Mistune 2.0.5 documentation
Python-markdownからMistuneへの移行
pip install mistuneでMistuneをインストールした後に、コードを書き換えていきます。といっても、書き換え自体は至極簡単です。
以下のコードがPython-Markdownを使ったコードです。
import markdown
text = '''
This is a **sample** text.
> This is a blockquote.
> This is another blockquote.
'''
html = markdown.markdown(text)
print(html)
# output:
# <p>This is a <strong>sample</strong> text.</p>
# <blockquote>
# <p>This is a blockquote.</p>
# <p>This is another blockquote.</p>
# </blockquote>
Mistuneを使うとこうなります。
import mistune
text = '''
This is a **sample** text.
> This is a blockquote.
> This is another blockquote.
'''
html = mistune.html(text)
print(html)
# output:
# <p>This is a <strong>sample</strong> text.</p>
# <blockquote>
# <p>This is a blockquote.</p>
# </blockquote>
# <blockquote>
# <p>This is another blockquote.</p>
# </blockquote>
単純にMarkdown形式の文書をHTMLに変換したい場合は、.html()メソッドが便利です。使い方はほとんど同じですが、出力結果を見ると、改行によって引用ブロックがしっかり2つに分割されていることがわかります。
クラス属性の付与
サイト上では、見出しや画像、リンクにCSSスタイルを適用するために、それらの要素にオリジナルのクラス名を付与することがあると思います。このブログでもそのようなやり方を取ってます。
Python-Markdownを使っていた際は、あらかじめ用意されている拡張機能(attr_list)を使って、クラス属性をHTML要素に付与していました。
text = '''
# Sample Heading {: .my_heading_class}
{: .my_image_class}
'''
html = markdown.markdown(text, extensions=['attr_list'])
print(html)
# output:
# <h1 class="my_heading_class">Sample Heading</h1>
# <p><img alt="image_alt" class="my_image_class" src="image_url" /></p>
しかし、MistuneにはHTML要素に属性を付与するような機能はあらかじめ備わっていません。そもそも、CommonMarkにそのような構文が定義されていないからです。HTMLタグをそのまま記述するという方法もありますが(Raw HTML)、毎回それを行うのも億劫です。
調べてみたところ、こういった場合の対応策としてはカスタムのHTMLレンダラーを実装するという方法が一般的のようです。
class MyRenderer(mistune.HTMLRenderer):
def heading(self, text, level):
if level == 1:
return f'<h{level} class="my_heading_class">{text}</h{level}>\n'
return super().heading(text, level)
def image(self, text, url):
src = self.safe_url(url)
alt = mistune.util.escape(mistune.util.striptags(text))
return f'<img class="my_image_class" src="{src}" alt="{alt}"/>\n'
text = '''
# Sample Heading

'''
markdown = mistune.create_markdown(renderer=MyRenderer())
html = markdown(text)
print(html)
# output:
# <h1 class="my_heading_class">Sample Heading</h1>
# <p><img class="my_image_class" src="image_url" alt="image_alt"/>
MistuneのHTMLRendererクラスを継承した独自のクラスを実装し、必要に応じてメソッドをオーバーライドしています。上の例では、見出し要素を返すheading()メソッドと画像要素を返すimage()メソッドを、クラス属性を含んだHTMLタグを返すように上書きしています。
どのようなメソッドが存在するのかは、公式ドキュメントから見ることができます。また、メソッドを実装する際には、GitHubにあるHTMLRendererクラスのオリジナルのソースコードを参考にするのが良いと思います。↓
Renderers - Mistune 3.2.0 documentation
mistune/src/mistune/renderers/html.py - GitHub
カスタムレンダラーを使う場合は、引数rendererを指定した上で.create_markdown()メソッドを呼び出し、Markdownインスタンスをあらかじめ生成しておくことが必要になります。あとは、インスタンスを関数として呼び出す(html = markdown(text)の部分)ことで変換可能です。
あるHTML要素に特定のクラス属性を常に付与したいというような場合、このような方法で対処可能だと思います。必要に応じてクラス属性を付与するといった、より複雑な例の場合は、公式ドキュメントにあるように独自の構文を定義してカスタムレンダラーを実装するか、諦めて生のHTMLをそのまま書くかになると思います。
エスケープの有無の設定
HTMLをそのまま書こうとした際、エスケープの有無の設定について少々引っかかりました。
.html()メソッドが生のHTMLをエスケープしないようにあらかじめ設定されている一方、.create_markdown()で生成されたMarkdownインスタンスはデフォルトではHTMLのエスケープを行うようになっています。
エスケープを行わないようにするには、引数にescape=Falseを設定することが必要です。
import mistune
text = '''
sample paragraph.
<div>
HTML content
</div>
'''
print(mistune.html(text))
# output:
# <p>sample paragraph.</p>
# <div>
# HTML content
# </div>
markdown = mistune.create_markdown()
print(markdown(text))
# output:
# <p>sample paragraph.</p>
# <p><div>
# HTML content
# </div></p>
markdown = mistune.create_markdown(escape=False)
print(markdown(text))
# output:
# <p>sample paragraph.</p>
# <div>
# HTML content
# </div>
これはカスタムレンダラーを使用する時も同様です。ただ、カスタムレンダラーを使用する際は、引数に指定したレンダラーの方にescapeを設定しなければならないようなので注意が必要です。
class MyRenderer(mistune.HTMLRenderer):
def heading(self, text, level):
...
markdown = mistune.create_markdown(escape=False, renderer=MyRenderer())
print(markdown(text))
# output:
# <p>sample paragraph.</p>
# <p><div>
# HTML content
# </div></p>
markdown = mistune.create_markdown(renderer=MyRenderer(escape=False))
print(markdown(text))
# output:
# <p>sample paragraph.</p>
# <div>
# HTML content
# </div>
常にHTMLタグのエスケープは行わないと決まっている場合、クラスのコンストラクタのほうでescapeを設定した方がよりシンプルに書けます。親クラス(HTMLRenderer)のコンストラクタをsuper()を使って呼び出し、その際にescape=Falseを設定しています。
class MyRenderer(mistune.HTMLRenderer):
def __init__(self):
super().__init__(escape=False)
def heading(self, text, level):
...
markdown = mistune.create_markdown(renderer=MyRenderer())
print(markdown(text))
# output:
# <p>sample paragraph.</p>
# <div>
# HTML content
# </div>
おわりに
リンク要素を別タブで開く(target='_blank'を付ける)か否かや、コードブロックのスタイル指定に使うdiv要素など、HTMLに変換した後に手動で書き直す場所もまだあるので、その辺りの自動化も行っていきたいです。
