Skip to content

Commit

Permalink
fix: Support \let via macros option (#3738)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
edemaine committed Apr 17, 2023
1 parent 62144e4 commit bdb0be2
Show file tree
Hide file tree
Showing 4 changed files with 44 additions and 24 deletions.
9 changes: 8 additions & 1 deletion docs/options.md
Expand Up @@ -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.
Expand Down
43 changes: 21 additions & 22 deletions src/MacroExpander.js
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -310,7 +310,7 @@ export default class MacroExpander implements MacroContextInterface {
}
// Concatenate expansion onto top of stack.
this.pushTokens(tokens);
return tokens;
return tokens.length;
}

/**
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/defineMacro.js
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions test/katex-spec.js
Expand Up @@ -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}));
Expand Down

0 comments on commit bdb0be2

Please sign in to comment.