👩‍🏫

Vue3 上で MathJax3 を便利に使えるようにしてみた

2024/07/31に公開

Vue3 上で数式を表示させたい機会があり、MathJax3 を使っての組版を試しました。のち、数式の変更を検知して自動で再組版を行うような、Vue に特化したコンポーネント化を行っています。

前提条件

  • Vue 3.4.34
  • MathJax v3.2.2
  • TypeScript 5.5.4

MathJaxを直接使う場合

MathJaxの導入

基本的には公式サイトの Getting Started にある通りに従えばいいはずです。ただし読み込むべきスクリプトは

https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js

ではなく、

https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js

を使います。

前者を使っても組版はできますが、任意のノードを組版する際に MathJax 内部でエラーが発生してしまって対処できません。

また、以前は公式でも Polyfill.io を事前に読み込むよう指示がされ、たとえばこの公式の指示を参照する文献でも同様にするよう案内が書かれていました。しかし、Polyfill.io についてはご存知の通りpolyfill.io から直接使うべきではありません。MathJax で Polyfill.io を使っていた理由は IE11 対応のためですが、2024年時点でもまだ IE11 対応をしなければならないケースは Vue3 を併用するケースにおいてはまずないはずですので、もはや Polyfill.io を使う必要すらありません

よって、MathJax の導入は以下のように記述しておきます。

<script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js"></script>

必要になるまで組版を行わないようにする

このまま MathJax を導入するとページ全体を探索して数式部分について組版が行われます。完全な静的サイトならばこれで問題ありません。しかし Vue のようにページ読み込み完了後も描画を行ったり DOM 操作が発生する場合、Vue の処理の前に組版処理が終わってしまって数式が正常に表示されない恐れがあります。

そこで MathJax のスクリプトが読み込まれた直後は組版をしないよう、以下のように MathJax の設定を追加しておきます。この設定は MathJax の読み込み前に記述する必要があります。

<script>
  window.MathJax = {
    startup: {
      typeset: false,
    },
    svg: {
      fontCache: 'global'
    },
  };
</script>
<script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js"></script>

明示的な組版実行

あとは必要なタイミングで MathJax.typesetPromise を呼び出せば組版が実行されます。

<script setup lang="ts">
import { onMounted } from 'vue';

declare global {
  interface Window {
    MathJax: {
      typesetPromise: (nodes?: Iterable<Node>) => Promise<void>;
    };
  }
}

const target = ref<HTMLElement>();

onMounted(async () => {
  if (target.value) {
    await window.MathJax.typesetPromise([target.value]);
  }
});
</script>

<template>
  <p ref="target">
    \(t\) は時間 [s]、\(R\) は抵抗値 [Ω]、\(C\) は静電容量 [F]、\(f\) は周波数 [Hz]、\(\ln(2)\) は2の自然対数です。
  </p>
</template>
実行結果

MathJax3 の型定義ファイルが存在しないため、アンビエント宣言で MathJax.typesetPromise の型を TypeScript に伝えています。

MathJax.typesetPromise は引数を省略することができ、その場合はページ全体を対象に数式を探索します。ノードを配列として指定すると、探索対象をノード自身とその子孫に絞れます。引数を省略しつつ MathJax の設定で特定の要素名やクラス名を持つノードのみに対して組版を行うことはできますが、対象のノードが判明している場合は比較的高速に組版が終わります。

最低限の組版は上記の通りでよいのですが、たとえば数式がページの複数箇所にある場合や、動的に数式が変わる場合の再組版など、使いやすくする余地があります。

コンポーネント化して使いやすく

数式の変更検知、自動組版、場合によっては強制的に組版処理ができるよう、コンポーネント化して再利用できるようにしてみました。

コンポーネント化したもの(長いので畳んでいます)
MathJax.vue
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';

const {
  tag = 'span',
  node,
  block,
  overlook,
} = defineProps<{
  tag?: string;
  node?: boolean;
  block?: boolean;
  overlook?: boolean;
}>();
const Tag = tag;

declare global {
  interface Window {
    MathJax: {
      typesetPromise: (nodes?: Iterable<Node>) => Promise<void>;
      typesetClear: (nodes?: Iterable<Node>) => Promise<void>;
    };
  }
}

if (!('MathJax' in window)) {
  console.warn('window.MathJax does not exist. For typesetting, MathJax import is required.');
}

const raw = ref<HTMLElement>();
const formula = ref<HTMLElement>();
const FormulaTag = computed<string>(() => (block ? 'div' : 'span'));
const observer = new MutationObserver(typeset);

async function typeset() {
  if (!formula.value) {
    return;
  }

  prepareFormulaNode();
  await window.MathJax.typesetPromise([formula.value]);
}

function prepareFormulaNode() {
  if (!raw.value || !formula.value) {
    return;
  }

  if (raw.value.children.length === 0 && !node) {
    if (block) {
      formula.value.innerText = `$$ ${raw.value.innerText} $$`;
    } else {
      formula.value.innerText = `\\( ${raw.value.innerText} \\)`;
    }
  } else {
    while (formula.value.lastElementChild) {
      formula.value.removeChild(formula.value.lastElementChild);
    }

    for (const childNode of raw.value.childNodes) {
      formula.value.appendChild(childNode.cloneNode(true));
    }
  }
}

async function updateObservation() {
  if (overlook) {
    if (formula.value) {
      window.MathJax.typesetClear([formula.value]);
      prepareFormulaNode();
    }
  } else {
    await typeset();
  }
}

watch(() => [node, block, overlook], updateObservation);
onMounted(async () => {
  await updateObservation();

  if (raw.value) {
    observer.observe(raw.value, { childList: true, subtree: true, characterData: true });
  }
});
onUnmounted(() => observer.disconnect);

defineExpose({ typeset });
</script>

<template>
  <Tag>
    <span ref="raw" class="mathjax-raw">
      <slot></slot>
    </span>
    <FormulaTag ref="formula"></FormulaTag>
  </Tag>
</template>

<style lang="scss">
.mathjax-raw {
  display: none;
}
</style>

主な機能は以下の通りです。

  • 自動組版実行
    コンポーネントのマウント時だけでなく、数式が変更された場合でも自動で再組版します。
  • ノードモード
    数式の中身に別のノード要素がある場合、従来の MathJax の使い方に近い形で組版処理を実行できます。この場合も変更を検知して自動的に組版が実行されます。
  • インラインとブロックの切り替え
    block プロパティを true にすると、数式がブロック要素として組版されるようになります。デフォルトではインライン要素として組版されます。さらに、ノードモードではなく、インラインまたはブロックを使う場合は数式を $$\\( \\) で囲う必要がありません。
  • 自動組版の抑止
    overlook プロパティを true にすると、数式が変更されても自動では組版を実行しなくなります。
  • 組版処理を強制する手段の提供
    typeset メソッドが expose されており、利用側の好きなタイミングで組版処理を実行できます。

使い方を一つずつ紹介していきます。

自動組版実行

MathJax.vue をインポートし、数式にしたい部分を <MathJax> で囲うだけです。このとき、数式部分を $$\\( \\) で囲う必要はありません。

<script setup lang="ts">
import MathJax from './MathJax.vue';
</script>

<template>
  <p>
    <MathJax>t</MathJax> は時間 [s]、<MathJax>R</MathJax> は抵抗値 [Ω]、
    <MathJax>C</MathJax> は静電容量 [F]、<MathJax>f</MathJax> は周波数 [Hz]、
    <MathJax>\ln(2)</MathJax> は2の自然対数です。
  </p>
</template>
実行結果

ノードモード

数式部分に別のノードがある場合はその子孫ノード内部に数式があるとみなして組版します。これにより従来の MathJax と同じような挙動になります。子孫ノードであっても変更を検知して自動で再組版します。このモードのときだけは数式の場所の明示のため、$$\\( \\) で囲う必要があります。

node プロパティに true を渡すと子孫ノードがなくても(テキストだけでも)強制的にノードモードになり、従来の MathJax 単体での使用に最も近い使い方ができます。また、tag プロパティを指定するとマウント時に指定した要素名で配置されます。

下記の例では結果的に2つ同じものが組版されます。

<script setup lang="ts">
import MathJax from './MathJax.vue';
</script>

<template>
  <MathJax>
    <p>
      \(t\) は時間 [s]、\(R\) は抵抗値 [Ω]、\(C\) は静電容量 [F]、
      \(f\) は周波数 [Hz]、\(\ln(2)\) は2の自然対数です。
    </p>
  </MathJax>

  <MathJax node tag="p">
    \(t\) は時間 [s]、\(R\) は抵抗値 [Ω]、\(C\) は静電容量 [F]、
    \(f\) は周波数 [Hz]、\(\ln(2)\) は2の自然対数です。
  </MathJax>
</template>
実行結果

インラインとブロックの切り替え

block プロパティに true を渡すとその数式はブロック要素として組版されます。指定しない場合はインライン要素になります。

<script setup lang="ts">
import MathJax from './MathJax.vue';
</script>

<template>
  <MathJax block>
    f = \frac{1}{t_\text{high} + t_\text{low}} = \frac{1}{\ln(2) \cdot (R_1 + 2 R_2) \cdot C_1}
  </MathJax>
  <p>
    デューティ比 <MathJax>D</MathJax> は次の式で求められます。
  </p>
  <MathJax block>
    D~(\%) = \frac{t_\text{high}}{t_\text{high} + t_\text{low}} \cdot 100 = \frac{R_1 + R_2}{R_1 + 2 R_2} \cdot 100
  </MathJax>
</template>
実行結果

自動組版の抑止と組版の強制

overlook プロパティに true を渡すと自動的な組版が抑止され、再度 false にするか明示的に typeset メソッドを呼び出さない限り組版がされなくなります。typeset メソッドは非同期メソッドです(戻り値の型が Promise<void>)。

<script setup lang="ts">
import MathJax from './MathJax.vue';
import { ref } from 'vue';

const formula = ref<InstanceType<typeof MathJax>>();
</script>

<template>
  <MathJax block overlook ref="formula">
    D~(\%) = \frac{t_\text{high}}{t_\text{high} + t_\text{low}} \cdot 100 = \frac{R_1 + R_2}{R_1 + 2 R_2} \cdot 100
  </MathJax>
  <button @click="formula?.typeset()">組版実行</button>
</template>
実行結果

「組版実行」ボタンを押すと組版が実行される様子です

overlook プロパティと typeset メソッドにより、利用側から見て任意のタイミングで組版を制御できます。組版処理は比較的重い処理のため、長い数式の内容を頻繁に書き換える場合には特にメリットが大きくなります。

注意として、ページが読み込まれたときに typeset メソッドを呼び出す場合は onMounted を使うようにしてください。watch のオプションで { immediate: true } を指定するとページ読み込み時には組版が行われません。これは MathJax.vue にてスロットを使用しているため、コンポーネントのマウントが完了する前に呼び出されてもスロット部分がまだマウントされず、組版すべき数式を見つけることができないためです。Vue3 における { immediate: true } の挙動を解説した記事も参考にしてください。

<script setup lang="ts">
import MathJax from './MathJax.vue';
import { onMounted, ref, watch } from 'vue';

const formula = ref<InstanceType<typeof MathJax>>();
const counter = ref<number>(0);

watch(
  () => counter.value,
  async () => {
    await formula.value?.typeset();
  },
);

onMounted(async () => {
  await formula.value?.typeset();
});
</script>

<template>
  <MathJax block overlook ref="formula"> y = x + {{ counter }} </MathJax>
  <button @click="counter++">加算</button>
  <button @click="counter = 0">リセット</button>
</template>
実行結果

「加算」ボタンを押すと数式の内容が書き換えられ、組版が実行される様子

類似のコンポーネント

今更になってしまいますが、似たことを実現するコンポーネントは複数存在します。

前者は MathJax 3 に対応していません。後者は MathJax 3 に対応しており、さらに MathJax のスクリプトの導入も自動で行ってくれます。

どちらも数式の変更検知を行って自動で再組版を行います。そしてどちらも数式はコンポーネントのプロパティに記述する必要があり、スロットを使っての記述はできません。

実際に使ってみた

こちらのページにて MathJax.vue を使って数式を表示してみました。計算した数値が頻繁に変わるページのため、使っているのは静的な部分のみになります。

参考文献

Discussion