diff --git a/.eslintrc b/.eslintrc index 96821cc6..74c9e4d3 100644 --- a/.eslintrc +++ b/.eslintrc @@ -39,7 +39,7 @@ "no-with": 2, "one-var": [2, "never"], "prefer-const": 2, - "prefer-spread": 2, + "prefer-spread": 0, // re-enable once we use es6 "semi": [2, "always"], "space-before-blocks": 2, "space-before-function-paren": [2, "never"], diff --git a/package.json b/package.json index 4f00ff38..6e794ffe 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "less": "~2.7.1", "morgan": "^1.7.0", "nomnom": "^1.8.1", + "object-assign": "^4.1.0", "pako": "1.0.4", "selenium-webdriver": "^2.48.2", "sri-toolbox": "^0.2.0", diff --git a/src/MacroExpander.js b/src/MacroExpander.js index 8569e6ad..608feafe 100644 --- a/src/MacroExpander.js +++ b/src/MacroExpander.js @@ -4,16 +4,23 @@ */ const Lexer = require("./Lexer"); +const builtinMacros = require("./macros"); +const ParseError = require("./ParseError"); +const objectAssign = require("object-assign"); function MacroExpander(input, macros) { this.lexer = new Lexer(input); - this.macros = macros; + this.macros = objectAssign({}, builtinMacros, macros); this.stack = []; // contains tokens in REVERSE order this.discardedWhiteSpace = []; } /** * Recursively expand first token, then return first non-expandable token. + * + * At the moment, macro expansion doesn't handle delimited macros, + * i.e. things like those defined by \def\foo#1\end{…}. + * See the TeX book page 202ff. for details on how those should behave. */ MacroExpander.prototype.nextToken = function() { for (;;) { @@ -25,18 +32,87 @@ MacroExpander.prototype.nextToken = function() { if (!(name.charAt(0) === "\\" && this.macros.hasOwnProperty(name))) { return topToken; } + let tok; let expansion = this.macros[name]; if (typeof expansion === "string") { + let numArgs = 0; + if (expansion.indexOf("#") !== -1) { + const stripped = expansion.replace(/##/g, ""); + while (stripped.indexOf("#" + (numArgs + 1)) !== -1) { + ++numArgs; + } + } const bodyLexer = new Lexer(expansion); expansion = []; - let tok = bodyLexer.lex(); + tok = bodyLexer.lex(); while (tok.text !== "EOF") { expansion.push(tok); tok = bodyLexer.lex(); } expansion.reverse(); // to fit in with stack using push and pop + expansion.numArgs = numArgs; this.macros[name] = expansion; } + if (expansion.numArgs) { + const args = []; + let i; + // obtain arguments, either single token or balanced {…} group + for (i = 0; i < expansion.numArgs; ++i) { + const startOfArg = this.get(true); + if (startOfArg.text === "{") { + const arg = []; + let depth = 1; + while (depth !== 0) { + tok = this.get(false); + arg.push(tok); + if (tok.text === "{") { + ++depth; + } else if (tok.text === "}") { + --depth; + } else if (tok.text === "EOF") { + throw new ParseError( + "End of input in macro argument", + startOfArg); + } + } + arg.pop(); // remove last } + arg.reverse(); // like above, to fit in with stack order + args[i] = arg; + } else if (startOfArg.text === "EOF") { + throw new ParseError( + "End of input expecting macro argument", topToken); + } else { + args[i] = [startOfArg]; + } + } + // paste arguments in place of the placeholders + expansion = expansion.slice(); // make a shallow copy + for (i = expansion.length - 1; i >= 0; --i) { + tok = expansion[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 + if (tok.text === "#") { // ## → # + expansion.splice(i + 1, 1); // drop first # + } else if (/^[1-9]$/.test(tok.text)) { + // expansion.splice(i, 2, arg[0], arg[1], …) + // to replace placeholder with the indicated argument. + // TODO: use spread once we move to ES2015 + expansion.splice.apply( + expansion, + [i, 2].concat(args[tok.text - 1])); + } else { + throw new ParseError( + "Not a valid argument number", + tok); + } + } + } + } this.stack = this.stack.concat(expansion); } }; diff --git a/src/macros.js b/src/macros.js new file mode 100644 index 00000000..fb1054bf --- /dev/null +++ b/src/macros.js @@ -0,0 +1,23 @@ +/** + * Predefined macros for KaTeX. + * This can be used to define some commands in terms of others. + */ + +// This function might one day accept additional argument and do more things. +function defineMacro(name, body) { + module.exports[name] = body; +} + +////////////////////////////////////////////////////////////////////// +// basics +defineMacro("\\bgroup", "{"); +defineMacro("\\egroup", "}"); +defineMacro("\\begingroup", "{"); +defineMacro("\\endgroup", "}"); + +////////////////////////////////////////////////////////////////////// +// amsmath.sty + +// \def\overset#1#2{\binrel@{#2}\binrel@@{\mathop{\kern\z@#2}\limits^{#1}}} +defineMacro("\\overset", "\\mathop{#2}\\limits^{#1}"); +defineMacro("\\underset", "\\mathop{#2}\\limits_{#1}"); diff --git a/test/katex-spec.js b/test/katex-spec.js index 36dd5f5b..651df70e 100644 --- a/test/katex-spec.js +++ b/test/katex-spec.js @@ -2011,6 +2011,13 @@ describe("A macro expander", function() { "\\bar": "a", }); }); + + it("should expand the \overset macro as expected", function() { + expect("\\overset?=").toParseLike("\\mathop{=}\\limits^{?}"); + expect("\\overset{x=y}{\sqrt{ab}}") + .toParseLike("\\mathop{\sqrt{ab}}\\limits^{x=y}"); + expect("\\overset {?} =").toParseLike("\\mathop{=}\\limits^{?}"); + }); }); describe("A parser taking String objects", function() { diff --git a/test/screenshotter/images/GroupMacros-chrome.png b/test/screenshotter/images/GroupMacros-chrome.png new file mode 100644 index 00000000..da77a3ac Binary files /dev/null and b/test/screenshotter/images/GroupMacros-chrome.png differ diff --git a/test/screenshotter/images/GroupMacros-firefox.png b/test/screenshotter/images/GroupMacros-firefox.png new file mode 100644 index 00000000..1813705b Binary files /dev/null and b/test/screenshotter/images/GroupMacros-firefox.png differ diff --git a/test/screenshotter/images/OverUnderset-chrome.png b/test/screenshotter/images/OverUnderset-chrome.png new file mode 100644 index 00000000..3c5c71d7 Binary files /dev/null and b/test/screenshotter/images/OverUnderset-chrome.png differ diff --git a/test/screenshotter/images/OverUnderset-firefox.png b/test/screenshotter/images/OverUnderset-firefox.png new file mode 100644 index 00000000..6ffe2e20 Binary files /dev/null and b/test/screenshotter/images/OverUnderset-firefox.png differ diff --git a/test/screenshotter/ss_data.js b/test/screenshotter/ss_data.js index 7dd926da..81ff909f 100644 --- a/test/screenshotter/ss_data.js +++ b/test/screenshotter/ss_data.js @@ -26,5 +26,8 @@ for (var key in dict) { } }); itm.query = querystring.stringify(query); + if (itm.macros) { + itm.query += "&" + querystring.stringify(itm.macros); + } } module.exports = dict; diff --git a/test/screenshotter/ss_data.yaml b/test/screenshotter/ss_data.yaml index db415846..51e6373d 100644 --- a/test/screenshotter/ss_data.yaml +++ b/test/screenshotter/ss_data.yaml @@ -74,6 +74,11 @@ Exponents: a^{a^a_a}_{a^a_a} FractionTest: \dfrac{a}{b}\frac{a}{b}\tfrac{a}{b}\;-\dfrac12\;1\tfrac12\;{1 \atop 2} Functions: \sin\cos\tan\ln\log GreekLetters: \alpha\beta\gamma\omega +GroupMacros: + macros: + \startExp: e^\bgroup + \endExp: \egroup + tex: \startExp a+b\endExp KaTeX: \KaTeX Kern: tex: \frac{a\kern{1em}b}{c}a\kern{1em}b\kern{1ex}c\kern{-0.25em}d @@ -128,6 +133,13 @@ OpLimits: | {\sin_2^2 \lim_2^2 \int_2^2 \sum_2^2} {\displaystyle \lim_2^2 \int_2^2 \intop_2^2 \sum_2^2} OverUnderline: x\underline{x}\underline{\underline{x}}\underline{x_{x_{x_x}}}\underline{x^{x^{x^x}}}\overline{x}\overline{x}\overline{x^{x^{x^x}}} \blue{\overline{\underline{x}}\underline{\overline{x}}} +OverUnderset: | + \begin{array}{l} + x\overset?=1\\ + {\displaystyle\lim_{t\underset{>0}\to0}}\\ + a+b+c+d\overset{b+c=0}\longrightarrow a+d\\ + \overset { x = y } { \sqrt { a b } } + \end{array} Phantom: \dfrac{1+\phantom{x^{\blue{2}}} = x}{1+x^{\blue{2}} = x} PrimeSpacing: f'+f_2'+f^{f'} PrimeSuper: x'^2+x'''^2+x'^2_3+x_3'^2 diff --git a/test/screenshotter/test.html b/test/screenshotter/test.html index 4d9745fe..b046e42a 100644 --- a/test/screenshotter/test.html +++ b/test/screenshotter/test.html @@ -47,6 +47,12 @@ if (query["errorColor"]) { settings.errorColor = query["errorColor"]; } + var macros = {}; + var macroRegex = /(?:^\?|&)(?:\\|%5[Cc])([A-Za-z]+)=([^&]*)/g; + while ((match = macroRegex.exec(window.location.search)) !== null) { + settings.macros = macros; + macros["\\" + match[1]] = decodeURIComponent(match[2]); + } katex.render(query["tex"], mathNode, settings); document.getElementById("pre").innerHTML = query["pre"] || "";