render-a11y-string.mjs (20200B)
1 import 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 */ 18 var 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 }; 91 var powerMap = { 92 "prime": "prime", 93 "degree": "degrees", 94 "circle": "degrees", 95 "2": "squared", 96 "3": "cubed" 97 }; 98 var openMap = { 99 "|": "open vertical bar", 100 ".": "" 101 }; 102 var closeMap = { 103 "|": "close vertical bar", 104 ".": "" 105 }; 106 var 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 }; 118 var 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 }; 134 var 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 143 var buildString = (str, type, a11yStrings) => { 144 if (!str) { 145 return; 146 } 147 148 var 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 168 // $FlowFixMe 169 /^\d+$/.test(a11yStrings[a11yStrings.length - 1])) { 170 a11yStrings[a11yStrings.length - 1] += ret; 171 } else if (ret) { 172 a11yStrings.push(ret); 173 } 174 }; 175 176 var buildRegion = (a11yStrings, callback) => { 177 var regionStrings = []; 178 a11yStrings.push(regionStrings); 179 callback(regionStrings); 180 }; 181 182 var handleObject = (tree, a11yStrings, atomType) => { 183 // Everything else is assumed to be an object... 184 switch (tree.type) { 185 case "accent": 186 { 187 buildRegion(a11yStrings, a11yStrings => { 188 buildA11yStrings(tree.base, a11yStrings, atomType); 189 a11yStrings.push("with"); 190 buildString(tree.label, "normal", a11yStrings); 191 a11yStrings.push("on top"); 192 }); 193 break; 194 } 195 196 case "accentUnder": 197 { 198 buildRegion(a11yStrings, a11yStrings => { 199 buildA11yStrings(tree.base, a11yStrings, atomType); 200 a11yStrings.push("with"); 201 buildString(accentUnderMap[tree.label], "normal", a11yStrings); 202 a11yStrings.push("underneath"); 203 }); 204 break; 205 } 206 207 case "accent-token": 208 { 209 // Used internally by accent symbols. 210 break; 211 } 212 213 case "atom": 214 { 215 var { 216 text 217 } = tree; 218 219 switch (tree.family) { 220 case "bin": 221 { 222 buildString(text, "bin", a11yStrings); 223 break; 224 } 225 226 case "close": 227 { 228 buildString(text, "close", a11yStrings); 229 break; 230 } 231 // TODO(kevinb): figure out what should be done for inner 232 233 case "inner": 234 { 235 buildString(tree.text, "inner", a11yStrings); 236 break; 237 } 238 239 case "open": 240 { 241 buildString(text, "open", a11yStrings); 242 break; 243 } 244 245 case "punct": 246 { 247 buildString(text, "punct", a11yStrings); 248 break; 249 } 250 251 case "rel": 252 { 253 buildString(text, "rel", a11yStrings); 254 break; 255 } 256 257 default: 258 { 259 tree.family; 260 throw new Error("\"" + tree.family + "\" is not a valid atom type"); 261 } 262 } 263 264 break; 265 } 266 267 case "color": 268 { 269 var color = tree.color.replace(/katex-/, ""); 270 buildRegion(a11yStrings, regionStrings => { 271 regionStrings.push("start color " + color); 272 buildA11yStrings(tree.body, regionStrings, atomType); 273 regionStrings.push("end color " + color); 274 }); 275 break; 276 } 277 278 case "color-token": 279 { 280 // Used by \color, \colorbox, and \fcolorbox but not directly rendered. 281 // It's a leaf node and has no children so just break. 282 break; 283 } 284 285 case "delimsizing": 286 { 287 if (tree.delim && tree.delim !== ".") { 288 buildString(tree.delim, "normal", a11yStrings); 289 } 290 291 break; 292 } 293 294 case "genfrac": 295 { 296 buildRegion(a11yStrings, regionStrings => { 297 // genfrac can have unbalanced delimiters 298 var { 299 leftDelim, 300 rightDelim 301 } = tree; // NOTE: Not sure if this is a safe assumption 302 // hasBarLine true -> fraction, false -> binomial 303 304 if (tree.hasBarLine) { 305 regionStrings.push("start fraction"); 306 leftDelim && buildString(leftDelim, "open", regionStrings); 307 buildA11yStrings(tree.numer, regionStrings, atomType); 308 regionStrings.push("divided by"); 309 buildA11yStrings(tree.denom, regionStrings, atomType); 310 rightDelim && buildString(rightDelim, "close", regionStrings); 311 regionStrings.push("end fraction"); 312 } else { 313 regionStrings.push("start binomial"); 314 leftDelim && buildString(leftDelim, "open", regionStrings); 315 buildA11yStrings(tree.numer, regionStrings, atomType); 316 regionStrings.push("over"); 317 buildA11yStrings(tree.denom, regionStrings, atomType); 318 rightDelim && buildString(rightDelim, "close", regionStrings); 319 regionStrings.push("end binomial"); 320 } 321 }); 322 break; 323 } 324 325 case "hbox": 326 { 327 buildA11yStrings(tree.body, a11yStrings, atomType); 328 break; 329 } 330 331 case "kern": 332 { 333 // No op: we don't attempt to present kerning information 334 // to the screen reader. 335 break; 336 } 337 338 case "leftright": 339 { 340 buildRegion(a11yStrings, regionStrings => { 341 buildString(tree.left, "open", regionStrings); 342 buildA11yStrings(tree.body, regionStrings, atomType); 343 buildString(tree.right, "close", regionStrings); 344 }); 345 break; 346 } 347 348 case "leftright-right": 349 { 350 // TODO: double check that this is a no-op 351 break; 352 } 353 354 case "lap": 355 { 356 buildA11yStrings(tree.body, a11yStrings, atomType); 357 break; 358 } 359 360 case "mathord": 361 { 362 buildString(tree.text, "normal", a11yStrings); 363 break; 364 } 365 366 case "op": 367 { 368 var { 369 body, 370 name 371 } = tree; 372 373 if (body) { 374 buildA11yStrings(body, a11yStrings, atomType); 375 } else if (name) { 376 buildString(name, "normal", a11yStrings); 377 } 378 379 break; 380 } 381 382 case "op-token": 383 { 384 // Used internally by operator symbols. 385 buildString(tree.text, atomType, a11yStrings); 386 break; 387 } 388 389 case "ordgroup": 390 { 391 buildA11yStrings(tree.body, a11yStrings, atomType); 392 break; 393 } 394 395 case "overline": 396 { 397 buildRegion(a11yStrings, function (a11yStrings) { 398 a11yStrings.push("start overline"); 399 buildA11yStrings(tree.body, a11yStrings, atomType); 400 a11yStrings.push("end overline"); 401 }); 402 break; 403 } 404 405 case "phantom": 406 { 407 a11yStrings.push("empty space"); 408 break; 409 } 410 411 case "raisebox": 412 { 413 buildA11yStrings(tree.body, a11yStrings, atomType); 414 break; 415 } 416 417 case "rule": 418 { 419 a11yStrings.push("rectangle"); 420 break; 421 } 422 423 case "sizing": 424 { 425 buildA11yStrings(tree.body, a11yStrings, atomType); 426 break; 427 } 428 429 case "spacing": 430 { 431 a11yStrings.push("space"); 432 break; 433 } 434 435 case "styling": 436 { 437 // We ignore the styling and just pass through the contents 438 buildA11yStrings(tree.body, a11yStrings, atomType); 439 break; 440 } 441 442 case "sqrt": 443 { 444 buildRegion(a11yStrings, regionStrings => { 445 var { 446 body, 447 index 448 } = tree; 449 450 if (index) { 451 var indexString = flatten(buildA11yStrings(index, [], atomType)).join(","); 452 453 if (indexString === "3") { 454 regionStrings.push("cube root of"); 455 buildA11yStrings(body, regionStrings, atomType); 456 regionStrings.push("end cube root"); 457 return; 458 } 459 460 regionStrings.push("root"); 461 regionStrings.push("start index"); 462 buildA11yStrings(index, regionStrings, atomType); 463 regionStrings.push("end index"); 464 return; 465 } 466 467 regionStrings.push("square root of"); 468 buildA11yStrings(body, regionStrings, atomType); 469 regionStrings.push("end square root"); 470 }); 471 break; 472 } 473 474 case "supsub": 475 { 476 var { 477 base, 478 sub, 479 sup 480 } = tree; 481 var isLog = false; 482 483 if (base) { 484 buildA11yStrings(base, a11yStrings, atomType); 485 isLog = base.type === "op" && base.name === "\\log"; 486 } 487 488 if (sub) { 489 var regionName = isLog ? "base" : "subscript"; 490 buildRegion(a11yStrings, function (regionStrings) { 491 regionStrings.push("start " + regionName); 492 buildA11yStrings(sub, regionStrings, atomType); 493 regionStrings.push("end " + regionName); 494 }); 495 } 496 497 if (sup) { 498 buildRegion(a11yStrings, function (regionStrings) { 499 var supString = flatten(buildA11yStrings(sup, [], atomType)).join(","); 500 501 if (supString in powerMap) { 502 regionStrings.push(powerMap[supString]); 503 return; 504 } 505 506 regionStrings.push("start superscript"); 507 buildA11yStrings(sup, regionStrings, atomType); 508 regionStrings.push("end superscript"); 509 }); 510 } 511 512 break; 513 } 514 515 case "text": 516 { 517 // TODO: handle other fonts 518 if (tree.font === "\\textbf") { 519 buildRegion(a11yStrings, function (regionStrings) { 520 regionStrings.push("start bold text"); 521 buildA11yStrings(tree.body, regionStrings, atomType); 522 regionStrings.push("end bold text"); 523 }); 524 break; 525 } 526 527 buildRegion(a11yStrings, function (regionStrings) { 528 regionStrings.push("start text"); 529 buildA11yStrings(tree.body, regionStrings, atomType); 530 regionStrings.push("end text"); 531 }); 532 break; 533 } 534 535 case "textord": 536 { 537 buildString(tree.text, atomType, a11yStrings); 538 break; 539 } 540 541 case "smash": 542 { 543 buildA11yStrings(tree.body, a11yStrings, atomType); 544 break; 545 } 546 547 case "enclose": 548 { 549 // TODO: create a map for these. 550 // TODO: differentiate between a body with a single atom, e.g. 551 // "cancel a" instead of "start cancel, a, end cancel" 552 if (/cancel/.test(tree.label)) { 553 buildRegion(a11yStrings, function (regionStrings) { 554 regionStrings.push("start cancel"); 555 buildA11yStrings(tree.body, regionStrings, atomType); 556 regionStrings.push("end cancel"); 557 }); 558 break; 559 } else if (/box/.test(tree.label)) { 560 buildRegion(a11yStrings, function (regionStrings) { 561 regionStrings.push("start box"); 562 buildA11yStrings(tree.body, regionStrings, atomType); 563 regionStrings.push("end box"); 564 }); 565 break; 566 } else if (/sout/.test(tree.label)) { 567 buildRegion(a11yStrings, function (regionStrings) { 568 regionStrings.push("start strikeout"); 569 buildA11yStrings(tree.body, regionStrings, atomType); 570 regionStrings.push("end strikeout"); 571 }); 572 break; 573 } else if (/phase/.test(tree.label)) { 574 buildRegion(a11yStrings, function (regionStrings) { 575 regionStrings.push("start phase angle"); 576 buildA11yStrings(tree.body, regionStrings, atomType); 577 regionStrings.push("end phase angle"); 578 }); 579 break; 580 } 581 582 throw new Error("KaTeX-a11y: enclose node with " + tree.label + " not supported yet"); 583 } 584 585 case "vcenter": 586 { 587 buildA11yStrings(tree.body, a11yStrings, atomType); 588 break; 589 } 590 591 case "vphantom": 592 { 593 throw new Error("KaTeX-a11y: vphantom not implemented yet"); 594 } 595 596 case "hphantom": 597 { 598 throw new Error("KaTeX-a11y: hphantom not implemented yet"); 599 } 600 601 case "operatorname": 602 { 603 buildA11yStrings(tree.body, a11yStrings, atomType); 604 break; 605 } 606 607 case "array": 608 { 609 throw new Error("KaTeX-a11y: array not implemented yet"); 610 } 611 612 case "raw": 613 { 614 throw new Error("KaTeX-a11y: raw not implemented yet"); 615 } 616 617 case "size": 618 { 619 // Although there are nodes of type "size" in the parse tree, they have 620 // no semantic meaning and should be ignored. 621 break; 622 } 623 624 case "url": 625 { 626 throw new Error("KaTeX-a11y: url not implemented yet"); 627 } 628 629 case "tag": 630 { 631 throw new Error("KaTeX-a11y: tag not implemented yet"); 632 } 633 634 case "verb": 635 { 636 buildString("start verbatim", "normal", a11yStrings); 637 buildString(tree.body, "normal", a11yStrings); 638 buildString("end verbatim", "normal", a11yStrings); 639 break; 640 } 641 642 case "environment": 643 { 644 throw new Error("KaTeX-a11y: environment not implemented yet"); 645 } 646 647 case "horizBrace": 648 { 649 buildString("start " + tree.label.slice(1), "normal", a11yStrings); 650 buildA11yStrings(tree.base, a11yStrings, atomType); 651 buildString("end " + tree.label.slice(1), "normal", a11yStrings); 652 break; 653 } 654 655 case "infix": 656 { 657 // All infix nodes are replace with other nodes. 658 break; 659 } 660 661 case "includegraphics": 662 { 663 throw new Error("KaTeX-a11y: includegraphics not implemented yet"); 664 } 665 666 case "font": 667 { 668 // TODO: callout the start/end of specific fonts 669 // TODO: map \BBb{N} to "the naturals" or something like that 670 buildA11yStrings(tree.body, a11yStrings, atomType); 671 break; 672 } 673 674 case "href": 675 { 676 throw new Error("KaTeX-a11y: href not implemented yet"); 677 } 678 679 case "cr": 680 { 681 // This is used by environments. 682 throw new Error("KaTeX-a11y: cr not implemented yet"); 683 } 684 685 case "underline": 686 { 687 buildRegion(a11yStrings, function (a11yStrings) { 688 a11yStrings.push("start underline"); 689 buildA11yStrings(tree.body, a11yStrings, atomType); 690 a11yStrings.push("end underline"); 691 }); 692 break; 693 } 694 695 case "xArrow": 696 { 697 throw new Error("KaTeX-a11y: xArrow not implemented yet"); 698 } 699 700 case "cdlabel": 701 { 702 throw new Error("KaTeX-a11y: cdlabel not implemented yet"); 703 } 704 705 case "cdlabelparent": 706 { 707 throw new Error("KaTeX-a11y: cdlabelparent not implemented yet"); 708 } 709 710 case "mclass": 711 { 712 // \neq and \ne are macros so we let "htmlmathml" render the mathmal 713 // side of things and extract the text from that. 714 var _atomType = tree.mclass.slice(1); // $FlowFixMe: drop the leading "m" from the values in mclass 715 716 717 buildA11yStrings(tree.body, a11yStrings, _atomType); 718 break; 719 } 720 721 case "mathchoice": 722 { 723 // TODO: track which which style we're using, e.g. dispaly, text, etc. 724 // default to text style if even that may not be the correct style 725 buildA11yStrings(tree.text, a11yStrings, atomType); 726 break; 727 } 728 729 case "htmlmathml": 730 { 731 buildA11yStrings(tree.mathml, a11yStrings, atomType); 732 break; 733 } 734 735 case "middle": 736 { 737 buildString(tree.delim, atomType, a11yStrings); 738 break; 739 } 740 741 case "internal": 742 { 743 // internal nodes are never included in the parse tree 744 break; 745 } 746 747 case "html": 748 { 749 buildA11yStrings(tree.body, a11yStrings, atomType); 750 break; 751 } 752 753 default: 754 tree.type; 755 throw new Error("KaTeX a11y un-recognized type: " + tree.type); 756 } 757 }; 758 759 var buildA11yStrings = function buildA11yStrings(tree, a11yStrings, atomType) { 760 if (a11yStrings === void 0) { 761 a11yStrings = []; 762 } 763 764 if (tree instanceof Array) { 765 for (var i = 0; i < tree.length; i++) { 766 buildA11yStrings(tree[i], a11yStrings, atomType); 767 } 768 } else { 769 handleObject(tree, a11yStrings, atomType); 770 } 771 772 return a11yStrings; 773 }; 774 775 var flatten = function flatten(array) { 776 var result = []; 777 array.forEach(function (item) { 778 if (item instanceof Array) { 779 result = result.concat(flatten(item)); 780 } else { 781 result.push(item); 782 } 783 }); 784 return result; 785 }; 786 787 var renderA11yString = function renderA11yString(text, settings) { 788 var tree = katex.__parse(text, settings); 789 790 var a11yStrings = buildA11yStrings(tree, [], "normal"); 791 return flatten(a11yStrings).join(", "); 792 }; 793 794 export default renderA11yString;