Skip to content
138 changes: 123 additions & 15 deletions bundle/latex2html5.bundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,10 +193,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.init = exports.macros = exports.math = exports.verbatim = exports.list = exports.enumerate = exports.nicebox = exports.pspicture = void 0;
exports.init = exports.DEFAULT_CONFIG = exports.macros = exports.math = exports.verbatim = exports.list = exports.enumerate = exports.nicebox = exports.pspicture = void 0;
exports.default = render;
const latex2js_1 = __importDefault(require("latex2js"));
const mathjaxjs_1 = require("mathjaxjs");
Object.defineProperty(exports, "DEFAULT_CONFIG", { enumerable: true, get: function () { return mathjaxjs_1.DEFAULT_CONFIG; } });
const pspicture_js_1 = __importDefault(require("./components/pspicture.js"));
exports.pspicture = pspicture_js_1.default;
const nicebox_js_1 = __importDefault(require("./components/nicebox.js"));
Expand All @@ -212,7 +213,7 @@ exports.math = math_js_1.default;
const macros_1 = __importDefault(require("./components/macros"));
exports.macros = macros_1.default;
const ELEMENTS = { pspicture: pspicture_js_1.default, nicebox: nicebox_js_1.default, enumerate: enumerate_js_1.default, itemize: list_js_1.default, description: list_js_1.default, verbatim: verbatim_js_1.default, math: math_js_1.default, macros: macros_1.default };
function render(tex, resolve) {
function render(tex, resolve, config) {
const done = () => {
const latex = new latex2js_1.default();
const parsed = latex.parse(tex);
Expand All @@ -231,16 +232,16 @@ function render(tex, resolve) {
if ((0, mathjaxjs_1.getMathJax)()) {
return done();
}
(0, mathjaxjs_1.loadMathJax)(done);
(0, mathjaxjs_1.loadMathJax)(done, config);
}
const init = () => {
(0, mathjaxjs_1.loadMathJax)();
const init = (config) => {
(0, mathjaxjs_1.loadMathJax)(undefined, config);
document.querySelectorAll('script[type="text/latex"]').forEach((el) => {
render(el.innerHTML, (div) => {
if (el.parentNode) {
el.parentNode.insertBefore(div, el.nextSibling);
}
});
}, config);
});
};
exports.init = init;
Expand Down Expand Up @@ -3314,7 +3315,17 @@ exports.default = String.raw `
},{}],19:[function(require,module,exports){
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.loadMathJax = exports.getMathJax = exports.DEFAULT_CONFIG = void 0;
exports.loadMathJax = exports.getMathJax = exports.DEFAULT_CONFIG = exports.DEFAULT_SCRIPT_URL = void 0;
/**
* Where MathJax is loaded from unless a caller overrides it with
* `config.scriptURL`.
*
* Pinned rather than floating on a major tag, so a build is reproducible and
* the URL can carry an integrity hash. Being a single constant is what made
* the move to MathJax 4 a one-line change: v4 dropped the `es5/` directory, so
* the path shape moved as well as the version.
*/
exports.DEFAULT_SCRIPT_URL = 'https://cdn.jsdelivr.net/npm/mathjax@4.1.3/tex-chtml.js';
exports.DEFAULT_CONFIG = {
tex: {
inlineMath: [['$', '$'], ['\\(', '\\)']],
Expand All @@ -3337,6 +3348,34 @@ exports.DEFAULT_CONFIG = {
}
}
};
/**
* Merges an override into a base, recursively, without mutating either.
*
* The config is nested more than one level — `chtml.linebreaks` holds both
* `automatic` and `width` — so merging only the top level silently drops the
* siblings of whatever a caller overrides: passing
* `{ chtml: { linebreaks: { width: "80%" } } }` lost `automatic: true`. The
* `MathJaxConfig` type says any subset may be overridden, and this is what
* makes that true.
*
* Arrays replace rather than merge: `tex.packages` and `tex.inlineMath` are
* whole values, and concatenating them would silently keep a default a caller
* meant to remove.
*/
function deepMerge(base, override) {
if (override === undefined)
return base;
const mergeable = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && typeof v !== 'function';
if (!mergeable(base) || !mergeable(override))
return override;
const out = { ...base };
for (const key of Object.keys(override)) {
out[key] = mergeable(base[key]) && mergeable(override[key])
? deepMerge(base[key], override[key])
: override[key];
}
return out;
}
let mathJaxInstance = null;
const getMathJax = () => mathJaxInstance || globalThis.MathJax;
exports.getMathJax = getMathJax;
Expand All @@ -3345,28 +3384,51 @@ const loadMathJax = async (callback = () => { }, config = exports.DEFAULT_CONFIG
callback();
return;
}
if (globalThis.MathJax) {
mathJaxInstance = globalThis.MathJax;
// Presence is not readiness. `window.MathJax` holds the configuration object
// long before the library that reads it has loaded, and pre-configuring the
// global is the documented way to set MathJax up — so treating any value
// here as a loaded library meant a page that configured MathJax itself never
// got the script injected at all, and MathJax never loaded.
const existing = globalThis.MathJax;
if (existing && typeof existing.typesetPromise === 'function') {
mathJaxInstance = existing;
callback();
return;
}
// Someone has already asked for the script; wait for that one rather than
// adding a second copy.
if (typeof document !== 'undefined' && document.getElementById('MathJax-script')) {
callback();
return;
}
// scriptURL is a loader concern, not a MathJax one: keep it out of the
// config object that is handed to MathJax itself.
const { scriptURL = exports.DEFAULT_SCRIPT_URL, ...mathjaxConfig } = config;
// Three sources, weakest first: our defaults, then any configuration the page
// had already put on the global, then what this caller passed. Without the
// merge a caller passing only { scriptURL } would drop the tex setup — ams,
// tags, equation numbering — entirely; without folding in `existing`, a page
// that pre-configured MathJax would have its settings thrown away by the
// very call that finally loads the library for it.
const preconfigured = existing && typeof existing === 'object' ? existing : {};
const merged = deepMerge(deepMerge(exports.DEFAULT_CONFIG, preconfigured), mathjaxConfig);
try {
globalThis.MathJax = {
...config,
...merged,
startup: {
...config.startup,
...merged.startup,
ready: () => {
globalThis.MathJax.startup.defaultReady();
mathJaxInstance = globalThis.MathJax;
if (config.startup?.ready) {
config.startup.ready();
if (merged.startup.ready) {
merged.startup.ready();
}
callback();
}
}
};
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js';
script.src = scriptURL;
script.async = true;
script.id = 'MathJax-script';
script.onload = () => {
Expand Down Expand Up @@ -3441,6 +3503,50 @@ exports.default = {
Object.defineProperty(exports, "__esModule", { value: true });
exports.arrow = arrow;
const utils_1 = require("@latex2js/utils");
/**
* How long to keep waiting for a MathJax that is present but not yet usable.
* Bounded, so a page that configures MathJax and never loads it still shows
* its labels rather than hiding them forever.
*/
const MATHJAX_READY_TIMEOUT_MS = 10000;
/**
* Resolves to a MathJax that can actually typeset, or null if none will be.
*
* `window.MathJax` is present long before it can typeset anything: the loader
* assigns the configuration object to the global and only then injects the CDN
* script, so between those two moments the global exists and `typesetPromise`
* does not. Reading that capability once, synchronously, therefore fails on a
* cold load and succeeds on a warm one — which is why an rput label sat
* off-centre on first paint and corrected itself on reload. It was centred on
* the width of the raw LaTeX, and never measured again once MathJax arrived
* and replaced it with the formula.
*
* The absence of the global is a different case from a global that is not
* ready: a page with no MathJax at all must show its labels immediately, so
* only the second waits.
*
* @returns the usable MathJax, or null when there is none to wait for
*/
function mathJaxWhenReady() {
const usable = (mj) => (mj && typeof mj.typesetPromise === 'function' ? mj : null);
const now = globalThis.MathJax;
if (!now)
return Promise.resolve(null);
if (usable(now))
return Promise.resolve(now);
return new Promise((resolve) => {
const started = Date.now();
const poll = () => {
const ready = usable(globalThis.MathJax);
if (ready)
return resolve(ready);
if (Date.now() - started >= MATHJAX_READY_TIMEOUT_MS)
return resolve(null);
setTimeout(poll, 50);
};
poll();
});
}
function arrow(x1, y1, x2, y2, arrowscale) {
var t = Math.PI / 6;
// arrowscale is a multiplier on the 8px default head size; anything that
Expand Down Expand Up @@ -4495,7 +4601,9 @@ const psgraph = {
};
// Enhanced MathJax processing with better async handling
const processContent = async () => {
const mathJax = window.MathJax;
// Awaited, not read once: the global is assigned before the library it
// names has loaded, so a synchronous check races the CDN.
const mathJax = await mathJaxWhenReady();
if (mathJax && mathJax.typesetPromise) {
try {
// Set content before MathJax processing
Expand Down
30 changes: 19 additions & 11 deletions packages/html5/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import LaTeX2JS from 'latex2js';
import { getMathJax, loadMathJax } from 'mathjaxjs';
import { getMathJax, loadMathJax, DEFAULT_CONFIG, type MathJaxConfig } from 'mathjaxjs';
import pspicture from './components/pspicture.js';
import nicebox from './components/nicebox.js';
import enumerate from './components/enumerate.js';
Expand All @@ -10,9 +10,13 @@ import macros from './components/macros';

const ELEMENTS = { pspicture, nicebox, enumerate, itemize: list, description: list, verbatim, math, macros };

export { pspicture, nicebox, enumerate, list, verbatim, math, macros };
export { pspicture, nicebox, enumerate, list, verbatim, math, macros, DEFAULT_CONFIG };

export default function render(tex: string, resolve: (div: HTMLDivElement) => void): void {
export default function render(
tex: string,
resolve: (div: HTMLDivElement) => void,
config?: MathJaxConfig
): void {
const done = () => {
const latex = new LaTeX2JS();
const parsed = latex.parse(tex);
Expand All @@ -32,16 +36,20 @@ export default function render(tex: string, resolve: (div: HTMLDivElement) => vo
if (getMathJax()) {
return done();
}
loadMathJax(done);
loadMathJax(done, config);
}

export const init = (): void => {
loadMathJax();
export const init = (config?: MathJaxConfig): void => {
loadMathJax(undefined, config);
document.querySelectorAll('script[type="text/latex"]').forEach((el) => {
render(el.innerHTML, (div: HTMLDivElement) => {
if (el.parentNode) {
el.parentNode.insertBefore(div, el.nextSibling);
}
});
render(
el.innerHTML,
(div: HTMLDivElement) => {
if (el.parentNode) {
el.parentNode.insertBefore(div, el.nextSibling);
}
},
config
);
});
};
121 changes: 121 additions & 0 deletions packages/html5/test/rput-mathjax-race.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/** @jest-environment jsdom */
import LaTeX2JS from 'latex2js';
import pspicture from '../src/components/pspicture';

/**
* An `\rput` label is centred on its coordinate by measuring the element and
* subtracting half its size. That measurement is only meaningful once MathJax
* has replaced the LaTeX source with the typeset formula, because the two have
* very different widths.
*
* The check for MathJax was read once, synchronously, at the moment the label
* was created:
*
* const mathJax = window.MathJax;
* if (mathJax && mathJax.typesetPromise) { ... } else { raw HTML }
*
* `window.MathJax` is present long before it can typeset anything. The loader
* assigns the configuration object to the global and only then injects the CDN
* script, so in between the global exists and `typesetPromise` does not. On a
* cold load the label took the else branch, centred itself on the width of the
* raw LaTeX, and was never measured again once MathJax arrived and swapped in
* the formula. On a reload the script came from cache and won the race, which
* is why it looked like a first-render-only bug.
*/
function stubViewport(w: number): void {
Object.defineProperty(document.documentElement, 'clientWidth', { value: w, configurable: true });
Object.defineProperty(window, 'innerWidth', { value: w, configurable: true });
}

function render(tex: string): HTMLElement {
const latex = new LaTeX2JS();
const env = latex.parse(tex).find((e: any) => e.type === 'pspicture');
expect(env).toBeDefined();
const div = pspicture(env);
document.body.appendChild(div);
return div;
}

const PICTURE = `\\begin{pspicture}(0,0)(4,4)
\\rput(2,2){$x^2$}
\\end{pspicture}`;

const tick = (ms: number) => new Promise((r) => setTimeout(r, ms));

beforeEach(() => {
stubViewport(1200);
document.body.innerHTML = '';
delete (window as any).MathJax;
});

afterEach(() => {
delete (window as any).MathJax;
});

describe('an rput label waits for MathJax to become able to typeset', () => {
it('typesets a label created while MathJax is still loading', async () => {
// Exactly the state the loader leaves behind: the configuration object is
// on the global, the CDN script has not executed yet.
(window as any).MathJax = { tex: { inlineMath: [['$', '$']] } };

render(PICTURE);

// The label is created on a requestAnimationFrame, so the script has to
// land well after that to reproduce a cold load — a CDN fetch takes
// hundreds of milliseconds, not one frame.
await tick(150);
const typeset: Element[] = [];
(window as any).MathJax = {
typesetPromise: (els: Element[]) => {
typeset.push(...els);
return Promise.resolve();
},
};

await tick(400);
expect(typeset.length).toBeGreaterThan(0);
});

it('typesets immediately when MathJax is already usable', async () => {
const typeset: Element[] = [];
(window as any).MathJax = {
typesetPromise: (els: Element[]) => {
typeset.push(...els);
return Promise.resolve();
},
};

render(PICTURE);
await tick(120);
expect(typeset.length).toBeGreaterThan(0);
});

it('does not wait when there is no MathJax at all', async () => {
// A page that never loads MathJax must still show its labels promptly,
// so the absence of the global is a different case from a global that is
// not ready yet.
render(PICTURE);
await tick(120);
const label = document.querySelector('.math') as HTMLElement;
expect(label).not.toBeNull();
expect(label.style.visibility).toBe('visible');
});

it('shows the label even if MathJax never finishes loading', async () => {
// Bounded, not indefinite: a page that configures MathJax and never loads
// the library must not leave its labels hidden forever. Driven on fake
// timers so the bound can be reached without the test waiting it out.
jest.useFakeTimers();
try {
(window as any).MathJax = { tex: {} };
render(PICTURE);
await jest.advanceTimersByTimeAsync(30000);
const label = document.querySelector('.math') as HTMLElement;
expect(label).not.toBeNull();
expect(label.textContent).toContain('x^2');
expect(label.style.visibility).toBe('visible');
} finally {
jest.useRealTimers();
}
});
});
Loading
Loading