Spaces:
Sleeping
Sleeping
| /** | |
| * 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"; | |
| import katex from "katex"; | |
| const stringMap: Record<string, string> = { | |
| "(": "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: Record<string, string> = { | |
| "prime": "prime", | |
| "degree": "degrees", | |
| "circle": "degrees", | |
| "2": "squared", | |
| "3": "cubed", | |
| }; | |
| const openMap: Record<string, string> = { | |
| "|": "open vertical bar", | |
| ".": "", | |
| }; | |
| const closeMap: Record<string, string> = { | |
| "|": "close vertical bar", | |
| ".": "", | |
| }; | |
| const binMap: Record<string, string> = { | |
| "+": "plus", | |
| "-": "minus", | |
| "\\pm": "plus minus", | |
| "\\cdot": "dot", | |
| "*": "times", | |
| "/": "divided by", | |
| "\\times": "times", | |
| "\\div": "divided by", | |
| "\\circ": "circle", | |
| "\\bullet": "bullet", | |
| }; | |
| const relMap: Record<string, string> = { | |
| "=": "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: Record<string, string> = { | |
| "\\underleftarrow": "left arrow", | |
| "\\underrightarrow": "right arrow", | |
| "\\underleftrightarrow": "left-right arrow", | |
| "\\undergroup": "group", | |
| "\\underlinesegment": "line segment", | |
| "\\utilde": "tilde", | |
| }; | |
| type NestedArray<T> = Array<T | NestedArray<T>>; | |
| const buildString = ( | |
| str: string, | |
| type: Atom | "normal", | |
| a11yStrings: NestedArray<string>, | |
| ) => { | |
| 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 | |
| const last = a11yStrings[a11yStrings.length - 1]; | |
| if ( | |
| /^\d+$/.test(ret) && | |
| a11yStrings.length > 0 && | |
| typeof last === "string" && | |
| /^\d+$/.test(last) | |
| ) { | |
| a11yStrings[a11yStrings.length - 1] += ret; | |
| } else if (ret) { | |
| a11yStrings.push(ret); | |
| } | |
| }; | |
| const buildRegion = ( | |
| a11yStrings: NestedArray<string>, | |
| callback: (regionStrings: NestedArray<string>) => void, | |
| ) => { | |
| const regionStrings: NestedArray<string> = []; | |
| a11yStrings.push(regionStrings); | |
| callback(regionStrings); | |
| }; | |
| const handleObject = ( | |
| tree: AnyParseNode, | |
| a11yStrings: NestedArray<string>, | |
| 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 as never); | |
| 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 "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); | |
| // TODO(ts): drop the leading "m" from the values in mclass | |
| buildA11yStrings(tree.body, a11yStrings, atomType as Atom | "normal"); | |
| 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: | |
| throw new Error("KaTeX a11y un-recognized type: " + (tree as AnyParseNode).type); | |
| } | |
| }; | |
| const buildA11yStrings = ( | |
| tree: AnyParseNode | AnyParseNode[], | |
| a11yStrings: NestedArray<string> = [], | |
| 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: NestedArray<string>): string[] { | |
| let result: string[] = []; | |
| array.forEach(function(item) { | |
| if (Array.isArray(item)) { | |
| 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; | |