// @flow /** * renderA11yString returns a readable string. * * In some cases the string will have the proper semantic math * meaning,: * renderA11yString("\\frac{1}{2}"") * -> "start fraction, 1, divided by, 2, end fraction" * * However, other cases do not: * renderA11yString("f(x) = x^2") * -> "f, left parenthesis, x, right parenthesis, equals, x, squared" * * The commas in the string aim to increase ease of understanding * when read by a screenreader. */ // NOTE: since we're importing types here these files won't actually be // included in the build. import type {Atom} from "../../src/symbols"; import type {AnyParseNode} from "../../src/parseNode"; import type {SettingsOptions} from "../../src/Settings"; // $FlowIgnore: we import the types directly anyways import katex from "katex"; const stringMap = { "(": "left parenthesis", ")": "right parenthesis", "[": "open bracket", "]": "close bracket", "\\{": "left brace", "\\}": "right brace", "\\lvert": "open vertical bar", "\\rvert": "close vertical bar", "|": "vertical bar", "\\uparrow": "up arrow", "\\Uparrow": "up arrow", "\\downarrow": "down arrow", "\\Downarrow": "down arrow", "\\updownarrow": "up down arrow", "\\leftarrow": "left arrow", "\\Leftarrow": "left arrow", "\\rightarrow": "right arrow", "\\Rightarrow": "right arrow", "\\langle": "open angle", "\\rangle": "close angle", "\\lfloor": "open floor", "\\rfloor": "close floor", "\\int": "integral", "\\intop": "integral", "\\lim": "limit", "\\ln": "natural log", "\\log": "log", "\\sin": "sine", "\\cos": "cosine", "\\tan": "tangent", "\\cot": "cotangent", "\\sum": "sum", "/": "slash", ",": "comma", ".": "point", "-": "negative", "+": "plus", "~": "tilde", ":": "colon", "?": "question mark", "'": "apostrophe", "\\%": "percent", " ": "space", "\\ ": "space", "\\$": "dollar sign", "\\angle": "angle", "\\degree": "degree", "\\circ": "circle", "\\vec": "vector", "\\triangle": "triangle", "\\pi": "pi", "\\prime": "prime", "\\infty": "infinity", "\\alpha": "alpha", "\\beta": "beta", "\\gamma": "gamma", "\\omega": "omega", "\\theta": "theta", "\\sigma": "sigma", "\\lambda": "lambda", "\\tau": "tau", "\\Delta": "delta", "\\delta": "delta", "\\mu": "mu", "\\rho": "rho", "\\nabla": "del", "\\ell": "ell", "\\ldots": "dots", // TODO: add entries for all accents "\\hat": "hat", "\\acute": "acute", }; const powerMap = { "prime": "prime", "degree": "degrees", "circle": "degrees", "2": "squared", "3": "cubed", }; const openMap = { "|": "open vertical bar", ".": "", }; const closeMap = { "|": "close vertical bar", ".": "", }; const binMap = { "+": "plus", "-": "minus", "\\pm": "plus minus", "\\cdot": "dot", "*": "times", "/": "divided by", "\\times": "times", "\\div": "divided by", "\\circ": "circle", "\\bullet": "bullet", }; const relMap = { "=": "equals", "\\approx": "approximately equals", "≠": "does not equal", "\\geq": "is greater than or equal to", "\\ge": "is greater than or equal to", "\\leq": "is less than or equal to", "\\le": "is less than or equal to", ">": "is greater than", "<": "is less than", "\\leftarrow": "left arrow", "\\Leftarrow": "left arrow", "\\rightarrow": "right arrow", "\\Rightarrow": "right arrow", ":": "colon", }; const accentUnderMap = { "\\underleftarrow": "left arrow", "\\underrightarrow": "right arrow", "\\underleftrightarrow": "left-right arrow", "\\undergroup": "group", "\\underlinesegment": "line segment", "\\utilde": "tilde", }; type NestedArray = Array>; const buildString = ( str: string, type: Atom | "normal", a11yStrings: NestedArray, ) => { if (!str) { return; } let ret; if (type === "open") { ret = str in openMap ? openMap[str] : stringMap[str] || str; } else if (type === "close") { ret = str in closeMap ? closeMap[str] : stringMap[str] || str; } else if (type === "bin") { ret = binMap[str] || str; } else if (type === "rel") { ret = relMap[str] || str; } else { ret = stringMap[str] || str; } // If the text to add is a number and there is already a string // in the list and the last string is a number then we should // combine them into a single number if ( /^\d+$/.test(ret) && a11yStrings.length > 0 && // TODO(kevinb): check that the last item in a11yStrings is a string // I think we might be able to drop the nested arrays, which would make // this easier to type // $FlowFixMe /^\d+$/.test(a11yStrings[a11yStrings.length - 1]) ) { a11yStrings[a11yStrings.length - 1] += ret; } else if (ret) { a11yStrings.push(ret); } }; const buildRegion = ( a11yStrings: NestedArray, callback: (regionStrings: NestedArray) => void, ) => { const regionStrings: NestedArray = []; a11yStrings.push(regionStrings); callback(regionStrings); }; const handleObject = ( tree: AnyParseNode, a11yStrings: NestedArray, atomType: Atom | "normal", ) => { // Everything else is assumed to be an object... switch (tree.type) { case "accent": { buildRegion(a11yStrings, (a11yStrings) => { buildA11yStrings(tree.base, a11yStrings, atomType); a11yStrings.push("with"); buildString(tree.label, "normal", a11yStrings); a11yStrings.push("on top"); }); break; } case "accentUnder": { buildRegion(a11yStrings, (a11yStrings) => { buildA11yStrings(tree.base, a11yStrings, atomType); a11yStrings.push("with"); buildString(accentUnderMap[tree.label], "normal", a11yStrings); a11yStrings.push("underneath"); }); break; } case "accent-token": { // Used internally by accent symbols. break; } case "atom": { const {text} = tree; switch (tree.family) { case "bin": { buildString(text, "bin", a11yStrings); break; } case "close": { buildString(text, "close", a11yStrings); break; } // TODO(kevinb): figure out what should be done for inner case "inner": { buildString(tree.text, "inner", a11yStrings); break; } case "open": { buildString(text, "open", a11yStrings); break; } case "punct": { buildString(text, "punct", a11yStrings); break; } case "rel": { buildString(text, "rel", a11yStrings); break; } default: { (tree.family: empty); throw new Error(`"${tree.family}" is not a valid atom type`); } } break; } case "color": { const color = tree.color.replace(/katex-/, ""); buildRegion(a11yStrings, (regionStrings) => { regionStrings.push("start color " + color); buildA11yStrings(tree.body, regionStrings, atomType); regionStrings.push("end color " + color); }); break; } case "color-token": { // Used by \color, \colorbox, and \fcolorbox but not directly rendered. // It's a leaf node and has no children so just break. break; } case "delimsizing": { if (tree.delim && tree.delim !== ".") { buildString(tree.delim, "normal", a11yStrings); } break; } case "genfrac": { buildRegion(a11yStrings, (regionStrings) => { // genfrac can have unbalanced delimiters const {leftDelim, rightDelim} = tree; // NOTE: Not sure if this is a safe assumption // hasBarLine true -> fraction, false -> binomial if (tree.hasBarLine) { regionStrings.push("start fraction"); leftDelim && buildString(leftDelim, "open", regionStrings); buildA11yStrings(tree.numer, regionStrings, atomType); regionStrings.push("divided by"); buildA11yStrings(tree.denom, regionStrings, atomType); rightDelim && buildString(rightDelim, "close", regionStrings); regionStrings.push("end fraction"); } else { regionStrings.push("start binomial"); leftDelim && buildString(leftDelim, "open", regionStrings); buildA11yStrings(tree.numer, regionStrings, atomType); regionStrings.push("over"); buildA11yStrings(tree.denom, regionStrings, atomType); rightDelim && buildString(rightDelim, "close", regionStrings); regionStrings.push("end binomial"); } }); break; } case "hbox": { buildA11yStrings(tree.body, a11yStrings, atomType); break; } case "kern": { // No op: we don't attempt to present kerning information // to the screen reader. break; } case "leftright": { buildRegion(a11yStrings, (regionStrings) => { buildString(tree.left, "open", regionStrings); buildA11yStrings(tree.body, regionStrings, atomType); buildString(tree.right, "close", regionStrings); }); break; } case "leftright-right": { // TODO: double check that this is a no-op break; } case "lap": { buildA11yStrings(tree.body, a11yStrings, atomType); break; } case "mathord": { buildString(tree.text, "normal", a11yStrings); break; } case "op": { const {body, name} = tree; if (body) { buildA11yStrings(body, a11yStrings, atomType); } else if (name) { buildString(name, "normal", a11yStrings); } break; } case "op-token": { // Used internally by operator symbols. buildString(tree.text, atomType, a11yStrings); break; } case "ordgroup": { buildA11yStrings(tree.body, a11yStrings, atomType); break; } case "overline": { buildRegion(a11yStrings, function(a11yStrings) { a11yStrings.push("start overline"); buildA11yStrings(tree.body, a11yStrings, atomType); a11yStrings.push("end overline"); }); break; } case "pmb": { a11yStrings.push("bold"); break; } case "phantom": { a11yStrings.push("empty space"); break; } case "raisebox": { buildA11yStrings(tree.body, a11yStrings, atomType); break; } case "rule": { a11yStrings.push("rectangle"); break; } case "sizing": { buildA11yStrings(tree.body, a11yStrings, atomType); break; } case "spacing": { a11yStrings.push("space"); break; } case "styling": { // We ignore the styling and just pass through the contents buildA11yStrings(tree.body, a11yStrings, atomType); break; } case "sqrt": { buildRegion(a11yStrings, (regionStrings) => { const {body, index} = tree; if (index) { const indexString = flatten( buildA11yStrings(index, [], atomType)).join(","); if (indexString === "3") { regionStrings.push("cube root of"); buildA11yStrings(body, regionStrings, atomType); regionStrings.push("end cube root"); return; } regionStrings.push("root"); regionStrings.push("start index"); buildA11yStrings(index, regionStrings, atomType); regionStrings.push("end index"); return; } regionStrings.push("square root of"); buildA11yStrings(body, regionStrings, atomType); regionStrings.push("end square root"); }); break; } case "supsub": { const {base, sub, sup} = tree; let isLog = false; if (base) { buildA11yStrings(base, a11yStrings, atomType); isLog = base.type === "op" && base.name === "\\log"; } if (sub) { const regionName = isLog ? "base" : "subscript"; buildRegion(a11yStrings, function(regionStrings) { regionStrings.push(`start ${regionName}`); buildA11yStrings(sub, regionStrings, atomType); regionStrings.push(`end ${regionName}`); }); } if (sup) { buildRegion(a11yStrings, function(regionStrings) { const supString = flatten( buildA11yStrings(sup, [], atomType)).join(","); if (supString in powerMap) { regionStrings.push(powerMap[supString]); return; } regionStrings.push("start superscript"); buildA11yStrings(sup, regionStrings, atomType); regionStrings.push("end superscript"); }); } break; } case "text": { // TODO: handle other fonts if (tree.font === "\\textbf") { buildRegion(a11yStrings, function(regionStrings) { regionStrings.push("start bold text"); buildA11yStrings(tree.body, regionStrings, atomType); regionStrings.push("end bold text"); }); break; } buildRegion(a11yStrings, function(regionStrings) { regionStrings.push("start text"); buildA11yStrings(tree.body, regionStrings, atomType); regionStrings.push("end text"); }); break; } case "textord": { buildString(tree.text, atomType, a11yStrings); break; } case "smash": { buildA11yStrings(tree.body, a11yStrings, atomType); break; } case "enclose": { // TODO: create a map for these. // TODO: differentiate between a body with a single atom, e.g. // "cancel a" instead of "start cancel, a, end cancel" if (/cancel/.test(tree.label)) { buildRegion(a11yStrings, function(regionStrings) { regionStrings.push("start cancel"); buildA11yStrings(tree.body, regionStrings, atomType); regionStrings.push("end cancel"); }); break; } else if (/box/.test(tree.label)) { buildRegion(a11yStrings, function(regionStrings) { regionStrings.push("start box"); buildA11yStrings(tree.body, regionStrings, atomType); regionStrings.push("end box"); }); break; } else if (/sout/.test(tree.label)) { buildRegion(a11yStrings, function(regionStrings) { regionStrings.push("start strikeout"); buildA11yStrings(tree.body, regionStrings, atomType); regionStrings.push("end strikeout"); }); break; } else if (/phase/.test(tree.label)) { buildRegion(a11yStrings, function(regionStrings) { regionStrings.push("start phase angle"); buildA11yStrings(tree.body, regionStrings, atomType); regionStrings.push("end phase angle"); }); break; } throw new Error( `KaTeX-a11y: enclose node with ${tree.label} not supported yet`); } case "vcenter": { buildA11yStrings(tree.body, a11yStrings, atomType); break; } case "vphantom": { throw new Error("KaTeX-a11y: vphantom not implemented yet"); } case "hphantom": { throw new Error("KaTeX-a11y: hphantom not implemented yet"); } case "operatorname": { buildA11yStrings(tree.body, a11yStrings, atomType); break; } case "array": { throw new Error("KaTeX-a11y: array not implemented yet"); } case "raw": { throw new Error("KaTeX-a11y: raw not implemented yet"); } case "size": { // Although there are nodes of type "size" in the parse tree, they have // no semantic meaning and should be ignored. break; } case "url": { throw new Error("KaTeX-a11y: url not implemented yet"); } case "tag": { throw new Error("KaTeX-a11y: tag not implemented yet"); } case "verb": { buildString(`start verbatim`, "normal", a11yStrings); buildString(tree.body, "normal", a11yStrings); buildString(`end verbatim`, "normal", a11yStrings); break; } case "environment": { throw new Error("KaTeX-a11y: environment not implemented yet"); } case "horizBrace": { buildString(`start ${tree.label.slice(1)}`, "normal", a11yStrings); buildA11yStrings(tree.base, a11yStrings, atomType); buildString(`end ${tree.label.slice(1)}`, "normal", a11yStrings); break; } case "infix": { // All infix nodes are replace with other nodes. break; } case "includegraphics": { throw new Error("KaTeX-a11y: includegraphics not implemented yet"); } case "font": { // TODO: callout the start/end of specific fonts // TODO: map \BBb{N} to "the naturals" or something like that buildA11yStrings(tree.body, a11yStrings, atomType); break; } case "href": { throw new Error("KaTeX-a11y: href not implemented yet"); } case "cr": { // This is used by environments. throw new Error("KaTeX-a11y: cr not implemented yet"); } case "underline": { buildRegion(a11yStrings, function(a11yStrings) { a11yStrings.push("start underline"); buildA11yStrings(tree.body, a11yStrings, atomType); a11yStrings.push("end underline"); }); break; } case "xArrow": { throw new Error("KaTeX-a11y: xArrow not implemented yet"); } case "cdlabel": { throw new Error("KaTeX-a11y: cdlabel not implemented yet"); } case "cdlabelparent": { throw new Error("KaTeX-a11y: cdlabelparent not implemented yet"); } case "mclass": { // \neq and \ne are macros so we let "htmlmathml" render the mathmal // side of things and extract the text from that. const atomType = tree.mclass.slice(1); // $FlowFixMe: drop the leading "m" from the values in mclass buildA11yStrings(tree.body, a11yStrings, atomType); break; } case "mathchoice": { // TODO: track which style we're using, e.g. display, text, etc. // default to text style if even that may not be the correct style buildA11yStrings(tree.text, a11yStrings, atomType); break; } case "htmlmathml": { buildA11yStrings(tree.mathml, a11yStrings, atomType); break; } case "middle": { buildString(tree.delim, atomType, a11yStrings); break; } case "internal": { // internal nodes are never included in the parse tree break; } case "html": { buildA11yStrings(tree.body, a11yStrings, atomType); break; } default: (tree.type: empty); throw new Error("KaTeX a11y un-recognized type: " + tree.type); } }; const buildA11yStrings = ( tree: AnyParseNode | AnyParseNode[], a11yStrings: NestedArray = [], atomType: Atom | "normal", ) => { if (tree instanceof Array) { for (let i = 0; i < tree.length; i++) { buildA11yStrings(tree[i], a11yStrings, atomType); } } else { handleObject(tree, a11yStrings, atomType); } return a11yStrings; }; const flatten = function(array) { let result = []; array.forEach(function(item) { if (item instanceof Array) { result = result.concat(flatten(item)); } else { result.push(item); } }); return result; }; const renderA11yString = function( text: string, settings?: SettingsOptions, ): string { const tree = katex.__parse(text, settings); const a11yStrings = buildA11yStrings(tree, [], "normal"); return flatten(a11yStrings).join(", "); }; export default renderA11yString;