最終更新日時:

MathJaxとKaTeXによるSSRを試す

はじめに

 WebサイトでLaTeX\LaTeX形式で記述された数式をレンダリングする際に用いるライブラリとしてMathJaxKaTeXが挙げられるが、そのレンダリング部分を可能な限りサーバサイドでやらせてみたい。基本的な方針としては、JavaScriptなしで数式を表示できるかつ、消費するリソースは最低限になるようにしたい(別に今どきそんなのに耐えられない貧弱な機器があるとは思えないがロマンがある)。あと、何ならネットに接続していないとしても数式が表示できるようなものを利用したい。ただし、今回はMathMLについては検討対象から除外する。
 基本的にWebサイト上でJavaScriptなしで数式をレンダリングすることを考えると、以下の方法が挙げられるだろう。

  • HTML+CSSによるレンダリング

  • ラスタ画像の埋め込み

  • ベクタ画像の埋め込み

 これらのうち、利用する場合の選択肢として入るのは、1つ目か3つ目の方法だろう。今回の目的と照らし合わせて考えると、1つ目の方法は通常WOFF(Web Open Font Format)が必要になり、3つ目の方法はディスプレイのサイズ変更に関する文字サイズの変更であったり、インライン画像として挿入するのに難があるだろうとパッと考え付く。

 結論としてはMathJaxでSVGを出力するかKaTeXでHTMLを出力するかの2択となるが、それは単にSVGの分かフォントファイルの分のどちらを取るかといった具合である。ただ、SVGの特性上、長いインライン数式だと文字の折り返しに難があるため、そこの部分については扱いに注意する必要がある。

MathJax

 MathJaxによる数式の出力形式には、HTML、SVGおよびMathMLの3つが存在する。どうやら、MathJaxのバージョン3からはネイティブでNode.jsが利用できるらしいため、Node.jsでレンダリングを行う(バージョン3の利用は初めて)。
 というわけで、早速MathJaxを導入する。

npm install mathjax
Bash

 パッケージのバージョンについては以下のとおりである。

{
  "dependencies": {
    "mathjax": "^3.2.2"
  }
}
Plain text

 Node.js上でのMathJaxの使い方については、以下に多くのサンプルが公開されているため、それを参考にして使い方を以下に示す。

単純な数式のレンダリング

 特に何も考えずにレンダリングをしたい場合、SVGで数式を出力する場合は以下のようになる。

const MathJax = require('mathjax');

(async () => {
    // MathJaxによる解析器の構成
    const inst = await MathJax.init({
        // TeX形式からSVGに変換
        loader: { load: ['input/tex', 'output/svg'] }
    });

    // 数式をブロック形式のSVGに変換して標準出力
    const svg = inst.tex2svg('\\frac{1}{x^2-1}', { display: true });
    console.log(inst.startup.adaptor.innerHTML(svg));
})();
JavaScript

 HTMLで出力する場合はCSSもセットで必要になるため、以下のように追加でCSSを出力する分も記載する必要がある。コメントにも記載してあるが、MathJaxにより生成されるCSSはコンテキストに依存するため、入力した数式によってCSSの内容も異なることに注意する必要がある(もちろん部分的には基本的な部分は一致する)。

const MathJax = require('mathjax');

(async () => {
    // MathJaxによる解析器の構成
    const inst = await MathJax.init({
        // TeX形式からHTMLに変換
        loader: { load: ['input/tex', 'output/chtml'] },
        chtml: {
            // htmlで利用するフォントへのパス(通常はCDNから利用すると思う)
            fontURL: "https://cdn.jsdelivr.net/npm/mathjax@3/es5/output/chtml/fonts/woff-v2",
        }
    });

    // 数式をブロック形式のHTMLに変換して標準出力
    const html = inst.tex2chtml('\\frac{1}{x^2-1}', { display: true });
    console.log(inst.startup.adaptor.outerHTML(html));
    // CSSの出力(出力内容はコンテキストに依存するため全ての数式を処理した後に出力する必要がある)
    console.log(inst.startup.adaptor.textContent(inst.chtmlStylesheet()));
})();
JavaScript

HTML上に記述された数式のレンダリング

 具体的にHTML上で記述された数式に対してレンダリングを行う方法について示す。基本的な方法は上記で述べた方法と同一であるが、typesetを用いることで従来のLaTeX\LaTeXでみられるようなマークアップが可能である。以下にそのコードを示す。なお、この例は数式をHTMLへ変換するものであるが、容易にSVGを出力するコードに変換可能である。

const MathJax = require('mathjax');
const fs = require('fs');

// 入力するファイル
const inFile = 'in.html';
// 出力するファイル
const outFile = 'out.html';

(async () => {
    // MathJaxによる解析器の構成
    const inst = await MathJax.init({
        loader: { load: ['input/tex', 'output/chtml'] },
        chtml: {
            // htmlで利用するフォントへのパス(通常はCDNから利用すると思う)
            fontURL: "https://cdn.jsdelivr.net/npm/mathjax@3/es5/output/chtml/fonts/woff-v2",
        },
        startup: {
            document: fs.readFileSync(inFile).toString()
            // init実行時に解析を行うかの指定(デフォルトではtrueで解析される)
            // typeset: false
        },
        tex: {
            inlineMath: [
                ['$', '$'],
                ['\\(', '\\)'],
            ],
            displayMath: [
                ['$$', '$$'],
                ['\\[', '\\]'],
            ],
        },
    });

    // 手動でtypesetを行う場合に実行
    // inst.typeset();

    // ファイルの出力
    const adaptor = inst.startup.adaptor;
    const html = inst.startup.document;
    if (Array.from(html.math).length === 0) {
        // 変換された数式が存在しないときはMathJax用のCSSの削除(数式をHTMLへ変換する場合限定の処理)
        adaptor.remove(html.outputJax.chtmlStyles);
    }
    fs.writeFileSync(outFile,
        adaptor.doctype(html.document) + 
        adaptor.outerHTML(adaptor.root(html.document))
    );
})();
JavaScript

 単純なテキストファイルの変換であればこの変換でよいのだが、実際の変換元はHTMLファイルなどといったマークアップ言語により構造化されたテキストを想定しているため、無作為に数式としてマークアップされてしまうのは困る。そのため、実際に利用する場合は適当にパースしながら変換をかける必要がある。
 今回はHTMLファイル中に記載された数式をパースすることを目的とするため、Node.jsでDOM操作が利用が可能なJSDOMを導入する。

npm install jsdom
Bash

 パッケージのバージョンについては以下のとおりである。

{
  "dependencies": {
    "jsdom": "^24.0.0",
    "mathjax": "^3.2.2"
  }
}
Plain text

 というわけで、JSDOMを用いてHTMLをパースしながら数式を変換して出力するコードを示す。上で示したコードはHTMLへ変換するものであったため、今回はSVGに変換する場合を示す。実際に用途を考えると、各数式にaria-label属性を持たせるべきであるため、どちらにしてもDOMの解析は必要だろう。

const MathJax = require('mathjax');
const { JSDOM } = require('jsdom');
const fs = require('fs');

// 入力するファイル
const inFile = 'in.html';
// 出力するファイル
const outFile = 'out.html';

(async () => {
    // 入力されたファイルについてのDOMツリーの構築
    const dom = new JSDOM(fs.readFileSync(inFile).toString(), { contentType: 'text/html'});
	const document = dom.window.document;

    // MathJaxによる解析器の構成
    const inst = await MathJax.init({
        loader: { load: ['input/tex', 'output/svg'] }
    });

    // 手動でtypesetを実行
    const adaptor = inst.startup.adaptor;
    document.querySelectorAll('.inline-formula').forEach(e => {
        // インライン数式の構築
        e.innerHTML = adaptor.innerHTML(inst.tex2svg(e.textContent, { display: false }));
    });
    document.querySelectorAll('.block-formula').forEach(e => {
        // ブロック数式の構築
        e.innerHTML = adaptor.innerHTML(inst.tex2svg(e.textContent, { display: true }));
    });

    // ファイルの出力
    fs.writeFileSync(outFile, dom.serialize());
})();
JavaScript

 実際に数式の変換元となるサンプルのHTMLファイルを以下に示す。このHTMLでは簡単なフォントサイズを変換するロジックを組み込んである。このHTMLを変換して動作を見てみるとわかることではあるが、SVGで出力してもフォントサイズは自動的にスケーリングしてくれるため、外部のフォントや動的に生成されるCSSが不要なSVGの利用だけでいい気もする(少なくともバージョン2の頃はできなかったはず)。

<!DOCTYPE html>
<html lang="ja">
	<head>
		<meta charset="utf-8">
		<title>数式を変換する</title>
	</head>
	<body>
		<div id="formula" style="font-size: 20px;">
			<p>インライン数式の例<span class="inline-formula">f(x)=ax+b</span>を表示する。以下はブロック数式の例。</p>
			<div class="block-formula">f(x)=\sum_{k=0}^{\infty}\frac{f^{(k)}(0)}{k!}x^{k}</div>
		</div>
		<div>
			<span>フォントサイズ</span><input id="resize" type="number" value="20"><span>px</span>
		</div>
	</body>
	<script>
		const formula = document.getElementById('formula');
		const input = document.getElementById('resize');
		// resizeへの入力によってフォントサイズを変更する
		input.addEventListener('input', e => {
			formula.style.fontSize = `${Math.max(input.value, 1)}px`;
		});
	</script>
</html>
Markup

 ただ、手元のスマートフォンで確認をするとかなり大きくSVGによる数式がレンダリングされる事象が発生した(Qiitaの数式付きの記事をスマホで見るとなんか数式が大きく見えるものと同じ事象)。現状確認できている発生する環境はChromium系(Android)である。おそらくであるが、MathJaxで出力する数式のサイズがexを基本としているのに対して、異常が発生する環境ではexにおけるスケールの方法が誤っているのだと思われる(パッと見ではexemとしてスケールされている?)。そのため、exからemの単位系に変換すれば正常に表示できるだろうということであるが、それぞれのサイズの比は環境依存であるため、完璧な変換は不可である。しかしながら、MathJaxの以下のドキュメントによれば、デフォルトのサイズ比は0.5を採用しているように見えるため、0.5でスケーリングするというのが妥当であろう。

 上記で述べたようなスケーリングを適用した場合のコードの該当部分は以下のとおりである。これにより、若干数式のSVGのスケールが変化するが、全然問題ない範囲であり(なんなら変換前の数式が若干大きく見える問題が解消して見やすいぐらいである)、問題が生じたい環境でも正常に表示される。

    // exからem単位系に変換する
    const exToEm = e => {
        // MathJaxにおけるデフォルトのex/emのサイズ比でスケーリング
        const scale = 1 / 2;
        e.setAttribute('width', `${parseFloat(e.getAttribute('width'), 10) * scale}em`);
        e.setAttribute('height', `${parseFloat(e.getAttribute('height'), 10) * scale}em`);
        e.style.verticalAlign = `${parseFloat(e.style.verticalAlign, 10) * scale}em`;
    };

    // 手動でtypesetを実行
    const adaptor = inst.startup.adaptor;
    document.querySelectorAll('.inline-formula').forEach(e => {
        // インライン数式の構築
        e.innerHTML = adaptor.innerHTML(inst.tex2svg(e.textContent, { display: false }));
        exToEm(e.children[0]);
    });
    document.querySelectorAll('.block-formula').forEach(e => {
        // ブロック数式の構築
        e.innerHTML = adaptor.innerHTML(inst.tex2svg(e.textContent, { display: true }));
        exToEm(e.children[0]);
    });
JavaScript

KaTeX

 KaTeXによる数式の出力形式はHTMLとMathMLの2つである。KaTeXもNode.js上で動作するため早速導入をする。

npm install katex
Bash

 パッケージのバージョンについては以下のとおりである。

{
  "dependencies": {
    "katex": "^0.16.10"
  }
}
Plain text

単純な数式のレンダリング

 特に何も考えずにレンダリングをしたい場合、以下のように出力することができる。MathJaxと比較してできることがシンプルな分、かなり簡潔に記述することができる。

const katex = require("katex");

try {
    console.log(katex.renderToString('\\frac{1}{x^2-1}', { displayMode: true, output: 'html', throwOnError: true }));
}
catch (e) {
    console.error(e);
    process.exit(1);
}
JavaScript

 CSSについてはMathJaxのように動的に生成されるものではなく、静的なものが与えられている。具体的にはnode_modules/katex/dist配下に存在するCSS(例えばkate.css)をリンクすればいい。ただし、CSSをセルフホストするとなると、フォントファイルもセルフホストする必要があるため、個人で利用する分にはCSSはCDNに設置するのが無難と思われる(もちろん、CSSに記載されたフォントファイルのパスを変更してもいいだろう)。

HTML上に記述された数式のレンダリング

 具体的にHTML上で記述された数式に対してレンダリングを行う方法について示す。基本的な方法は上記で述べた方法と同一であり、やり方もMathJaxの場合とほとんど変わらない。以下にその実装を示す。本来であればkatex.renderを使いたいところであったが、どうやらdocumentが見つからないとエラーが出るっぽいため、しかたなくkatex.renderToStringを利用している(回避手段もあるが、使わなくても真っ当にできる手段があるがあるならばそちらを利用した方がいいだろう)。

const katex = require("katex");
const { JSDOM } = require('jsdom');
const fs = require('fs');

// 入力するファイル
const inFile = 'in.html';
// 出力するファイル
const outFile = 'out.html';

(async () => {
    // 入力されたファイルについてのDOMツリーの構築
    const dom = new JSDOM(fs.readFileSync(inFile).toString(), { contentType: 'text/html'});
	const document = dom.window.document;

    try {
        document.querySelectorAll('.inline-formula').forEach(e => {
            // インライン数式の構築
            // katex.render(e.textContent, e, { displayMode: false, output: 'html', throwOnError: true });
            e.innerHTML = katex.renderToString(e.textContent, { displayMode: false, output: 'html', throwOnError: true });
        });
        document.querySelectorAll('.block-formula').forEach(e => {
            // ブロック数式の構築
            // katex.render(e.textContent, e, { displayMode: true, output: 'html', throwOnError: true });
            e.innerHTML = katex.renderToString(e.textContent, { displayMode: true, output: 'html', throwOnError: true });
        });
    }
    catch (e) {
        console.error(e);
        process.exit(1);
    }

    // ファイルの出力
    fs.writeFileSync(outFile, dom.serialize());
})();
JavaScript

 実際に数式の変換元となるサンプルのHTMLファイルを以下に示す。このHTMLはCSSを手動でリンクしていることを除き、MathJaxで示したものと同一である。

<!DOCTYPE html>
<html lang="ja">
	<head>
		<meta charset="utf-8">
		<title>数式を変換する</title>
		<link rel="stylesheet" href="./node_modules/katex/dist/katex.css">
	</head>
	<body>
		<div id="formula" style="font-size: 20px;">
			<p>インライン数式の例<span class="inline-formula">f(x)=ax+b</span>を表示する。以下はブロック数式の例。</p>
			<div class="block-formula">f(x)=\sum_{k=0}^{\infty}\frac{f^{(k)}(0)}{k!}x^{k}</div>
		</div>
		<div>
			<span>フォントサイズ</span><input id="resize" type="number" value="20"><span>px</span>
		</div>
	</body>
	<script>
		const formula = document.getElementById('formula');
		const input = document.getElementById('resize');
		// resizeへの入力によってフォントサイズを変更する
		input.addEventListener('input', e => {
			formula.style.fontSize = `${Math.max(input.value, 1)}px`;
		});
	</script>
</html>
Markup

おわりに

 今回はMathJaxとKaTeXの双方を利用してみたが、フォントファイルのロードを考えると、単一のHTMLファイルで完結するという意味でMathJaxでSVGを出力する場合の方が個人的には好みではある。実際に利用する場合はMathJaxでSVGを出力するかKaTeXでHTMLを出力するかの2択となるが、それは単にSVGの分かフォントファイルの分のどちらを取るかといった具合である。致命的な速度差もないと思うので、使いたい方を使えばいいだろう。ただ、MathJaxでSVGを出力する場合は、SVGの特性上、長いインライン数式だと文字の折り返しに難があるという欠点があるため、そこには注意する必要がある。
 正直なところ、MathMLがネイティブでLaTeXライクな数式のレンダリングをサポートするのが一番簡潔だと思うのだが、そんな未来は今のところ来そうにない。CSS組版という概念もあるのだし、そろそろ来てもいいと思う。