Dynamic MathJax typesetting in Svelte


2024-07-31 // 447 words // ~3 minutes



Hacking together reactivity with the `use` directive and `{#key}` blocks
Svelte + MathJax
Svelte + MathJax

(TL;DR;give-me-the-code: Dynamic MathJax rendering — Svelte REPL)

I am building a quiz platform for the startup I am working on right now. The questions in the quiz often contain LaTeX math elements which need to be typeset by MathJax.

The setup

MathJax and its config is brought in with the <svelte:head> tag, like this:

<svelte:head>
  <script>
    window.MathJax = {
      tex: {
        inlineMath: [
          ["$", "$"],
          ["\\(", "\\)"],
        ],
        displayMath: [["$$", "$$"]],
      },
      svg: {
        fontCache: "global",
      },
      startup: {
        typeset: false,
      },
    };
  </script>
  <script
    id="MathJax-script"
    defer
    src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js"
  ></script>
</svelte:head>

I have an array of questions (which are just HTML text with LaTeX expressions) and a variable called current, which keeps track of the index of the current question. There are also couple of buttons to switch between the questions.

<script>
  const current = 0;
  const questions = [
    `This is $ e^x $: <br /> $$ {\\bf{e}}^x = \\sum\\limits_{n = 0}^\\infty {\\frac{{{x^n}}}{{n!}}}	$$`,
    `And this is $ \\cos x $: <br />  $$ \\cos x = \\sum\\limits_{n = 0}^\\infty {\\frac{{{{\\left( { - 1} \\right)}^n}{x^{2n}}}}{{\\left( {2n} \\right)!}}} $$`,
    `Finally, this is $ \\sin x $: <br /> $$ \\sin x = \\sum\\limits_{n = 0}^\\infty {\\frac{{{{\\left( { - 1} \\right)}^n}{x^{2n + 1}}}}{{\\left( {2n + 1} \\right)!}}} $$`,
  ];
</script>

<svelte:head>
  ...
</svelte:head>


<main>
	<button on:click={() => current--}>Prev</button>
	<button on:click={() => current++}>Next</button>

	<p> {@html questions[current] }	</p>
</main>

GIF showing what the initial setup looks like

The problem

MathJax runs the typesetter just once, as soon as it's loaded on the page. At this stage, the question text has not loaded in yet, so the typesetting doesn't take effect.

We need a way to make this dynamic, so that MathJax will re-typeset the text when the question number (i.e., value of current) changes.

The solution

We can use two nifty tools that Svelte provides — the use directive and the {#key} block — to solve this problem.

Implementing use:typesetMath

The use directive calls the attached function (called an action) when the element is created.

We can use this to create an action that will check if MathJax has been loaded and run the formatter (window.MathJax.typesetPromise) on our element.

<script>
  // ... //

  // Action functions accept the HTML element as an argument when they are called
  function typesetMath(node) {
    const typesetPromise = window.MathJax.typesetPromise;

    // Check if MathJax has actually loaded before calling `typesetPromise`
    if (typesetPromise) typesetPromise([node]);
  }

  //...//
</script>

...

<p use:typesetMath>{@html questions[current] }</p>
...

Now, we need to make sure that this action is fired whenever the variable current changes.

Setting up the {#key} block

The {#key} block will cause the elements inside it to re-render whenever the key (in this case, the variable current) changes, which then goes ahead and triggers the typesetMath action.

{#key current}
<p use:typesetMath>{@html questions[current] }</p>
{/key}

So far so good! Everything should work more-or-less as expected now, but there is one little bug.

The typesetting might not happen properly for the question that is shown by default, because MathJax might not have been loaded by the time the typesetMath call is made, and the function exits without doing anything.

GIF showing that typesetting for the first question is broken.

To fix this, we can use the else branch in the conditional in typesetMath to call itself recursively (with a small delay) until MathJax is loaded.

function typesetMath(node) {
  const typesetPromise = window.MathJax.typesetPromise;
  if (typesetPromise) typesetPromise([node]);
  // recursively call itself every 500ms until MathJax loads
  else setTimeout(() => typesetMath(node), 500);
}

And that's it! That's how I hacked it together. It is not an elegant fix but it works pretty well for now.

GIF showing MathJax typesetting everything dynamically.

Click here for the full code.
<script>
	import { afterUpdate } from 'svelte';

	let current = 0;
	const questions = [
	`This is $ e^x $: <br /> $$ 	{\\bf{e}}^x = \\sum\\limits_{n = 0}^\\infty {\\frac{{{x^n}}}{{n!}}}		$$`,
	`And this is $ \\cos x $: <br />  $$ \\cos x = \\sum\\limits_{n = 0}^\\infty {\\frac{{{{\\left( { - 1} \\right)}^n}{x^{2n}}}}{{\\left( {2n} \\right)!}}} $$`,
	`Finally, this is $ \\sin x $: <br /> $$ \\sin x = \\sum\\limits_{n = 0}^\\infty {\\frac{{{{\\left( { - 1} \\right)}^n}{x^{2n + 1}}}}{{\\left( {2n + 1} \\right)!}}} $$`
	]

	function typesetMath(node) {
		const typesetPromise = window.MathJax.typesetPromise;
		if (typesetPromise) typesetPromise([ node ]);
		else setTimeout(() => typesetMath(node), 500)
	}
</script>

<svelte:head>
	<script>
		window.MathJax = {
			tex: {
				inlineMath: [
					['$', '$'],
					['\\(', '\\)']
				],
				displayMath: [['$$', '$$']]
			},
			svg: {
				fontCache: 'global'
			},
			startup: {
				typeset: false
			}
		};
	</script>
	<script
		id="MathJax-script"
		defer
		src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js"
	></script>
</svelte:head>

<main>
	<button disabled={current===0} on:click={() => current--}>Prev</button>
	<button disabled={current===questions.length-1} on:click={() => current++}>Next</button>

	{#key current}
	<p use:typesetMath>
		{@html questions[current] }
	</p>
	{/key}
</main>

I have also created a Svelte REPL if you want to play around with this.

Comments

Comment via