yu00’s blog

プログラミングに関する備忘録です

MarkdownでTex数式をSVGに変換する(Pandoc+MathJax+Python Selenium)

はじめに

Markdown中のTex数式は、Webサイトによってサポートされていなかったり、
方言があったりします。
また、JavaScriptTex数式変換ツールであるMathJaxがありますが、
レンダリングが遅いという問題があります。
そこで、Tex数式をMathJaxを使い事前にSVG画像に変換することで、
汎用的かつ高速なレンダリングを目指します。

環境構築

https://yu00.hatenablog.com/entry/2022/07/07/133441

方針

以下のようなPython Pandoc filterを作成します。

  • Markdownの中からMath要素だけを抜き出す
  • Math要素テキストだけを書き出したMathJax記法の
    テンポラリHTMLファイルを作成
  • Seleniumで テンポラリHTMLファイルを開きMathJaxでコンパイル
  • コンパイルしたSVG Math要素を取得
  • PandocのMath要素をSVG要素に変換

ソース

import panflute as pf
import chromedriver_binary
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from string import Template
import os

class MathConverter:
    TMP_HTML_FILE_NAME = os.path.abspath('tmp.html')
    "テンポラリHTMLファイル名"

    MATH_JAX_CNFIG = '''{
        "tex2jax":{
            "inlineMath":[["$","$"]],
            "displayMath":[["$$","$$"]]
        },
    }'''
    "MathJax 設定"

    MATH_JAX_CDN_URL = 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.5/MathJax.js?config=TeX-AMS_SVG'
    "MathJax CDN URL"

    SELENIUM_WAIT_TIME = 5
    "Seleniumで要素検索の最大待ち時間"

    MATH_CLASS_NAME = 'my-math-class'
    MATH_JAX_END_CLASS_NAME = 'math-jax-end'
    class MathElemId:
        """Pandoc JSON ASTとSVGのMath要素対応付けのためのID"""
        def __init__(self):
            self._id = 0
        def get(self):
            return f'my-math-id-{self._id}'
        def __iadd__(self, b):
            self._id += b
            return self

    def __init__(self):
        self._math_elems = [] 
        "Pandoc JSON ASTのMath要素"
        
        self._svg_math_htmls = {}
        "dict[MathElemId, svg_html]"

        self._svg_src_html = ''
        "SVGソースHTML"

        self._pf_id_to_math_elem_id = {}
        "dict[id(pf.Math), MathElemId]"

        self._math_elem_id = self.MathElemId()

    def prepare(self, doc):
        doc.walk(self._collect_pandoc_math_elems)
        self._create_tmp_html_file()
        self._compile_tmp_html_to_svg()

    def action(self, elem, doc):        
        if isinstance(elem, pf.Para):
            # Math要素が<p>の子かつDisplayMathの場合は<p>ごと<svg>に書き換え
            if len(elem.content) == 1 and isinstance(elem.content[0], pf.Math) and elem.content[0].format=='DisplayMath':
                return self._convert_pandoc_math_to_svg(elem.content[0])
        elif isinstance(elem, pf.Math):
            if not isinstance(elem.parent, pf.Para) or elem.format!='DisplayMath':
                # <span> or plain <div>
                return self._convert_pandoc_math_to_svg(elem)

    def finalize(self, doc):
        # SVGソースをbodyの最初に追加
        doc.content.insert(0, pf.RawBlock(f'<div hidden>{self._svg_src_html}</div>', 'html'))

    def _collect_pandoc_math_elems(self, elem, doc):
        """
        Pandoc JSON ASTからMath要素だけを抜き出す
        """
        if isinstance(elem, pf.Math):
            self._math_elems += [{'text':elem.text, 'format':elem.format, 'id':self._math_elem_id.get()}]
            self._pf_id_to_math_elem_id[id(elem)] = self._math_elem_id.get()
            self._math_elem_id += 1

    def _create_tmp_html_file(self):
        """
        Math要素テキストだけを書き出したMathJax記法のテンポラリHTMLファイルを作成
        """

        # StartupHook : MathJaxコンパイルが終了した時にdiv要素を作成する関数を登録
        html_template = Template('''<html><head>
        <meta charset="utf-8">
        <script type="text/x-mathjax-config">
        MathJax.Hub.Config($math_jax_config)
        MathJax.Hub.Register.StartupHook("End",function () {
            const elem = document.createElement('div');
            elem.className = '$math_jax_end_class_name';
            document.body.prepend(elem);
        });
        </script>
        <script type="text/javascript" id="MathJax-script" async src="$cdn_url"></script>
        </head>
        <body>$body</body>
        </body></html>''')

        # Math要素テキストを「$」と「div」で囲った文字列を作成
        body = ''
        for math_elem in self._math_elems:
            math_delim = '$$' if math_elem['format'] == 'DisplayMath' else '$'
            body += f'<div class="{self.MATH_CLASS_NAME}" id="{math_elem["id"]}" format="{math_elem["format"]}">{math_delim}{math_elem["text"]}{math_delim}</div>'
        
        # HTML書き出し
        with open(self.TMP_HTML_FILE_NAME, 'w', encoding='utf-8') as f:
            f.write(html_template.substitute(
                body=body, 
                cdn_url=self.MATH_JAX_CDN_URL, 
                math_jax_config=self.MATH_JAX_CNFIG,
                math_jax_end_class_name=self.MATH_JAX_END_CLASS_NAME
                ))

    def _compile_tmp_html_to_svg(self):
        """
        テンポラリHTMLファイルを開きコンパイル済みのSVG Math要素を取得
        """

        options = Options()
        options.add_argument('--headless')
        driver = webdriver.Chrome(options=options)
        driver.implicitly_wait(self.SELENIUM_WAIT_TIME)

        # テンポラリHTMLファイルを開く
        driver.get('file:///'+self.TMP_HTML_FILE_NAME.replace('\\', '/'))
        # MathJaxのコンパイル終了待ち
        x = driver.find_element_by_class_name(self.MATH_JAX_END_CLASS_NAME)
        assert x, 'Error MathJax is not End'
        
        # bodyの最初にあるSVGのソースを取得
        self._svg_src_html = driver.find_element_by_tag_name('svg').get_attribute('outerHTML')
        
        # 各SVG Math要素を取得
        elems = driver.find_elements_by_class_name(self.MATH_CLASS_NAME)
        for elem in elems:
            tag = 'div' if elem.get_attribute('format')=='DisplayMath' else 'span'
            svg_math_html = elem.find_element_by_tag_name('svg').get_attribute('outerHTML')
            math_elem_id = elem.get_attribute('id')
            self._svg_math_htmls[math_elem_id] = f'<{tag} class="{self.MATH_CLASS_NAME}" id="{math_elem_id}">{svg_math_html}</{tag}>'

    def _convert_pandoc_math_to_svg(self, math_elem):
        """
        Pandoc JSON ASTのMath要素をSVG要素に変換
        """
        svg_math_html = self._svg_math_htmls[self._pf_id_to_math_elem_id[id(math_elem)]]
        if math_elem.format=='DisplayMath':
            ret = pf.RawBlock(svg_math_html, 'html')
        else:
            ret = pf.RawInline(svg_math_html, 'html')
        return ret

x = MathConverter()
pf.run_filter(x.action, prepare=x.prepare, finalize=x.finalize)

サンプル

以下のようなマークダウンを作成します。

$$
\newcommand{\mat}[1]{\begin{bmatrix}#1\end{bmatrix}}
$$

$$
\boldsymbol{E} = \mat{1 & 0 & 0\\ 0 & 1 & 0\\ 0 & 0 & 1}
$$

+ $\boldsymbol{E}$ : 単位行列

MarkdownからHTMLに変換

以下コマンドを実行します。
pandoc -s src.md -o dst.html --filter filter.py

実行結果

MarkdownからMarkdownに変換

以下コマンドを実行します。

pandoc -s src.md -o dst.md --to=markdown-raw_attribute --wrap=none --filter filter.py

実行結果

<div hidden><svg><defs id="MathJax_SVG_glyphs"><path stroke-width="1" id="MJMATHBI-45" d="M257 618H231Q198 618 198 636Q202 672 214 678L219 680H811Q817 677 820 673T824 666L825 664Q825 659 814 549T799 433Q793 424 771 424Q752 424 746 427T740 441Q740 445 742 466T744 505Q744 561 722 585T646 616Q639 617 545 618H456Q456 617 427 502T398 385Q398 384 435 384Q461 385 471 385T499 391T526 405T545 433T562 478Q566 494 571 497T595 501H604Q622 501 626 486Q626 482 593 349T557 213Q552 205 530 205Q499 205 499 219Q499 222 503 242T508 281Q508 308 491 314T429 322Q425 322 423 322H382L317 64Q317 62 390 62Q460 62 493 64T569 80T640 124Q665 149 686 187T719 253T733 283Q739 289 760 289Q791 289 791 274Q791 267 763 201T706 71L678 8Q676 4 667 0H58Q47 5 43 15Q47 54 60 60Q64 62 113 62H162L163 66Q163 67 231 341T301 616Q301 618 257 618Z"></path><path stroke-width="1" id="MJMAIN-3D" d="M56 347Q56 360 70 367H707Q722 359 722 347Q722 336 708 328L390 327H72Q56 332 56 347ZM56 153Q56 168 72 173H708Q722 163 722 153Q722 140 707 133H70Q56 140 56 153Z"></path><path stroke-width="1" id="MJMAIN-5B" d="M118 -250V750H255V710H158V-210H255V-250H118Z"></path><path stroke-width="1" id="MJMAIN-31" d="M213 578L200 573Q186 568 160 563T102 556H83V602H102Q149 604 189 617T245 641T273 663Q275 666 285 666Q294 666 302 660V361L303 61Q310 54 315 52T339 48T401 46H427V0H416Q395 3 257 3Q121 3 100 0H88V46H114Q136 46 152 46T177 47T193 50T201 52T207 57T213 61V578Z"></path><path stroke-width="1" id="MJMAIN-30" d="M96 585Q152 666 249 666Q297 666 345 640T423 548Q460 465 460 320Q460 165 417 83Q397 41 362 16T301 -15T250 -22Q224 -22 198 -16T137 16T82 83Q39 165 39 320Q39 494 96 585ZM321 597Q291 629 250 629Q208 629 178 597Q153 571 145 525T137 333Q137 175 145 125T181 46Q209 16 250 16Q290 16 318 46Q347 76 354 130T362 333Q362 478 354 524T321 597Z"></path><path stroke-width="1" id="MJMAIN-5D" d="M22 710V750H159V-250H22V-210H119V710H22Z"></path><path stroke-width="1" id="MJSZ4-23A1" d="M319 -645V1154H666V1070H403V-645H319Z"></path><path stroke-width="1" id="MJSZ4-23A3" d="M319 -644V1155H403V-560H666V-644H319Z"></path><path stroke-width="1" id="MJSZ4-23A2" d="M319 0V602H403V0H319Z"></path><path stroke-width="1" id="MJSZ4-23A4" d="M0 1070V1154H347V-645H263V1070H0Z"></path><path stroke-width="1" id="MJSZ4-23A6" d="M263 -560V1155H347V-644H0V-560H263Z"></path><path stroke-width="1" id="MJSZ4-23A5" d="M263 0V602H347V0H263Z"></path></defs></svg></div>
<div class="my-math-class" id="my-math-id-0"><svg xmlns:xlink="http://www.w3.org/1999/xlink" width="0" height="0.289ex" viewBox="0 -62.2 0 124.4" role="img" focusable="false" style="vertical-align: -0.145ex;" aria-hidden="true"><g stroke="currentColor" fill="currentColor" stroke-width="0" transform="matrix(1 0 0 -1 0 0)"></g></svg></div>
<div class="my-math-class" id="my-math-id-1"><svg xmlns:xlink="http://www.w3.org/1999/xlink" width="16.996ex" height="9.24ex" viewBox="0 -2230 7317.6 3978.3" role="img" focusable="false" style="vertical-align: -4.061ex;" aria-hidden="true"><g stroke="currentColor" fill="currentColor" stroke-width="0" transform="matrix(1 0 0 -1 0 0)"><use xlink:href="#MJMATHBI-45" x="0" y="0"></use><use xlink:href="#MJMAIN-3D" x="1103" y="0"></use><g transform="translate(2159,0)"><g transform="translate(0,2150)"><use xlink:href="#MJSZ4-23A1" x="0" y="-1155"></use><g transform="translate(0,-2048.5066225165565) scale(1,0.49337748344370863)"><use xlink:href="#MJSZ4-23A2"></use></g><use xlink:href="#MJSZ4-23A3" x="0" y="-3155"></use></g><g transform="translate(834,0)"><g transform="translate(-13,0)"><use xlink:href="#MJMAIN-31" x="0" y="1350"></use><use xlink:href="#MJMAIN-30" x="0" y="-50"></use><use xlink:href="#MJMAIN-30" x="0" y="-1450"></use></g><g transform="translate(1488,0)"><use xlink:href="#MJMAIN-30" x="0" y="1350"></use><use xlink:href="#MJMAIN-31" x="0" y="-50"></use><use xlink:href="#MJMAIN-30" x="0" y="-1450"></use></g><g transform="translate(2988,0)"><use xlink:href="#MJMAIN-30" x="0" y="1350"></use><use xlink:href="#MJMAIN-30" x="0" y="-50"></use><use xlink:href="#MJMAIN-31" x="0" y="-1450"></use></g></g><g transform="translate(4490,2150)"><use xlink:href="#MJSZ4-23A4" x="0" y="-1155"></use><g transform="translate(0,-2048.5066225165565) scale(1,0.49337748344370863)"><use xlink:href="#MJSZ4-23A5"></use></g><use xlink:href="#MJSZ4-23A6" x="0" y="-3155"></use></g></g></g></svg></div>

-   <span class="my-math-class" id="my-math-id-2"><svg xmlns:xlink="http://www.w3.org/1999/xlink" width="1.917ex" height="2.107ex" viewBox="0 -784.8 825.5 907.3" role="img" focusable="false" style="vertical-align: -0.284ex;" aria-hidden="true"><g stroke="currentColor" fill="currentColor" stroke-width="0" transform="matrix(1 0 0 -1 0 0)"><use xlink:href="#MJMATHBI-45" x="0" y="0"></use></g></svg></span> : 単位行列


Powered by MathJax

This page is based on MathJax technology.