UNPKG

19.5 kBJavaScriptView Raw
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 case "internal":
701 {
702 // internal nodes are never included in the parse tree
703 break;
704 }
705
706 case "html":
707 {
708 buildA11yStrings(tree.body, a11yStrings, atomType);
709 break;
710 }
711
712 default:
713 tree.type;
714 throw new Error("KaTeX a11y un-recognized type: " + tree.type);
715 }
716};
717
718const buildA11yStrings = function buildA11yStrings(tree, a11yStrings, atomType) {
719 if (a11yStrings === void 0) {
720 a11yStrings = [];
721 }
722
723 if (tree instanceof Array) {
724 for (let i = 0; i < tree.length; i++) {
725 buildA11yStrings(tree[i], a11yStrings, atomType);
726 }
727 } else {
728 handleObject(tree, a11yStrings, atomType);
729 }
730
731 return a11yStrings;
732};
733
734const flatten = function flatten(array) {
735 let result = [];
736 array.forEach(function (item) {
737 if (item instanceof Array) {
738 result = result.concat(flatten(item));
739 } else {
740 result.push(item);
741 }
742 });
743 return result;
744};
745
746const renderA11yString = function renderA11yString(text, settings) {
747 const tree = katex.__parse(text, settings);
748
749 const a11yStrings = buildA11yStrings(tree, [], "normal");
750 return flatten(a11yStrings).join(", ");
751};
752
753export default renderA11yString;