/************************************************************* * * Copyright (c) 2017 The MathJax Consortium * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * @fileoverview Implements the interface and abstract class for MathDocument objects * * @author dpvc@mathjax.org (Davide Cervone) */ import {userOptions, defaultOptions, OptionList, expandable} from '../util/Options.js'; import {InputJax, AbstractInputJax} from './InputJax.js'; import {OutputJax, AbstractOutputJax} from './OutputJax.js'; import {MathList, AbstractMathList} from './MathList.js'; import {MathItem, AbstractMathItem, STATE} from './MathItem.js'; import {MmlNode, TextNode} from './MmlTree/MmlNode.js'; import {MmlFactory} from '../core/MmlTree/MmlFactory.js'; import {DOMAdaptor} from '../core/DOMAdaptor.js'; import {BitField, BitFieldClass} from '../util/BitField.js'; import {PrioritizedList, PrioritizedListItem} from '../util/PrioritizedList.js'; /*****************************************************************/ /** * A function to call while rendering a document (usually calls a MathDocument method) * * @template N The HTMLElement node class * @template T The Text node class * @template D The Document class */ export type RenderDoc = (document: MathDocument) => boolean; /** * A function to call while rendering a MathItem (usually calls one of its methods) * * @template N The HTMLElement node class * @template T The Text node class * @template D The Document class */ export type RenderMath = (math: MathItem, document: MathDocument) => boolean; /** * The data for an action to perform during rendering or conversion * * @template N The HTMLElement node class * @template T The Text node class * @template D The Document class */ export type RenderData = { id: string, // The name for the action renderDoc: RenderDoc, // The action to take during a render() call renderMath: RenderMath, // The action to take during a rerender() or convert() call convert: boolean // Whether the action is to be used during convert() }; /** * The data used to define a render action in configurations and options objects * (the key is used as the id, the number in the data below is the priority, and * the remainind data is as described below; if no boolean is given, convert = true * by default) * * @template N The HTMLElement node class * @template T The Text node class * @template D The Document class */ export type RenderAction = [number] | // id (i.e., key) is method name to use [number, string] | // string is method to call [number, string, string] | // the strings are methods names for doc and math [number, RenderDoc, RenderMath] | // explicit functions for doc and math [number, boolean] | // same as first above, with boolean for convert [number, string, boolean] | // same as second above, with boolean for convert [number, string, string, boolean] | // same as third above, with boolean for convert [number, RenderDoc, RenderMath, boolean]; // same as forth above, with boolean for convert /** * An object representing a collection of rendering actions (id's tied to priority-and-method data) * * @template N The HTMLElement node class * @template T The Text node class * @template D The Document class */ export type RenderActions = {[id: string]: RenderAction}; /** * Implements a prioritized list of render actions. Extensions can add actions to the list * to make it easy to extend the normal typesetting and conversion operations. * * @template N The HTMLElement node class * @template T The Text node class * @template D The Document class */ export class RenderList extends PrioritizedList> { /** * Creates a new RenderList from an initial list of rendering actions * * @param {RenderActions} The list of actions to take during render(), rerender(), and convert() calls * @returns {RenderList} The newly created prioritied list */ public static create(actions: RenderActions) { const list = new this(); for (const id of Object.keys(actions)) { const [action, priority] = this.action(id, actions[id]); if (priority) { list.add(action, priority); } } return list; } /** * Parses a RenderAction to produce the correspinding RenderData item * (e.g., turn method names into actual functions that call the method) * * @param {string} id The id of the action * @param {RenderAction} action The RenderAction defining the action * @returns {RenderData} The corresponding RenderData definition for the action */ public static action(id: string, action: RenderAction) { let renderDoc, renderMath; let convert = true; let priority = action[0]; if (action.length === 1 || typeof action[1] === 'boolean') { action.length === 2 && (convert = action[1] as boolean); [renderDoc, renderMath] = this.methodActions(id); } else if (typeof action[1] === 'string') { if (typeof action[2] === 'string') { action.length === 4 && (convert = action[3] as boolean); const [method1, method2] = action.slice(1) as [string, string]; [renderDoc, renderMath] = this.methodActions(method1, method2); } else { action.length === 3 && (convert = action[2] as boolean); [renderDoc, renderMath] = this.methodActions(action[1] as string); } } else { action.length === 4 && (convert = action[3] as boolean); [renderDoc, renderMath] = action.slice(1) as [RenderDoc, RenderMath]; } return [{id, renderDoc, renderMath, convert}, priority] as [RenderData, number]; } /** * Produces the doc and math actions for the given method name(s) * (a blank name is a no-op) * * @param {string} method1 The method to use for the render() call * @param {string} method1 The method to use for the rerender() and convert() calls */ protected static methodActions(method1: string, method2: string = method1) { return [ (document: any) => {method1 && document[method1](); return false}, (math: any, document: any) => {method2 && math[method2](document); return false} ]; } /** * Perform the document-level rendering functions * * @param {MathDocument} document The MathDocument whose methods are to be called * @param {number=} start The state at which to start rendering (default is UNPROCESSED) */ public renderDoc(document: MathDocument, start: number = STATE.UNPROCESSED) { for (const item of this.items) { if (item.priority >= start) { if (item.item.renderDoc(document)) return; } } } /** * Perform the MathItem-level rendering functions * * @param {MathItem} math The MathItem whose methods are to be called * @param {MathDocument} document The MathDocument to pass to the MathItem methods * @param {number=} start The state at which to start rendering (default is UNPROCESSED) */ public renderMath(math: MathItem, document: MathDocument, start: number = STATE.UNPROCESSED) { for (const item of this.items) { if (item.priority >= start) { if (item.item.renderMath(math, document)) return; } } } /** * Perform the MathItem-level conversion functions * * @param {MathItem} math The MathItem whose methods are to be called * @param {MathDocument} document The MathDocument to pass to the MathItem methods * @param {number=} end The state at which to end rendering (default is LAST) */ public renderConvert(math: MathItem, document: MathDocument, end = STATE.LAST) { for (const item of this.items) { if (item.priority >= end) return; if (item.item.convert) { if (item.item.renderMath(math, document)) return; } } } /** * Find an entry in the list with a given ID * * @param {string} id The id to search for * @returns {RenderData|null} The data for the given id, if found, or null */ public findID(id: string) { for (const item of this.items) { if (item.item.id === id) { return item.item; } } return null; } } /*****************************************************************/ /** * The MathDocument interface * * The MathDocument is created by MathJax.Document() and holds the * document, the math found in it, and so on. The methods of the * MathDocument all return the MathDocument itself, so you can * chain the method calls. E.g., * * const html = MathJax.Document('...'); * html.findMath() * .compile() * .getMetrics() * .typeset() * .updateDocument(); * * The MathDocument is the main interface for page authors to * interact with MathJax. * * @template N The HTMLElement node class * @template T The Text node class * @template D The Document class */ export interface MathDocument { /** * The document being processed (e.g., DOM document, or Markdown string) */ document: D; /** * The kind of MathDocument (e.g., "HTML") */ kind: string; /** * The options for the document */ options: OptionList; /** * The list of MathItems found in this page */ math: MathList; /** * The list of actions to take durring a render() or convert() call */ renderActions: RenderList; /** * This object tracks what operations have been performed, so that (when * asynchronous operations are used), the ones that have already been * completed won't be performed again. */ processed: BitField; /** * An array of input jax to run on the document */ inputJax: InputJax[]; /** * The output jax to use for the document */ outputJax: OutputJax; /** * The DOM adaotor to use for input and output */ adaptor: DOMAdaptor; /** * The MmlFactory to be used for input jax and error processing */ mmlFactory: MmlFactory; /** * @param {string} id The id of the action to add * @param {any[]} action The RenderAction to take */ addRenderAction(id: string, ...action: any[]): void; /** * @param {string} id The id of the action to remove */ removeRenderAction(id: string): void; /** * Perform the renderActions on the document */ render(): MathDocument; /** * Rerender the MathItems on the page * * @param {number=} start The state to start rerendering at * @return {MathDocument} The math document instance */ rerender(start?: number): MathDocument; /** * Convert a math string to the document's output format * * @param {string} math The math string to convert * @params {OptionList} optoins The options for the conversion (e.g., format, ex, em, etc.) * @return {MmlNode|N} The MmlNode or N node for the converted content */ convert(math: string, options?: OptionList): MmlNode | N; /** * Locates the math in the document and constructs the MathList * for the document. * * @param {OptionList} options The options for locating the math * @return {MathDocument} The math document instance */ findMath(options?: OptionList): MathDocument; /** * Calls the input jax to process the MathItems in the MathList * * @return {MathDocument} The math document instance */ compile(): MathDocument; /** * Gets the metric information for the MathItems * * @return {MathDocument} The math document instance */ getMetrics(): MathDocument; /** * Calls the output jax to process the compiled math in the MathList * * @return {MathDocument} The math document instance */ typeset(): MathDocument; /** * Updates the document to include the typeset math * * @return {MathDocument} The math document instance */ updateDocument(): MathDocument; /** * Removes the typeset math from the document * * @param {boolean} restore True if the original math should be put * back into the document as well * @return {MathDocument} The math document instance */ removeFromDocument(restore?: boolean): MathDocument; /** * Set the state of the document (allowing you to roll back * the state to a previous one, if needed). * * @param {boolean} restore True if the original math should be put * back into the document during the rollback * @return {MathDocument} The math document instance */ state(state: number, restore?: boolean): MathDocument; /** * Rerender the MathItems on the page * * @param {number=} start The state to start rerendering at * @param {number=} end The state to end rerendering at * @return {MathDocument} The math document instance */ rerender(start?: number, end?: number): MathDocument; /** * Clear the processed values so that the document can be reprocessed * * @return {MathDocument} The math document instance */ reset(): MathDocument; /** * Reset the processed values and clear the MathList (so that new math * can be processed in the document). * * @return {MathDocument} The math document instance */ clear(): MathDocument; /** * Merges a MathList into the list for this document. * * @param {MathList} list The MathList to be merged into this document's list * @return {MathDocument} The math document instance */ concat(list: MathList): MathDocument; } /*****************************************************************/ /** * Defaults used when input jax isn't specified * * @template N The HTMLElement node class * @template T The Text node class * @template D The Document class */ class DefaultInputJax extends AbstractInputJax { /** * @override */ public compile(math: MathItem) { return null as MmlNode; } } /** * Defaults used when ouput jax isn't specified * * @template N The HTMLElement node class * @template T The Text node class * @template D The Document class */ class DefaultOutputJax extends AbstractOutputJax { /** * @override */ public typeset(math: MathItem, document: MathDocument = null) { return null as N; } public escaped(math: MathItem, document?: MathDocument) { return null as N; } } /** * Default for the MathList when one isn't specified * * @template N The HTMLElement node class * @template T The Text node class * @template D The Document class */ class DefaultMathList extends AbstractMathList {} /** * Default for the Mathitem when one isn't specified * * @template N The HTMLElement node class * @template T The Text node class * @template D The Document class */ class DefaultMathItem extends AbstractMathItem {} /*****************************************************************/ /** * Implements the abstract MathDocument class * * @template N The HTMLElement node class * @template T The Text node class * @template D The Document class */ export abstract class AbstractMathDocument implements MathDocument { public static KIND: string = 'MathDocument'; public static OPTIONS: OptionList = { OutputJax: null, // instance of an OutputJax for the document InputJax: null, // instance of an InputJax or an array of them MmlFactory: null, // instance of a MmlFactory for this document MathList: DefaultMathList, // constructor for a MathList to use for the document MathItem: DefaultMathItem, // constructor for a MathItem to use for the MathList compileError: (doc: AbstractMathDocument, math: MathItem, err: Error) => { doc.compileError(math, err); }, typesetError: (doc: AbstractMathDocument, math: MathItem, err: Error) => { doc.typesetError(math, err); }, renderActions: expandable({ find: [STATE.FINDMATH, (document: MathDocument) => { const elements = document.options.elements; document.findMath(elements ? {elements} : {}); }, () => {}, false], compile: [STATE.COMPILED], metrics: [STATE.METRICS, 'getMetrics', '', false], typeset: [STATE.TYPESET], update: [STATE.INSERTED, 'updateDocument', false], reset: [STATE.RESET, 'reset', '', false] }) as RenderActions }; /** * A bit-field for the actions that heve been processed */ public static ProcessBits = BitFieldClass('findMath', 'compile', 'getMetrics', 'typeset', 'updateDocument'); public document: D; public options: OptionList; public math: MathList; public renderActions: RenderList; public processed: BitField; public inputJax: InputJax[]; public outputJax: OutputJax; public adaptor: DOMAdaptor; public mmlFactory: MmlFactory; /** * @param {any} document The document (HTML string, parsed DOM, etc.) to be processed * @param {DOMAdaptor} adaptor The DOM adaptor for this document * @param {OptionList} options The options for this document * @constructor */ constructor (document: D, adaptor: DOMAdaptor, options: OptionList) { let CLASS = this.constructor as typeof AbstractMathDocument; this.document = document; this.options = userOptions(defaultOptions({}, CLASS.OPTIONS), options); this.math = new (this.options['MathList'] || DefaultMathList)(); this.renderActions = RenderList.create(this.options['renderActions']); this.processed = new AbstractMathDocument.ProcessBits(); this.outputJax = this.options['OutputJax'] || new DefaultOutputJax(); let inputJax = this.options['InputJax'] || [new DefaultInputJax()]; if (!Array.isArray(inputJax)) { inputJax = [inputJax]; } this.inputJax = inputJax; // // Pass the DOM adaptor to the jax // this.adaptor = adaptor; this.outputJax.setAdaptor(adaptor); this.inputJax.map(jax => jax.setAdaptor(adaptor)); // // Pass the MmlFactory to the jax // this.mmlFactory = this.options['MmlFactory'] || new MmlFactory(); this.inputJax.map(jax => jax.setMmlFactory(this.mmlFactory)); // // Do any initialization that requires adaptors or factories // this.outputJax.initialize(); this.inputJax.map(jax => jax.initialize()); } /** * @return {string} The kind of document */ public get kind() { return (this.constructor as typeof AbstractMathDocument).KIND; } /** * @override */ public addRenderAction(id: string, ...action: any[]) { const [fn, p] = RenderList.action(id, action as RenderAction); this.renderActions.add(fn, p); } /** * @override */ public removeRenderAction(id: string) { const action = this.renderActions.findID(id); if (action) { this.renderActions.remove(action); } } /** * @override */ public render() { this.renderActions.renderDoc(this); return this; } /** * @override */ public rerender(start: number = STATE.RERENDER) { this.state(start - 1); this.render(); return this; } /** * @override */ public convert(math: string, options: OptionList = {}) { const {format, display, end, ex, em, cwidth, lwidth, scale} = userOptions({ format: this.inputJax[0].name, display: true, end: STATE.LAST, em: 16, ex: 8, cwidth: 1000000, lwidth: 1000000, scale: 1 }, options); const jax = this.inputJax.reduce((jax, ijax) => (ijax.name === format ? ijax : jax), null); const mitem = new this.options.MathItem(math, jax, display); mitem.setMetrics(em, ex, cwidth, lwidth, scale); mitem.convert(this, end); return (mitem.typesetRoot || mitem.root); } /** * @override */ public findMath(options: OptionList = null) { this.processed.set('findMath'); return this; } /** * @override */ public compile() { if (!this.processed.isSet('compile')) { for (const math of this.math) { try { math.compile(this); } catch (err) { if (err.retry || err.restart) { throw err; } this.options['compileError'](this, math, err); math.inputData['error'] = err; } } this.processed.set('compile'); } return this; } /** * Produce an error using MmlNodes * * @param {MathItem} math The MathItem producing the error * @param {Error} err The Error object for the error */ public compileError(math: MathItem, err: Error) { math.root = this.mmlFactory.create('math', null, [ this.mmlFactory.create('merror', {'data-mjx-error': err.message}, [ this.mmlFactory.create('mtext', null, [ (this.mmlFactory.create('text') as TextNode).setText('Math input error') ]) ]) ]); if (math.display) { math.root.attributes.set('display', 'block'); } } /** * @override */ public typeset() { if (!this.processed.isSet('typeset')) { for (const math of this.math) { try { math.typeset(this); } catch (err) { if (err.retry || err.restart) { throw err; } this.options['typesetError'](this, math, err); math.outputData['error'] = err; } } this.processed.set('typeset'); } return this; } /** * Produce an error using HTML * * @param {MathItem} math The MathItem producing the error * @param {Error} err The Error object for the error */ public typesetError(math: MathItem, err: Error) { math.typesetRoot = this.adaptor.node('span', {'data-mjx-error': err.message}, [this.adaptor.text('Math output error')]); } /** * @override */ public getMetrics() { if (!this.processed.isSet('getMetrics')) { this.outputJax.getMetrics(this); this.processed.set('getMetrics'); } return this; } /** * @override */ public updateDocument() { if (!this.processed.isSet('updateDocument')) { for (const math of this.math.reversed()) { math.updateDocument(this); } this.processed.set('updateDocument'); } return this; } /** * @override */ public removeFromDocument(restore: boolean = false) { return this; } /** * @override */ public state(state: number, restore: boolean = false) { for (const math of this.math) { math.state(state, restore); } if (state < STATE.INSERTED) { this.processed.clear('updateDocument'); } if (state < STATE.TYPESET) { this.processed.clear('typeset'); this.processed.clear('getMetrics'); } if (state < STATE.COMPILED) { this.processed.clear('compile'); } return this; } /** * @override */ public reset() { this.processed.reset(); return this; } /** * @override */ public clear() { this.reset(); this.math.clear(); return this; } /** * @override */ public concat(list: MathList) { this.math.merge(list); return this; } } /** * The constructor type for a MathDocument * * @template D The MathDocument type this constructor is for */ export interface MathDocumentConstructor> { KIND: string; OPTIONS: OptionList; ProcessBits: typeof BitField; new (...args: any[]): D; };