• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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