javascripthtmlsvgmathjax

How to render MathJax inside of an SVG


I have a website which includes svg-images which themselves include mathematical formulas that should be rendered using MathJax. As far as I know, this isn't supported natively (i.e., by just putting the LaTeX-formulas within the SVG.

In the past, I have used the javascript code from this answer by Freddie Page which worked quite nicely for MathJax 2. However, I would now like to switch to MathJax 4 and can't quite get this code to work again. The following code is my current best try of adapting it:

const mathSVG = async function(latex, target) {
  let mathBuffer = document.createElement("div");
  document.body.appendChild(mathBuffer);

  mathBuffer.textContent = latex;
  await MathJax.typeset([mathBuffer]);

  var svg = mathBuffer.childNodes[0];
  svg.setAttribute("y", "0pt");
  target.appendChild(svg);
};

async function generateTeXinSVGs() {
  listOfSVGs = document.getElementsByClassName("TeXinSVG");
  var i;
  for (i = 0; i < listOfSVGs.length; i++) {
    svgElement = listOfSVGs[i];
    latex = svgElement.children[0].textContent;
    svgElement.innerHTML = "";
    await mathSVG("$" + latex + "$", svgElement);
  }
}
<script>
  MathJax = {
    tex: {
      inlineMath: {
        '[+]': [
          ['$', '$']
        ]
      }
    },
    startup: {
      ready: () => {
        MathJax.startup.defaultReady();
        MathJax.startup.promise.then(() => {
          generateTeXinSVGs();
        });
      }
    }
  };
</script>
<script id="MathJax-script" defer src="https://cdn.jsdelivr.net/npm/mathjax@4.0.0/tex-svg.js"></script>


<h3>Working MathJax parsing</h3>
<p>
  An SVG with the formula $a^2+b^2=c^2$ inside it:
</p>

<h3>Failed MathJax parsing in SVG</h3>
<svg width="360" height="170" viewBox="0 0 360 170">
    <circle cx="80" cy="80" r="40" stroke-width="10" fill="None" stroke="red" />
    <text x="80" y="60">a^2+b^2=c^2</text>
    <svg class="TeXinSVG" x="80" y="80"><text>a^2+b^2=c^2</text></svg>
</svg>

It works insofar as it adds the output of MathJax to the SVG - however, nothing is displayed. As far as I can see, (part of) the problem seems to be that MathJax 2 created an SVG as top-level element which could then be placed within the SVG, whereas MathJax 4 creates a mjx-container as top-level element which, I guess, cannot be part of an SVG?

If one changes the line

var svg = mathBuffer.childNodes[0];

in the above code to

var svg = mathBuffer.childNodes[0].childNodes[0];

then the first part of the formula (the a^2) gets added to the SVG (and displayed - though slightly cropped). But adding the formula piece-by-piece does not feel like the right direction to me.

Can you help me adapt this code to MathJax 4?
(or maybe there are now better ways of achieving what I want in MathJax 4? Then I am of course also happy to hear about those)


Solution

  • Note: this has been edited to address changes in @HeikoTheißen's answer.

    Both @HeikoTheißen and @herrstrietzel have good ideas about how to approach this, but I think each can be improved. The first solution, from @HeikoTheißen, doesn't quite work for for me, as the math is not accurately centered. In his original version, he used the size of the <span> to center the mathematics, but that may not be the size of the math within it, as the height of a span should always be at least the height of a normal line of text, but in some browsers, the width is also wrong (it appears that the width may be the width of the original TeX string, so there may be a screen-update timing issue).

    His later modification measure the size of the <mjx-math> element within the MathJax output. This is a good idea, but unfortunately there are still some problems. First, Firefox will return 0 for both the width and height of that element, so Firefox will not display the math at all, as the code sets the width and height of the <foreignObject> element to 0 in that case. Even if you set overflow: visible on the <foreignObject>, which would let the content be seen, it would not be properly centered, since the code thinks its width and height are 0.

    Another issue, however, is that the <mjx-math> element is inside a <span>, and if that has any text within it, it will always have height at least that of a line of text, even when the math is not that tall. That throws off the positioning in the case where the math is shorter than a line of text.

    For example:

    svg {
      display: inline
    }
    span.bg {
      background-color: rgb(255, 0, 0, .33);
    }
    span.math mjx-math {
      background-color: rgb(0, 0, 255, .33);
    }
    span.flex {
      display: flex;
    }
    <script>
      MathJax = {
        tex: {
          inlineMath: {'[+]': [['$', '$']]}
        },
        startup: {
          ready: () => {
            MathJax.startup.defaultReady();
            MathJax.startup.promise.then(() => {
              for (const s of document.querySelectorAll('span.math')) {
                const {
                  width,
                  height
                } = s.querySelector('mjx-math').getBoundingClientRect();
                s.parentElement.x.baseVal.value -= width / 2;
                s.parentElement.y.baseVal.value -= height / 2;
                s.parentElement.width.baseVal.value = width;
                s.parentElement.height.baseVal.value = height;
              }
            });
          }
        }
      };
    </script>
    <script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js" type="text/javascript"></script>
    
    <svg width="130" height="160" viewbox="0 0 130 160">
      <circle cx="80" cy="80" r="40" stroke-width="10" fill="None" stroke="red" />
      <line x1="60" y1="80" x2="100" y2="80" stroke="red" stroke-width="2"/>
      <line y1="60" x1="80" y2="100" x2="80" stroke="red" stroke-width="2"/>
      <foreignObject x="80" y="80">
        <span class="math">$a\Rule{1em}{0em}{1em}$</span>
      </foreignObject>
    </svg>
    
    <svg width="130" height="160" viewbox="0 0 130 160">
      <circle cx="80" cy="80" r="40" stroke-width="10" fill="None" stroke="red" />
      <line x1="60" y1="80" x2="100" y2="80" stroke="red" stroke-width="2"/>
      <line y1="60" x1="80" y2="100" x2="80" stroke="red" stroke-width="2"/>
      <foreignObject x="80" y="80">
        <span class="math bg">$a\Rule{1em}{0em}{1em}$</span>
      </foreignObject>
    </svg>
    
    <svg width="130" height="160" viewbox="0 0 130 160">
      <circle cx="80" cy="80" r="40" stroke-width="10" fill="None" stroke="red" />
      <line x1="60" y1="80" x2="100" y2="80" stroke="red" stroke-width="2"/>
      <line y1="60" x1="80" y2="100" x2="80" stroke="red" stroke-width="2"/>
      <foreignObject x="80" y="80">
        <span class="math flex">$a\Rule{1em}{0em}{1em}$</span>
      </foreignObject>
    </svg>

    produces three circles like those below

    Three red circles, the first with math not centered vertically, the second the same but with the span element's background highlighted, and the third with the math properly centered

    except in Firefox, where the red circles are empty other than the cross at the center.

    The first circle shows the output of @HeikoTheißen's code (modified only slightly to process all three math expressions rather than just the first one), and with the background of the <mjx-math> element shown as a transparent blue. Note that it is not vertically centered. The second circle shows why: here the background of the <span> is colored a transparent red. Because that span is as high as a usual line of text, but the math is not, the span has extra height that is not accounted for in the centering; i.e., the span does not act as a tight bounding box for the math it contains.

    One way to correct for that is to set the CSS for the <span> to have display: flex. That is done in the third circle, where the content is properly centered.


    The answer from @herrstrietzel is nice in that it moves the MathJax <svg> element directly into the larger SVG. But it doesn't currently deal properly with the dimensions or placement of the inner SVG.

    The reason that your original attempt to move the MathJax SVG failed is that MathJax v4 has inline-breaking on by default, and so an in-line expression like a^2+b^2=c^2 will be broken into several separate <svg> elements at the potential breakpoints so that the browser can break the expression where it needs to. You moved the first one, but not the others. So you would need to turn of in-line breaking in order to have only a single <svg> element. Alternatively, you could place an extra set of braces around the entire in-line expression, which will suppress in-line breaks.

    It is also possible for the characters within the MathJax <svg> to extend beyond the boarders of the <svg> itself, so you would want to set their overflow CSS to visible.


    Either of these two approaches can be made to work more fully, as I illustrate below.

    Here is a version of the <foreignObject> approach:

    foreignObject {
      overflow: visible;
    }
    foreignObject > mjx-container {
      display: flex ! important;
      margin: 0 ! important;
      width: auto ! important;
    }
    <script>
      MathJax = {
        output: {
          displayOverflow: 'overflow',
          linebreaks: {inline: false},
        },
        tex: {
          inlineMath: {'[+]': [['$', '$']]}
        },
        options: {
          renderActions: {
            svgMath: [500,
              (doc) => {for (const math of doc.math) MathJax.config.svgMath(math, doc)},
              (math, doc) => MathJax.config.svgMath(math, doc)
            ],
          },
        },
        svgMath(math, doc) {
          const parent = math.typesetRoot.parentElement;
          if (parent.nodeName.toLowerCase() !== 'foreignobject') return;
          const px = doc.outputJax.pxPerEm;
          const {w, h, d} = doc.outputJax.getBBox(math);
          const [W, H] = [w * px, (h + d) * px];
          parent.setAttribute('width', W);
          parent.setAttribute('height', H);
          parent.setAttribute('x', parseFloat(parent.getAttribute('x')) - (W / 2));
          parent.setAttribute('y', parseFloat(parent.getAttribute('y')) - (H / 2));
        },
      };
    </script>
    
    <script src="https://cdn.jsdelivr.net/npm/mathjax@4/tex-svg.js" type="text/javascript"></script>
    
    <svg width="160" height="160" viewbox="0 0 160 160">
      <circle cx="80" cy="80" r="40" stroke-width="10" fill="None" stroke="red" />
      <line x1="60" y1="80" x2="100" y2="80" stroke="red" stroke-width="2"/>
      <line y1="60" x1="80" y2="100" x2="80" stroke="red" stroke-width="2"/>
      <foreignObject x="80" y="80">
        $a^2+b^2=c^2$
      </foreignObject>
    </svg>

    Here I've removed the <span> and use the <foreignObject> itself to contain the math, and rather than using getBoundingClientRect(), I have MathJax compute the bounding box that it is using internally. We do this all within a renderAction so that it happens automatically. We define the render action at priority 300 so that it happens after the math is inserted into the page, so that we can get the parent foreignObject element.

    Because the MathJax bounding box is in em units, we use the output Jax's pxPerEm value to convert to pixels in order to set the width and height of the foreignObject and adjust its x and y attributes.

    The CSS is to make sure that any overflow in the foreignObject is visible. The mjx-container has display: flex so that its bounding box matches the math (an inline or block element would have at least the hight of a line box, as in the previous example above). The other CSS values are so that if you use display math delimiters, the size will just match the math instead of being full width with margin above and below.

    This code centers the math on the coordinates of the foreignObject element, but it might be more natural to place the baseline of the beginning of the math on that point. In that case, replace the two lines that set the x and y attributes with the one line

          parent.setAttribute('y', parseFloat(parent.getAttribute('y')) - h * px);
    

    I place a cross at the center of the circle to make it easier to see the alignment.

    One downside to using <foreignObject> is the WebKit (and hence Safari and other WebViews that use WebKit) don't handle transforms on foreignObject elements properly. For example, adding

    
    <g transform="translate(-20, -20)">
    ...
    </g>
    

    around the content of the second <svg> element in the first example above produces

    An example where a transform doesn't move the foreignObejct in Safari

    in Safari, though it works properly in Firefox, Chrome, and Edge. Safari moves the <span> element, but not the math it contains (weird). Other transforms can produce even worse results.


    The other approach is to move the <svg> element into the SVG itself. As with @herrstrietzel's code, I use a <text> element to hold the math, but instead of having MathJax process it directly, I extract the math and pass it to MathJax.tex2svgPromise() to produce the output and replace the <text> element with the result. In this case, we mark the <text> elements to be processed by adding class="math". This math is processed as in-line math, using class="math display" processes it as displayed math.

    Because we are converting the TeX notation ourselves, we don't get a MathItem to work with (which is what we need when we call the output jax's getBBox() method), so we patch the SVG output jax to trap its processMath() method and use that to obtain the bounding box as the math is being processed. We attach that to the typeset output via a data-WHD attribute that we can look up later when we move the SVG into place. This is done in a ready() function specific to the output/svg component.

    The startup.ready() function loops through all the text.math elements and uses MathJax.tex2svgPromise() to typeset the expressions, then obtains the <svg> element and reads its size from the data-WHD attribute of the mjx-container. It uses these to set the width and height of the <svg> and removes some unneeded (though not actually detrimental) attributes and styles. It uses a <g> element with a transform attribute to move the math into place, as not all browsers apply a transform to an <svg> element (I'm looking at you, Safari).

    <script>
      MathJax = {
        options: {
          enableMenu: false,
          enableEnrichment: false,
        },
        output: {
          displayOverflow: 'overflow',
          linebreaks: {inline: false},
        },
        tex: {
          inlineMath: {'[+]': [['$', '$']]}
        },
        loader: {
          'output/svg': {
            ready() {
              const {SVG} = MathJax._.output.svg_ts;
              Object.assign(SVG.prototype, {
                _processMath_: SVG.prototype.processMath,
                processMath(wrapper, node) {
                  this._processMath_(wrapper, node);
                  const {w, h, d} = wrapper.getOuterBBox();
                  const px = this.pxPerEm;
                  node.setAttribute('data-WHD', [w, h, d].map((x) => this.fixed(x * px)).join(','));
                },
              });
            },
          },
        },
        startup: {
          async ready() {
            MathJax.startup.defaultReady();
            const jax = MathJax.startup.document.outputJax;
            await MathJax.startup.promise;
            for (const text of Array.from(document.querySelectorAll('text.math'))) {
              const math = await MathJax.tex2svgPromise(text.textContent, {display: text.classList.contains('display')});
              const [w, h, d] = math.getAttribute('data-WHD').split(',').map((x) => parseFloat(x));
              const svg = math.querySelector('svg');
              svg.style.verticalAlign = '';
              svg.setAttribute('width', w);
              svg.setAttribute('height', h + d);
              ['role', 'focusable', 'aria-hidden'].forEach((attr) => svg.removeAttribute(attr));
              const x = parseFloat(text.getAttribute('x')) - w / 2;
              const y = parseFloat(text.getAttribute('y')) - (h + d) /2;
              const g = jax.svg('g', {transform: `translate(${x},${y})`}, [svg]);
              text.replaceWith(g);
            }
          }
        },
      };
    </script>
    <script src="https://cdn.jsdelivr.net/npm/mathjax@4/tex-svg.js" type="text/javascript"></script>
    
    <svg width="160" height="160" viewbox="0 0 160 160">
      <circle cx="80" cy="80" r="40" stroke-width="10" fill="None" stroke="red" />
      <line x1="60" y1="80" x2="100" y2="80" stroke="red" stroke-width="2"/>
      <line y1="60" x1="80" y2="100" x2="80" stroke="red" stroke-width="2"/>
      <text x="80" y="80" class="math">a^2+b^2=c^2</text>
    </svg>

    Again, if you want to place the baseline on the point given by the text element's x and y attributes, then replace the lines that set x and y by

              const x = parseFloat(text.getAttribute('x'));
              const y = parseFloat(text.getAttribute('y')) - h;
    

    In this example, I turn off the menu code and the semantic enrichment, since we are discarding the mjx-container that handles the menu and expression explorer events. This means you can't run the explorer on these expressions (as you can in the previous example), but that is probably not something that people would do anyway.