diff --git a/README.md b/README.md index 3fd2431..c1aa5f7 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,9 @@ Make sure to include the CSS and font files, but there is no need to include the You can provide an object of options as the last argument to `katex.render` and `katex.renderToString`. Available options are: - `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`) +- `breakOnUnsupportedCmds`: `boolean`. If `true`, KaTeX will generate a `ParseError` when it encounters an unsupported command. If `false`, KaTeX will render the command as text +in the color given by `errorColor`. (default: `true`) +- `errorColor`: `string`. A color string given in the format `"#XXX"` or `"#XXXXXX"`. This option determines the color which unsupported commands are rendered in. (default: `#cc0000`) For example: diff --git a/src/Parser.js b/src/Parser.js index 64514f4..600b259 100644 --- a/src/Parser.js +++ b/src/Parser.js @@ -129,6 +129,14 @@ Parser.prototype.parseExpression = function(pos, mode, breakOnInfix, breakOnToke } var atom = this.parseAtom(pos, mode); if (!atom) { + if (!this.settings.breakOnUnsupportedCmds && lex.text[0] === "\\") { + var errorNode = this.handleUnsupportedCmd(lex.text, mode); + body.push(errorNode); + + pos = lex.position; + continue; + } + break; } if (breakOnInfix && atom.result.type === "infix") { @@ -204,8 +212,16 @@ Parser.prototype.handleSupSubscript = function(pos, mode, symbol, name) { var group = this.parseGroup(pos, mode); if (!group) { - throw new ParseError( - "Expected group after '" + symbol + "'", this.lexer, pos); + var lex = this.lexer.lex(pos, mode); + + if (!this.settings.breakOnUnsupportedCmds && lex.text[0] === "\\") { + return new ParseResult( + this.handleUnsupportedCmd(lex.text, mode), + lex.position); + } else { + throw new ParseError( + "Expected group after '" + symbol + "'", this.lexer, pos); + } } else if (group.isFunction) { // ^ and _ have a greediness, so handle interactions with functions' // greediness @@ -223,6 +239,37 @@ Parser.prototype.handleSupSubscript = function(pos, mode, symbol, name) { } }; +/** + * Converts the textual input of an unsupported command into a text node + * contained within a color node whose color is determined by errorColor + */ + Parser.prototype.handleUnsupportedCmd = function(text, mode) { + var textordArray = []; + + for (var i = 0; i < text.length; i++) { + textordArray.push(new ParseNode("textord", text[i], "text")); + } + + var textNode = new ParseNode( + "text", + { + body: textordArray, + type: "text" + }, + mode); + + var colorNode = new ParseNode( + "color", + { + color: this.settings.errorColor, + value: [textNode], + type: "color" + }, + mode); + + return colorNode; + }; + /** * Parses a group with optional super/subscripts. * @@ -499,9 +546,18 @@ Parser.prototype.parseArguments = function(pos, mode, func, funcData, args) { arg = this.parseGroup(newPos, mode); } if (!arg) { - throw new ParseError( - "Expected group after '" + func + "'", - this.lexer, newPos); + var lex = this.lexer.lex(newPos, mode); + + if (!this.settings.breakOnUnsupportedCmds && lex.text[0] === "\\") { + arg = new ParseFuncOrArgument( + new ParseResult( + this.handleUnsupportedCmd(lex.text, mode), + lex.position), + false); + } else { + throw new ParseError( + "Expected group after '" + func + "'", this.lexer, pos); + } } } var argNode; diff --git a/src/Settings.js b/src/Settings.js index 49395d9..b1dd30d 100644 --- a/src/Settings.js +++ b/src/Settings.js @@ -21,6 +21,8 @@ function Settings(options) { // allow null options options = options || {}; this.displayMode = get(options.displayMode, false); + this.breakOnUnsupportedCmds = get(options.breakOnUnsupportedCmds, true); + this.errorColor = get(options.errorColor, "#cc0000"); } module.exports = Settings; diff --git a/src/buildCommon.js b/src/buildCommon.js index 21fd4f4..62eceb3 100644 --- a/src/buildCommon.js +++ b/src/buildCommon.js @@ -54,7 +54,11 @@ var mathit = function(value, mode, color, classes) { var mathrm = function(value, mode, color, classes) { // Decide what font to render the symbol in by its entry in the symbols // table. - if (symbols[mode][value].font === "main") { + // Have a special case for when the value = \ because the \ is used as a + // textord in unsupported command errors but cannot be parsed as a regular + // text ordinal and is therefore not present as a symbol in the symbols + // table for text + if (value === "\\" || symbols[mode][value].font === "main") { return makeSymbol(value, "Main-Regular", mode, color, classes); } else { return makeSymbol( diff --git a/test/katex-spec.js b/test/katex-spec.js index 927cd9d..52f3442 100644 --- a/test/katex-spec.js +++ b/test/katex-spec.js @@ -13,33 +13,39 @@ var Settings = require("../src/Settings"); var defaultSettings = new Settings({}); -var getBuilt = function(expr) { - expect(expr).toBuild(); +var getBuilt = function(expr, settings) { + var usedSettings = settings ? settings : defaultSettings; - var built = buildHTML(parseTree(expr), defaultSettings); + expect(expr).toBuild(usedSettings); + + var parsedTree = parseTree(expr, usedSettings); + var built = buildHTML(parsedTree, usedSettings); // Remove the outer .katex and .katex-inner layers return built.children[2].children; }; -var getParsed = function(expr) { - expect(expr).toParse(); +var getParsed = function(expr, settings) { + var usedSettings = settings ? settings : defaultSettings; - return parseTree(expr, defaultSettings); + expect(expr).toParse(usedSettings); + return parseTree(expr, usedSettings); }; beforeEach(function() { jasmine.addMatchers({ toParse: function() { return { - compare: function(actual) { + compare: function(actual, settings) { + var usedSettings = settings ? settings : defaultSettings; + var result = { pass: true, message: "'" + actual + "' succeeded parsing" }; try { - parseTree(actual, defaultSettings); + parseTree(actual, usedSettings); } catch (e) { result.pass = false; if (e instanceof ParseError) { @@ -58,7 +64,9 @@ beforeEach(function() { toNotParse: function() { return { - compare: function(actual) { + compare: function(actual, settings) { + var usedSettings = settings ? settings : defaultSettings; + var result = { pass: false, message: "Expected '" + actual + "' to fail " + @@ -66,7 +74,7 @@ beforeEach(function() { }; try { - parseTree(actual, defaultSettings); + parseTree(actual, usedSettings); } catch (e) { if (e instanceof ParseError) { result.pass = true; @@ -85,16 +93,18 @@ beforeEach(function() { toBuild: function() { return { - compare: function(actual) { + compare: function(actual, settings) { + var usedSettings = settings ? settings : defaultSettings; + var result = { pass: true, message: "'" + actual + "' succeeded in building" }; - expect(actual).toParse(); + expect(actual).toParse(usedSettings); try { - buildHTML(parseTree(actual), defaultSettings); + buildHTML(parseTree(actual, usedSettings), usedSettings); } catch (e) { result.pass = false; if (e instanceof ParseError) { @@ -1359,10 +1369,12 @@ describe("A cases environment", function() { }); -var getMathML = function(expr) { - expect(expr).toParse(); +var getMathML = function(expr, settings) { + var usedSettings = settings ? settings : defaultSettings; - var built = buildMathML(parseTree(expr)); + expect(expr).toParse(usedSettings); + + var built = buildMathML(parseTree(expr, usedSettings), expr, usedSettings); // Strip off the surrounding return built.children[0]; @@ -1400,3 +1412,45 @@ describe("A MathML builder", function() { expect(phantom.children[0].type).toEqual("mphantom"); }); }); + +describe("A parser that does not break on unsupported commands", function() { + // The parser breaks on unsupported commands unless it is explicitly + // told not to + var errorColor = "#933"; + var doNotBreakSettings = new Settings({ + breakOnUnsupportedCmds: false, + errorColor: errorColor + }); + + it("should still parse on unrecognized control sequences", function() { + expect("\\error").toParse(doNotBreakSettings); + }); + + describe("should allow unrecognized controls sequences anywhere, including", function() { + it("in superscripts and subscripts", function() { + expect("2_\\error").toBuild(doNotBreakSettings); + expect("3^{\\error}_\\error").toBuild(doNotBreakSettings); + expect("\\int\\nolimits^\\error_\\error").toBuild(doNotBreakSettings); + }); + + it("in fractions", function() { + expect("\\frac{345}{\\error}").toBuild(doNotBreakSettings); + expect("\\frac\\error{\\error}").toBuild(doNotBreakSettings); + }); + + it("in square roots", function() { + expect("\\sqrt\\error").toBuild(doNotBreakSettings); + expect("\\sqrt{234\\error}").toBuild(doNotBreakSettings); + }); + + it("in text boxes", function() { + expect("\\text{\\error}").toBuild(doNotBreakSettings); + }); + }); + + it("should produce color nodes with a color value given by errorColor", function() { + var parsedInput = getParsed("\\error", doNotBreakSettings); + expect(parsedInput[0].type).toBe("color"); + expect(parsedInput[0].value.color).toBe(errorColor); + }); +}); diff --git a/test/screenshotter/images/UnsupportedCmds-chrome.png b/test/screenshotter/images/UnsupportedCmds-chrome.png new file mode 100644 index 0000000..0c374bb Binary files /dev/null and b/test/screenshotter/images/UnsupportedCmds-chrome.png differ diff --git a/test/screenshotter/images/UnsupportedCmds-firefox.png b/test/screenshotter/images/UnsupportedCmds-firefox.png new file mode 100644 index 0000000..e4d9123 Binary files /dev/null and b/test/screenshotter/images/UnsupportedCmds-firefox.png differ diff --git a/test/screenshotter/ss_data.json b/test/screenshotter/ss_data.json index 3374168..6838826 100644 --- a/test/screenshotter/ss_data.json +++ b/test/screenshotter/ss_data.json @@ -38,5 +38,6 @@ "SupSubHorizSpacing": "http://localhost:7936/test/screenshotter/test.html?m=x^{x^{x}}\\Big|x_{x_{x_{x_{x}}}}\\bigg|x^{x^{x_{x_{x_{x_{x}}}}}}\\bigg|", "SupSubOffsets": "http://localhost:7936/test/screenshotter/test.html?m=\\displaystyle \\int_{2+3}x f^{2+3}+3\\lim_{2+3+4+5}f", "Text": "http://localhost:7936/test/screenshotter/test.html?m=\\frac{a}{b}\\text{c~ {ab} \\ e}+fg", + "UnsupportedCmds": "http://localhost:7936/test/screenshotter/test.html?m=\\err\\,\\frac\\fracerr3\\,2^\\superr_\\suberr\\,\\sqrt\\sqrterr&doNotBreak=1&errorColor=%23dd4c4c", "VerticalSpacing": "http://localhost:7936/test/screenshotter/test.html?pre=potato
blah&post=
moo&m=x^{\\Huge y}z" } diff --git a/test/screenshotter/test.html b/test/screenshotter/test.html index f03df4d..5540f5e 100644 --- a/test/screenshotter/test.html +++ b/test/screenshotter/test.html @@ -25,9 +25,16 @@ query[match[1]] = decodeURIComponent(match[2]); } var mathNode = document.getElementById("math"); - katex.render(query["m"], mathNode, { - displayMode: !!query["display"] - }); + + var settings = { + displayMode: !!query["display"], + breakOnUnsupportedCmds: !query["doNotBreak"] + }; + if (query["errorColor"]) { + settings.errorColor = query["errorColor"]; + } + + katex.render(query["m"], mathNode, settings); document.getElementById("pre").innerHTML = query["pre"] || ""; document.getElementById("post").innerHTML = query["post"] || "";