MathJax icon indicating copy to clipboard operation
MathJax copied to clipboard

Issue with MathJax on server side across multiple page renders and impacting the rendering of pages after the first - essential CSS styles are missing from the output.

Open gorai-sunil opened this issue 8 months ago • 2 comments

Issue Summary

Here issue is when I convert MathJax for a single XHTML file, then all respective styles are generated, but with multiple files in the same XHTML file doesn't create all styles for MathJax after the first file.

Steps to Reproduce:

  1. Extract an EPUB/ZIP file and provide the path for each extracted file.
  2. Convert MathJax tags using the MathJax object for each page render.
  3. Write output files

The below MathJax styles are not generating in the case of multiple file conversions, while they generate when we process a single file or the first file.

mjx-c.mjx-c2A7D.TEX-A::before { padding: 0.636em 0.778em 0.138em 0; content: "\2A7D"; } mjx-c.mjx-c3E::before { padding: 0.54em 0.778em 0.04em 0; content: ">"; } mjx-c.mjx-c2A7E.TEX-A::before { padding: 0.636em 0.778em 0.138em 0; content: "\2A7E"; } mjx-c.mjx-c2260::before { padding: 0.716em 0.778em 0.215em 0; content: "\2260"; }
mjx-c.mjx-c31::before { padding: 0.666em 0.5em 0 0; content: "1"; } mjx-c.mjx-c30::before { padding: 0.666em 0.5em 0.022em 0; content: "0"; } mjx-c.mjx-cD7::before { padding: 0.491em 0.778em 0 0; content: "\D7"; } mjx-c.mjx-c2009::before { padding: 0 0.167em 0 0; content: ""; } mjx-c.mjx-c3D::before { padding: 0.583em 0.778em 0.082em 0; content: "="; } mjx-c.mjx-c2C::before { padding: 0.121em 0.278em 0.194em 0; content: ","; } mjx-c.mjx-c3C::before { padding: 0.54em 0.778em 0.04em 0; content: "<"; }

Technical details:

  • MathJax Version: 3.2
  • Node.js v20

I am using the following MathJax configuration:

async function renderMathML(INPUT_PATH, callback) {
 try {
     const FILENAME = path.basename(INPUT_PATH);
     const OUTPUT_PATH = path.join('output', FILENAME);
    if (!fs.existsSync(INPUT_PATH)) {
      return callback(new Error(`Input file not found: ${INPUT_PATH}`));
    }

    const HTML_FILE = fs.readFileSync(INPUT_PATH, 'utf-8');

    // Initialize MathJax instance
      const MathJax = require('mathjax');
      mjInstance = await MathJax.init({
        loader: {
          load: ['adaptors/liteDOM', 'input/mml', 'output/chtml', 'a11y/assistive-mml']
        },
        chtml: {
          fontURL: FONT_URL,
        },
        startup: {
          document: HTML_FILE
        }
      });
    

    // Get content
    const adaptor = mjInstance.startup.adaptor;
    const doc = mjInstance.startup.document;

    if (Array.from(doc.math).length === 0) adaptor.remove(doc.outputJax.chtmlStyles);
    const renderedDoc = adaptor.outerHTML(adaptor.root(doc.document));
     fs.writeFileSync(OUTPUT_PATH, renderedDoc);
    // Return the output
    callback(null, renderedDoc);
  } catch (error) {
    console.error("MathJax Error:", error);
    callback(error);
  }
}
module.exports = { renderMathML };

and calling the above function via a file in for loop as below:

      return new Promise((resolve, reject) => {
        renderMathML(folderpath, (err, result) => {
          if (err) reject(err);
          else resolve(result);
        });
      });
    }
   
    for (const zipEntry of zipEntries) {
      if (zipEntry.entryName.endsWith(".xhtml")) {
        let found = MathJaxFileList.find((file) => zipEntry.entryName.endsWith(file)); 
        if (found) {
          let fileData = zip.readAsText(zipEntry.entryName, 'utf8');
          if (!fileData.match(/mathjax/g)) {
            let folderpath = path.join("/tmp/" + s3FileName.replace(".epub", ""), zipEntry.entryName);
              try {
                    const renderedHTML = await renderMathMLAsync(folderpath);
                    zip.updateFile(zipEntry, Buffer.from(renderedHTML, 'utf8'));
              } catch (err) {
                console.error("MathJax render error", err);
              }
          }
        }
      }
    }

Supporting information:

Image

gorai-sunil avatar Jun 11 '25 11:06 gorai-sunil

The problem you are facing is that the MathJax.init() function was not intended to be used more than once, and there are some side-effects of calling it that aren't reset before the next tine you call it. In particular, when the output/chtml component is loaded, it sets up an instance of the font that is being used and passes that to the CHTML output jax in the chtml.font option, if there isn't one specified already. When you call MathJax.init(), your options are merged into the options in the MathJax global object, and so that font instance is stored there. When you call MathJax.init() a second time, the original font instance is already there, so it is not replaced, and is reused by the CHTML output jax for the second document.

The font object maintains a cache indicating which characters have been used so that the CSS stylesheet can be updated to include any new characters that are needed for new expressions when they are typeset. Because your second document is using the same font object as the first, it thinks that any characters used in the first document have already been set up, and so doesn't add them to the second document. That is why you are missing some characters in the second and subsequent documents. These will be any that appeared in the earlier documents.

There are several possible solutions. One is to call

doc.outputJax.clearCache()

before calling your callback() function. That will make sure that the next document will have a clean cache to work from.

Alternatively, you could change

const MathJax = require('mathjax');
mjInstance = await MathJax.init({

to be

const MJX = require('mathjax');
if (MathJax.config.chtml?.font) {
  MathJax.config.chtml.font = new MathJax.config.chtml.font.constructor();
}
mjInstance = await MJX.init({

(You have to change the local variable name so that you can access the global MathJax variable.)

Either of these should let you do what you are trying to do.

dpvc avatar Jun 12 '25 15:06 dpvc

As an aside, your code is a bit more complicated than it needs to be, as you seem to be working harder than necessary to manage promises. Your async function already returns a promise automatically, so you don't need a separate renderMathMLAsync() function. You can simply do

const MathJax = require('mathjax');

async function renderMath(INPUT_PATH) {
  const FILENAME = path.basename(INPUT_PATH);
  const OUTPUT_PATH = path.join('output', FILENAME);
  if (!fs.existsSync(INPUT_PATH)) {
    throw new Error(`Input file not found: ${INPUT_PATH}`);
  }

  const HTML_FILE = fs.readFileSync(INPUT_PATH, 'utf-8');

  // Initialize MathJax instance
  mjInstance = await MathJax.init({
    loader: {
      load: ['adaptors/liteDOM', 'input/tex', 'output/chtml']
    },
    chtml: {
          fontURL: FONT_URL,
    },
    startup: {
      document: html
    }
  });

  // Get content
  const adaptor = mjInstance.startup.adaptor;
  const doc = mjInstance.startup.document;
  doc.outputJax.clearCache();
  if (Array.from(doc.math).length === 0) adaptor.remove(doc.outputJax.chtmlStyles);
  const renderedDoc = adaptor.outerHTML(adaptor.root(doc.document));
  return renderedDoc;
};

and then use

const renderedHTML = await renderMathML(folderpath);

later directly. Just a thought.

dpvc avatar Jun 12 '25 15:06 dpvc