diff katex/contrib/render-a11y-string.mjs @ 8:4a25b534c81c javascript-experiment

Add v8 engine and include katex
author Jonatan Werpers <jonatan@werpers.com>
date Wed, 17 Jun 2020 21:43:52 +0200
parents
children
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/katex/contrib/render-a11y-string.mjs	Wed Jun 17 21:43:52 2020 +0200
@@ -0,0 +1,741 @@
+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;