From bdb0be201794d22adaee05438b07a2830efea9da Mon Sep 17 00:00:00 2001 From: Erik Demaine Date: Mon, 17 Apr 2023 15:59:56 -0400 Subject: [PATCH] fix: Support `\let` via `macros` option (#3738) * fix: Support `\let` via `macros` option Issue #3737 turned out to be how we handled the return value of `expandOnce`. We assumed that, if the return value isn't an `Array`, it's an `instanceof Token`. This isn't necessary true with a user's `macros` object, and given that we don't currently export `Token`, it's pretty difficult to bypass. Given that we never actually use the array return values from `expandOnce`, I changed the return value for `expandOnce` to either a `number` (to indicate the number of expanded tokens, so you could still look up the tokens in the stack if you wanted to) or `false` (to indicate no expansion happened). We can't use `0` for the latter because an actual expansion might result in zero tokens. The resulting code is arguably cleaner. I also documented that `macros` can have object expansions, and specified how to simulate `\let`. Fixes #3737 * Revise macros documentation according to comments --- docs/options.md | 9 ++++++++- src/MacroExpander.js | 43 +++++++++++++++++++++---------------------- src/defineMacro.js | 2 +- test/katex-spec.js | 14 ++++++++++++++ 4 files changed, 44 insertions(+), 24 deletions(-) diff --git a/docs/options.md b/docs/options.md index b55603f2a6..23ab2b5a7a 100644 --- a/docs/options.md +++ b/docs/options.md @@ -15,7 +15,14 @@ You can provide an object of options as the last argument to [`katex.render` and - `fleqn`: `boolean`. If `true`, display math renders flush left with a `2em` left margin, like `\documentclass[fleqn]` in LaTeX with the `amsmath` package. - `throwOnError`: `boolean`. If `true` (the default), KaTeX will throw a `ParseError` when it encounters an unsupported command or invalid LaTeX. If `false`, KaTeX will render unsupported commands as text, and render invalid LaTeX as its source code with hover text giving the error, in the color given by `errorColor`. - `errorColor`: `string`. A color string given in the format `"#XXX"` or `"#XXXXXX"`. This option determines the color that unsupported commands and invalid LaTeX are rendered in when `throwOnError` is set to `false`. (default: `#cc0000`) -- `macros`: `object`. A collection of custom macros. Each macro is a property with a name like `\name` (written `"\\name"` in JavaScript) which maps to a string that describes the expansion of the macro, or a function that accepts an instance of `MacroExpander` as first argument and returns the expansion as a string. `MacroExpander` is an internal API and subject to non-backwards compatible changes. See [`src/defineMacro.js`](https://github.com/KaTeX/KaTeX/blob/main/src/defineMacro.js) for its usage. Single-character keys can also be included in which case the character will be redefined as the given macro (similar to TeX active characters). *This object will be modified* if the LaTeX code defines its own macros via `\gdef` (or via `\def` or `\newcommand` when using `globalGroup`), which enables consecutive calls to KaTeX to share state. +- `macros`: `object`. A collection of custom macros. + - Each macro is a key-value pair where the key is a new command name and the value is the expansion of the macro. + - Example: `macros: {"\\R": "\\mathbb{R}"}` + - More precisely, each property of `macros` can have a name that starts with a backslash like `"\\foo"` (defining command `\foo`) or is a single character like `"α"` (defining the equivalent of a TeX active character), and a value that is one of the following: + - A string with the LaTeX expansion of the macro (which will be recursively expanded when used). The string can invoke (required) arguments via `#1`, `#2`, etc. + - A function that accepts an instance of `MacroExpander` as first argument and returns the expansion as a string. `MacroExpander` is an internal API and subject to non-backwards compatible changes. See [`src/defineMacro.js`](https://github.com/KaTeX/KaTeX/blob/main/src/defineMacro.js) for its usage. + - An expansion object matching [an internal `MacroExpansion` specification](https://github.com/KaTeX/KaTeX/blob/main/src/defineMacro.js), which is what results from global `\def` or `\let`. For example, you can simulate the effect of `\let\realint=\int` via `{"\\realint": {tokens: [{text: "\\int", noexpand: true}], numArgs: 0}}`. + - *This object will be modified* if the LaTeX code defines its own macros via `\gdef` or `\global\let` (or via `\def` or `\newcommand` or `\let` when using `globalGroup`). This enables consecutive calls to KaTeX to share state (in particular, user macro definitions) if you pass in the same `macros` object each time. - `minRuleThickness`: `number`. Specifies a minimum thickness, in ems, for fraction lines, `\sqrt` top lines, `{array}` vertical lines, `\hline`, `\hdashline`, `\underline`, `\overline`, and the borders of `\fbox`, `\boxed`, and `\fcolorbox`. The usual value for these items is `0.04`, so for `minRuleThickness` to be effective it should probably take a value slightly above `0.04`, say `0.05` or `0.06`. Negative values will be ignored. - `colorIsTextColor`: `boolean`. In early versions of both KaTeX (<0.8.0) and MathJax, the `\color` function expected the content to be a function argument, as in `\color{blue}{hello}`. In current KaTeX, `\color` is a switch, as in `\color{blue} hello`. This matches LaTeX behavior. If you want the old `\color` behavior, set option `colorIsTextColor` to true. - `maxSize`: `number`. All user-specified sizes, e.g. in `\rule{500em}{500em}`, will be capped to `maxSize` ems. If set to `Infinity` (the default), users can make elements and spaces arbitrarily large. diff --git a/src/MacroExpander.js b/src/MacroExpander.js index 805dc0e5ec..08532fd6c6 100644 --- a/src/MacroExpander.js +++ b/src/MacroExpander.js @@ -249,22 +249,22 @@ export default class MacroExpander implements MacroContextInterface { * Expand the next token only once if possible. * * If the token is expanded, the resulting tokens will be pushed onto - * the stack in reverse order and will be returned as an array, - * also in reverse order. + * the stack in reverse order, and the number of such tokens will be + * returned. This number might be zero or positive. * - * If not, the next token will be returned without removing it - * from the stack. This case can be detected by a `Token` return value - * instead of an `Array` return value. + * If not, the return value is `false`, and the next token remains at the + * top of the stack. * * In either case, the next token will be on the top of the stack, - * or the stack will be empty. + * or the stack will be empty (in case of empty expansion + * and no other tokens). * * Used to implement `expandAfterFuture` and `expandNextToken`. * * If expandableOnly, only expandable tokens are expanded and * an undefined control sequence results in an error. */ - expandOnce(expandableOnly?: boolean): Token | Token[] { + expandOnce(expandableOnly?: boolean): number | boolean { const topToken = this.popToken(); const name = topToken.text; const expansion = !topToken.noexpand ? this._getExpansion(name) : null; @@ -274,7 +274,7 @@ export default class MacroExpander implements MacroContextInterface { throw new ParseError("Undefined control sequence: " + name); } this.pushToken(topToken); - return topToken; + return false; } this.expansionCount++; if (this.expansionCount > this.settings.maxExpand) { @@ -310,7 +310,7 @@ export default class MacroExpander implements MacroContextInterface { } // Concatenate expansion onto top of stack. this.pushTokens(tokens); - return tokens; + return tokens.length; } /** @@ -329,15 +329,14 @@ export default class MacroExpander implements MacroContextInterface { */ expandNextToken(): Token { for (;;) { - const expanded = this.expandOnce(); - // expandOnce returns Token if and only if it's fully expanded. - if (expanded instanceof Token) { + if (this.expandOnce() === false) { // fully expanded + const token = this.stack.pop(); // the token after \noexpand is interpreted as if its meaning // were ‘\relax’ - if (expanded.treatAsRelax) { - expanded.text = "\\relax"; + if (token.treatAsRelax) { + token.text = "\\relax"; } - return this.stack.pop(); // === expanded + return token; } } @@ -365,15 +364,15 @@ export default class MacroExpander implements MacroContextInterface { const oldStackLength = this.stack.length; this.pushTokens(tokens); while (this.stack.length > oldStackLength) { - const expanded = this.expandOnce(true); // expand only expandable tokens - // expandOnce returns Token if and only if it's fully expanded. - if (expanded instanceof Token) { - if (expanded.treatAsRelax) { + // Expand only expandable tokens + if (this.expandOnce(true) === false) { // fully expanded + const token = this.stack.pop(); + if (token.treatAsRelax) { // the expansion of \noexpand is the token itself - expanded.noexpand = false; - expanded.treatAsRelax = false; + token.noexpand = false; + token.treatAsRelax = false; } - output.push(this.stack.pop()); + output.push(token); } } return output; diff --git a/src/defineMacro.js b/src/defineMacro.js index 7b3cd49a11..c829f38ea9 100644 --- a/src/defineMacro.js +++ b/src/defineMacro.js @@ -35,7 +35,7 @@ export interface MacroContextInterface { /** * Expand the next token only once if possible. */ - expandOnce(expandableOnly?: boolean): Token | Token[]; + expandOnce(expandableOnly?: boolean): number | boolean; /** * Expand the next token only once (if possible), and return the resulting diff --git a/test/katex-spec.js b/test/katex-spec.js index 968159851d..8f6a2e5996 100644 --- a/test/katex-spec.js +++ b/test/katex-spec.js @@ -3499,6 +3499,20 @@ describe("A macro expander", function() { expect`\futurelet\foo\frac1{2+\foo}`.toParseLike`\frac1{2+1}`; }); + it("macros argument can simulate \\let", () => { + expect("\\int").toParseLike("\\int\\limits", {macros: { + "\\Oldint": { + tokens: [{text: "\\int", noexpand: true}], + numArgs: 0, + unexpandable: true, + }, + "\\int": { + tokens: [{text: "\\limits"}, {text: "\\Oldint"}], + numArgs: 0, + }, + }}); + }); + it("\\newcommand doesn't change settings.macros", () => { const macros = {}; expect`\newcommand\foo{x^2}\foo+\foo`.toParse(new Settings({macros}));