render-a11y-string.mjs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698
  1. import katex from '../katex.mjs';
  2. /**
  3. * renderA11yString returns a readable string.
  4. *
  5. * In some cases the string will have the proper semantic math
  6. * meaning,:
  7. * renderA11yString("\\frac{1}{2}"")
  8. * -> "start fraction, 1, divided by, 2, end fraction"
  9. *
  10. * However, other cases do not:
  11. * renderA11yString("f(x) = x^2")
  12. * -> "f, left parenthesis, x, right parenthesis, equals, x, squared"
  13. *
  14. * The commas in the string aim to increase ease of understanding
  15. * when read by a screenreader.
  16. */
  17. var stringMap = {
  18. "(": "left parenthesis",
  19. ")": "right parenthesis",
  20. "[": "open bracket",
  21. "]": "close bracket",
  22. "\\{": "left brace",
  23. "\\}": "right brace",
  24. "\\lvert": "open vertical bar",
  25. "\\rvert": "close vertical bar",
  26. "|": "vertical bar",
  27. "\\uparrow": "up arrow",
  28. "\\Uparrow": "up arrow",
  29. "\\downarrow": "down arrow",
  30. "\\Downarrow": "down arrow",
  31. "\\updownarrow": "up down arrow",
  32. "\\leftarrow": "left arrow",
  33. "\\Leftarrow": "left arrow",
  34. "\\rightarrow": "right arrow",
  35. "\\Rightarrow": "right arrow",
  36. "\\langle": "open angle",
  37. "\\rangle": "close angle",
  38. "\\lfloor": "open floor",
  39. "\\rfloor": "close floor",
  40. "\\int": "integral",
  41. "\\intop": "integral",
  42. "\\lim": "limit",
  43. "\\ln": "natural log",
  44. "\\log": "log",
  45. "\\sin": "sine",
  46. "\\cos": "cosine",
  47. "\\tan": "tangent",
  48. "\\cot": "cotangent",
  49. "\\sum": "sum",
  50. "/": "slash",
  51. ",": "comma",
  52. ".": "point",
  53. "-": "negative",
  54. "+": "plus",
  55. "~": "tilde",
  56. ":": "colon",
  57. "?": "question mark",
  58. "'": "apostrophe",
  59. "\\%": "percent",
  60. " ": "space",
  61. "\\ ": "space",
  62. "\\$": "dollar sign",
  63. "\\angle": "angle",
  64. "\\degree": "degree",
  65. "\\circ": "circle",
  66. "\\vec": "vector",
  67. "\\triangle": "triangle",
  68. "\\pi": "pi",
  69. "\\prime": "prime",
  70. "\\infty": "infinity",
  71. "\\alpha": "alpha",
  72. "\\beta": "beta",
  73. "\\gamma": "gamma",
  74. "\\omega": "omega",
  75. "\\theta": "theta",
  76. "\\sigma": "sigma",
  77. "\\lambda": "lambda",
  78. "\\tau": "tau",
  79. "\\Delta": "delta",
  80. "\\delta": "delta",
  81. "\\mu": "mu",
  82. "\\rho": "rho",
  83. "\\nabla": "del",
  84. "\\ell": "ell",
  85. "\\ldots": "dots",
  86. // TODO: add entries for all accents
  87. "\\hat": "hat",
  88. "\\acute": "acute"
  89. };
  90. var powerMap = {
  91. "prime": "prime",
  92. "degree": "degrees",
  93. "circle": "degrees",
  94. "2": "squared",
  95. "3": "cubed"
  96. };
  97. var openMap = {
  98. "|": "open vertical bar",
  99. ".": ""
  100. };
  101. var closeMap = {
  102. "|": "close vertical bar",
  103. ".": ""
  104. };
  105. var binMap = {
  106. "+": "plus",
  107. "-": "minus",
  108. "\\pm": "plus minus",
  109. "\\cdot": "dot",
  110. "*": "times",
  111. "/": "divided by",
  112. "\\times": "times",
  113. "\\div": "divided by",
  114. "\\circ": "circle",
  115. "\\bullet": "bullet"
  116. };
  117. var relMap = {
  118. "=": "equals",
  119. "\\approx": "approximately equals",
  120. "≠": "does not equal",
  121. "\\geq": "is greater than or equal to",
  122. "\\ge": "is greater than or equal to",
  123. "\\leq": "is less than or equal to",
  124. "\\le": "is less than or equal to",
  125. ">": "is greater than",
  126. "<": "is less than",
  127. "\\leftarrow": "left arrow",
  128. "\\Leftarrow": "left arrow",
  129. "\\rightarrow": "right arrow",
  130. "\\Rightarrow": "right arrow",
  131. ":": "colon"
  132. };
  133. var accentUnderMap = {
  134. "\\underleftarrow": "left arrow",
  135. "\\underrightarrow": "right arrow",
  136. "\\underleftrightarrow": "left-right arrow",
  137. "\\undergroup": "group",
  138. "\\underlinesegment": "line segment",
  139. "\\utilde": "tilde"
  140. };
  141. var buildString = (str, type, a11yStrings) => {
  142. if (!str) {
  143. return;
  144. }
  145. var ret;
  146. if (type === "open") {
  147. ret = str in openMap ? openMap[str] : stringMap[str] || str;
  148. } else if (type === "close") {
  149. ret = str in closeMap ? closeMap[str] : stringMap[str] || str;
  150. } else if (type === "bin") {
  151. ret = binMap[str] || str;
  152. } else if (type === "rel") {
  153. ret = relMap[str] || str;
  154. } else {
  155. ret = stringMap[str] || str;
  156. }
  157. // If the text to add is a number and there is already a string
  158. // in the list and the last string is a number then we should
  159. // combine them into a single number
  160. var last = a11yStrings[a11yStrings.length - 1];
  161. if (/^\d+$/.test(ret) && a11yStrings.length > 0 && typeof last === "string" && /^\d+$/.test(last)) {
  162. a11yStrings[a11yStrings.length - 1] += ret;
  163. } else if (ret) {
  164. a11yStrings.push(ret);
  165. }
  166. };
  167. var buildRegion = (a11yStrings, callback) => {
  168. var regionStrings = [];
  169. a11yStrings.push(regionStrings);
  170. callback(regionStrings);
  171. };
  172. var handleObject = (tree, a11yStrings, atomType) => {
  173. // Everything else is assumed to be an object...
  174. switch (tree.type) {
  175. case "accent":
  176. {
  177. buildRegion(a11yStrings, a11yStrings => {
  178. _buildA11yStrings(tree.base, a11yStrings, atomType);
  179. a11yStrings.push("with");
  180. buildString(tree.label, "normal", a11yStrings);
  181. a11yStrings.push("on top");
  182. });
  183. break;
  184. }
  185. case "accentUnder":
  186. {
  187. buildRegion(a11yStrings, a11yStrings => {
  188. _buildA11yStrings(tree.base, a11yStrings, atomType);
  189. a11yStrings.push("with");
  190. buildString(accentUnderMap[tree.label], "normal", a11yStrings);
  191. a11yStrings.push("underneath");
  192. });
  193. break;
  194. }
  195. case "accent-token":
  196. {
  197. // Used internally by accent symbols.
  198. break;
  199. }
  200. case "atom":
  201. {
  202. var {
  203. text
  204. } = tree;
  205. switch (tree.family) {
  206. case "bin":
  207. {
  208. buildString(text, "bin", a11yStrings);
  209. break;
  210. }
  211. case "close":
  212. {
  213. buildString(text, "close", a11yStrings);
  214. break;
  215. }
  216. // TODO(kevinb): figure out what should be done for inner
  217. case "inner":
  218. {
  219. buildString(tree.text, "inner", a11yStrings);
  220. break;
  221. }
  222. case "open":
  223. {
  224. buildString(text, "open", a11yStrings);
  225. break;
  226. }
  227. case "punct":
  228. {
  229. buildString(text, "punct", a11yStrings);
  230. break;
  231. }
  232. case "rel":
  233. {
  234. buildString(text, "rel", a11yStrings);
  235. break;
  236. }
  237. default:
  238. {
  239. tree.family;
  240. throw new Error("\"" + tree.family + "\" is not a valid atom type");
  241. }
  242. }
  243. break;
  244. }
  245. case "color":
  246. {
  247. var color = tree.color.replace(/katex-/, "");
  248. buildRegion(a11yStrings, regionStrings => {
  249. regionStrings.push("start color " + color);
  250. _buildA11yStrings(tree.body, regionStrings, atomType);
  251. regionStrings.push("end color " + color);
  252. });
  253. break;
  254. }
  255. case "color-token":
  256. {
  257. // Used by \color, \colorbox, and \fcolorbox but not directly rendered.
  258. // It's a leaf node and has no children so just break.
  259. break;
  260. }
  261. case "delimsizing":
  262. {
  263. if (tree.delim && tree.delim !== ".") {
  264. buildString(tree.delim, "normal", a11yStrings);
  265. }
  266. break;
  267. }
  268. case "genfrac":
  269. {
  270. buildRegion(a11yStrings, regionStrings => {
  271. // genfrac can have unbalanced delimiters
  272. var {
  273. leftDelim,
  274. rightDelim
  275. } = tree;
  276. // NOTE: Not sure if this is a safe assumption
  277. // hasBarLine true -> fraction, false -> binomial
  278. if (tree.hasBarLine) {
  279. regionStrings.push("start fraction");
  280. leftDelim && buildString(leftDelim, "open", regionStrings);
  281. _buildA11yStrings(tree.numer, regionStrings, atomType);
  282. regionStrings.push("divided by");
  283. _buildA11yStrings(tree.denom, regionStrings, atomType);
  284. rightDelim && buildString(rightDelim, "close", regionStrings);
  285. regionStrings.push("end fraction");
  286. } else {
  287. regionStrings.push("start binomial");
  288. leftDelim && buildString(leftDelim, "open", regionStrings);
  289. _buildA11yStrings(tree.numer, regionStrings, atomType);
  290. regionStrings.push("over");
  291. _buildA11yStrings(tree.denom, regionStrings, atomType);
  292. rightDelim && buildString(rightDelim, "close", regionStrings);
  293. regionStrings.push("end binomial");
  294. }
  295. });
  296. break;
  297. }
  298. case "hbox":
  299. {
  300. _buildA11yStrings(tree.body, a11yStrings, atomType);
  301. break;
  302. }
  303. case "kern":
  304. {
  305. // No op: we don't attempt to present kerning information
  306. // to the screen reader.
  307. break;
  308. }
  309. case "leftright":
  310. {
  311. buildRegion(a11yStrings, regionStrings => {
  312. buildString(tree.left, "open", regionStrings);
  313. _buildA11yStrings(tree.body, regionStrings, atomType);
  314. buildString(tree.right, "close", regionStrings);
  315. });
  316. break;
  317. }
  318. case "leftright-right":
  319. {
  320. // TODO: double check that this is a no-op
  321. break;
  322. }
  323. case "lap":
  324. {
  325. _buildA11yStrings(tree.body, a11yStrings, atomType);
  326. break;
  327. }
  328. case "mathord":
  329. {
  330. buildString(tree.text, "normal", a11yStrings);
  331. break;
  332. }
  333. case "op":
  334. {
  335. var {
  336. body,
  337. name
  338. } = tree;
  339. if (body) {
  340. _buildA11yStrings(body, a11yStrings, atomType);
  341. } else if (name) {
  342. buildString(name, "normal", a11yStrings);
  343. }
  344. break;
  345. }
  346. case "op-token":
  347. {
  348. // Used internally by operator symbols.
  349. buildString(tree.text, atomType, a11yStrings);
  350. break;
  351. }
  352. case "ordgroup":
  353. {
  354. _buildA11yStrings(tree.body, a11yStrings, atomType);
  355. break;
  356. }
  357. case "overline":
  358. {
  359. buildRegion(a11yStrings, function (a11yStrings) {
  360. a11yStrings.push("start overline");
  361. _buildA11yStrings(tree.body, a11yStrings, atomType);
  362. a11yStrings.push("end overline");
  363. });
  364. break;
  365. }
  366. case "pmb":
  367. {
  368. a11yStrings.push("bold");
  369. break;
  370. }
  371. case "phantom":
  372. {
  373. a11yStrings.push("empty space");
  374. break;
  375. }
  376. case "raisebox":
  377. {
  378. _buildA11yStrings(tree.body, a11yStrings, atomType);
  379. break;
  380. }
  381. case "rule":
  382. {
  383. a11yStrings.push("rectangle");
  384. break;
  385. }
  386. case "sizing":
  387. {
  388. _buildA11yStrings(tree.body, a11yStrings, atomType);
  389. break;
  390. }
  391. case "spacing":
  392. {
  393. a11yStrings.push("space");
  394. break;
  395. }
  396. case "styling":
  397. {
  398. // We ignore the styling and just pass through the contents
  399. _buildA11yStrings(tree.body, a11yStrings, atomType);
  400. break;
  401. }
  402. case "sqrt":
  403. {
  404. buildRegion(a11yStrings, regionStrings => {
  405. var {
  406. body,
  407. index
  408. } = tree;
  409. if (index) {
  410. var indexString = _flatten(_buildA11yStrings(index, [], atomType)).join(",");
  411. if (indexString === "3") {
  412. regionStrings.push("cube root of");
  413. _buildA11yStrings(body, regionStrings, atomType);
  414. regionStrings.push("end cube root");
  415. return;
  416. }
  417. regionStrings.push("root");
  418. regionStrings.push("start index");
  419. _buildA11yStrings(index, regionStrings, atomType);
  420. regionStrings.push("end index");
  421. return;
  422. }
  423. regionStrings.push("square root of");
  424. _buildA11yStrings(body, regionStrings, atomType);
  425. regionStrings.push("end square root");
  426. });
  427. break;
  428. }
  429. case "supsub":
  430. {
  431. var {
  432. base,
  433. sub,
  434. sup
  435. } = tree;
  436. var isLog = false;
  437. if (base) {
  438. _buildA11yStrings(base, a11yStrings, atomType);
  439. isLog = base.type === "op" && base.name === "\\log";
  440. }
  441. if (sub) {
  442. var regionName = isLog ? "base" : "subscript";
  443. buildRegion(a11yStrings, function (regionStrings) {
  444. regionStrings.push("start " + regionName);
  445. _buildA11yStrings(sub, regionStrings, atomType);
  446. regionStrings.push("end " + regionName);
  447. });
  448. }
  449. if (sup) {
  450. buildRegion(a11yStrings, function (regionStrings) {
  451. var supString = _flatten(_buildA11yStrings(sup, [], atomType)).join(",");
  452. if (supString in powerMap) {
  453. regionStrings.push(powerMap[supString]);
  454. return;
  455. }
  456. regionStrings.push("start superscript");
  457. _buildA11yStrings(sup, regionStrings, atomType);
  458. regionStrings.push("end superscript");
  459. });
  460. }
  461. break;
  462. }
  463. case "text":
  464. {
  465. // TODO: handle other fonts
  466. if (tree.font === "\\textbf") {
  467. buildRegion(a11yStrings, function (regionStrings) {
  468. regionStrings.push("start bold text");
  469. _buildA11yStrings(tree.body, regionStrings, atomType);
  470. regionStrings.push("end bold text");
  471. });
  472. break;
  473. }
  474. buildRegion(a11yStrings, function (regionStrings) {
  475. regionStrings.push("start text");
  476. _buildA11yStrings(tree.body, regionStrings, atomType);
  477. regionStrings.push("end text");
  478. });
  479. break;
  480. }
  481. case "textord":
  482. {
  483. buildString(tree.text, atomType, a11yStrings);
  484. break;
  485. }
  486. case "smash":
  487. {
  488. _buildA11yStrings(tree.body, a11yStrings, atomType);
  489. break;
  490. }
  491. case "enclose":
  492. {
  493. // TODO: create a map for these.
  494. // TODO: differentiate between a body with a single atom, e.g.
  495. // "cancel a" instead of "start cancel, a, end cancel"
  496. if (/cancel/.test(tree.label)) {
  497. buildRegion(a11yStrings, function (regionStrings) {
  498. regionStrings.push("start cancel");
  499. _buildA11yStrings(tree.body, regionStrings, atomType);
  500. regionStrings.push("end cancel");
  501. });
  502. break;
  503. } else if (/box/.test(tree.label)) {
  504. buildRegion(a11yStrings, function (regionStrings) {
  505. regionStrings.push("start box");
  506. _buildA11yStrings(tree.body, regionStrings, atomType);
  507. regionStrings.push("end box");
  508. });
  509. break;
  510. } else if (/sout/.test(tree.label)) {
  511. buildRegion(a11yStrings, function (regionStrings) {
  512. regionStrings.push("start strikeout");
  513. _buildA11yStrings(tree.body, regionStrings, atomType);
  514. regionStrings.push("end strikeout");
  515. });
  516. break;
  517. } else if (/phase/.test(tree.label)) {
  518. buildRegion(a11yStrings, function (regionStrings) {
  519. regionStrings.push("start phase angle");
  520. _buildA11yStrings(tree.body, regionStrings, atomType);
  521. regionStrings.push("end phase angle");
  522. });
  523. break;
  524. }
  525. throw new Error("KaTeX-a11y: enclose node with " + tree.label + " not supported yet");
  526. }
  527. case "vcenter":
  528. {
  529. _buildA11yStrings(tree.body, a11yStrings, atomType);
  530. break;
  531. }
  532. case "vphantom":
  533. {
  534. throw new Error("KaTeX-a11y: vphantom not implemented yet");
  535. }
  536. case "operatorname":
  537. {
  538. _buildA11yStrings(tree.body, a11yStrings, atomType);
  539. break;
  540. }
  541. case "array":
  542. {
  543. throw new Error("KaTeX-a11y: array not implemented yet");
  544. }
  545. case "raw":
  546. {
  547. throw new Error("KaTeX-a11y: raw not implemented yet");
  548. }
  549. case "size":
  550. {
  551. // Although there are nodes of type "size" in the parse tree, they have
  552. // no semantic meaning and should be ignored.
  553. break;
  554. }
  555. case "url":
  556. {
  557. throw new Error("KaTeX-a11y: url not implemented yet");
  558. }
  559. case "tag":
  560. {
  561. throw new Error("KaTeX-a11y: tag not implemented yet");
  562. }
  563. case "verb":
  564. {
  565. buildString("start verbatim", "normal", a11yStrings);
  566. buildString(tree.body, "normal", a11yStrings);
  567. buildString("end verbatim", "normal", a11yStrings);
  568. break;
  569. }
  570. case "environment":
  571. {
  572. throw new Error("KaTeX-a11y: environment not implemented yet");
  573. }
  574. case "horizBrace":
  575. {
  576. buildString("start " + tree.label.slice(1), "normal", a11yStrings);
  577. _buildA11yStrings(tree.base, a11yStrings, atomType);
  578. buildString("end " + tree.label.slice(1), "normal", a11yStrings);
  579. break;
  580. }
  581. case "infix":
  582. {
  583. // All infix nodes are replace with other nodes.
  584. break;
  585. }
  586. case "includegraphics":
  587. {
  588. throw new Error("KaTeX-a11y: includegraphics not implemented yet");
  589. }
  590. case "font":
  591. {
  592. // TODO: callout the start/end of specific fonts
  593. // TODO: map \BBb{N} to "the naturals" or something like that
  594. _buildA11yStrings(tree.body, a11yStrings, atomType);
  595. break;
  596. }
  597. case "href":
  598. {
  599. throw new Error("KaTeX-a11y: href not implemented yet");
  600. }
  601. case "cr":
  602. {
  603. // This is used by environments.
  604. throw new Error("KaTeX-a11y: cr not implemented yet");
  605. }
  606. case "underline":
  607. {
  608. buildRegion(a11yStrings, function (a11yStrings) {
  609. a11yStrings.push("start underline");
  610. _buildA11yStrings(tree.body, a11yStrings, atomType);
  611. a11yStrings.push("end underline");
  612. });
  613. break;
  614. }
  615. case "xArrow":
  616. {
  617. throw new Error("KaTeX-a11y: xArrow not implemented yet");
  618. }
  619. case "cdlabel":
  620. {
  621. throw new Error("KaTeX-a11y: cdlabel not implemented yet");
  622. }
  623. case "cdlabelparent":
  624. {
  625. throw new Error("KaTeX-a11y: cdlabelparent not implemented yet");
  626. }
  627. case "mclass":
  628. {
  629. // \neq and \ne are macros so we let "htmlmathml" render the mathmal
  630. // side of things and extract the text from that.
  631. var _atomType = tree.mclass.slice(1);
  632. // TODO(ts): drop the leading "m" from the values in mclass
  633. _buildA11yStrings(tree.body, a11yStrings, _atomType);
  634. break;
  635. }
  636. case "mathchoice":
  637. {
  638. // TODO: track which style we're using, e.g. display, text, etc.
  639. // default to text style if even that may not be the correct style
  640. _buildA11yStrings(tree.text, a11yStrings, atomType);
  641. break;
  642. }
  643. case "htmlmathml":
  644. {
  645. _buildA11yStrings(tree.mathml, a11yStrings, atomType);
  646. break;
  647. }
  648. case "middle":
  649. {
  650. buildString(tree.delim, atomType, a11yStrings);
  651. break;
  652. }
  653. case "internal":
  654. {
  655. // internal nodes are never included in the parse tree
  656. break;
  657. }
  658. case "html":
  659. {
  660. _buildA11yStrings(tree.body, a11yStrings, atomType);
  661. break;
  662. }
  663. default:
  664. throw new Error("KaTeX a11y un-recognized type: " + tree.type);
  665. }
  666. };
  667. var _buildA11yStrings = function buildA11yStrings(tree, a11yStrings, atomType) {
  668. if (a11yStrings === void 0) {
  669. a11yStrings = [];
  670. }
  671. if (tree instanceof Array) {
  672. for (var i = 0; i < tree.length; i++) {
  673. _buildA11yStrings(tree[i], a11yStrings, atomType);
  674. }
  675. } else {
  676. handleObject(tree, a11yStrings, atomType);
  677. }
  678. return a11yStrings;
  679. };
  680. var _flatten = function flatten(array) {
  681. var result = [];
  682. array.forEach(function (item) {
  683. if (Array.isArray(item)) {
  684. result = result.concat(_flatten(item));
  685. } else {
  686. result.push(item);
  687. }
  688. });
  689. return result;
  690. };
  691. var renderA11yString = function renderA11yString(text, settings) {
  692. var tree = katex.__parse(text, settings);
  693. var a11yStrings = _buildA11yStrings(tree, [], "normal");
  694. return _flatten(a11yStrings).join(", ");
  695. };
  696. export { renderA11yString as default };