ことれいのもり

PHPのParsedownを拡張してMathJaxでLatex数式を表示させてみた

はじめに

マークダウン式の文章をHTMLに変換してくれるParsedownのライブラリを使っていて、Latex形式の数式も表示させたいと思った事はありませんか?

Latex形式の数式はJavaScriptのライブラリである、MathJaxを読み込むことで実現できます。

しかし、そのままParsedownに使うとうまく反映されないことがあります。


以前このブログで、ParsedownのHTMLにCSSをつける拡張クラスを作成しました。

今回は、このコードを発展させて、Latex形式の数式をMathJaxで表示できるようにします。

以前の記事を読んでいない方は、先に読むと理解しやすいかなと思います!

もちろん、この記事だけ読んでも大丈夫なように書いているので安心してくださいね。

前回の記事

ParsedownのHTMLにCSSクラスをつける拡張クラスを作ってみた

前提

言語

PHP

JavaScript

MathJaxとは?

MathJaxとは、JavaScriptのライブラリで、WebページにLatex形式の数式を埋め込めるようにしたものです。

今回はCDNという形で読み込みます。

以下のコードをfooterもしくはheaderで読み込みます。

  <!-- MathJaxの読み込み -->
  <script>
    window.MathJax = {
      tex: {
        inlineMath: [
          ['$', '$'],
          ['\\(', '\\)']
        ],
        displayMath: [
          ['$$', '$$'],
          ['\\[', '\\]']
        ],
      },
      svg: {
        fontCache: 'global'
      }
    };
  </script>
  
   <script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js"></script>


注意点として、

window.MathJax = {...};

この部分はMathJax本体を読み込む前に書く必要があります。

詳しくは公式のドキュメントや記事を参考にしてください。

MathJaxでLatex形式を検知させる考え方

まず、MathJaxがどのようにLatex形式を検知しているかを考えてみます。

いくつか方法はありますが、今回は以下の条件にします。


$$...$$で囲まれた領域をLatex形式にする


この条件を選んだ理由は、私の環境下だと他の書き方ではMathJaxがLatex形式を検知してくれなかったからです。

例えばマークダウン式でこのように書きます。


Latex形式を表示するための式


すると、ブラウザ側ではこのように見えます。


$$ tanθ = \frac{b}{a} $$


このような表示をさせることを目標にしていきます。

実装の考え方

実装の考え方を端的に説明すると、「Parsedownの影響を受けさせないために、一時的に別の文字列に変換して、後から戻す」 という操作を行ないます。

回りくどいように見えますが、普通にParsedownに通すとうまく「$」を認識してくれないことがあります。


なお、GitHub Gistにてコードの全体像を載せています。

コードのみ知りたい方はこちらを参考にしてください。

GitHub Gistで見る

では、順番に見ていきましょう。

1. 数式ブロックを検知して一時置換する

// 数式部分を一時的に置換(ブロック式 $$...$$)
$text = preg_replace_callback('/\$\$(.*?)\$\$/s', function ($matches) {
    $key = 'MATHBLOCK_' . count($this->mathBlocks);
    $this->mathBlocks[$key] = $matches[0];    // $$...$$を保存する
    return $key;    // プレースホルダーに置き換える
}, $text);


まず、正規表現を使って

$$ ... $$

にマッチさせます。 見つかった数式ブロックは 「MATHBLOCK _ 0」のような一時的な名前 に置き換えます。

このような置き換えをする理由は、今後の処理で「$」という文字が消えたりそのまま文字列として認識されるのを防ぐためです。

普通にMathJaxを利用するだけならこの処理は不要ですが、Parsedownを使うなら必要です。

2. Parsedownを使ってHTMLに変換させる

// 親のメソッドでマークダウンをHTMLに変換
$html = parent::text($text);

Parsedownのライブラリを使ってそのままHTMLに変換させます。

この時点では、数式ブロックは一時的な名前(MATHBLOCK _ 0など)に置き換えられているので普通のテキストとして扱われています。

そのため、マークダウン式の影響を受けません。

3. 置換した文字列を元の数式に戻す

private function restoreMathBlocks(string $html)
  {
    $keys = array_keys($this->mathBlocks);
    usort($keys, function ($a, $b) {
      return strlen($b) - strlen($a);
    });

    foreach ($keys as $key) {
      $html = str_replace($key, $this->mathBlocks[$key], $html);
    }
    return $html;
  }


ここまでの処理で、$this->mathBlocksには、以下のようなデータが入っています。

[
    "$$', '$$" => "$$1+1=2$$",
    "$$...$$" => "$$x^2=4$$",
    ...
]

ここで取り出したキーは、一見どの順番でも良さそうですが、実際には文字列の長い順に並び替える必要があります。

例えば、以下のような形です。

["$$/s', $line)) {
      return $line;
    }

    // ブロック式の開始行
    if ($stripped === '$$", "$$', '$$", "$$...$$"]

このような長い順に並び替える理由は、MATHBLOCK 10がMATHBLOCK 1の一部として置換されるのを防ぐためです。

これにより、置換されていた文字列を元の数式に戻すことができます。

最後に復元済みのHTMLを返して終わりです。

補足:短い順に並び替えたらどうなる?

短い順に並び替えた場合はどうなるでしょうか。

「MATHBLOCK 10」という文字列の中には、「MATHBLOCK 1」という文字列が含まれています。

そのため、本来は「MATHBLOCK 10」として置換してほしいのに、先に「MATHBLOCK 1」と置換されてしまいます。

結果として後ろの「0」が残ってしまい、「MATHBLOCK _ 1」の数式の後ろにくっついてしまうのです。


$$...$$ = "1+1=2",
$$/s', $line)) {
      return $line;
    }

    // ブロック式の開始行
    if ($stripped === '$$ = "E=mc^2"

このような式の場合に、短い順で並び替えるとこうなります。


$$/s', $line)) {
      return $line;
    }

    // ブロック式の開始行
    if ($stripped === '$$ 
=> $$...$$ + 0 と誤認する
=> 1+1=2 + 0
=> 表示される数式は 1+1=20


変な式ができてしまいましたね!

このような状態を防ぐために長い順で並び替えることが大事です。

改良した拡張クラスの全体像

GitHub Gistにコードを載せていますので、コピペしたい方などはこちらをご覧ください。

GitHub Gistで見る

<?php

class CustomParsedown extends Parsedown
{
  protected $mathBlocks = [];

  public function text($text)
  {
    // 数式部分を一時的に置換(ブロック式 $$...$$)
    $text = preg_replace_callback('/\$\$(.*?)\$\$/s', function ($matches) {
      $key = 'MATHBLOCK_' . count($this->mathBlocks);
      $this->mathBlocks[$key] = $matches[0];
      return $key;
    }, $text);

    // 親のメソッドでマークダウンをHTMLに変換
    $html = parent::text($text);

    // MathBlockを復元する
    $html = $this->restoreMathBlocks($html);

    $html = $this->wrapParagraphs($html);
    $html = $this->wrapHeadingWithDiv($html);
    $html = $this->wrapCodeWithDiv($html);
    $html = $this->addClassToH2($html);
    $html = $this->addClassToH3($html);
    $html = $this->addClassToPre($html);

    return $html;
  }

  private function restoreMathBlocks(string $html)
  {
    $keys = array_keys($this->mathBlocks);
    usort($keys, function ($a, $b) {
      return strlen($b) - strlen($a);
    });

    foreach ($keys as $key) {
      $html = str_replace($key, $this->mathBlocks[$key], $html);
    }
    return $html;
  }

  /**
   * <h2>~<h6>タグから次のh系タグまでのコンテンツを囲む
   *
   * @param string $html HTMLに変換された文字列
   * @return void
   */
  private function wrapHeadingWithDiv($html)
  {
    $pattern = '/(<h[2-6][^>]*>.*?<\/h[2-6]>)(.*?)(?=<h[2-6]|$)/s';  // h2~h6タグが基準
    $replacement = '<div class="article__content">$1$2</div>';
    return preg_replace($pattern, $replacement, $html);
  }

  /**
   * <pre>~</pre>のコードブロック間を<div>で囲む
   *
   * @param string $html HTMLに変換された文字列
   * @return void
   */
  private function wrapCodeWithDiv($html)
  {
    $pattern = '/(<pre[^>]*>.*?<\/pre>)/s';
    $replacement = '<div class="code-block">$1</div>';
    return preg_replace($pattern, $replacement, $html);
  }

  /**
   * h2タグにクラスをつける
   *
   * @param string $html HTMLに変換された文字列
   * @return void
   */
  private function addClassToH2($html)
  {
    $pattern = '/(<h2)([^>]*>)/';
    $replacement = '$1 class="underline-with-background"$2';
    return preg_replace($pattern, $replacement, $html);
  }

  /**
   * h3タグにクラスをつける
   *
   * @param string $html HTMLに変換された文字列
   * @return void
   */
  private function addClassToH3($html)
  {
    $pattern = '/(<h3)([^>]*>)/';
    $replacement = '$1 class="orange-circle"$2';
    return preg_replace($pattern, $replacement, $html);
  }

  private function addClassToPre($html)
  {
    $pattern = '/(<pre)([^>]*>)/';
    $replacement = '$1 class="line-numbers" data-copy="true"$2';
    return preg_replace($pattern, $replacement, $html);
  }

  /**
   * 段落内を<p>タグで囲む
   *
   * @param string $html
   * @return void
   */
  private function wrapParagraphs($html)
  {
    // 文の中身をマッチさせる
    $html = preg_replace_callback('/([^\n\r]+(\n|\r\n)?)/', function ($matches) use (&$isInsidePre, &$isInsideMath) {
      $line = $matches[0];

      // 空行の場合は何も返さない
      if ($line === '') {
        return '';
      }

      // <pre>タグ内でのみ<p>タグはつけない
      $preHandled = $this->handlePreTags($line, $isInsidePre);
      if ($preHandled !== null) {
        return $preHandled;
      }

      // 数式ブロック中は<p>タグをつけない
      $mathHandled = $this->handleMathJax($line, $isInsideMath);
      if ($mathHandled !== null) {
        return $mathHandled;
      }

      // 他のHTMLタグがある場合はそのまま返す
      if ($this->hasOtherTags($line)) {
        return $line . "\n";
      }

      // 既に<p></p>で囲まれているときはそのまま返す
      if ($this->isWrappedInPTags($line)) {
        return $line . "\n";
      }

      // <p>タグが一切ついていないときは両方追加
      if (!$this->hasPTags($line)) {
        return '<p>' . trim($matches[1]) . '</p>' . "\n";
      }

      // <p>のみないときは追加
      if ($this->hasClosingPTag($line)) {
        return '<p>' . trim($matches[1]);
      }

      // </p>のみないときは追加
      if (!$this->hasClosingPTag($line)) {
        return trim($matches[1]) . '</p>' . "\n";
      }

      return $line;
    }, $html);

    return $html;
  }

  /**
   * 先頭に<p>タグ以外のタグがあるか判定
   *
   * @param string $line 
   * @return boolean
   */
  private function hasOtherTags($line)
  {
    // 先頭に<p>
    return substr($line, 0, 1) === '<' && substr($line, 1, 1) !== 'p';
  }

  /**
   * <pre>タグ内部の処理
   *
   * @param string $line
   * @param boolean $isInsidePre  <pre>タグ内部かどうか
   * @return boolean
   */
  private function handlePreTags($line, &$isInsidePre)
  {
    // <pre>タグの開始を検知
    if (strpos($line, '<pre') !== false) {
      $isInsidePre = true;
      return $line;
    }

    // <pre>タグの終了を検知
    if (strpos($line, '</pre>') !== false) {
      $isInsidePre = false;
      return $line;
    }

    if ($isInsidePre) {
      return $line;
    }

    return null;
  }

  /**
   * <p>タグで囲まれているか判定
   *
   * @param string $line
   * @return boolean
   */
  private function isWrappedInPTags($line)
  {
    return preg_match('/^<p>.*<\/p>$/s', $line);
  }

  /**
   * <p>と</p>の両方があるか判定
   *
   * @param string $line
   * @return boolean
   */
  private function hasPTags($line)
  {
    return strpos($line, '<p>') !== false || strpos($line, '</p>') !== false;
  }

  /**
   * </p>で終わっているかを判定
   *
   * @param string $line
   * @return boolean
   */
  private function hasClosingPTag($line)
  {
    return substr(rtrim($line), -4) === '</p>';
  }

  /**
   * 数式ブロック中の処理
   *
   * @param string $line
   * @param boolean $isInsideMath 数式ブロック中かどうか
   * @return void
   */
  private function handleMathJax($line, &$isInsideMath)
  {
    $stripped = trim(strip_tags($line));

    // 一行完結の場合($$ ... $$)
    if (preg_match('/^\$\$(.*?)\$\$$/s', $line)) {
      return $line;
    }

    // ブロック式の開始行
    if ($stripped === '$$') {
      $isInsideMath = !$isInsideMath;
      return "$$";
    }

    // 数式ブロック中はそのまま返す
    if ($isInsideMath) {
      return $line . "\n";
    }

    return null;
  }
}

おわりに

今回は、前回の拡張クラスを改良して、Latex形式の数式をMathJaxで表示できるようにしました。

一旦置換してから処理する、という工程で簡単に実装できるのでぜひ試してくださいね!