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