MathJax というのはLaTeXなどで書いた数式を美しく描画するための処理系です。 なおこのエントリは MathJax v2.7.0 を対象にしています。
まず前提として、ほとんどのケースでは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(); }); } }