Implement environments, for arrays and matrices in particular

This commit introduces environments, and implements the parser
infrastructure to handle them, even including arguments after the
“\begin{name}” construct.  It also offers a way to turn array-like data
structures, i.e. delimited by “&” and “\\”, into nested arrays of groups.
Environments are essentially functions which call back to the parser to
parse their body.  It is their responsibility to stop at the next “\end”,
while the parser takes care of verifing that the names match between
“\begin” and “\end”.  The environment has to return a ParseResult, to
provide the position that goes with the resulting node.

One application of this is the “array” environment.  So far, it supports
column alignment, but no column separators, and no multi-column shorthands
using “*{…}”.  Building on the same infrastructure, there are “matrix”,
“pmatrix”, “bmatrix”, “vmatrix” and “Vmatrix” environments.  Internally
these are just “\left..\right” wrapped around an array with no margins at
its ends.  Spacing for arrays and matrices was derived from the LaTeX
sources, and comments indicate the appropriate references.

Now we have hard-wired breaks in parseExpression, to always break on “}”,
“\end”, “\right”, “&”, “\\” and “\cr”.  This means that these symbols are
never PART of an expression, at least not without some nesting.  They may
follow AFTER an expression, and the caller of parseExpression should be
expecting them.  The implicit groups for sizing or styling don't care what
ended the expression, which is all right for them.  We still have support
for breakOnToken, but now it is only used for “]” since that MAY be used to
terminate an optional argument, but otherwise it's an ordinary symbol.
This commit is contained in:
Martin von Gagern 2015-06-12 18:58:16 +02:00
parent 5cf5617c09
commit 2f7a54877a
12 changed files with 520 additions and 112 deletions

View File

@ -37,7 +37,9 @@ var mathNormals = [
/['\^_{}]/, // misc
/[(\[]/, // opens
/[)\]?!]/, // closes
/~/ // spacing
/~/, // spacing
/&/, // horizontal alignment
/\\\\/ // line break
];
// These are "normal" tokens like above, but should instead be parsed in text
@ -45,7 +47,9 @@ var mathNormals = [
var textNormals = [
/[a-zA-Z0-9`!@*()-=+\[\]'";:?\/.,]/, // ords
/[{}]/, // grouping
/~/ // spacing
/~/, // spacing
/&/, // horizontal alignment
/\\\\/ // line break
];
// Regexes for matching whitespace

View File

@ -1,8 +1,10 @@
var functions = require("./functions");
var environments = require("./environments");
var Lexer = require("./Lexer");
var symbols = require("./symbols");
var utils = require("./utils");
var parseData = require("./parseData");
var ParseError = require("./ParseError");
/**
@ -50,22 +52,8 @@ function Parser(input, settings) {
this.settings = settings;
}
/**
* The resulting parse tree nodes of the parse tree.
*/
function ParseNode(type, value, mode) {
this.type = type;
this.value = value;
this.mode = mode;
}
/**
* A result and final position returned by the `.parse...` functions.
*/
function ParseResult(result, newPosition) {
this.result = result;
this.position = newPosition;
}
var ParseNode = parseData.ParseNode;
var ParseResult = parseData.ParseResult;
/**
* An initial function (without its arguments), or an argument to a function.
@ -106,13 +94,14 @@ Parser.prototype.parse = function(input) {
*/
Parser.prototype.parseInput = function(pos, mode) {
// Parse an expression
var expression = this.parseExpression(pos, mode, false, null);
var expression = this.parseExpression(pos, mode, false);
// If we succeeded, make sure there's an EOF at the end
var EOF = this.lexer.lex(expression.position, mode);
this.expect(EOF, "EOF");
this.expect(expression.peek, "EOF");
return expression;
};
var endOfExpression = ["}", "\\end", "\\right", "&", "\\\\", "\\cr"];
/**
* Parses an "expression", which is a list of atoms.
*
@ -127,11 +116,15 @@ Parser.prototype.parseInput = function(pos, mode) {
*/
Parser.prototype.parseExpression = function(pos, mode, breakOnInfix, breakOnToken) {
var body = [];
var lex = null;
// Keep adding atoms to the body until we can't parse any more atoms (either
// we reached the end, a }, or a \right)
while (true) {
var lex = this.lexer.lex(pos, mode);
if (breakOnToken != null && lex.text === breakOnToken) {
lex = this.lexer.lex(pos, mode);
if (endOfExpression.indexOf(lex.text) !== -1) {
break;
}
if (breakOnToken && lex.text === breakOnToken) {
break;
}
var atom = this.parseAtom(pos, mode);
@ -144,7 +137,9 @@ Parser.prototype.parseExpression = function(pos, mode, breakOnInfix, breakOnToke
body.push(atom.result);
pos = atom.position;
}
return new ParseResult(this.handleInfixNodes(body, mode), pos);
var res = new ParseResult(this.handleInfixNodes(body, mode), pos);
res.peek = lex;
return res;
};
/**
@ -353,31 +348,48 @@ Parser.prototype.parseImplicitGroup = function(pos, mode) {
// Parse the entire left function (including the delimiter)
var left = this.parseFunction(pos, mode);
// Parse out the implicit body
body = this.parseExpression(left.position, mode, false, "}");
body = this.parseExpression(left.position, mode, false);
// Check the next token
var rightLex = this.parseSymbol(body.position, mode);
if (rightLex && rightLex.result.result === "\\right") {
// If it's a \right, parse the entire right function (including the delimiter)
var right = this.parseFunction(body.position, mode);
return new ParseResult(
new ParseNode("leftright", {
body: body.result,
left: left.result.value.value,
right: right.result.value.value
}, mode),
right.position);
} else {
throw new ParseError("Missing \\right", this.lexer, body.position);
this.expect(body.peek, "\\right");
var right = this.parseFunction(body.position, mode);
return new ParseResult(
new ParseNode("leftright", {
body: body.result,
left: left.result.value.value,
right: right.result.value.value
}, mode),
right.position);
} else if (func === "\\begin") {
// begin...end is similar to left...right
var begin = this.parseFunction(pos, mode);
var envName = begin.result.value.name;
if (!environments.hasOwnProperty(envName)) {
throw new ParseError(
"No such environment: " + envName,
this.lexer, begin.result.value.namepos);
}
} else if (func === "\\right") {
// If we see a right, explicitly fail the parsing here so the \left
// handling ends the group
return null;
// Build the environment object. Arguments and other information will
// be made available to the begin and end methods using properties.
var env = environments[envName];
var args = [null, mode, envName];
var newPos = this.parseArguments(
begin.position, mode, "\\begin{" + envName + "}", env, args);
args[0] = newPos;
var result = env.handler.apply(this, args);
var endLex = this.lexer.lex(result.position, mode);
this.expect(endLex, "\\end");
var end = this.parseFunction(result.position, mode);
if (end.result.value.name !== envName) {
throw new ParseError(
"Mismatch: \\begin{" + envName + "} matched " +
"by \\end{" + end.result.value.name + "}",
this.lexer, end.namepos);
}
result.position = end.position;
return result;
} else if (utils.contains(sizeFuncs, func)) {
// If we see a sizing function, parse out the implict body
body = this.parseExpression(start.result.position, mode, false, "}");
body = this.parseExpression(start.result.position, mode, false);
return new ParseResult(
new ParseNode("sizing", {
// Figure out what size to use based on the list of functions above
@ -387,7 +399,7 @@ Parser.prototype.parseImplicitGroup = function(pos, mode) {
body.position);
} else if (utils.contains(styleFuncs, func)) {
// If we see a styling function, parse out the implict body
body = this.parseExpression(start.result.position, mode, true, "}");
body = this.parseExpression(start.result.position, mode, true);
return new ParseResult(
new ParseNode("styling", {
// Figure out what style to use by pulling out the style from
@ -420,71 +432,10 @@ Parser.prototype.parseFunction = function(pos, mode) {
this.lexer, baseGroup.position);
}
var newPos = baseGroup.result.position;
var result;
var totalArgs = funcData.numArgs + funcData.numOptionalArgs;
if (totalArgs > 0) {
var baseGreediness = funcData.greediness;
var args = [func];
var positions = [newPos];
for (var i = 0; i < totalArgs; i++) {
var argType = funcData.argTypes && funcData.argTypes[i];
var arg;
if (i < funcData.numOptionalArgs) {
if (argType) {
arg = this.parseSpecialGroup(newPos, argType, mode, true);
} else {
arg = this.parseOptionalGroup(newPos, mode);
}
if (!arg) {
args.push(null);
positions.push(newPos);
continue;
}
} else {
if (argType) {
arg = this.parseSpecialGroup(newPos, argType, mode);
} else {
arg = this.parseGroup(newPos, mode);
}
if (!arg) {
throw new ParseError(
"Expected group after '" + baseGroup.result.result +
"'",
this.lexer, newPos);
}
}
var argNode;
if (arg.isFunction) {
var argGreediness =
functions.funcs[arg.result.result].greediness;
if (argGreediness > baseGreediness) {
argNode = this.parseFunction(newPos, mode);
} else {
throw new ParseError(
"Got function '" + arg.result.result + "' as " +
"argument to function '" +
baseGroup.result.result + "'",
this.lexer, arg.result.position - 1);
}
} else {
argNode = arg.result;
}
args.push(argNode.result);
positions.push(argNode.position);
newPos = argNode.position;
}
args.push(positions);
result = functions.funcs[func].handler.apply(this, args);
} else {
result = functions.funcs[func].handler.apply(this, [func]);
}
var args = [func];
var newPos = this.parseArguments(
baseGroup.result.position, mode, func, funcData, args);
var result = functions.funcs[func].handler.apply(this, args);
return new ParseResult(
new ParseNode(result.type, result, mode),
newPos);
@ -496,6 +447,77 @@ Parser.prototype.parseFunction = function(pos, mode) {
}
};
/**
* Parses the arguments of a function or environment
*
* @param {string} func "\name" or "\begin{name}"
* @param {{numArgs:number,numOptionalArgs:number|undefined}} funcData
* @param {Array} args list of arguments to which new ones will be pushed
* @return the position after all arguments have been parsed
*/
Parser.prototype.parseArguments = function(pos, mode, func, funcData, args) {
var totalArgs = funcData.numArgs + funcData.numOptionalArgs;
if (totalArgs === 0) {
return pos;
}
var newPos = pos;
var baseGreediness = funcData.greediness;
var positions = [newPos];
for (var i = 0; i < totalArgs; i++) {
var argType = funcData.argTypes && funcData.argTypes[i];
var arg;
if (i < funcData.numOptionalArgs) {
if (argType) {
arg = this.parseSpecialGroup(newPos, argType, mode, true);
} else {
arg = this.parseOptionalGroup(newPos, mode);
}
if (!arg) {
args.push(null);
positions.push(newPos);
continue;
}
} else {
if (argType) {
arg = this.parseSpecialGroup(newPos, argType, mode);
} else {
arg = this.parseGroup(newPos, mode);
}
if (!arg) {
throw new ParseError(
"Expected group after '" + func + "'",
this.lexer, newPos);
}
}
var argNode;
if (arg.isFunction) {
var argGreediness =
functions.funcs[arg.result.result].greediness;
if (argGreediness > baseGreediness) {
argNode = this.parseFunction(newPos, mode);
} else {
throw new ParseError(
"Got function '" + arg.result.result + "' as " +
"argument to '" + func + "'",
this.lexer, arg.result.position - 1);
}
} else {
argNode = arg.result;
}
args.push(argNode.result);
positions.push(argNode.position);
newPos = argNode.position;
}
args.push(positions);
return newPos;
};
/**
* Parses a group when the mode is changing. Takes a position, a new mode, and
* an outer mode that is used to parse the outside.
@ -556,7 +578,7 @@ Parser.prototype.parseGroup = function(pos, mode) {
// Try to parse an open brace
if (start.text === "{") {
// If we get a brace, parse an expression
var expression = this.parseExpression(start.position, mode, false, "}");
var expression = this.parseExpression(start.position, mode, false);
// Make sure we get a close brace
var closeBrace = this.lexer.lex(expression.position, mode);
this.expect(closeBrace, "}");
@ -625,4 +647,6 @@ Parser.prototype.parseSymbol = function(pos, mode) {
}
};
Parser.prototype.ParseNode = ParseNode;
module.exports = Parser;

View File

@ -43,6 +43,7 @@ var groupToType = {
close: "mclose",
inner: "minner",
genfrac: "minner",
array: "minner",
spacing: "mord",
punct: "mpunct",
ordgroup: "mord",
@ -498,6 +499,108 @@ var groupTypes = {
options.getColor());
},
array: function(group, options, prev) {
var r, c;
var nr = group.value.body.length;
var nc = 0;
var body = new Array(nr);
// Horizontal spacing
var pt = 1 / fontMetrics.metrics.ptPerEm;
var arraycolsep = 5 * pt; // \arraycolsep in article.cls
// Vertical spacing
var baselineskip = 12 * pt; // see size10.clo
var arraystretch = 1; // factor, see lttab.dtx
var arrayskip = arraystretch * baselineskip;
var arstrutHeight = 0.7 * arrayskip; // \strutbox in ltfsstrc.dtx and
var arstrutDepth = 0.3 * arrayskip; // \@arstrutbox in lttab.dtx
var totalHeight = 0;
for (r = 0; r < group.value.body.length; ++r) {
var inrow = group.value.body[r];
var height = arstrutHeight; // \@array adds an \@arstrut
var depth = arstrutDepth; // to each tow (via the template)
if (nc < inrow.length) {
nc = inrow.length;
}
var outrow = new Array(inrow.length);
for (c = 0; c < inrow.length; ++c) {
var elt = buildGroup(inrow[c], options);
if (depth < elt.depth) {
depth = elt.depth;
}
if (height < elt.height) {
height = elt.height;
}
outrow[c] = elt;
}
var gap = 0;
if (group.value.rowGaps[r]) {
gap = group.value.rowGaps[r].value;
switch (gap.unit) {
case "em":
gap = gap.number;
break;
case "ex":
gap = gap.number * fontMetrics.metrics.emPerEx;
break;
default:
console.error("Can't handle unit " + gap.unit);
gap = 0;
}
if (gap > 0) { // \@argarraycr
gap += arstrutDepth;
if (depth < gap) {
depth = gap; // \@xargarraycr
}
gap = 0;
}
}
outrow.height = height;
outrow.depth = depth;
totalHeight += height;
outrow.pos = totalHeight;
totalHeight += depth + gap; // \@yargarraycr
body[r] = outrow;
}
var offset = totalHeight / 2 + fontMetrics.metrics.axisHeight;
var colalign = group.value.colalign || [];
var cols = [];
var colsep;
for (c = 0; c < nc; ++c) {
if (c > 0 || group.value.hskipBeforeAndAfter) {
colsep = makeSpan(["arraycolsep"], []);
colsep.style.width = arraycolsep + "em";
cols.push(colsep);
}
var col = [];
for (r = 0; r < nr; ++r) {
var row = body[r];
var elem = row[c];
if (!elem) {
continue;
}
var shift = row.pos - offset;
elem.depth = row.depth;
elem.height = row.height;
col.push({type: "elem", elem: elem, shift: shift});
}
col = buildCommon.makeVList(col, "individualShift", null, options);
col = makeSpan(
["col-align-" + (colalign[c] || "c")],
[col]);
cols.push(col);
if (c < nc - 1 || group.value.hskipBeforeAndAfter) {
colsep = makeSpan(["arraycolsep"], []);
colsep.style.width = arraycolsep + "em";
cols.push(colsep);
}
}
body = makeSpan(["mtable"], cols);
return makeSpan(["minner"], [body], options.getColor());
},
spacing: function(group, options, prev) {
if (group.value === "\\ " || group.value === "\\space" ||
group.value === " " || group.value === "~") {

View File

@ -186,6 +186,17 @@ var groupTypes = {
return node;
},
array: function(group) {
return new mathMLTree.MathNode(
"mtable", group.value.body.map(function(row) {
return new mathMLTree.MathNode(
"mtr", row.map(function(cell) {
return new mathMLTree.MathNode(
"mtd", [buildGroup(cell)]);
}));
}));
},
sqrt: function(group) {
var node;
if (group.value.index) {

132
src/environments.js Normal file
View File

@ -0,0 +1,132 @@
var parseData = require("./parseData");
var ParseError = require("./ParseError");
var ParseNode = parseData.ParseNode;
var ParseResult = parseData.ParseResult;
/**
* Parse the body of the environment, with rows delimited by \\ and
* columns delimited by &, and create a nested list in row-major order
* with one group per cell.
*/
function parseArray(parser, pos, mode, result) {
var row = [], body = [row], rowGaps = [];
while (true) {
var cell = parser.parseExpression(pos, mode, false, null);
row.push(new ParseNode("ordgroup", cell.result, mode));
pos = cell.position;
var next = cell.peek.text;
if (next === "&") {
pos = cell.peek.position;
} else if (next === "\\end") {
break;
} else if (next === "\\\\" || next === "\\cr") {
var cr = parser.parseFunction(pos, mode);
rowGaps.push(cr.result.value.size);
pos = cr.position;
row = [];
body.push(row);
} else {
throw new ParseError("Expected & or \\\\ or \\end",
parser.lexer, cell.peek.position);
}
}
result.body = body;
result.rowGaps = rowGaps;
return new ParseResult(new ParseNode(result.type, result, mode), pos);
}
/*
* An environment definition is very similar to a function definition.
* Each element of the following array may contain
* - names: The names associated with a function. This can be used to
* share one implementation between several similar environments.
* - numArgs: The number of arguments after the \begin{name} function.
* - argTypes: (optional) Just like for a function
* - allowedInText: (optional) Whether or not the environment is allowed inside
* text mode (default false) (not enforced yet)
* - numOptionalArgs: (optional) Just like for a function
* - handler: The function that is called to handle this environment.
* It will receive the following arguments:
* - pos: the current position of the parser.
* - mode: the current parsing mode.
* - envName: the name of the environment, one of the listed names.
* - [args]: the arguments passed to \begin.
* - positions: the positions associated with these arguments.
*/
var environmentDefinitions = [
// Arrays are part of LaTeX, defined in lttab.dtx so its documentation
// is part of the source2e.pdf file of LaTeX2e source documentation.
{
names: ["array"],
numArgs: 1,
handler: function(pos, mode, envName, colalign, positions) {
var parser = this;
// Currently only supports alignment, no separators like | yet.
colalign = colalign.value.map ? colalign.value : [colalign];
colalign = colalign.map(function(node) {
var ca = node.value;
if ("lcr".indexOf(ca) !== -1) {
return ca;
}
throw new ParseError(
"Unknown column alignment: " + node.value,
parser.lexer, positions[1]);
});
var res = {
type: "array",
colalign: colalign,
hskipBeforeAndAfter: true // \@preamble in lttab.dtx
};
res = parseArray(parser, pos, mode, res);
return res;
}
},
// The matrix environments of amsmath builds on the array environment
// of LaTeX, which is discussed above.
{
names: ["matrix", "pmatrix", "bmatrix", "vmatrix", "Vmatrix"],
handler: function(pos, mode, envName) {
var delimiters = {
"matrix": null,
"pmatrix": ["(", ")"],
"bmatrix": ["[", "]"],
"vmatrix": ["|", "|"],
"Vmatrix": ["\\Vert", "\\Vert"]
}[envName];
var res = {
type: "array",
hskipBeforeAndAfter: false // \hskip -\arraycolsep in amsmath
};
res = parseArray(this, pos, mode, res);
if (delimiters) {
res.result = new ParseNode("leftright", {
body: [res.result],
left: delimiters[0],
right: delimiters[1]
}, mode);
}
return res;
}
}
];
module.exports = (function() {
// nested function so we don't leak i and j into the module scope
var exports = {};
for (var i = 0; i < environmentDefinitions.length; ++i) {
var def = environmentDefinitions[i];
def.greediness = 1;
def.allowedInText = !!def.allowedInText;
def.numArgs = def.numArgs || 0;
def.numOptionalArgs = def.numOptionalArgs || 0;
for (var j = 0; j < def.names.length; ++j) {
exports[def.names[j]] = def;
}
}
return exports;
})();

View File

@ -93,6 +93,7 @@ var metrics = {
bigOpSpacing4: xi12,
bigOpSpacing5: xi13,
ptPerEm: ptPerEm,
emPerEx: sigma5 / sigma6,
// TODO(alpert): Missing parallel structure here. We should probably add
// style-specific metrics for all of these.

View File

@ -517,6 +517,47 @@ var duplicatedFunctions = [
};
}
}
},
// Row breaks for aligned data
{
funcs: ["\\\\", "\\cr"],
data: {
numArgs: 0,
numOptionalArgs: 1,
argTypes: ["size"],
handler: function(func, size) {
return {
type: "cr",
size: size
};
}
}
},
// Environment delimiters
{
funcs: ["\\begin", "\\end"],
data: {
numArgs: 1,
argTypes: ["text"],
handler: function(func, nameGroup, positions) {
if (nameGroup.type !== "ordgroup") {
throw new ParseError(
"Invalid environment name",
this.lexer, positions[1]);
}
var name = "";
for (var i = 0; i < nameGroup.value.length; ++i) {
name += nameGroup.value[i].value;
}
return {
type: "environment",
name: name,
namepos: positions[1]
};
}
}
}
];

23
src/parseData.js Normal file
View File

@ -0,0 +1,23 @@
/**
* The resulting parse tree nodes of the parse tree.
*/
function ParseNode(type, value, mode) {
this.type = type;
this.value = value;
this.mode = mode;
}
/**
* A result and final position returned by the `.parse...` functions.
*
*/
function ParseResult(result, newPosition, peek) {
this.result = result;
this.position = newPosition;
}
module.exports = {
ParseNode: ParseNode,
ParseResult: ParseResult
};

View File

@ -462,4 +462,21 @@
left: 0.326em;
}
}
.arraycolsep {
display: inline-block;
}
.col-align-c > .vlist {
text-align: center;
}
.col-align-l > .vlist {
text-align: left;
}
.col-align-r > .vlist {
text-align: right;
}
}

View File

@ -880,6 +880,47 @@ describe("A left/right parser", function() {
});
});
describe("A begin/end parser", function() {
it("should parse a simple environment", function() {
expect("\\begin{matrix}a&b\\\\c&d\\end{matrix}").toParse();
});
it("should parse an environment with argument", function() {
expect("\\begin{array}{cc}a&b\\\\c&d\\end{array}").toParse();
});
it("should error when name is mismatched", function() {
expect("\\begin{matrix}a&b\\\\c&d\\end{pmatrix}").toNotParse();
});
it("should error when commands are mismatched", function() {
expect("\\begin{matrix}a&b\\\\c&d\\right{pmatrix}").toNotParse();
});
it("should error when end is missing", function() {
expect("\\begin{matrix}a&b\\\\c&d").toNotParse();
});
it("should error when braces are mismatched", function() {
expect("{\\begin{matrix}a&b\\\\c&d}\\end{matrix}").toNotParse();
});
it("should cooperate with infix notation", function() {
expect("\\begin{matrix}0&1\\over2&3\\\\4&5&6\\end{matrix}").toParse();
});
it("should nest", function() {
var m1 = "\\begin{pmatrix}1&2\\\\3&4\\end{pmatrix}";
var m2 = "\\begin{array}{rl}" + m1 + "&0\\\\0&" + m1 + "\\end{array}";
expect(m2).toParse();
});
it("should allow \\cr as a line terminator", function() {
expect("\\begin{matrix}a&b\\cr c&d\\end{matrix}").toParse();
});
});
describe("A sqrt parser", function() {
var sqrt = "\\sqrt{x}";
var missingGroup = "\\sqrt";
@ -1264,6 +1305,16 @@ describe("An optional argument parser", function() {
});
});
describe("An array environment", function() {
it("should accept a single alignment character", function() {
var parse = getParsed("\\begin{array}r1\\\\20\\end{array}");
expect(parse[0].type).toBe("array");
expect(parse[0].value.colalign).toEqual(["r"]);
});
});
var getMathML = function(expr) {
expect(expr).toParse();

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

@ -1,5 +1,6 @@
{
"Accents": "http://localhost:7936/test/screenshotter/test.html?m=\\vec{A}\\vec{x}\\vec x^2\\vec{x}_2^2\\vec{A}^2\\vec{xA}^2",
"Arrays": "http://localhost:7936/test/screenshotter/test.html?m=\\left(\\begin{array}{rlc}1%262%263\\\\1+1%262+1%263+1\\cr1\\over2%26\\scriptstyle 1/2%26\\frac12\\\\[1ex]\\begin{pmatrix}x\\\\y\\end{pmatrix}%260%26\\begin{vmatrix}a%26b\\\\c%26d\\end{vmatrix}\\end{array}\\right]",
"Baseline": "http://localhost:7936/test/screenshotter/test.html?m=a+b-c\\cdot d/e",
"BasicTest": "http://localhost:7936/test/screenshotter/test.html?m=a",
"BinomTest": "http://localhost:7936/test/screenshotter/test.html?m=\\dbinom{a}{b}\\tbinom{a}{b}^{\\binom{a}{b}+17}",