Islands in the byte stream

Technical notes by a software engineer

MathJaxによる描画を細かく制御する

MathJax というのはLaTeXなどで書いた数式を美しく描画するための処理系です。 なおこのエントリは MathJax v2.7.0 を対象にしています。

www.mathjax.org

まず前提として、ほとんどのケースではMathJaxをconfigパラメータを与えてロードするだけで自動的にtypeset *1 が行われます。

この自動typesetは、HTML中のすべての要素に対してただ一度だけ行われるので、content editableでウェブエディタを実装しているケースやリアルタイムプレビューとの相性が非常に悪いのです。つまりcontent editable中のLaTeX記法は置き換えるべきではないのに置き換えてしまうし、プレビューが更新されてもtypesetされないのでこちらはLaTeX記法が処理されません。

そこで細かくMathJaxを制御する必要があるのですが、これがなかなか苦労したので、ハマったことや考えたことをここにメモしておきます。MathJaxは奥が深すぎて同じことをするのに何通りもの方法があり、ここに書いたものが唯一の方法ではありません。実際に実装するときは最新のドキュメントを参照することをおすすめします。

APIリファレンス

リファレンスはこちら。 ぐぐるとよく過去のバージョンのものが出てくることが多いです。何かを探すときは最新版をサイト内検索するのがいいでしょう。

http://docs.mathjax.org/en/latest/api/

SSRかCSRか

MathJax-node があるので、SSR: Server Side Renderingもできるようです。

MathJaxの描画処理はかなり時間がかかり、またpreviewとして仮描画したものを出したりするため、数式の描画を表示するときにかなりガクガクします。

SSRするとこのガクガクがおそらくなくなるので、可能であればSSRしたいところです。しかしそのためには専用のnodejsサーバを立てなければならず、ほかの(たとえばReact SSR)レンダリングサーバに相乗りできるならいいですが、そうではなくMathJaxのためだけに新規にnodejsサーバを立てるのであれば大抵の場合オーバースペックでしょう。

今回はCSR: Client Side Renderingにしました。

MathJaxのロード

MathJaxパッケージはnpmにもありますが、それをwebpackで固めるとextensionのロードが404になるという現象になり、結局解決できませんでした。

なのでcdnjsのようなpublic CDNを使うか、MathJax配布キットのとおりの構造でデプロイするかしないといけないようです。

MathJax 3.0でwebpack対応予定 ということなので、webpackに組み込みたいときは3.0を待つのがいいようです。

自動typesetを無効にする

configに skipStartupTypeset: true を渡します。

MathJax.Hub を設定するためのドキュメントはこちら:

http://docs.mathjax.org/en/latest/options/hub.html

MathJaxの初期化終了コールバック

MathJaxは非同期でコンポーネントをロードするので、 MathJax オブジェクトが存在するからといって使用可能とは限りません。

MathJax.isReady は一つの指標としては使えますが、初期化の終了イベントをフックしたいところです。

初期化シーケンスは Startup Sequence にあり、それぞれのタイイングでコールバックを呼ぶのでイベントリスナを登録すればよさそうです。

このシーケンスの詳細は省きますが、End が初期終了時に呼ばれるイベントです。

MathJax.Hub.Register.StartupHook('End', () => {
  // MathJax APIがすべて利用可能になった!
});

MathJax Typeset API

MathJax.Hub.Typeset(target, callback) を使うと、target elementを指定してtypesetを起動できます。

target は HTML element またはその配列、あるいは null で、 nullの場合は document.body 全体が処理の対象になります。

MathJaxは処理をシーケンシャルにするために MathJax.Callback.Queue というjob queueを持っているので、typesetの処理は実際にはこのようになります *2

MathJax.Callback.Queue(() => {
  MathJax.Hub.Typeset(elements, () => {
    console.log(`typeset is done for ${elements.length} elements.`);
  });
});

エラーハンドリング

プロダクション環境ではエラーがあってもサービス提供者としては何もでないことのほうが多いでしょうが、開発中はエラーの詳細を知りたいというケースでは “Math Processing Error” イベントをフックすることでコンソールにエラーの詳細を出力できます。

MathJax.Hub.Register.MessageHook("Math Processing Error", (message) => {
  console.error(message);
});

MathJax Preview

LaTeXをちゃんとレンダリングするのはかなり時間がかかるようで、デフォルトだと本描画のまえに数回プレビュー描画が入ります。

このpreviewは様々なレイヤでやっているらしく制御の仕方がはっきりしないのですが、 MathJax_Preview CSS classが指定されているので、これに対してスタイルを当てると見た目はそれなりに制御できます。

全体像

というわけで全体像です。実際に運用しているコードそのものではありませんが、だいたいこんな感じです。 requestTypeset() を呼ぶと (1) MathJaxがロードされていなければロードする (2) MathJaxが使えるようになるまで待ってtypesetを起動する (3) requestTypset() は呼ぶたびにtypesetを起動する、ということを行うコードです。

const CONFIG = 'TeX-AMS-MML_HTMLorMML'; // 適当なconfig

const DEV = (process.env.NODE_ENV != 'production');

let isLoading = false;

function loadMathJax(onComplete) {
  const script = document.createElement('script');
  // from https://cdnjs.com/libraries/mathjax
  script.src = `https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/MathJax.js?config=${CONFIG}`;
  script.async = true;
  script.onload = () => {
    const MathJax = window.MathJax;
    console.log(`loaded MathJax/${MathJax.version} (${CONFIG})`);

    MathJax.Hub.Config({
      messageStyle: DEV ? "normal" : "none", // 左下に出るメッセージボックスは開発時のみ有効にする
      skipStartupTypeset: true,
      tex2jax: {
        inlineMath: [
          ['$','$'],
        ],
        displayMath: [
          ['$$', '$$'],
        ],
        processEscapes: true,
      },
    });
    MathJax.Hub.Register.MessageHook("Math Processing Error", (message) => {
      console.error(message);
    });
    MathJax.Hub.Register.StartupHook('End', onComplete);
  };
  document.head.appendChild(script);
}

export function triggerTypeset() {
  const elements = [...document.querySelectorAll('.content')];

  const MathJax = window.MathJax;
  console.assert(MathJax && MathJax.isReady);

  MathJax.Callback.Queue(() => {
    MathJax.Hub.Typeset(elements, () => {
      console.log(`typeset is done for ${elements.length} elements.`);
    });
  });
}

export function requestTypeset() {
  if (!document.querySelector('.content')) {
    return;
  }

  if (window.MathJax && window.MathJax.isReady) {
    triggerTypeset();
  } else {
    if (isLoading) {
      return;
    }
    isLoading = true;

    loadMathJax(() => {
      triggerTypeset();
    });
  }
}

*1:MathJaxの描画処理のことで、原義は「活字を組む」という意味です。MathJaxでこの用語を使うのはTeXに由来すると思われます。

*2:queueを使う必要はもしかしたらないのかもしれませんが、MathJaxは内部で様々なイベントが飛び交う複雑な非同期システムなのでこのようにするのが無難でしょう。