Skip to content

Commit

Permalink
[Locked Labels + Aria] Create math only parser to help parse TeX how …
Browse files Browse the repository at this point in the history
…we want (#1890)

## Summary:
We want to be able to parse a string meant for TeX into its most basic
essential parts. To do this, we are creating a `mathOnlyParser()` parser.

Using the default `parse()` function from SimpleMarkdown breaks up
text into too many sections, and parsing from scratch can get very
complicated. The ideal way to do this is to use the existing `parserFor()`
function from SimpleMarkdown that lets us customize the parser.

This will be used in
- Generating the TeX for Locked Labels and Locked Figure labels so that
  it shows up correctly on the graph. Non-TeX should show up with regular
  text styling and allow spaces.
- Generating the aria label for locked figures with TeX labels. The speech
  rule engine will only be run on the math sections of the parsed output.

Issue: https://khanacademy.atlassian.net/browse/LEMS-2548
(and also https://khanacademy.atlassian.net/browse/LEMS-2591)

## Test plan:
`yarn jest packages/perseus/src/widgets/interactive-graphs/utils.test.ts`

Author: nishasy

Reviewers: nishasy, anakaren-rojas, #perseus, benchristel, catandthemachines

Required Reviewers:

Approved By: anakaren-rojas

Checks: ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x), ✅ gerald

Pull Request URL: #1890
  • Loading branch information
nishasy authored Nov 20, 2024
1 parent 4b8836b commit 0afb1a4
Show file tree
Hide file tree
Showing 4 changed files with 216 additions and 3 deletions.
5 changes: 5 additions & 0 deletions .changeset/mighty-cobras-kick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/perseus": patch
---

[Locked Labels + Aria] Create math only parser to help parse TeX how we want
1 change: 1 addition & 0 deletions packages/perseus/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export {
containerSizeClass,
getInteractiveBoxFromSizeClass,
} from "./util/sizing-utils";
export {mathOnlyParser} from "./widgets/interactive-graphs/utils";
export {
getAnswersFromWidgets,
injectWidgets,
Expand Down
179 changes: 178 additions & 1 deletion packages/perseus/src/widgets/interactive-graphs/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import {normalizePoints, normalizeCoords, replaceOutsideTeX} from "./utils";
import {
normalizePoints,
normalizeCoords,
replaceOutsideTeX,
mathOnlyParser,
} from "./utils";

import type {Coord} from "../../interactive2/types";
import type {GraphRange} from "../../perseus-types";
Expand Down Expand Up @@ -148,3 +153,175 @@ describe("replaceOutsideTeX", () => {
expect(convertedString).toEqual("\\text{\\\\}");
});
});

describe("mathOnlyParser", () => {
test("empty string", () => {
const nodes = mathOnlyParser("");

expect(nodes).toEqual([]);
});

test("text-only string", () => {
const nodes = mathOnlyParser("abc");

expect(nodes).toEqual([{content: "abc", type: "text"}]);
});

test("math", () => {
const nodes = mathOnlyParser("$x^2$");

expect(nodes).toEqual([{content: "x^2", type: "math"}]);
});

test("math at the start", () => {
const nodes = mathOnlyParser("$x^2$ yippee");

expect(nodes).toEqual([
{content: "x^2", type: "math"},
{content: " yippee", type: "text"},
]);
});

test("math at the end", () => {
const nodes = mathOnlyParser("yippee $x^2$");

expect(nodes).toEqual([
{content: "yippee ", type: "text"},
{content: "x^2", type: "math"},
]);
});

test("math contained within text", () => {
const nodes = mathOnlyParser("The equation is $x^2$ yippee");

expect(nodes).toEqual([
{content: "The equation is ", type: "text"},
{content: "x^2", type: "math"},
{content: " yippee", type: "text"},
]);
});

test("math text without math markers", () => {
const nodes = mathOnlyParser("yippee x^2");

expect(nodes).toEqual([{content: "yippee x^2", type: "text"}]);
});

test("lone unescaped dollar sign middle", () => {
const nodes = mathOnlyParser("yippee $x^2");

expect(nodes).toEqual([
{content: "yippee ", type: "text"},
{content: "$", type: "specialCharacter"},
{content: "x^2", type: "text"},
]);
});

test("lone unescaped dollar sign end", () => {
const nodes = mathOnlyParser("yippee x^2$");

expect(nodes).toEqual([
{content: "yippee x^2", type: "text"},
{content: "$", type: "specialCharacter"},
]);
});

test("multiple math blocks", () => {
const nodes = mathOnlyParser("$x^2$ and $y^2$");

expect(nodes).toEqual([
{content: "x^2", type: "math"},
{content: " and ", type: "text"},
{content: "y^2", type: "math"},
]);
});

test("TeX syntax without dollars", () => {
const nodes = mathOnlyParser("\\frac{1}{2}");

// This looks odd, but this is expected based on our logic
// since this is within a text block, not a math block
expect(nodes).toEqual([
{content: "\\f", type: "specialCharacter"},
{content: "rac", type: "text"},
{content: "{", type: "specialCharacter"},
{content: "1", type: "text"},
{content: "}", type: "specialCharacter"},
{content: "{", type: "specialCharacter"},
{content: "2", type: "text"},
{content: "}", type: "specialCharacter"},
]);
});

test.each`
character
${">"}
${"> "}
${" "}
${"["}
${"]"}
${"("}
${")"}
${"^"}
${"*"}
${"/"}
`("nonspecial special character as text: '$character'", ({character}) => {
const nodes = mathOnlyParser(character);

expect(nodes).toEqual([{content: character, type: "text"}]);
});

test.each`
character
${"\\"}
${"\\\\"}
${"{"}
${"}"}
${"$"}
${"\\$"}
`("actually special character: '$character'", ({character}) => {
const nodes = mathOnlyParser(character);

expect(nodes).toEqual([{content: character, type: "specialCharacter"}]);
});

test("special character in text", () => {
const nodes = mathOnlyParser("a\\$b");

expect(nodes).toEqual([
{content: "a", type: "text"},
{content: "\\$", type: "specialCharacter"},
{content: "b", type: "text"},
]);
});

test("special character in math", () => {
const nodes = mathOnlyParser("$\\$$");

expect(nodes).toEqual([{content: "\\$", type: "math"}]);
});

test("mix of special characters", () => {
const nodes = mathOnlyParser("\\$\\\\\\$$");

expect(nodes).toEqual([
{content: "\\$", type: "specialCharacter"},
{content: "\\\\", type: "specialCharacter"},
{content: "\\$", type: "specialCharacter"},
{content: "$", type: "specialCharacter"},
]);
});

test("mix all types", () => {
const nodes = mathOnlyParser("Hello \\$ \\\\ world $\\frac{1}{2}$");

expect(nodes).toEqual([
{content: "Hello ", type: "text"},
{content: "\\$", type: "specialCharacter"},
{content: " ", type: "text"},
{content: "\\\\", type: "specialCharacter"},
{content: " world ", type: "text"},
{content: "\\frac{1}{2}", type: "math"},
]);
});
});
34 changes: 32 additions & 2 deletions packages/perseus/src/widgets/interactive-graphs/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as SimpleMarkdown from "@khanacademy/pure-markdown";
import {parse, pureMarkdownRules} from "@khanacademy/pure-markdown";
import SimpleMarkdown from "@khanacademy/simple-markdown";

import {clampToBox, inset, MIN, size} from "./math";

Expand Down Expand Up @@ -81,7 +82,7 @@ export function isUnlimitedGraphState(
export function replaceOutsideTeX(mathString: string) {
// All the information we need is in the first section,
// whether it's typed as "blockmath" or "paragraph"
const firstSection = SimpleMarkdown.parse(mathString)[0];
const firstSection = parse(mathString)[0];

// If it's blockMath, the outer level has the full math content.
if (firstSection.type === "blockMath") {
Expand Down Expand Up @@ -141,3 +142,32 @@ function escapeSpecialChars(str) {
// Escape $, \, {, and } characters
return str.replace(/([$\\{}])/g, "\\$1");
}

/**
* Parse a string of text and math into a list of objects with type and content
*
* Example: "Pi is about $\frac{22}{7}$" ==>
* [
* {type: "text", content: "Pi is about "},
* {type: "math", content: "\\frac{22}{7}"},
* ]
*/
export const mathOnlyParser = SimpleMarkdown.parserFor(
{
math: {
...pureMarkdownRules.math,
order: 0,
},
text: {
order: 1,
match: SimpleMarkdown.anyScopeRegex(/^([^$\\{}]+)/),
parse: (capture) => ({content: capture[0]}),
},
specialCharacter: {
order: 2,
match: SimpleMarkdown.anyScopeRegex(/^(\\[\S\s]|\$|\\$|{|})/),
parse: (capture) => ({content: capture[0]}),
},
},
{inline: true},
);

0 comments on commit 0afb1a4

Please sign in to comment.