mathlive
Version:
Render and edit beautifully typeset math
375 lines (327 loc) • 13.7 kB
JavaScript
/* eslint no-console:0 */
import '../core/mathAtom.js'; // eslint-disable-line no-unused-vars
import Definitions from '../core/definitions.js';
function findEndOfMath(delimiter, text, startIndex) {
// Adapted from
// https://github.com/Khan/perseus/blob/master/src/perseus-markdown.jsx
let index = startIndex;
let braceLevel = 0;
const delimLength = delimiter.length;
while (index < text.length) {
const character = text[index];
if (braceLevel <= 0 &&
text.slice(index, index + delimLength) === delimiter) {
return index;
} else if (character === '\\') {
index++;
} else if (character === '{') {
braceLevel++;
} else if (character === '}') {
braceLevel--;
}
index++;
}
return -1;
}
function splitAtDelimiters(startData, leftDelim, rightDelim, mathstyle) {
const finalData = [];
for (let i = 0; i < startData.length; i++) {
if (startData[i].type === 'text') {
const text = startData[i].data;
let lookingForLeft = true;
let currIndex = 0;
let nextIndex;
nextIndex = text.indexOf(leftDelim);
if (nextIndex !== -1) {
currIndex = nextIndex;
if (currIndex > 0) {
finalData.push({
type: 'text',
data: text.slice(0, currIndex)
});
}
lookingForLeft = false;
}
let done = false;
while (!done) {
if (lookingForLeft) {
nextIndex = text.indexOf(leftDelim, currIndex);
if (nextIndex === -1) {
done = true;
break;
}
if (currIndex !== nextIndex) {
finalData.push({
type: 'text',
data: text.slice(currIndex, nextIndex)
});
}
currIndex = nextIndex;
} else {
nextIndex = findEndOfMath(
rightDelim,
text,
currIndex + leftDelim.length);
if (nextIndex === -1) {
done = true;
break;
}
finalData.push({
type: 'math',
data: text.slice(
currIndex + leftDelim.length,
nextIndex),
rawData: text.slice(
currIndex,
nextIndex + rightDelim.length),
mathstyle: mathstyle
});
currIndex = nextIndex + rightDelim.length;
}
lookingForLeft = !lookingForLeft;
}
if (currIndex < text.length) {
finalData.push({
type: 'text',
data: text.slice(currIndex)
});
}
} else {
finalData.push(startData[i]);
}
}
return finalData;
}
function splitWithDelimiters(text, delimiters) {
let data = [{type: 'text', data: text}];
for (let i = 0; i < delimiters.inline.length; i++) {
const delimiter = delimiters.inline[i];
data = splitAtDelimiters(
data, delimiter[0], delimiter[1], 'textstyle');
}
for (let i = 0; i < delimiters.display.length; i++) {
const delimiter = delimiters.display[i];
data = splitAtDelimiters(
data, delimiter[0], delimiter[1], 'displaystyle');
}
return data;
}
function createMathMLNode(latex, options) {
// Create a node for AT (Assistive Technology, e.g. screen reader) to speak, etc.
// This node has a style that makes it be invisible to display but is seen by AT
const span = document.createElement('span');
try {
span.innerHTML = "<math xmlns='http://www.w3.org/1998/Math/MathML'>" +
options.renderToMathML(latex, options) +
"</math>";
} catch (e) {
console.error( 'Could not convert\'' + latex + '\' to MathML with ', e );
span.textContent = latex;
}
span.className = 'sr-only';
return span;
}
function createMarkupNode(text, options, mathstyle, createNodeOnFailure) {
// Create a node for displaying math.
// This is slightly ugly because in the case of failure to create the markup,
// sometimes a text node is desired and sometimes not.
// 'createTextNodeOnFailure' controls this and null is returned when no node is created.
// This node is made invisible to AT (screen readers)
let span = document.createElement('span');
span.setAttribute('aria-hidden','true');
if (options.preserveOriginalContent) {
span.setAttribute('data-' + options.namespace + 'original-content', text);
if (mathstyle) {
span.setAttribute('data-' + options.namespace + 'original-mathstyle', mathstyle);
}
}
try {
span.innerHTML = options.renderToMarkup(text, mathstyle || 'displaystyle', 'html', options.macros);
} catch (e) {
console.error( 'Could not parse\'' + text + '\' with ', e );
if (createNodeOnFailure) {
span = document.createTextNode(text);
} else {
return null;
}
}
return span;
}
function createAccessibleMarkupPair(text, mathstyle, options, createNodeOnFailure) {
// Create a math node (a span with an accessible component and a visual component)
// If there is an error in parsing the latex, 'createNodeOnFailure' controls whether
// 'null' is returned or an accessible node with the text used.
const markupNode = createMarkupNode(text, options, mathstyle, createNodeOnFailure);
if (markupNode && /\b(mathml|speakable-text)\b/i.test(options.renderAccessibleContent)) {
const fragment = document.createDocumentFragment();
if (/\bmathml\b/i.test(options.renderAccessibleContent) && options.renderToMathML) {
fragment.appendChild(createMathMLNode(text, options));
}
if (/\bspeakable-text\b/i.test(options.renderAccessibleContent) && options.renderToSpeakableText) {
const span = document.createElement('span');
span.innerHTML = options.renderToSpeakableText(text, options);
span.className = 'sr-only';
fragment.appendChild(span);
}
fragment.appendChild(markupNode);
return fragment;
}
return markupNode;
}
function scanText(text, options) {
// If the text starts with '\begin'...
// (this is a MathJAX behavior)
let fragment = null;
if (options.TeX.processEnvironments && /^\s*\\begin/.test(text)) {
fragment = document.createDocumentFragment();
fragment.appendChild(createAccessibleMarkupPair(text, undefined, options, true));
} else {
const data = splitWithDelimiters(text, options.TeX.delimiters);
if (data.length === 1 && data[0].type === 'text') {
// This text contains no math. No need to continue processing
return null;
}
fragment = document.createDocumentFragment();
for (let i = 0; i < data.length; i++) {
if (data[i].type === 'text') {
fragment.appendChild(document.createTextNode(data[i].data));
} else {
fragment.appendChild(createAccessibleMarkupPair(data[i].data, data[i].mathstyle, options, true));
}
}
}
return fragment;
}
function scanElement(elem, options) {
const originalContent = elem.getAttribute('data-' + options.namespace +
'original-content');
if (originalContent) {
const mathstyle = elem.getAttribute('data-' + options.namespace + 'mathstyle');
const span = createAccessibleMarkupPair(originalContent, mathstyle, options, false);
if (span != null) {
elem.textContent = '';
elem.appendChild(span);
}
return;
}
if (elem.childNodes.length === 1 && elem.childNodes[0].nodeType === 3) {
// This is a node with textual content only. Perhaps an opportunity
// to simplify and avoid creating extra nested elements...
const text = elem.childNodes[0].textContent;
if (options.TeX.processEnvironments && /^\s*\\begin/.test(text)) {
elem.textContent = '';
elem.appendChild( createAccessibleMarkupPair(text, undefined, options, true) );
return;
}
const data = splitWithDelimiters(text, options.TeX.delimiters);
if (data.length === 1 && data[0].type === 'math') {
// The entire content is a math expression: we can replace the content
// with the latex markup without creating additional wrappers.
elem.textContent = '';
elem.appendChild( createAccessibleMarkupPair(data[0].data, data[0].mathstyle, options, true) );
return;
} else if (data.length === 1 && data[0].type === 'text') {
// This element only contained text with no math. No need to
// do anything.
return;
}
}
for (let i = 0; i < elem.childNodes.length; i++) {
const childNode = elem.childNodes[i];
if (childNode.nodeType === 3) {
// A text node
// Look for math mode delimiters inside the text
const frag = scanText(childNode.textContent, options);
if (frag) {
i += frag.childNodes.length - 1;
elem.replaceChild(frag, childNode);
}
} else if (childNode.nodeType === 1) {
// An element node
const tag = childNode.nodeName.toLowerCase();
if (tag === 'script' &&
options.processScriptTypePattern.test(childNode.type)) {
let style = 'displaystyle';
for (const l of childNode.type.split(';')) {
const v = l.split('=');
if (v[0].toLowerCase() === 'mode') {
if (v[1].toLoweCase() === 'display') {
style = 'displaystyle';
} else {
style = 'textstyle';
}
}
}
const span = createAccessibleMarkupPair(childNode.textContent,
style, options, true)
childNode.parentNode.replaceChild(span, childNode);
} else {
// Element node
const shouldRender =
options.processClassPattern.test(childNode.className) ||
!(options.skipTags.includes(tag) ||
options.ignoreClassPattern.test(childNode.className));
if (shouldRender) {
scanElement(childNode, options);
}
}
}
// Otherwise, it's something else, and ignore it.
}
}
const defaultOptions = {
// Optional namespace for the `data-` attributes.
namespace: '',
// Name of tags whose content will not be scanned for math delimiters
skipTags: ['noscript', 'style', 'textarea', 'pre', 'code',
'annotation', 'annotation-xml'],
// <script> tags of the following types will be processed. Others, ignored.
processScriptType: "math/tex",
// Regex pattern of the class name of elements whose contents should not
// be processed
ignoreClass: "tex2jax_ignore",
// Regex pattern of the class name of elements whose contents should
// be processed when they appear inside ones that are ignored.
processClass: "tex2jax_process",
// Indicate whether to preserve or discard the original content of the
// elements being rendered in a 'data-original-content' attribute.
preserveOriginalContent: true,
// Indicate the format to use to render accessible content
renderAccessibleContent: 'mathml',
TeX: {
disabled: false,
processEnvironments : true,
delimiters: {
inline: [['\\(','\\)']],
display: [['$$', '$$'], ['\\[', '\\]']],
}
}
}
function renderMathInElement(elem, options) {
try {
options = Object.assign({}, defaultOptions, options);
options.ignoreClassPattern = new RegExp(options.ignoreClass);
options.processClassPattern = new RegExp(options.processClass);
options.processScriptTypePattern = new RegExp(options.processScriptType);
options.macros = Definitions.MACROS;
// Validate the namespace (used for `data-` attributes)
if (options.namespace) {
if (!/^[a-z]+[-]?$/.test(options.namespace)) {
throw Error('options.namespace must be a string of lowercase characters only');
}
if (!/-$/.test(options.namespace)) {
options.namespace += '-';
}
}
scanElement(elem, options);
} catch(e) {
if (e instanceof Error) {
console.error('renderMathInElement(): ' + e.message);
} else {
console.error('renderMathInElement(): Could not render math for element ' + elem);
}
}
}
export default {
renderMathInElement,
}