www

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

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;