/************************************************************* * * Copyright (c) 2018-2025 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. */ /** * @file Implements a class that marks complex items for collapsing * * @author dpvc@mathjax.org (Davide Cervone) */ import { MmlNode, AbstractMmlTokenNode, TextNode, } from '../../core/MmlTree/MmlNode.js'; import { PropertyList } from '../../core/Tree/Node.js'; import { ComplexityVisitor } from './visitor.js'; /*==========================================================================*/ /** * Function for checking if a node should be collapsible */ export type CollapseFunction = (node: MmlNode, complexity: number) => number; /** * Map of types to collase functions */ export type CollapseFunctionMap = Map; /** * A list of values indexed by semantic-type, possibly sub-indexed by semantic-role * * @template T The type of the indexed item */ export type TypeRole = { [type: string]: T | { [role: string]: T } }; /** * The class for determining of a subtree can be collapsed */ export class Collapse { /** * A constant to use to indicate no collapsing */ public static NOCOLLAPSE: number = 10000000; // really big complexity /** * The complexity object containing this one */ public complexity: ComplexityVisitor; /** * The cutt-off complexity values for when a structure * of the given type should collapse */ public cutoff: TypeRole = { identifier: 3, number: 3, text: 10, infixop: 15, relseq: 15, multirel: 15, fenced: 18, bigop: 20, integral: 20, fraction: 12, sqrt: 9, root: 12, vector: 15, matrix: 15, cases: 15, superscript: 9, subscript: 9, subsup: 9, punctuated: { endpunct: Collapse.NOCOLLAPSE, startpunct: Collapse.NOCOLLAPSE, value: 12, }, }; /** * These are the characters to use for the various collapsed elements * (if an object, then semantic-role is used to get the character * from the object) */ public marker: TypeRole = { identifier: 'x', number: '#', text: '...', appl: { 'limit function': 'lim', value: 'f()', }, fraction: '/', sqrt: '\u221A', root: '\u221A', superscript: '\u25FD\u02D9', subscript: '\u25FD.', subsup: '\u25FD:', vector: { binomial: '(:)', determinant: '|:|', value: '\u27E8:\u27E9', }, matrix: { squarematrix: '[::]', rowvector: '\u27E8\u22EF\u27E9', columnvector: '\u27E8\u22EE\u27E9', determinant: '|::|', value: '(::)', }, cases: '{:', infixop: { addition: '+', subtraction: '\u2212', multiplication: '\u22C5', implicit: '\u22C5', value: '+', }, punctuated: { text: '...', value: ',', }, }; /** * The type-to-function mapping for semantic types * * @param {MmlNode} node The current node * @param {number} complexity The current complexity * @returns {number} The newly computed complexity */ public collapse: CollapseFunctionMap = new Map([ // // For fenced elements, if the contents are collapsed, // collapse the fence instead. // [ 'fenced', (node, complexity) => { complexity = this.uncollapseChild(complexity, node, 1); if ( complexity > (this.cutoff.fenced as number) && node.attributes.get('data-semantic-role') === 'leftright' ) { complexity = this.recordCollapse( node, complexity, this.getText(node.childNodes[0]) + this.getText(node.childNodes[node.childNodes.length - 1]) ); } return complexity; }, ], // // Collapse function applications if the argument is collapsed // (FIXME: Handle role="limit function" a bit better?) // [ 'appl', (node, complexity) => { if (this.canUncollapse(node, 2, 2)) { complexity = this.complexity.visitNode(node, false); const marker = this.marker.appl as { [name: string]: string }; const text = marker[node.attributes.get('data-semantic-role') as string] || marker.value; complexity = this.recordCollapse(node, complexity, text); } return complexity; }, ], // // For sqrt elements, if the contents are collapsed, // collapse the sqrt instead. // [ 'sqrt', (node, complexity) => { complexity = this.uncollapseChild(complexity, node, 0); if (complexity > (this.cutoff.sqrt as number)) { node.setProperty('collapse-variant', true); complexity = this.recordCollapse( node, complexity, this.marker.sqrt as string ); } return complexity; }, ], [ 'root', (node, complexity) => { complexity = this.uncollapseChild(complexity, node, 0, 2); if (complexity > (this.cutoff.sqrt as number)) { node.setProperty('collapse-variant', true); complexity = this.recordCollapse( node, complexity, this.marker.sqrt as string ); } return complexity; }, ], // // For enclose, include enclosure in collapse // [ 'enclose', (node, complexity) => { if (this.splitAttribute(node, 'children').length === 1) { const child = this.canUncollapse(node, 1); if (child) { const marker = child.getProperty('collapse-marker') as string; this.unrecordCollapse(child); complexity = this.recordCollapse( node, this.complexity.visitNode(node, false), marker ); } } return complexity; }, ], // // For bigops, get the character to use from the largeop at its core. // [ 'bigop', (node, complexity) => { if (complexity > (this.cutoff.bigop as number) || !node.isKind('mo')) { const id = this.splitAttribute(node, 'content').pop(); const op = this.findChildText(node, id); complexity = this.recordCollapse(node, complexity, op); } return complexity; }, ], [ 'integral', (node, complexity) => { if ( complexity > (this.cutoff.integral as number) || !node.isKind('mo') ) { const id = this.splitAttribute(node, 'content').pop(); const op = this.findChildText(node, id); complexity = this.recordCollapse(node, complexity, op); } return complexity; }, ], // // For relseq and multirel, use proper symbol // [ 'relseq', (node, complexity) => { if (complexity > (this.cutoff.relseq as number)) { const id = this.splitAttribute(node, 'content')[0]; const text = this.findChildText(node, id); complexity = this.recordCollapse(node, complexity, text); } return complexity; }, ], [ 'multirel', (node, complexity) => { if (complexity > (this.cutoff.relseq as number)) { const id = this.splitAttribute(node, 'content')[0]; const text = this.findChildText(node, id) + '\u22EF'; complexity = this.recordCollapse(node, complexity, text); } return complexity; }, ], // // Include super- and subscripts into a collapsed base // [ 'superscript', (node, complexity) => { complexity = this.uncollapseChild(complexity, node, 0, 2); if (complexity > (this.cutoff.superscript as number)) { complexity = this.recordCollapse( node, complexity, this.marker.superscript as string ); } return complexity; }, ], [ 'subscript', (node, complexity) => { complexity = this.uncollapseChild(complexity, node, 0, 2); if (complexity > (this.cutoff.subscript as number)) { complexity = this.recordCollapse( node, complexity, this.marker.subscript as string ); } return complexity; }, ], [ 'subsup', (node, complexity) => { complexity = this.uncollapseChild(complexity, node, 0, 3); if (complexity > (this.cutoff.subsup as number)) { complexity = this.recordCollapse( node, complexity, this.marker.subsup as string ); } return complexity; }, ], ] as [string, CollapseFunction][]); /** * The highest id number used for mactions so far */ private idCount = 0; /** * @param {ComplexityVisitor} visitor The visitor for computing complexities */ constructor(visitor: ComplexityVisitor) { this.complexity = visitor; } /** * Check if a node should be collapsible and insert the * maction node to handle that. Return the updated * complexity. * * @param {MmlNode} node The node to check * @param {number} complexity The current complexity of the node * @returns {number} The revised complexity */ public check(node: MmlNode, complexity: number): number { const type = node.attributes.get('data-semantic-type') as string; if (this.collapse.has(type)) { return this.collapse.get(type).call(this, node, complexity); } if (Object.hasOwn(this.cutoff, type)) { return this.defaultCheck(node, complexity, type); } return complexity; } /** * Check if the complexity exceeds the cutoff value for the type * * @param {MmlNode} node The node to check * @param {number} complexity The current complexity of the node * @param {string} type The semantic type of the node * @returns {number} The revised complexity */ protected defaultCheck( node: MmlNode, complexity: number, type: string ): number { const role = node.attributes.get('data-semantic-role') as string; const check = this.cutoff[type]; const cutoff = typeof check === 'number' ? check : check[role] || check.value; if (complexity > cutoff) { const marker = this.marker[type] || '??'; const text = typeof marker === 'string' ? marker : marker[role] || marker.value; complexity = this.recordCollapse(node, complexity, text); } return complexity; } /** * @param {MmlNode} node The node to check * @param {number} complexity The current complexity of the node * @param {string} text The text to use for the collapsed node * @returns {number} The revised complexity for the collapsed node */ protected recordCollapse( node: MmlNode, complexity: number, text: string ): number { text = '\u25C2' + text + '\u25B8'; node.setProperty('collapse-marker', text); node.setProperty('collapse-complexity', complexity); return text.length * this.complexity.complexity.text; } /** * Remove collapse markers (to move them to a parent node) * * @param {MmlNode} node The node to uncollapse */ protected unrecordCollapse(node: MmlNode) { const complexity = node.getProperty('collapse-complexity'); if (complexity != null) { node.attributes.set('data-semantic-complexity', complexity); node.removeProperty('collapse-complexity'); node.removeProperty('collapse-marker'); } } /** * @param {MmlNode} node The node to check if its child is collapsible * @param {number} n The position of the child node to check * @param {number=} m The number of children node must have * @returns {MmlNode|null} The child node that was collapsed (or null) */ protected canUncollapse( node: MmlNode, n: number, m: number = 1 ): MmlNode | null { if (this.splitAttribute(node, 'children').length === m) { const mml = node.childNodes.length === 1 && node.childNodes[0].isInferred ? node.childNodes[0] : node; if (mml && mml.childNodes[n]) { const child = mml.childNodes[n]; if (child.getProperty('collapse-marker')) { return child; } } } return null; } /** * @param {number} complexity The current complexity * @param {MmlNode} node The node to check * @param {number} n The position of the child node to check * @param {number=} m The number of children the node must have * @returns {number} The updated complexity */ protected uncollapseChild( complexity: number, node: MmlNode, n: number, m: number = 1 ): number { const child = this.canUncollapse(node, n, m); if (child) { this.unrecordCollapse(child); if (child.parent !== node) { child.parent.attributes.set('data-semantic-complexity', undefined); } complexity = this.complexity.visitNode(node, false) as number; } return complexity; } /** * @param {MmlNode} node The node whose attribute is to be split * @param {string} id The name of the data-semantic attribute to split * @returns {string[]} Array of ids in the attribute split at commas */ protected splitAttribute(node: MmlNode, id: string): string[] { return ((node.attributes.get('data-semantic-' + id) as string) || '').split( /,/ ); } /** * @param {MmlNode} node The node whose text content is needed * @returns {string} The text of the node (and its children), combined */ protected getText(node: MmlNode): string { if (node.isToken) return (node as AbstractMmlTokenNode).getText(); return node.childNodes.map((n: MmlNode) => this.getText(n)).join(''); } /** * @param {MmlNode} node The node whose child text is needed * @param {string} id The (semantic) id of the child needed * @returns {string} The text of the specified child node */ protected findChildText(node: MmlNode, id: string): string { const child = this.findChild(node, id); return this.getText(child.coreMO() || child); } /** * @param {MmlNode} node The node whose child is to be located * @param {string} id The (semantic) id of the child to be found * @returns {MmlNode|null} The child node (or null if not found) */ protected findChild(node: MmlNode, id: string): MmlNode | null { if (!node || node.attributes.get('data-semantic-id') === id) return node; if (!node.isToken) { for (const mml of node.childNodes) { const child = this.findChild(mml, id); if (child) return child; } } return null; } /** * Add maction nodes to the nodes in the tree that can collapse * * @param {MmlNode} node The root of the tree to check * @param {number|null} id The initial id to use * @returns {number} The initial id used */ public makeCollapse(node: MmlNode, id: number | null): number { let oldCount = null; if (id === null) { id = this.idCount; } else { oldCount = this.idCount; this.idCount = id; } const nodes: MmlNode[] = []; node.walkTree((child: MmlNode) => { if (child.getProperty('collapse-marker')) { nodes.push(child); } }); this.makeActions(nodes); if (oldCount !== null) { this.idCount = oldCount; } return id; } /** * @param {MmlNode[]} nodes The list of nodes to replace by maction nodes */ public makeActions(nodes: MmlNode[]) { for (const node of nodes) { this.makeAction(node); } } /** * @returns {string} A unique id string. */ private makeId(): string { return 'mjx-collapse-' + this.idCount++; } /** * @param {MmlNode} node The node to make collapsible by replacing with an maction */ public makeAction(node: MmlNode) { if (node.isKind('math')) { node = this.addMrow(node); } const factory = this.complexity.factory; const marker = node.getProperty('collapse-marker') as string; const parent = node.parent; const variant = { 'data-mjx-collapsed': true } as PropertyList; if (node.getProperty('collapse-variant')) { variant.mathvariant = '-tex-variant'; } const maction = factory.create( 'maction', { actiontype: 'toggle', selection: 2, 'data-collapsible': true, id: this.makeId(), 'data-semantic-complexity': node.attributes.get( 'data-semantic-complexity' ), }, [ factory.create('mtext', variant, [ (factory.create('text') as TextNode).setText(marker), ]), ] ); maction.inheritAttributesFrom(node); node.attributes.set( 'data-semantic-complexity', node.getProperty('collapse-complexity') ); node.removeProperty('collapse-marker'); node.removeProperty('collapse-complexity'); parent.replaceChild(maction, node); maction.appendChild(node); } /** * If the node is to be collapsible, add an mrow to it instead so that we can wrap it * in an maction (can't put one around the node). * * @param {MmlNode} node The math node to create an mrow for * @returns {MmlNode} The newly created mrow */ public addMrow(node: MmlNode): MmlNode { const mrow = this.complexity.factory.create( 'mrow', null, node.childNodes[0].childNodes ); node.childNodes[0].setChildren([mrow]); const attributes = node.attributes.getAllAttributes(); for (const name of Object.keys(attributes)) { if ( name.substring(0, 14) === 'data-semantic-' || name.substring(0, 12) === 'data-speech-' || name.substring(0, 5) === 'aria-' || name === 'role' ) { mrow.attributes.set(name, attributes[name]); delete attributes[name]; } } mrow.setProperty('collapse-marker', node.getProperty('collapse-marker')); mrow.setProperty( 'collapse-complexity', node.getProperty('collapse-complexity') ); node.removeProperty('collapse-marker'); node.removeProperty('collapse-complexity'); return mrow; } }