ari23の研究ノート

メーカ勤務エンジニアの技術ブログです

数式のTex記法からはてなTex記法へ自動変換するPythonプログラム

前回の記事で、はてなブログ(Markdown)で数式を書くコツを整理しました。

はてなブログMarkdownでTex数式を書くコツとチートシート - ari23の研究ノート

今回は、Tex記法→はてなTex記法に自動変換するプログラムを紹介します🐜

あ、ただし、私はうまく正規表現を使いこなせていないことを、先にお伝えしておきます(正規表現はまじ魔物)。

自動変換項目

自動変換する項目は以下の通りです。

  • ディスプレイ数式
  • インライン数式
  • 角括弧
  • アンダースコア(下付き文字)

変換元と変換先の詳細は、前回の記事を参照してください。

なお、アンダースコアのディスプレイ数式では、Tex記法とはてなTex記法に違いがないため、変換しません。

また、不等号などその他については、Tex記法とはてなTex記法に違いがないため、今回は対象としません。

Pythonプログラム

Pythonで実装しました。3系であれば、問題なく動くと思います。

開発環境

開発環境は以下の通りです。

項目 内容
OS Windows 10
Python 3.6.8

tex2hatenatex.py

Pythonスクリプトは以下の通りです。コピペで動きます。 なお、最新版はこちらを参照してください。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Tex→はてなTex変換

MathJaxで書いた数式Tex形式をはてなTex形式に変換する
- 第1引数|.mdファイルのファイルパス

"""
__author__  = 'ari23(Twitter: @ari23ant)'
__version__ = '0.0.4'
__date__    = '2020/10/16'
__status__  = 'Development'

import os
import re
import sys
import time

DEBUG_MODE = False  # デバッグしたいときはTrueにする


class Tex2HatenaTex:

    def __init__(self, fpath=''):
        self.msg = 'Tex to HatenaTex'
        # ------- 入力 ------- #
        # 変換したいファイルパス
        self.fpath = fpath

        # ------- 正規表現 ------- #
        ####################################
        # $マーク以外→$マークの順で変換する
        ####################################
        # 改行コード
        # Mac->\r, Unix->\n, Windows->\r\n, 自動->os.linesep
        self.linesep = r'\n'

        # --- 角括弧[] --- #
        # - ディスプレイ数式 - #
        #  [→\[, ]→\] \を1つ足す
        # Tex記法 置換元
        self.kaku_l = '\['  # [の意味 r'\['でもよい
        self.kaku_r = '\]'  # ]の意味 r'\]'でもよい
        # HatenaTex記法 置換先
        self.kaku_l_ht_display = '\\['  # \[の意味 r'\['でもよい
        self.kaku_r_ht_display = '\\]'  # \]の意味 r'\]'でもよい

        # - インライン数式 - #
        # [→\\[, ]→\\] \を2つ足す
        # HatenaTex記法 置換先
        self.kaku_l_ht_inline = '\\\\['  # \\[の意味 r'\\['でもよい
        self.kaku_r_ht_inline = '\\\\]'  # \\]の意味 r'\\]'でもよい

        # --- アンダースコア_ --- #
        # - ディスプレイ数式 - #
        # 対応不要
        # - インライン数式 - #
        # _→\_ \を1つ足す
        # Tex記法 置換元
        self.underscore = '_'  # r'_'でもよい
        # HatenaTex記法 置換先
        self.underscore_ht_inline = '\\_'  # \_の意味 r'\_'でもよい

        # --- $マーク --- #
        # - ディスプレイ数式 - #
        # Tex記法 置換元
        pattern_line_1 = r'\$\$[\n|\r\n|\r]'
        pattern_line_2 = r'([\s\S]+?)'
        pattern_line_3 = r'\$\$[\n|\r\n|\r]'
        self.pattern_display = pattern_line_1 + pattern_line_2 + pattern_line_3
        # HatenaTex記法 置換先
        repl_line_1 = r"<div align='center' class='scroll'>" + self.linesep  # 'のために"でくくった
        repl_line_2 = r'[tex: \displaystyle' + self.linesep
        repl_line_3 = r'\1'
        repl_line_4 = r']' + self.linesep
        repl_line_5 = r'</div>' + self.linesep
        self.repl_display = repl_line_1 + repl_line_2 + repl_line_3 + repl_line_4 + repl_line_5

        # - インライン数式 - #
        # Tex記法 置換元
        self.pattern_inline = r'\$(.+?)\$'  # 非欲張り型
        # HatenaTex記法 置換先
        self.repl_inline = r'[tex: \1 ]'

        # ------- 出力 ------- #
        # 出力先のファイル名
        self.fname_out = 'hatenatex.md'


    def Process(self):
        print(self.msg)

        # ------- ファイル読み込み ------- #
        # 有無確認
        if not os.path.isfile(self.fpath):
            print('file NOT FOUND: ' + self.fpath)
            return False

        # ファイル名とディレクトリパス用意
        fname = os.path.basename(self.fpath)
        dpath = os.path.dirname(self.fpath)

        # 読み込み
        with open(self.fpath, mode='r', encoding='utf-8') as f:
            self.s = f.read()

        # ------- 置換 ------- #
        # --- 角括弧[] --- #
        # ディスプレイ数式 [→\[, ]→\] \が1つ
        self.s = self.replace_symbol_display(self.kaku_l, self.kaku_l_ht_display, self.s)
        self.s = self.replace_symbol_display(self.kaku_r, self.kaku_r_ht_display, self.s)

        # インライン数式 [→\\[, ]→\\] \が2つ
        self.s = self.replace_symbol_inline(self.kaku_l, self.kaku_l_ht_inline, self.s)
        self.s = self.replace_symbol_inline(self.kaku_r, self.kaku_r_ht_inline, self.s)

        # --- アンダースコア_ --- #
        # インライン数式 _→\_ \が1つ
        self.s = self.replace_symbol_inline(self.underscore, self.underscore_ht_inline, self.s)

        # --- $マーク --- #
        # ディスプレイ数式
        self.s = self.replace_dollar(self.pattern_display, self.repl_display, self.s)
        # インライン数式
        self.s = self.replace_dollar(self.pattern_inline, self.repl_inline, self.s)

        # ------- ファイル出力 ------- #
        fpath_out = os.path.join(dpath, self.fname_out)
        with open(fpath_out, mode='w', encoding='utf-8') as f:
            f.write(self.s)

        return True


    def replace_symbol_display(self, word1, word2, body, num_max=100):
        """
        数式内に複数ある同じパターンをfor文で置換する
        同じパターンの最大個数はnum_maxで設定
        """
        # いったん以下のワードに置き換える
        tmpword = 'tmpworddisplay'
        pattern = r'\$\$([^\$]+?)'+ word1 + r'([^\$]*?)\$\$[\n|\r\n|\r][\n|\r\n|\r]'
        repl = r'$$\1' + tmpword + r'\2$$' + self.linesep + self.linesep
        # [^\$]を[\s\S]にすると、数式内でないものもヒットしてしまう20201015
        ## pattern = r'\$\$([\s\S]+?)'+ word1 +r'([\s\S]*?)\$\$[\n|\r\n|\r][\n|\r\n|\r]'
        ## repl =  r'$$' + r'\1' + tmpword + r'\2$$' + self.linesep + self.linesep

        # 正規表現で置換
        tuple_subn = (body, None)
        cnt = 0
        for num in range(num_max):
            tuple_subn = re.subn(pattern, repl, tuple_subn[0])
            cnt += tuple_subn[1]
            if tuple_subn[1] == 0:
                # 置換し終わったらbreak
                print(pattern + ' --> ' + str(cnt))
                break

        # いっぺんに置き換え 計算量は増えるが、汎用性考慮してこの方法を選択した
        if not DEBUG_MODE:
            # 通常処理
            s = tuple_subn[0].replace(tmpword, word2)
        else:
            # デバッグ
            s = tuple_subn[0]  # debug用 tmpwordで確認できる

        return s


    def replace_symbol_inline(self, word1, word2, body, num_max=100):
        """
        インライン数式内に複数ある同じパターンをfor文で置換する
        同じパターンの最大個数はnum_maxで設定
        """
        # いったん以下のワードに置き換える
        tmpword = 'tmpwordinline'
        pattern = r'\$(.+?)' + word1 + r'(.*?)\$'
        repl = r'$\1' + tmpword + r'\2$'

        # 正規表現で置換
        tuple_subn = (body, None)
        cnt = 0
        for num in range(num_max):
            tuple_subn = re.subn(pattern, repl, tuple_subn[0])
            cnt += tuple_subn[1]
            if tuple_subn[1] == 0:
                # 置換し終わったらbreak
                print(pattern + ' --> ' + str(cnt))
                break

        # いっぺんに置き換え 計算量は増えるが、汎用性考慮してこの方法を選択した
        if not DEBUG_MODE:
            # 通常処理
            s = tuple_subn[0].replace(tmpword, word2)
        else:
            # デバッグ処理
            s = tuple_subn[0]  # debug用 tmpwordで確認できる

        return s


    def replace_dollar(self, pattern, repl, body):
        """
        数式の$マークを置換する
        """
        tuple_subn = re.subn(pattern, repl, body)
        print(pattern + ' --> ' + str(tuple_subn[1]))

        return tuple_subn[0]


if __name__ == '__main__':
    # ---------- Program Start ---------- #
    start_time = time.perf_counter()
    print('---------- Start ----------')

    # --- Get Argument --- #
    args = sys.argv  # list
    # --- Main Process --- #
    if len(args) == 1:
        proc = Tex2HatenaTex()
    else:  # argsの大きさが0になることはない
        proc = Tex2HatenaTex(args[1])
    proc.Process()

    # ---------- Program End ---------- #
    end_time = time.perf_counter()
    execution_time = end_time - start_time
    print('Execution Time: ' + str(execution_time) + 's')
    print('----------  End  ----------')

使い方

Tex記法で書かれた.mdファイル(testtex.mdとします)と、上記tex2hatenatex.pyが同じディレクトリにあるとします。

このとき、以下のコマンドを叩きます。

python tex2hatenatex.py testtex.md

すると、同じディレクトリにhatenatex.mdというファイルが生成されます。そのファイルを開いて中身をコピーして、はてなブログのブログ編集画面に貼り付けすればOKです。

解説

かなり丁寧にコメントを書いたので、要所だけ解説します。 解説の都合上、プログラムの順序とは異なるので注意してください。

replace_dollar()

Tex数式($hoge$)から、はてなTex数式([tex: hoge ])の形式に変換するメソッドです。 ディスプレイ数式もインライン数式も同じメソッドで変換します。

該当箇所を以下に抜粋します(見やすくするためタブを削除)。

# --- $マーク --- #
# - ディスプレイ数式 - #
# Tex記法 置換元
pattern_line_1 = r'\$\$[\n|\r\n|\r]'
pattern_line_2 = r'([\s\S]+?)'
pattern_line_3 = r'\$\$[\n|\r\n|\r]'
self.pattern_display = pattern_line_1 + pattern_line_2 + pattern_line_3
# HatenaTex記法 置換先
repl_line_1 = r"<div align='center' class='scroll'>" + self.linesep  # 'のために"でくくった
repl_line_2 = r'[tex: \displaystyle' + self.linesep
repl_line_3 = r'\1'
repl_line_4 = r']' + self.linesep
repl_line_5 = r'</div>' + self.linesep
self.repl_display = repl_line_1 + repl_line_2 + repl_line_3 + repl_line_4 + repl_line_5

# - インライン数式 - #
# Tex記法 置換元
self.pattern_inline = r'\$(.+?)\$'  # 非欲張り型
# HatenaTex記法 置換先
self.repl_inline = r'[tex: \1 ]'
def replace_dollar(self, pattern, repl, body):
    """
    数式の$マークを置換する
    """
    tuple_subn = re.subn(pattern, repl, body)
    print(pattern + ' --> ' + str(tuple_subn[1]))

    return tuple_subn[0]

正規表現パターンにある[\n|\r\n|\r]は、改行コードを示しており、Windows, Mac, UnixのどのOSでも対応できるようにしてあります。 もちろん、プログラム開始直後にOSを認識して、改行コードを自動設定することもできると思います。
私の環境だと置換はされるのですが、エディタの設定できれいに表示されないので、上記のようなコードで自分の環境に合うよう工夫しました。

([\s\S]+?)は、改行コードを含む1つ以上の文字列を示します。このように設定することで、複数行にまたがる数式にマッチすることができます。

置換先の表現にある\1は、正規表現で取得した文字列を表していて、変換の際にこの箇所だけは何も変えないように指定します。詳細はこちらを参照してください。

replace_symbol_display()

ディスプレイ数式内の角括弧やアンダースコアなど記号に、置換する(ここではバックスラッシュ\を付ける)メソッドです。

該当箇所を以下に抜粋します(見やすくするためタブを削除)。

def replace_symbol_display(self, word1, word2, body, num_max=100):
    """
    数式内に複数ある同じパターンをfor文で置換する
    同じパターンの最大個数はnum_maxで設定
    """
    # いったん以下のワードに置き換える
    tmpword = 'tmpworddisplay'
    pattern = r'\$\$([^\$]+?)'+ word1 + r'([^\$]*?)\$\$[\n|\r\n|\r][\n|\r\n|\r]'
    repl = r'$$\1' + tmpword + r'\2$$' + self.linesep + self.linesep
    # [^\$]を[\s\S]にすると、数式内でないものもヒットしてしまう20201015
    ## pattern = r'\$\$([\s\S]+?)'+ word1 +r'([\s\S]*?)\$\$[\n|\r\n|\r][\n|\r\n|\r]'
    ## repl =  r'$$' + r'\1' + tmpword + r'\2$$' + self.linesep + self.linesep

    # 正規表現で置換
    tuple_subn = (body, None)
    cnt = 0
    for num in range(num_max):
        tuple_subn = re.subn(pattern, repl, tuple_subn[0])
        cnt += tuple_subn[1]
        if tuple_subn[1] == 0:
            # 置換し終わったらbreak
            print(pattern + ' --> ' + str(cnt))
            break

    # いっぺんに置き換え 計算量は増えるが、汎用性考慮してこの方法を選択した
    if not DEBUG_MODE:
        # 通常処理
        s = tuple_subn[0].replace(tmpword, word2)
    else:
        # デバッグ
        s = tuple_subn[0]  # debug用 tmpwordで確認できる

    return s

このメソッドでは以下のような順序で処理します。説明のため、[\[と処理する場合を考えます。

  1. ディスプレイ数式内にある[を、すべてtmpworddisplayという文字列に置き換える
  2. tmpworddisplayを、いっぺんに\[に置き換える

本当は一気に[\[と変換すべきなのですが、変換元も変換先も同じ文字列[があるため正規表現の構築が難しく、さらに汎用性も著しく低下してしまうので、上記のような方法を取りました。

この手法であれば、DEBUG_MODETrueを入れれば、すぐに置換箇所が確認できるため、デバッグもしやすいという利点があります。

少しコードと解説が前後しますが、正規表現パターンは、

$が2つ続く + $以外の文字列 + [ + $以外の文字列 + $が2つ続く + 改行コードが2つ続く

という意味です。 (コメントアウトした箇所の方法でもできると思うのですが、正規表現になれていないせいか、うまくできませんでした。)

replace_symbol_inline()

インライン数式内の角括弧やアンダースコアなど記号に、置換する(ここではバックスラッシュ\を付ける)メソッドです。

該当箇所を以下に抜粋します(見やすくするためタブを削除)。

def replace_symbol_inline(self, word1, word2, body, num_max=100):
    """
    インライン数式内に複数ある同じパターンをfor文で置換する
    同じパターンの最大個数はnum_maxで設定
    """
    # いったん以下のワードに置き換える
    tmpword = 'tmpwordinline'
    pattern = r'\$(.+?)' + word1 + r'(.*?)\$'
    repl = r'$\1' + tmpword + r'\2$'

    # 正規表現で置換
    tuple_subn = (body, None)
    cnt = 0
    for num in range(num_max):
        tuple_subn = re.subn(pattern, repl, tuple_subn[0])
        cnt += tuple_subn[1]
        if tuple_subn[1] == 0:
            # 置換し終わったらbreak
            print(pattern + ' --> ' + str(cnt))
            break

    # いっぺんに置き換え 計算量は増えるが、汎用性考慮してこの方法を選択した
    if not DEBUG_MODE:
        # 通常処理
        s = tuple_subn[0].replace(tmpword, word2)
    else:
        # デバッグ処理
        s = tuple_subn[0]  # debug用 tmpwordで確認できる

    return s

このメソッドでは以下のような順序で処理します。説明のため、[\[と処理する場合を考えます。

  1. インライン数式内にある[を、すべてtmpwordinlineという文字列に置き換える
  2. tmpworddisplayを、いっぺんに\[に置き換える

ご覧の通り、アルゴリズムはreplace_symbol_display()と全く同じものですので、両者を共通化すべきかもしれません。 しかし、私の正規表現に対する理解が乏しいため、それによって難読化してしまうことを回避するために、今回はあえて分けて管理しています。

正規表現、難しいです^^;

ところで、仮に置換する文字列(tmpword)ですが、これは本文には絶対に出てこないような文字列を指定してください。そうでないと、デバッグのときにかなり混乱してしまうので要注意です。

参考URL

プログラム作成するときに参考にしたサイトは、以下の通りです。

おわりに

正規表現自体は業務でたまーに使うくらいで、今回初めてがっつり使いましたが、すごく便利ですよね! でも、しばらく時間が経ったあとに自分のコード見ても、何が書いてあるのかわからないw

なので、この記事を書くことで、情報を整理できてよかったです。

参考になれば幸いです(^^)