| import katex from '../katex.mjs'; |
| |
| /** |
| * 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. |
| */ |
| 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" |
| }; |
| |
| const buildString = (str, type, a11yStrings) => { |
| 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, callback) => { |
| const regionStrings = []; |
| a11yStrings.push(regionStrings); |
| callback(regionStrings); |
| }; |
| |
| const handleObject = (tree, a11yStrings, atomType) => { |
| // 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.text; |
| |
| 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; |
| 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 = tree.leftDelim, |
| rightDelim = tree.rightDelim; // 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 "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 = tree.body, |
| name = tree.name; |
| |
| 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 "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 = tree.body, |
| index = tree.index; |
| |
| 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 = tree.base, |
| sub = tree.sub, |
| sup = tree.sup; |
| 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; |
| } |
| |
| throw new Error(`KaTeX-a11y: enclose node with ${tree.label} not supported yet`); |
| } |
| |
| 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 "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 which style we're using, e.g. dispaly, 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; |
| } |
| |
| default: |
| tree.type; |
| throw new Error("KaTeX a11y un-recognized type: " + tree.type); |
| } |
| }; |
| |
| const buildA11yStrings = function buildA11yStrings(tree, a11yStrings, atomType) { |
| if (a11yStrings === void 0) { |
| a11yStrings = []; |
| } |
| |
| 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 flatten(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 renderA11yString(text, settings) { |
| const tree = katex.__parse(text, settings); |
| |
| const a11yStrings = buildA11yStrings(tree, [], "normal"); |
| return flatten(a11yStrings).join(", "); |
| }; |
| |
| export default renderA11yString; |