diff --git a/src/Parser.js b/src/Parser.js index 49139891..5b5bdf32 100644 --- a/src/Parser.js +++ b/src/Parser.js @@ -16,7 +16,7 @@ import Settings from "./Settings"; import { Token } from "./Token"; import type { Mode, ArgType, BreakToken } from "./types"; -import type { FunctionContext, FunctionSpec } from "./defineFunction" ; +import type { FunctionContext, FunctionSpec } from "./defineFunction"; import type { EnvSpec } from "./defineEnvironment"; /** @@ -48,7 +48,7 @@ import type { EnvSpec } from "./defineEnvironment"; * * The earlier functions return ParseNodes. * The later functions (which are called deeper in the parse) sometimes return - * ParsedFuncOrArgOrDollar, which contain a ParseNode as well as some data about + * ParsedFuncOrArg, which contain a ParseNode as well as some data about * whether the parsed object is a function which is missing some arguments, or a * standalone object which can be used as an argument to another function. */ @@ -63,13 +63,6 @@ type ParsedArg = {| result: ParseNode, token: Token, |}; -type ParsedDollar = {| - // Math mode switch - type: "$", - result: "$", - token: Token, -|}; -type ParsedFuncOrArgOrDollar = ParsedFunc | ParsedArg | ParsedDollar; type ParsedFuncOrArg = ParsedFunc | ParsedArg; function newArgument(result: ParseNode, token: Token): ParsedArg { @@ -80,17 +73,6 @@ function newFunction(token: Token): ParsedFunc { return {type: "fn", result: token.text, token}; } -function newDollar(token: Token): ParsedDollar { - return {type: "$", result: "$", token}; -} - -function assertFuncOrArg(parsed: ParsedFuncOrArgOrDollar): ParsedFuncOrArg { - if (parsed.type === "$") { - throw new ParseError("Unexpected $", parsed.token); - } - return parsed; -} - export default class Parser { mode: Mode; gullet: MacroExpander; @@ -294,20 +276,19 @@ export default class Parser { } } - const arg = assertFuncOrArg(group); - if (arg.type === "fn") { + if (group.type === "fn") { // ^ and _ have a greediness, so handle interactions with functions' // greediness - const funcGreediness = functions[arg.result].greediness; + const funcGreediness = functions[group.result].greediness; if (funcGreediness > Parser.SUPSUB_GREEDINESS) { return this.parseGivenFunction(group); } else { throw new ParseError( - "Got function '" + arg.result + "' with no arguments " + + "Got function '" + group.result + "' with no arguments " + "as " + name, symbolToken); } } else { - return arg.result; + return group.result; } } @@ -455,25 +436,7 @@ export default class Parser { const func = start.result; - if (func === "$") { - if (this.mode === "math") { - throw new ParseError("$ within math mode"); - } - const outerMode = this.mode; - this.switchMode("math"); - // Expand next symbol now that we're in math mode. - this.consume(); - const body = this.parseExpression(false, "$"); - // We can't expand the next symbol after the $ until after - // switching modes back. So don't consume within expect. - this.expect("$", false); - this.switchMode(outerMode); - this.consume(); - return new ParseNode("styling", { - style: "text", - value: body, - }, "math"); - } else if (func === "\\begin") { + if (func === "\\begin") { // begin...end is similar to left...right const begin = this.parseGivenFunction(start); const envName = begin.value.name; @@ -524,10 +487,9 @@ export default class Parser { * non-nullable result. */ parseGivenFunction( - baseGroup: ParsedFuncOrArgOrDollar, + baseGroup: ParsedFuncOrArg, breakOnTokenText?: BreakToken, ): ParseNode { - baseGroup = assertFuncOrArg(baseGroup); if (baseGroup.type === "fn") { const func = baseGroup.result; const funcData = functions[func]; @@ -542,10 +504,21 @@ export default class Parser { baseGroup.token); } + // Consume the command token after possibly switching to the + // mode specified by the function (for instant mode switching), + // and then immediately switch back. + if (funcData.consumeMode) { + const oldMode = this.mode; + this.switchMode(funcData.consumeMode); + this.consume(); + this.switchMode(oldMode); + } else { + this.consume(); + } const {args, optArgs} = this.parseArguments(func, funcData); const token = baseGroup.token; - const result = - this.callFunction(func, args, optArgs, token, breakOnTokenText); + const result = this.callFunction( + func, args, optArgs, token, breakOnTokenText); return new ParseNode(result.type, result, this.mode); } else { return baseGroup.result; @@ -632,7 +605,6 @@ export default class Parser { } } let argNode: ParseNode; - arg = assertFuncOrArg(arg); if (arg.type === "fn") { const argGreediness = functions[arg.result].greediness; @@ -658,7 +630,7 @@ export default class Parser { parseGroupOfType( type: ArgType, // Used to describe the mode in error messages. optional: boolean, - ): ?ParsedFuncOrArgOrDollar { + ): ?ParsedFuncOrArg { // Handle `original` argTypes if (type === "original") { type = this.mode; @@ -861,7 +833,7 @@ export default class Parser { * If `mode` is present, switches to that mode while parsing the group, * and switches back after. */ - parseGroup(optional?: boolean, mode?: Mode): ?ParsedFuncOrArgOrDollar { + parseGroup(optional?: boolean, mode?: Mode): ?ParsedFuncOrArg { const outerMode = this.mode; const firstToken = this.nextToken; // Try to parse an open brace @@ -936,14 +908,15 @@ export default class Parser { * Parse a single symbol out of the string. Here, we handle both the functions * we have defined, as well as the single character symbols */ - parseSymbol(): ?ParsedFuncOrArgOrDollar { + parseSymbol(): ?ParsedFuncOrArg { const nucleus = this.nextToken; let text = nucleus.text; if (functions[text]) { - this.consume(); - // If there exists a function with this name, we return the function and - // say that it is a function. + // If there exists a function with this name, we return the + // function and say that it is a function. + // The token will be consumed later in parseGivenFunction + // (after possibly switching modes). return newFunction(nucleus); } else if (/^\\verb[^a-zA-Z]/.test(text)) { this.consume(); @@ -964,8 +937,6 @@ export default class Parser { body: arg, star: star, }, "text"), nucleus); - } else if (text === "$") { - return newDollar(nucleus); } // At this point, we should have a symbol, possibly with accents. // First expand any accented base symbol according to unicodeSymbols, diff --git a/src/defineFunction.js b/src/defineFunction.js index 119ce804..186bf674 100644 --- a/src/defineFunction.js +++ b/src/defineFunction.js @@ -2,11 +2,11 @@ import {groupTypes as htmlGroupTypes} from "./buildHTML"; import {groupTypes as mathmlGroupTypes} from "./buildMathML"; -import type Parser from "./Parser" ; -import type ParseNode from "./ParseNode" ; +import type Parser from "./Parser"; +import type ParseNode from "./ParseNode"; import type Options from "./Options"; -import type {ArgType, BreakToken} from "./types" ; -import type {Token} from "./Token" ; +import type {ArgType, BreakToken, Mode} from "./types"; +import type {Token} from "./Token"; /** Context provided to function handlers for error messages. */ export type FunctionContext = {| @@ -70,6 +70,14 @@ export type FunctionPropSpec = { // Must be true if the function is an infix operator. infix?: boolean, + + // Switch to the specified mode while consuming the command token. + // This is useful for commands that switch between math and text mode, + // for making sure that a switch happens early enough. Note that the + // mode is switched immediately back to its original value after consuming + // the command token, so that the argument parsing and/or function handler + // can easily access the old mode while doing their own mode switching. + consumeMode?: ?Mode, }; type FunctionDefSpec = {| @@ -119,6 +127,7 @@ export type FunctionSpec = {| allowedInMath: boolean, numOptionalArgs: number, infix: boolean, + consumeMode: ?Mode, // Must be specified unless it's handled directly in the parser. handler: ?FunctionHandler, |}; @@ -149,6 +158,7 @@ export default function defineFunction({ : props.allowedInMath, numOptionalArgs: props.numOptionalArgs || 0, infix: !!props.infix, + consumeMode: props.consumeMode, handler: handler, }; for (let i = 0; i < names.length; ++i) { diff --git a/src/functions.js b/src/functions.js index 4ac4c8df..3c2b42ea 100644 --- a/src/functions.js +++ b/src/functions.js @@ -8,7 +8,7 @@ import { _functions, } from "./defineFunction"; -import type {FunctionPropSpec, FunctionHandler} from "./defineFunction" ; +import type {FunctionPropSpec, FunctionHandler} from "./defineFunction"; // WARNING: New functions should be added to src/functions and imported here. @@ -33,6 +33,8 @@ import "./functions/color"; import "./functions/text"; +import "./functions/math"; + import "./functions/enclose"; import "./functions/overline"; diff --git a/src/functions/math.js b/src/functions/math.js new file mode 100644 index 00000000..502761ef --- /dev/null +++ b/src/functions/math.js @@ -0,0 +1,44 @@ +// @flow +import defineFunction from "../defineFunction"; +import ParseError from "../ParseError"; + +// Switching from text mode back to math mode +defineFunction({ + names: ["\\(", "$"], + props: { + numArgs: 0, + allowedInText: true, + allowedInMath: false, + consumeMode: "math", + }, + handler(context, args) { + const {funcName, parser} = context; + const outerMode = parser.mode; + parser.switchMode("math"); + const close = (funcName === "\\(" ? "\\)" : "$"); + const body = parser.parseExpression(false, close); + // We can't expand the next symbol after the closing $ until after + // switching modes back. So don't consume within expect. + parser.expect(close, false); + parser.switchMode(outerMode); + parser.consume(); + return { + type: "styling", + style: "text", + value: body, + }; + }, +}); + +// Check for extra closing math delimiters +defineFunction({ + names: ["\\)", "\\]"], + props: { + numArgs: 0, + allowedInText: true, + allowedInMath: false, + }, + handler(context, args) { + throw new ParseError(`Mismatched ${context.funcName}`); + }, +}); diff --git a/src/functions/text.js b/src/functions/text.js index 2b0e98a1..ce1cf189 100644 --- a/src/functions/text.js +++ b/src/functions/text.js @@ -35,6 +35,7 @@ defineFunction({ argTypes: ["text"], greediness: 2, allowedInText: true, + consumeMode: "text", }, handler(context, args) { const body = args[0]; diff --git a/src/types.js b/src/types.js index 1b91de0a..a1e7865b 100644 --- a/src/types.js +++ b/src/types.js @@ -23,4 +23,5 @@ export type ArgType = "color" | "size" | "url" | "original" | Mode; // LaTeX display style. export type StyleStr = "text" | "display"; -export type BreakToken = "]" | "}" | "$"; +// Allowable token text for "break" arguments in parser +export type BreakToken = "]" | "}" | "$" | "\\)"; diff --git a/test/katex-spec.js b/test/katex-spec.js index 3abde22b..470cd880 100644 --- a/test/katex-spec.js +++ b/test/katex-spec.js @@ -812,7 +812,6 @@ describe("A text parser", function() { const badTextExpression = "\\text{a b%}"; const badFunctionExpression = "\\text{\\sqrt{x}}"; const mathTokenAfterText = "\\text{sin}^2"; - const textWithEmbeddedMath = "\\text{graph: $y = mx + b$}"; it("should not fail", function() { expect(textExpression).toParse(); @@ -872,7 +871,40 @@ describe("A text parser", function() { }); it("should parse math within text group", function() { - expect(textWithEmbeddedMath).toParse(); + expect("\\text{graph: $y = mx + b$}").toParse(); + expect("\\text{graph: \\(y = mx + b\\)}").toParse(); + }); + + it("should parse math within text within math within text", function() { + expect("\\text{hello $x + \\text{world $y$} + z$}").toParse(); + expect("\\text{hello \\(x + \\text{world $y$} + z\\)}").toParse(); + expect("\\text{hello $x + \\text{world \\(y\\)} + z$}").toParse(); + expect("\\text{hello \\(x + \\text{world \\(y\\)} + z\\)}").toParse(); + }); + + it("should forbid \\( within math mode", function() { + expect("\\(").toNotParse(); + expect("\\text{$\\(x\\)$}").toNotParse(); + }); + + it("should forbid $ within math mode", function() { + expect("$x$").toNotParse(); + expect("\\text{\\($x$\\)}").toNotParse(); + }); + + it("should detect unbalanced \\)", function() { + expect("\\)").toNotParse(); + expect("\\text{\\)}").toNotParse(); + }); + + it("should detect unbalanced $", function() { + expect("$").toNotParse(); + expect("\\text{$}").toNotParse(); + }); + + it("should not mix $ and \\(..\\)", function() { + expect("\\text{$x\\)}").toNotParse(); + expect("\\text{\\(x$}").toNotParse(); }); it("should parse spacing functions", function() { @@ -2824,8 +2856,13 @@ describe("A macro expander", function() { {"\\mode": "\\TextOrMath{text}{math}"}); }); - // TODO(edemaine): This doesn't work yet. Parses like `\text math`, - // which doesn't even treat all four letters as an argument. + it("\\TextOrMath should work in a macro passed to \\text", function() { + compareParseTree("\\text\\mode", "\\text t", + {"\\mode": "\\TextOrMath{t}{m}"}); + }); + + // TODO(edemaine): This doesn't work yet. Parses like `\text text`, + // which doesn't treat all four letters as an argument. //it("\\TextOrMath should work in a macro passed to \\text", function() { // compareParseTree("\\text\\mode", "\\text{text}", // {"\\mode": "\\TextOrMath{text}{math}"});