\def support (and \gdef and \global\def) (#1348)

* Nested environments of macro definitions

* Rename environment -> namespace

* \def support

* Clean up \df@tag at beginning

* \global\def support

* Fix \global via new setMacro helper

* Fix caching behavior and build array on top of it

Also avoid double lookup of macros

* Add tests

* Add argument tests

* Remove global pointer

* Note about macros object being modified

* add __defineMacro

* Add \global\def test

* More \global tests

* Constant-time lookup

Rewrite to use an "undo" stack similar to TeX, so get and set are
constant-time operations.  get() still has to check two objects: one with all
current settings, and then the built-ins.  Local set() sets the current value
and (when appropriate) adds an undo operation to the undo stack.  Global set()
still takes time linear in the number of groups, possibly changing the undo
operation at every level.

`Namespace` now refers to a space of things like macros or lengths.

* Add \def to-dos

* Put optional arguments in their own group

* Rename `pushNamespace` -> `beginGroup`

* Wrap each expression in a group namespace

* Add comments
This commit is contained in:
Erik Demaine
2018-05-28 15:58:57 -04:00
committed by Kevin Barabash
parent 3ec752f5f1
commit acccce801d
11 changed files with 273 additions and 56 deletions

View File

@@ -81,7 +81,7 @@ You can provide an object of options as the last argument to `katex.render` and
- `displayMode`: `boolean`. If `true` the math will be rendered in display mode, which will put the math in display style (so `\int` and `\sum` are large, for example), and will center the math on the page on its own line. If `false` the math will be rendered in inline mode. (default: `false`)
- `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. Single-character keys can also be included in which case the character will be redefined as the given macro (similar to TeX active characters).
- `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. 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`, which enables consecutive calls to KaTeX to share state.
- `colorIsTextColor`: `boolean`. If `true`, `\color` will work like LaTeX's `\textcolor`, and take two arguments (e.g., `\color{blue}{hello}`), which restores the old behavior of KaTeX (pre-0.8.0). If `false` (the default), `\color` will work like LaTeX's `\color`, and take one argument (e.g., `\color{blue}hello`). In both cases, `\textcolor` works as in LaTeX (e.g., `\textcolor{blue}{hello}`).
- `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.
- `maxExpand`: `number`. Limit the number of macro expansions to the specified number, to prevent e.g. infinite macro loops. If set to `Infinity` (the default), the macro expander will try to fully expand as in LaTeX.

View File

@@ -21,6 +21,7 @@ import type {SettingsOptions} from "./src/Settings";
import type ParseNode from "./src/ParseNode";
import { defineSymbol } from './src/symbols';
import { defineMacro } from './src/macros';
import { version } from "./package.json";
@@ -176,6 +177,10 @@ export default {
__renderToHTMLTree: renderToHTMLTree,
/**
* adds a new symbol to internal symbols table
*/
*/
__defineSymbol: defineSymbol,
/**
* adds a new macro to builtin macro list
*/
__defineMacro: defineMacro,
};

View File

@@ -6,23 +6,26 @@
import Lexer from "./Lexer";
import {Token} from "./Token";
import builtinMacros from "./macros";
import type {Mode} from "./types";
import ParseError from "./ParseError";
import Namespace from "./Namespace";
import builtinMacros from "./macros";
import type {MacroContextInterface, MacroMap, MacroExpansion} from "./macros";
import type {MacroContextInterface, MacroDefinition, MacroExpansion}
from "./macros";
import type Settings from "./Settings";
export default class MacroExpander implements MacroContextInterface {
lexer: Lexer;
macros: MacroMap;
maxExpand: number;
lexer: Lexer;
macros: Namespace<MacroDefinition>;
stack: Token[];
mode: Mode;
constructor(input: string, settings: Settings, mode: Mode) {
this.feed(input);
this.macros = Object.assign({}, builtinMacros, settings.macros);
// Make new global namespace
this.macros = new Namespace(builtinMacros, settings.macros);
this.maxExpand = settings.maxExpand;
this.mode = mode;
this.stack = []; // contains tokens in REVERSE order
@@ -43,6 +46,20 @@ export default class MacroExpander implements MacroContextInterface {
this.mode = newMode;
}
/**
* Start a new group nesting within all namespaces.
*/
beginGroup() {
this.macros.beginGroup();
}
/**
* End current group nesting within all namespaces.
*/
endGroup() {
this.macros.endGroup();
}
/**
* Returns the topmost token on the stack, without expanding it.
* Similar in behavior to TeX's `\futurelet`.
@@ -153,12 +170,12 @@ export default class MacroExpander implements MacroContextInterface {
expandOnce(): Token | Token[] {
const topToken = this.popToken();
const name = topToken.text;
if (!this.macros.hasOwnProperty(name)) {
const expansion = this._getExpansion(name);
if (expansion == null) { // mainly checking for undefined here
// Fully expanded
this.pushToken(topToken);
return topToken;
}
const {tokens, numArgs} = this._getExpansion(name);
if (this.maxExpand !== Infinity) {
this.maxExpand--;
if (this.maxExpand < 0) {
@@ -166,25 +183,25 @@ export default class MacroExpander implements MacroContextInterface {
"need to increase maxExpand setting");
}
}
let expansion = tokens;
if (numArgs) {
const args = this.consumeArgs(numArgs);
let tokens = expansion.tokens;
if (expansion.numArgs) {
const args = this.consumeArgs(expansion.numArgs);
// paste arguments in place of the placeholders
expansion = expansion.slice(); // make a shallow copy
for (let i = expansion.length - 1; i >= 0; --i) {
let tok = expansion[i];
tokens = tokens.slice(); // make a shallow copy
for (let i = tokens.length - 1; i >= 0; --i) {
let tok = tokens[i];
if (tok.text === "#") {
if (i === 0) {
throw new ParseError(
"Incomplete placeholder at end of macro body",
tok);
}
tok = expansion[--i]; // next token on stack
tok = tokens[--i]; // next token on stack
if (tok.text === "#") { // ## → #
expansion.splice(i + 1, 1); // drop first #
tokens.splice(i + 1, 1); // drop first #
} else if (/^[1-9]$/.test(tok.text)) {
// replace the placeholder with the indicated argument
expansion.splice(i, 2, ...args[+tok.text - 1]);
tokens.splice(i, 2, ...args[+tok.text - 1]);
} else {
throw new ParseError(
"Not a valid argument number",
@@ -194,8 +211,8 @@ export default class MacroExpander implements MacroContextInterface {
}
}
// Concatenate expansion onto top of stack.
this.pushTokens(expansion);
return expansion;
this.pushTokens(tokens);
return tokens;
}
/**
@@ -234,11 +251,13 @@ export default class MacroExpander implements MacroContextInterface {
/**
* Returns the expanded macro as a reversed array of tokens and a macro
* argument count.
* Caches macro expansions for those that were defined simple TeX strings.
* argument count. Or returns `null` if no such macro.
*/
_getExpansion(name: string): MacroExpansion {
const definition = this.macros[name];
_getExpansion(name: string): ?MacroExpansion {
const definition = this.macros.get(name);
if (definition == null) { // mainly checking for undefined here
return definition;
}
const expansion =
typeof definition === "function" ? definition(this) : definition;
if (typeof expansion === "string") {
@@ -258,11 +277,6 @@ export default class MacroExpander implements MacroContextInterface {
}
tokens.reverse(); // to fit in with stack using push and pop
const expanded = {tokens, numArgs};
// Cannot cache a macro defined using a function since it relies on
// parser context.
if (typeof definition !== "function") {
this.macros[name] = expanded;
}
return expanded;
}

98
src/Namespace.js Normal file
View File

@@ -0,0 +1,98 @@
// @flow
/**
* A `Namespace` refers to a space of nameable things like macros or lengths,
* which can be `set` either globally or local to a nested group, using an
* undo stack similar to how TeX implements this functionality.
* Performance-wise, `get` and local `set` take constant time, while global
* `set` takes time proportional to the depth of group nesting.
*/
import ParseError from "./ParseError";
export type Mapping<Value> = {[string]: Value};
export default class Namespace<Value> {
current: Mapping<Value>;
builtins: Mapping<Value>;
undefStack: Mapping<Value>[];
/**
* Both arguments are optional. The first argument is an object of
* built-in mappings which never change. The second argument is an object
* of initial (global-level) mappings, which will constantly change
* according to any global/top-level `set`s done.
*/
constructor(builtins: Mapping<Value> = {},
globalMacros: Mapping<Value> = {}) {
this.current = globalMacros;
this.builtins = builtins;
this.undefStack = [];
}
/**
* Start a new nested group, affecting future local `set`s.
*/
beginGroup() {
this.undefStack.push({});
}
/**
* End current nested group, restoring values before the group began.
*/
endGroup() {
if (this.undefStack.length === 0) {
throw new ParseError("Unbalanced namespace destruction: attempt " +
"to pop global namespace; please report this as a bug");
}
const undefs = this.undefStack.pop();
for (const undef of Object.getOwnPropertyNames(undefs)) {
if (undefs[undef] === undefined) {
delete this.current[undef];
} else {
this.current[undef] = undefs[undef];
}
}
}
/**
* Get the current value of a name.
*/
get(name: string): ?Value {
if (this.current.hasOwnProperty(name)) {
return this.current[name];
} else {
return this.builtins[name];
}
}
/**
* Set the current value of a name, and optionally set it globally too.
* Local set() sets the current value and (when appropriate) adds an undo
* operation to the undo stack. Global set() may change the undo
* operation at every level, so takes time linear in their number.
*/
set(name: string, value: Value, global: boolean = false) {
if (global) {
// Global set is equivalent to setting in all groups. Simulate this
// by destroying any undos currently scheduled for this name,
// and adding an undo with the *new* value (in case it later gets
// locally reset within this environment).
for (let i = 0; i < this.undefStack.length; i++) {
delete this.undefStack[i][name];
}
if (this.undefStack.length > 0) {
this.undefStack[this.undefStack.length - 1][name] = value;
}
} else {
// Undo this set at end of this group (possibly to `undefined`),
// unless an undo is already in place, in which case that older
// value is the correct one.
const top = this.undefStack[this.undefStack.length - 1];
if (top && !top.hasOwnProperty(name)) {
top[name] = this.current[name];
}
}
this.current[name] = value;
}
}

View File

@@ -86,11 +86,6 @@ export default class Parser {
// Create a new macro expander (gullet) and (indirectly via that) also a
// new lexer (mouth) for this parser (stomach, in the language of TeX)
this.gullet = new MacroExpander(input, settings, this.mode);
// Use old \color behavior (same as LaTeX's \textcolor) if requested.
// We do this after the macros object has been copied by MacroExpander.
if (settings.colorIsTextColor) {
this.gullet.macros["\\color"] = "\\textcolor";
}
// Store the settings for use in parsing
this.settings = settings;
// Count leftright depth (for \middle errors)
@@ -133,21 +128,27 @@ export default class Parser {
* Main parsing function, which parses an entire input.
*/
parse(): ParseNode<*>[] {
// Create a group namespace for the math expression.
// (LaTeX creates a new group for every $...$, $$...$$, \[...\].)
this.gullet.beginGroup();
// Use old \color behavior (same as LaTeX's \textcolor) if requested.
// We do this within the group for the math expression, so it doesn't
// pollute settings.macros.
if (this.settings.colorIsTextColor) {
this.gullet.macros.set("\\color", "\\textcolor");
}
// Try to parse the input
this.consume();
const parse = this.parseInput();
return parse;
}
const parse = this.parseExpression(false);
/**
* Parses an entire input tree.
*/
parseInput(): ParseNode<*>[] {
// Parse an expression
const expression = this.parseExpression(false);
// If we succeeded, make sure there's an EOF at the end
this.expect("EOF", false);
return expression;
// End the group namespace for the expression
this.gullet.endGroup();
return parse;
}
static endOfExpression = ["}", "\\end", "\\right", "&"];
@@ -851,6 +852,8 @@ export default class Parser {
if (mode) {
this.switchMode(mode);
}
// Start a new group namespace
this.gullet.beginGroup();
// If we get a brace, parse an expression
this.consume();
const expression = this.parseExpression(false, optional ? "]" : "}");
@@ -859,6 +862,8 @@ export default class Parser {
if (mode) {
this.switchMode(outerMode);
}
// End group namespace before consuming symbol after close brace
this.gullet.endGroup();
// Make sure we get a close brace
this.expect(optional ? "]" : "}");
if (mode === "text") {

View File

@@ -68,8 +68,8 @@ function parseArray(
style: StyleStr,
): ParseNode<"array"> {
// Parse body of array with \\ temporarily mapped to \cr
const oldNewline = parser.gullet.macros["\\\\"];
parser.gullet.macros["\\\\"] = "\\cr";
parser.gullet.beginGroup();
parser.gullet.macros.set("\\\\", "\\cr");
let row = [];
const body = [row];
@@ -125,7 +125,7 @@ function parseArray(
result.numHLinesBeforeRow = numHLinesBeforeRow;
// $FlowFixMe: The required fields were added immediately above.
const res: ArrayEnvNodeData = result;
parser.gullet.macros["\\\\"] = oldNewline;
parser.gullet.endGroup();
return new ParseNode("array", res, parser.mode);
}

View File

@@ -9,6 +9,7 @@ import symbols from "./symbols";
import utils from "./utils";
import {Token} from "./Token";
import ParseError from "./ParseError";
import type Namespace from "./Namespace";
import type {Mode} from "./types";
@@ -22,7 +23,7 @@ export interface MacroContextInterface {
/**
* Object mapping macros to their expansions.
*/
macros: MacroMap;
macros: Namespace<MacroDefinition>;
/**
* Returns the topmost token on the stack, without expanding it.
@@ -47,7 +48,7 @@ export interface MacroContextInterface {
/** Macro tokens (in reverse order). */
export type MacroExpansion = {tokens: Token[], numArgs: number};
type MacroDefinition = string | MacroExpansion |
export type MacroDefinition = string | MacroExpansion |
(MacroContextInterface => (string | MacroExpansion));
export type MacroMap = {[string]: MacroDefinition};
@@ -110,7 +111,7 @@ defineMacro("\\TextOrMath", function(context) {
// \gdef\macro#1{expansion}
// \gdef\macro#1#2{expansion}
// \gdef\macro#1#2#3#4#5#6#7#8#9{expansion}
defineMacro("\\gdef", function(context) {
const def = (context, global: boolean) => {
let arg = context.consumeArgs(1)[0];
if (arg.length !== 1) {
throw new ParseError("\\gdef's first argument must be a macro name");
@@ -134,11 +135,26 @@ defineMacro("\\gdef", function(context) {
arg = context.consumeArgs(1)[0];
}
// Final arg is the expansion of the macro
context.macros[name] = {
context.macros.set(name, {
tokens: arg,
numArgs,
};
}, global);
return '';
};
defineMacro("\\gdef", (context) => def(context, true));
defineMacro("\\def", (context) => def(context, false));
defineMacro("\\global", (context) => {
const next = context.consumeArgs(1)[0];
if (next.length !== 1) {
throw new ParseError("Invalid command after \\global");
}
const command = next[0].text;
if (command === "\\def") {
// \global\def is equivalent to \gdef
return def(context, true);
} else {
throw new ParseError(`Invalid command '${command}' after \\global`);
}
});
//////////////////////////////////////////////////////////////////////
@@ -404,7 +420,7 @@ defineMacro("\\thickspace", "\\;"); // \let\thickspace\;
defineMacro("\\tag", "\\@ifstar\\tag@literal\\tag@paren");
defineMacro("\\tag@paren", "\\tag@literal{({#1})}");
defineMacro("\\tag@literal", (context) => {
if (context.macros["\\df@tag"]) {
if (context.macros.get("\\df@tag")) {
throw new ParseError("Multiple \\tag");
}
return "\\gdef\\df@tag{\\text{#1}}";

View File

@@ -18,11 +18,13 @@ const parseTree = function(toParse: string, settings: Settings): ParseNode<*>[]
throw new TypeError('KaTeX can only parse string typed expression');
}
const parser = new Parser(toParse, settings);
// Blank out any \df@tag to avoid spurious "Duplicate \tag" errors
delete parser.gullet.macros.current["\\df@tag"];
let tree = parser.parse();
// If the input used \tag, it will set the \df@tag macro to the tag.
// In this case, we separately parse the tag and wrap the tree.
if (parser.gullet.macros["\\df@tag"]) {
if (parser.gullet.macros.get("\\df@tag")) {
if (!settings.displayMode) {
throw new ParseError("\\tag works only in display equations");
}

View File

@@ -14,7 +14,8 @@ function init() {
input.addEventListener("input", reprocess, false);
permalink.addEventListener("click", setSearch);
const options = {displayMode: true, throwOnError: false, macros: {}};
const options = {displayMode: true, throwOnError: false};
const macros = {};
const query = queryString.parse(window.location.search);
if (query.text) {
@@ -65,7 +66,7 @@ function init() {
// `c=expansion`.
Object.getOwnPropertyNames(query).forEach((key) => {
if (key.match(/^\\|^[^]$/)) {
options.macros[key] = query[key];
macros[key] = query[key];
}
});
@@ -78,6 +79,8 @@ function init() {
}
function reprocess() {
// Ignore changes to global macros caused by the expression
options.macros = Object.assign({}, macros);
try {
katex.render(input.value, math, options);
} catch (e) {

View File

@@ -11,6 +11,9 @@ export const defaultSettings = new Settings({
export const strictSettings = new Settings({strict: true});
export const _getBuilt = function(expr, settings = defaultSettings) {
if (settings === defaultSettings) {
settings.macros = {};
}
let rootNode = katex.__renderToDomTree(expr, settings);
if (rootNode.classes.indexOf('katex-error') >= 0) {

View File

@@ -757,6 +757,15 @@ describe("A color parser", function() {
colorIsTextColor: true,
});
});
it("should not define \\color in global context", function() {
const macros = {};
expect(oldColorExpression).toParseLike("\\textcolor{#fA6}{x}y", {
colorIsTextColor: true,
macros: macros,
});
expect(macros).toEqual({});
});
});
describe("A tie parser", function() {
@@ -2690,6 +2699,62 @@ describe("A macro expander", function() {
expect("\\gdef\\foo\\bar").toParse();
expect("\\gdef{\\foo\\bar}{}").toNotParse();
expect("\\gdef{}{}").toNotParse();
// TODO: These shouldn't work, but `1` and `{1}` are currently treated
// the same, as are `\foo` and `{\foo}`.
//expect("\\gdef\\foo1").toNotParse();
//expect("\\gdef{\\foo}{}").toNotParse();
});
it("\\def works locally", () => {
expect("\\def\\x{1}\\x{\\def\\x{2}\\x{\\def\\x{3}\\x}\\x}\\x")
.toParseLike("1{2{3}2}1");
expect("\\def\\x{1}\\x\\def\\x{2}\\x{\\def\\x{3}\\x\\def\\x{4}\\x}\\x")
.toParseLike("12{34}2");
});
it("\\gdef overrides at all levels", () => {
expect("\\def\\x{1}\\x{\\def\\x{2}\\x{\\gdef\\x{3}\\x}\\x}\\x")
.toParseLike("1{2{3}3}3");
expect("\\def\\x{1}\\x{\\def\\x{2}\\x{\\global\\def\\x{3}\\x}\\x}\\x")
.toParseLike("1{2{3}3}3");
expect("\\def\\x{1}\\x{\\def\\x{2}\\x{\\gdef\\x{3}\\x\\def\\x{4}\\x}" +
"\\x\\def\\x{5}\\x}\\x").toParseLike("1{2{34}35}3");
});
it("\\global needs to followed by \\def", () => {
expect("\\global\\def\\foo{}\\foo").toParseLike("");
// TODO: This doesn't work yet; \global needs to expand argument.
//expect("\\def\\DEF{\\def}\\global\\DEF\\foo{}\\foo").toParseLike("");
expect("\\global\\foo").toNotParse();
expect("\\global\\bar x").toNotParse();
});
it("Macro arguments do not generate groups", () => {
expect("\\def\\x{1}\\x\\def\\foo#1{#1}\\foo{\\x\\def\\x{2}\\x}\\x")
.toParseLike("1122");
});
it("\\textbf arguments do generate groups", () => {
expect("\\def\\x{1}\\x\\textbf{\\x\\def\\x{2}\\x}\\x")
.toParseLike("1\\textbf{12}1");
});
it("\\sqrt optional arguments generate groups", () => {
expect("\\def\\x{1}\\def\\y{1}\\x\\y" +
"\\sqrt[\\def\\x{2}\\x]{\\def\\y{2}\\y}\\x\\y")
.toParseLike("11\\sqrt[2]{2}11");
});
it("\\gdef changes settings.macros", () => {
const macros = {};
expect("\\gdef\\foo{1}").toParse(new Settings({macros}));
expect(macros["\\foo"]).toBeTruthy();
});
it("\\def doesn't change settings.macros", () => {
const macros = {};
expect("\\def\\foo{1}").toParse(new Settings({macros}));
expect(macros["\\foo"]).toBeFalsy();
});
// This may change in the future, if we support the extra features of
@@ -2967,6 +3032,12 @@ describe("Newlines via \\\\ and \\newline", function() {
it("should not allow \\cr at top level", () => {
expect("hello \\cr world").toNotBuild();
});
it("array redefines and resets \\\\", () => {
expect("a\\\\b\\begin{matrix}x&y\\\\z&w\\end{matrix}\\\\c")
.toParseLike("a\\newline b\\begin{matrix}x&y\\cr z&w\\end{matrix}" +
"\\newline c");
});
});
describe("Symbols", function() {