collapse.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519
  1. /*************************************************************
  2. *
  3. * Copyright (c) 2018-2022 The MathJax Consortium
  4. *
  5. * Licensed under the Apache License, Version 2.0 (the "License");
  6. * you may not use this file except in compliance with the License.
  7. * You may obtain a copy of the License at
  8. *
  9. * http://www.apache.org/licenses/LICENSE-2.0
  10. *
  11. * Unless required by applicable law or agreed to in writing, software
  12. * distributed under the License is distributed on an "AS IS" BASIS,
  13. * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  14. * See the License for the specific language governing permissions and
  15. * limitations under the License.
  16. */
  17. /**
  18. * @fileoverview Implements a class that marks complex items for collapsing
  19. *
  20. * @author dpvc@mathjax.org (Davide Cervone)
  21. */
  22. import {MmlNode, AbstractMmlTokenNode, TextNode} from '../../core/MmlTree/MmlNode.js';
  23. import {ComplexityVisitor} from './visitor.js';
  24. /*==========================================================================*/
  25. /**
  26. * Function for checking if a node should be collapsible
  27. */
  28. export type CollapseFunction = (node: MmlNode, complexity: number) => number;
  29. /**
  30. * Map of types to collase functions
  31. */
  32. export type CollapseFunctionMap = Map<string, CollapseFunction>;
  33. /**
  34. * A list of values indexed by semantic-type, possibly sub-indexed by semantic-role
  35. *
  36. * @template T The type of the indexed item
  37. */
  38. export type TypeRole<T> = {[type: string]: T | {[role: string]: T}};
  39. /**
  40. * The class for determining of a subtree can be collapsed
  41. */
  42. export class Collapse {
  43. /**
  44. * A constant to use to indicate no collapsing
  45. */
  46. public static NOCOLLAPSE: number = 10000000; // really big complexity
  47. /**
  48. * The complexity object containing this one
  49. */
  50. public complexity: ComplexityVisitor;
  51. /**
  52. * The cutt-off complexity values for when a structure
  53. * of the given type should collapse
  54. */
  55. public cutoff: TypeRole<number> = {
  56. identifier: 3,
  57. number: 3,
  58. text: 10,
  59. infixop: 15,
  60. relseq: 15,
  61. multirel: 15,
  62. fenced: 18,
  63. bigop: 20,
  64. integral: 20,
  65. fraction: 12,
  66. sqrt: 9,
  67. root: 12,
  68. vector: 15,
  69. matrix: 15,
  70. cases: 15,
  71. superscript: 9,
  72. subscript: 9,
  73. subsup: 9,
  74. punctuated: {
  75. endpunct: Collapse.NOCOLLAPSE,
  76. startpunct: Collapse.NOCOLLAPSE,
  77. value: 12
  78. }
  79. };
  80. /**
  81. * These are the characters to use for the various collapsed elements
  82. * (if an object, then semantic-role is used to get the character
  83. * from the object)
  84. */
  85. public marker: TypeRole<string> = {
  86. identifier: 'x',
  87. number: '#',
  88. text: '...',
  89. appl: {
  90. 'limit function': 'lim',
  91. value: 'f()'
  92. },
  93. fraction: '/',
  94. sqrt: '\u221A',
  95. root: '\u221A',
  96. superscript: '\u25FD\u02D9',
  97. subscript: '\u25FD.',
  98. subsup: '\u25FD:',
  99. vector: {
  100. binomial: '(:)',
  101. determinant: '|:|',
  102. value: '\u27E8:\u27E9'
  103. },
  104. matrix: {
  105. squarematrix: '[::]',
  106. rowvector: '\u27E8\u22EF\u27E9',
  107. columnvector: '\u27E8\u22EE\u27E9',
  108. determinant: '|::|',
  109. value: '(::)'
  110. },
  111. cases: '{:',
  112. infixop: {
  113. addition: '+',
  114. subtraction: '\u2212',
  115. multiplication: '\u22C5',
  116. implicit: '\u22C5',
  117. value: '+'
  118. },
  119. punctuated: {
  120. text: '...',
  121. value: ','
  122. }
  123. };
  124. /**
  125. * The type-to-function mapping for semantic types
  126. */
  127. public collapse: CollapseFunctionMap = new Map([
  128. //
  129. // For fenced elements, if the contents are collapsed,
  130. // collapse the fence instead.
  131. //
  132. ['fenced', (node, complexity) => {
  133. complexity = this.uncollapseChild(complexity, node, 1);
  134. if (complexity > this.cutoff.fenced && node.attributes.get('data-semantic-role') === 'leftright') {
  135. complexity = this.recordCollapse(
  136. node, complexity,
  137. this.getText(node.childNodes[0] as MmlNode) +
  138. this.getText(node.childNodes[node.childNodes.length - 1] as MmlNode)
  139. );
  140. }
  141. return complexity;
  142. }],
  143. //
  144. // Collapse function applications if the argument is collapsed
  145. // (FIXME: Handle role="limit function" a bit better?)
  146. //
  147. ['appl', (node, complexity) => {
  148. if (this.canUncollapse(node, 2, 2)) {
  149. complexity = this.complexity.visitNode(node, false);
  150. const marker = this.marker.appl as {[name: string]: string};
  151. const text = marker[node.attributes.get('data-semantic-role') as string] || marker.value;
  152. complexity = this.recordCollapse(node, complexity, text);
  153. }
  154. return complexity;
  155. }],
  156. //
  157. // For sqrt elements, if the contents are collapsed,
  158. // collapse the sqrt instead.
  159. //
  160. ['sqrt', (node, complexity) => {
  161. complexity = this.uncollapseChild(complexity, node, 0);
  162. if (complexity > this.cutoff.sqrt) {
  163. complexity = this.recordCollapse(node, complexity, this.marker.sqrt as string);
  164. }
  165. return complexity;
  166. }],
  167. ['root', (node, complexity) => {
  168. complexity = this.uncollapseChild(complexity, node, 0, 2);
  169. if (complexity > this.cutoff.sqrt) {
  170. complexity = this.recordCollapse(node, complexity, this.marker.sqrt as string);
  171. }
  172. return complexity;
  173. }],
  174. //
  175. // For enclose, include enclosure in collapse
  176. //
  177. ['enclose', (node, complexity) => {
  178. if (this.splitAttribute(node, 'children').length === 1) {
  179. const child = this.canUncollapse(node, 1);
  180. if (child) {
  181. const marker = child.getProperty('collapse-marker') as string;
  182. this.unrecordCollapse(child);
  183. complexity = this.recordCollapse(node, this.complexity.visitNode(node, false), marker);
  184. }
  185. }
  186. return complexity;
  187. }],
  188. //
  189. // For bigops, get the character to use from the largeop at its core.
  190. //
  191. ['bigop', (node, complexity) => {
  192. if (complexity > this.cutoff.bigop || !node.isKind('mo')) {
  193. const id = this.splitAttribute(node, 'content').pop();
  194. const op = this.findChildText(node, id);
  195. complexity = this.recordCollapse(node, complexity, op);
  196. }
  197. return complexity;
  198. }],
  199. ['integral', (node, complexity) => {
  200. if (complexity > this.cutoff.integral || !node.isKind('mo')) {
  201. const id = this.splitAttribute(node, 'content').pop();
  202. const op = this.findChildText(node, id);
  203. complexity = this.recordCollapse(node, complexity, op);
  204. }
  205. return complexity;
  206. }],
  207. //
  208. // For relseq and multirel, use proper symbol
  209. //
  210. ['relseq', (node, complexity) => {
  211. if (complexity > this.cutoff.relseq) {
  212. const id = this.splitAttribute(node, 'content')[0];
  213. const text = this.findChildText(node, id);
  214. complexity = this.recordCollapse(node, complexity, text);
  215. }
  216. return complexity;
  217. }],
  218. ['multirel', (node, complexity) => {
  219. if (complexity > this.cutoff.relseq) {
  220. const id = this.splitAttribute(node, 'content')[0];
  221. const text = this.findChildText(node, id) + '\u22EF';
  222. complexity = this.recordCollapse(node, complexity, text);
  223. }
  224. return complexity;
  225. }],
  226. //
  227. // Include super- and subscripts into a collapsed base
  228. //
  229. ['superscript', (node, complexity) => {
  230. complexity = this.uncollapseChild(complexity, node, 0, 2);
  231. if (complexity > this.cutoff.superscript) {
  232. complexity = this.recordCollapse(node, complexity, this.marker.superscript as string);
  233. }
  234. return complexity;
  235. }],
  236. ['subscript', (node, complexity) => {
  237. complexity = this.uncollapseChild(complexity, node, 0, 2);
  238. if (complexity > this.cutoff.subscript) {
  239. complexity = this.recordCollapse(node, complexity, this.marker.subscript as string);
  240. }
  241. return complexity;
  242. }],
  243. ['subsup', (node, complexity) => {
  244. complexity = this.uncollapseChild(complexity, node, 0, 3);
  245. if (complexity > this.cutoff.subsup) {
  246. complexity = this.recordCollapse(node, complexity, this.marker.subsup as string);
  247. }
  248. return complexity;
  249. }]
  250. ] as [string, CollapseFunction][]);
  251. /**
  252. * The highest id number used for mactions so far
  253. */
  254. private idCount = 0;
  255. /**
  256. * @param {ComplexityVisitor} visitor The visitor for computing complexities
  257. */
  258. constructor(visitor: ComplexityVisitor) {
  259. this.complexity = visitor;
  260. }
  261. /**
  262. * Check if a node should be collapsible and insert the
  263. * maction node to handle that. Return the updated
  264. * complexity.
  265. *
  266. * @param {MmlNode} node The node to check
  267. * @param {number} complexity The current complexity of the node
  268. * @return {number} The revised complexity
  269. */
  270. public check(node: MmlNode, complexity: number): number {
  271. const type = node.attributes.get('data-semantic-type') as string;
  272. if (this.collapse.has(type)) {
  273. return this.collapse.get(type).call(this, node, complexity);
  274. }
  275. if (this.cutoff.hasOwnProperty(type)) {
  276. return this.defaultCheck(node, complexity, type);
  277. }
  278. return complexity;
  279. }
  280. /**
  281. * Check if the complexity exceeds the cutoff value for the type
  282. *
  283. * @param {MmlNode} node The node to check
  284. * @param {number} complexity The current complexity of the node
  285. * @param {string} type The semantic type of the node
  286. * @return {number} The revised complexity
  287. */
  288. protected defaultCheck(node: MmlNode, complexity: number, type: string): number {
  289. const role = node.attributes.get('data-semantic-role') as string;
  290. const check = this.cutoff[type];
  291. const cutoff = (typeof check === 'number' ? check : check[role] || check.value);
  292. if (complexity > cutoff) {
  293. const marker = this.marker[type] || '??';
  294. const text = (typeof marker === 'string' ? marker : marker[role] || marker.value);
  295. complexity = this.recordCollapse(node, complexity, text);
  296. }
  297. return complexity;
  298. }
  299. /**
  300. * @param {MmlNode} node The node to check
  301. * @param {number} complexity The current complexity of the node
  302. * @param {string} text The text to use for the collapsed node
  303. * @return {number} The revised complexity for the collapsed node
  304. */
  305. protected recordCollapse(node: MmlNode, complexity: number, text: string): number {
  306. text = '\u25C2' + text + '\u25B8';
  307. node.setProperty('collapse-marker', text);
  308. node.setProperty('collapse-complexity', complexity);
  309. return text.length * this.complexity.complexity.text;
  310. }
  311. /**
  312. * Remove collapse markers (to move them to a parent node)
  313. *
  314. * @param {MmlNode} node The node to uncollapse
  315. */
  316. protected unrecordCollapse(node: MmlNode) {
  317. const complexity = node.getProperty('collapse-complexity');
  318. if (complexity != null) {
  319. node.attributes.set('data-semantic-complexity', complexity);
  320. node.removeProperty('collapse-complexity');
  321. node.removeProperty('collapse-marker');
  322. }
  323. }
  324. /**
  325. * @param {MmlNode} node The node to check if its child is collapsible
  326. * @param {number} n The position of the child node to check
  327. * @param {number=} m The number of children node must have
  328. * @return {MmlNode|null} The child node that was collapsed (or null)
  329. */
  330. protected canUncollapse(node: MmlNode, n: number, m: number = 1): MmlNode | null {
  331. if (this.splitAttribute(node, 'children').length === m) {
  332. const mml = (node.childNodes.length === 1 &&
  333. (node.childNodes[0] as MmlNode).isInferred ? node.childNodes[0] as MmlNode : node);
  334. if (mml && mml.childNodes[n]) {
  335. const child = mml.childNodes[n] as MmlNode;
  336. if (child.getProperty('collapse-marker')) {
  337. return child;
  338. }
  339. }
  340. }
  341. return null;
  342. }
  343. /**
  344. * @param {number} complexity The current complexity
  345. * @param {MmlNode} node The node to check
  346. * @param {number} n The position of the child node to check
  347. * @param {number=} m The number of children the node must have
  348. * @return {number} The updated complexity
  349. */
  350. protected uncollapseChild(complexity: number, node: MmlNode, n: number, m: number = 1): number {
  351. const child = this.canUncollapse(node, n, m);
  352. if (child) {
  353. this.unrecordCollapse(child);
  354. if (child.parent !== node) {
  355. child.parent.attributes.set('data-semantic-complexity', undefined);
  356. }
  357. complexity = this.complexity.visitNode(node, false) as number;
  358. }
  359. return complexity;
  360. }
  361. /**
  362. * @param {MmlNode} node The node whose attribute is to be split
  363. * @param {string} id The name of the data-semantic attribute to split
  364. * @return {string[]} Array of ids in the attribute split at commas
  365. */
  366. protected splitAttribute(node: MmlNode, id: string): string[] {
  367. return (node.attributes.get('data-semantic-' + id) as string || '').split(/,/);
  368. }
  369. /**
  370. * @param {MmlNode} node The node whose text content is needed
  371. * @return{string} The text of the node (and its children), combined
  372. */
  373. protected getText(node: MmlNode): string {
  374. if (node.isToken) return (node as AbstractMmlTokenNode).getText();
  375. return node.childNodes.map((n: MmlNode) => this.getText(n)).join('');
  376. }
  377. /**
  378. * @param {MmlNode} node The node whose child text is needed
  379. * @param {string} id The (semantic) id of the child needed
  380. * @return {string} The text of the specified child node
  381. */
  382. protected findChildText(node: MmlNode, id: string): string {
  383. const child = this.findChild(node, id);
  384. return this.getText(child.coreMO() || child);
  385. }
  386. /**
  387. * @param {MmlNode} node The node whose child is to be located
  388. * @param {string} id The (semantic) id of the child to be found
  389. * @return {MmlNode|null} The child node (or null if not found)
  390. */
  391. protected findChild(node: MmlNode, id: string): MmlNode | null {
  392. if (!node || node.attributes.get('data-semantic-id') === id) return node;
  393. if (!node.isToken) {
  394. for (const mml of node.childNodes) {
  395. const child = this.findChild(mml as MmlNode, id);
  396. if (child) return child;
  397. }
  398. }
  399. return null;
  400. }
  401. /**
  402. * Add maction nodes to the nodes in the tree that can collapse
  403. *
  404. * @paramn {MmlNode} node The root of the tree to check
  405. */
  406. public makeCollapse(node: MmlNode) {
  407. const nodes: MmlNode[] = [];
  408. node.walkTree((child: MmlNode) => {
  409. if (child.getProperty('collapse-marker')) {
  410. nodes.push(child);
  411. }
  412. });
  413. this.makeActions(nodes);
  414. }
  415. /**
  416. * @param {MmlNode[]} nodes The list of nodes to replace by maction nodes
  417. */
  418. public makeActions(nodes: MmlNode[]) {
  419. for (const node of nodes) {
  420. this.makeAction(node);
  421. }
  422. }
  423. /**
  424. * @return {string} A unique id string.
  425. */
  426. private makeId(): string {
  427. return 'mjx-collapse-' + this.idCount++;
  428. }
  429. /**
  430. * @param {MmlNode} node The node to make collapsible by replacing with an maction
  431. */
  432. public makeAction(node: MmlNode) {
  433. if (node.isKind('math')) {
  434. node = this.addMrow(node);
  435. }
  436. const factory = this.complexity.factory;
  437. const marker = node.getProperty('collapse-marker') as string;
  438. const parent = node.parent;
  439. let maction = factory.create('maction', {
  440. actiontype: 'toggle',
  441. selection: 2,
  442. 'data-collapsible': true,
  443. id: this.makeId(),
  444. 'data-semantic-complexity': node.attributes.get('data-semantic-complexity')
  445. }, [
  446. factory.create('mtext', {mathcolor: 'blue'}, [
  447. (factory.create('text') as TextNode).setText(marker)
  448. ])
  449. ]);
  450. maction.inheritAttributesFrom(node);
  451. node.attributes.set('data-semantic-complexity', node.getProperty('collapse-complexity'));
  452. node.removeProperty('collapse-marker');
  453. node.removeProperty('collapse-complexity');
  454. parent.replaceChild(maction, node);
  455. maction.appendChild(node);
  456. }
  457. /**
  458. * If the <math> node is to be collapsible, add an mrow to it instead so that we can wrap it
  459. * in an maction (can't put one around the <math> node).
  460. *
  461. * @param {MmlNode} node The math node to create an mrow for
  462. * @return {MmlNode} The newly created mrow
  463. */
  464. public addMrow(node: MmlNode): MmlNode {
  465. const mrow = this.complexity.factory.create('mrow', null, node.childNodes[0].childNodes as MmlNode[]);
  466. node.childNodes[0].setChildren([mrow]);
  467. const attributes = node.attributes.getAllAttributes();
  468. for (const name of Object.keys(attributes)) {
  469. if (name.substr(0, 14) === 'data-semantic-') {
  470. mrow.attributes.set(name, attributes[name]);
  471. delete attributes[name];
  472. }
  473. }
  474. mrow.setProperty('collapse-marker', node.getProperty('collapse-marker'));
  475. mrow.setProperty('collapse-complexity', node.getProperty('collapse-complexity'));
  476. node.removeProperty('collapse-marker');
  477. node.removeProperty('collapse-complexity');
  478. return mrow;
  479. }
  480. }