render-a11y-string.ts 22 KB

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