Skip to content

Commit 422c77b

Browse files
author
Emily Eisenberg
committed
Add environments (\begin/\end)
Summary: Add basic support for some environments in KaTeX. To create multiple columns of objects, we simply stack vlists next to each other, and then manually manage the vertical alignment ourselves. So far, this only supports `matrix` (to an artibrary size, where real LaTeX only supports 10 columns!), and the `align*` environment with only two columns in it. Support for more environments shouldn't be terribly hard with this basic structure though. Fixes KaTeX#43 Partially fixes KaTeX#61 Ping KaTeX#206 Test Plan: - `make test` - Make sure screenshots look good Reviewers: kevinb, alpert Differential Revision: https://phabricator.khanacademy.org/D17235
1 parent 0f65300 commit 422c77b

File tree

14 files changed

+573
-5
lines changed

14 files changed

+573
-5
lines changed

src/Lexer.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ var mathNormals = [
3737
/['\^_{}]/, // misc
3838
/[(\[]/, // opens
3939
/[)\]?!]/, // closes
40-
/~/ // spacing
40+
/~/, // spacing
41+
/&/ // environment alignment
4142
];
4243

4344
// These are "normal" tokens like above, but should instead be parsed in text
@@ -169,6 +170,17 @@ Lexer.prototype._innerLexWhitespace = function(pos) {
169170
return new Token(whitespace[0], null, pos);
170171
};
171172

173+
Lexer.prototype._innerLexEnvironmentName = function(pos) {
174+
var input = this._input.slice(pos);
175+
176+
var name = input.match(/^[^}]*/)[0];
177+
if (!/^[a-zA-Z]+\*?/.test(name)) {
178+
throw new ParseError("Invalid environment name: '" + name + "'", this, pos);
179+
}
180+
181+
return new Token(name, name, pos + name.length);
182+
};
183+
172184
/**
173185
* This function lexes a single token starting at `pos` and of the given mode.
174186
* Based on the mode, we defer to one of the `_innerLex` functions.
@@ -184,6 +196,10 @@ Lexer.prototype.lex = function(pos, mode) {
184196
return this._innerLexSize(pos);
185197
} else if (mode === "whitespace") {
186198
return this._innerLexWhitespace(pos);
199+
} else if (mode === "environmentname") {
200+
return this._innerLexEnvironmentName(pos);
201+
} else {
202+
throw new ParseError("Invalid lexing mode: " + mode, this, pos);
187203
}
188204
};
189205

src/Parser.js

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
var environments = require("./environments");
12
var functions = require("./functions");
23
var Lexer = require("./Lexer");
34
var symbols = require("./symbols");
@@ -396,6 +397,88 @@ Parser.prototype.parseImplicitGroup = function(pos, mode) {
396397
value: body.result
397398
}, mode),
398399
body.position);
400+
} else if (func === "\\begin") {
401+
// Handle environments
402+
403+
// Parse out the \begin and it's environment name
404+
var begin = this.parseFunction(pos, mode);
405+
406+
if (!environments[begin.result.value.name]) {
407+
throw new ParseError(
408+
"Unsupported environment: " + begin.result.value.name,
409+
this.lexer, begin.position);
410+
}
411+
412+
body = this.parseExpression(begin.position, mode, false, "}");
413+
414+
var endLex = this.parseSymbol(body.position, mode);
415+
416+
if (endLex && endLex.result.result === "\\end") {
417+
var end = this.parseFunction(body.position, mode);
418+
419+
if (begin.result.value.name !== end.result.value.name) {
420+
throw new ParseError(
421+
"Mismatched environments, '" + begin.result.value.name +
422+
"' vs '" + end.result.value.name + "'",
423+
this.lexer, end.position);
424+
}
425+
426+
var envData = environments[begin.result.value.name];
427+
428+
// Parse out the rows and columns of the environment
429+
var rows = [];
430+
var currRow = [];
431+
var currElement = [];
432+
var lastWasNewline = false;
433+
434+
for (var i = 0; i < body.result.length; i++) {
435+
var elem = body.result[i];
436+
lastWasNewline = false;
437+
438+
if (elem.type === "envseparator") {
439+
// If we see an environment separator, we move to the next
440+
// column in the current row.
441+
currRow.push(currElement);
442+
currElement = [];
443+
if (currRow.length > envData.maxColumns) {
444+
throw new ParseError(
445+
"Environment '" + begin.result.value.name +
446+
"' can't have more than " +
447+
envData.maxColumns + " columns",
448+
this.lexer, end.position);
449+
}
450+
451+
// If the environment separator is a newline, we also move
452+
// to the next row, and store the current row.
453+
if (elem.value === "\\\\" || elem.value === "\\cr") {
454+
rows.push(currRow);
455+
currRow = [];
456+
lastWasNewline = true;
457+
}
458+
} else {
459+
// Otherwise, just push the element into the elements in
460+
// the current box.
461+
currElement.push(elem);
462+
}
463+
}
464+
465+
// Do special handling to ignore newlines at the end.
466+
if (!lastWasNewline) {
467+
currRow.push(currElement);
468+
rows.push(currRow);
469+
}
470+
471+
return new ParseResult(
472+
new ParseNode("environment", {
473+
name: begin.result.value.name,
474+
rows: rows
475+
}, mode),
476+
end.position);
477+
} else {
478+
throw new ParseError("Missing \\end", this.lexer, body.position);
479+
}
480+
} else if (func === "\\end") {
481+
return null;
399482
} else {
400483
// Defer to parseFunction if it's not a function we handle
401484
return this.parseFunction(pos, mode);
@@ -508,7 +591,7 @@ Parser.prototype.parseSpecialGroup = function(pos, mode, outerMode, optional) {
508591
mode = outerMode;
509592
}
510593

511-
if (mode === "color" || mode === "size") {
594+
if (mode === "color" || mode === "size" || mode === "environmentname") {
512595
// color and size modes are special because they should have braces and
513596
// should only lex a single symbol inside
514597
var openBrace = this.lexer.lex(pos, outerMode);

src/buildCommon.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ var makeFontSizer = function(options, fontSize) {
156156
* so positive values move up)
157157
* - "bottom": The positionData specifies the bottommost point
158158
* of the vlist (note this is expected to be a
159-
* depth, so positive values move down
159+
* depth, so positive values move down)
160160
* - "shift": The vlist will be positioned such that its
161161
* baseline is positionData away from the baseline
162162
* of the first child. Positive values move

src/buildHTML.js

Lines changed: 156 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ var Style = require("./Style");
1212
var buildCommon = require("./buildCommon");
1313
var delimiter = require("./delimiter");
1414
var domTree = require("./domTree");
15+
var environments = require("./environments");
1516
var fontMetrics = require("./fontMetrics");
1617
var utils = require("./utils");
1718

@@ -52,7 +53,8 @@ var groupToType = {
5253
rule: "mord",
5354
leftright: "minner",
5455
sqrt: "mord",
55-
accent: "mord"
56+
accent: "mord",
57+
environment: "mord"
5658
};
5759

5860
/**
@@ -1077,6 +1079,156 @@ var groupTypes = {
10771079
// \phantom isn't supposed to affect the elements it contains.
10781080
// See "color" for more details.
10791081
return new buildCommon.makeFragment(elements);
1082+
},
1083+
1084+
environment: function(group, options, _prev) {
1085+
var i, row, col;
1086+
1087+
// Pull out the data about this environment
1088+
var envData = environments[group.value.name];
1089+
1090+
// Figure out the maximum number of columns used in all of the rows
1091+
var mostColumns = 0;
1092+
for (i = 0; i < group.value.rows.length; i++) {
1093+
mostColumns = Math.max(mostColumns, group.value.rows[i].length);
1094+
}
1095+
1096+
// Keep track of the column data
1097+
var columns = [];
1098+
1099+
for (col = 0; col < mostColumns; col++) {
1100+
// The current column
1101+
var column = [];
1102+
1103+
var alignMerge = envData.alignMerge(group.value, col);
1104+
1105+
for (row = 0; row < group.value.rows.length; row++) {
1106+
var elems = [];
1107+
1108+
// Figure out if a previous element should be passed in to this
1109+
// row.
1110+
var prev = null;
1111+
if (alignMerge) {
1112+
// When the column should merge (like in align), we add a
1113+
// fake empty ordgroup as the previous element.
1114+
prev = {
1115+
type: "ordgroup",
1116+
value: [],
1117+
mode: "math"
1118+
};
1119+
}
1120+
1121+
// Check if there's actually an element here, in the row data
1122+
if (group.value.rows[row].length > col) {
1123+
var expr = group.value.rows[row][col];
1124+
// Build the expression
1125+
elems = buildExpression(expr, options.reset(), prev);
1126+
}
1127+
1128+
if (prev) {
1129+
// If we had a previous element, we also put in an empty
1130+
// element of the right group type so that CSS spacing
1131+
// works.
1132+
elems.unshift(makeSpan([getTypeOfGroup(prev)], []));
1133+
}
1134+
1135+
column.push(makeSpan([options.style.cls()], elems));
1136+
}
1137+
1138+
columns.push(column);
1139+
}
1140+
1141+
// Calculate the maximum heights and depths of all of the rows, so we
1142+
// can align them correctly.
1143+
var rowHeights = [];
1144+
var rowDepths = [];
1145+
for (row = 0; row < group.value.rows.length; row++) {
1146+
var rowHeight = 0;
1147+
var rowDepth = 0;
1148+
1149+
for (col = 0; col < mostColumns; col++) {
1150+
rowHeight = Math.max(rowHeight, columns[col][row].height);
1151+
rowDepth = Math.max(rowDepth, columns[col][row].depth);
1152+
}
1153+
1154+
rowHeights.push(rowHeight);
1155+
rowDepths.push(rowDepth);
1156+
}
1157+
1158+
// Figure out spacing between rows based on skip metrics. Explanations
1159+
// of how `baselineskip`, `lineskiplimit` and `lineskip` work provided
1160+
// by TeX for the Impatient, page 153.
1161+
var rowSpacing = [0];
1162+
for (row = 1; row < rowHeights.length; row++) {
1163+
// Initially, we try to have the baselines be `baselineSkip` apart.
1164+
var space = fontMetrics.metrics.baselineSkip -
1165+
(rowDepths[row - 1] + rowHeights[row]);
1166+
1167+
if (space < fontMetrics.metrics.lineSkipLimit) {
1168+
// If that space is less than `lineSkipLimit`, we instead use a
1169+
// constant `lineSkip` amount of space.
1170+
space = fontMetrics.metrics.lineSkip;
1171+
}
1172+
1173+
rowSpacing.push(space);
1174+
}
1175+
1176+
// Calculate the total height + depth + spacing of the environment, so
1177+
// we can center it correctly.
1178+
var totalSize = 0;
1179+
for (row = 0; row < rowHeights.length; row++) {
1180+
totalSize += rowHeights[row] + rowDepths[row];
1181+
if (row > 0) {
1182+
totalSize += rowSpacing[row];
1183+
}
1184+
}
1185+
1186+
// We center the environment about the axis, so we need to shift it by
1187+
// the axis height.
1188+
var centerShift = fontMetrics.metrics.axisHeight;
1189+
1190+
// Build vlists for each of the columns
1191+
var colElems = [];
1192+
for (col = 0; col < columns.length; col++) {
1193+
// Since the elements need to take into account the heights and
1194+
// depths of the other columns, not just their own, we need to
1195+
// position each element in the vlists manually, we can't let
1196+
// `makeVList` do it for us.
1197+
var vlistNodes = [];
1198+
// Start at the bottom
1199+
var currPos = totalSize / 2 - centerShift;
1200+
for (row = columns[col].length - 1; row >= 0; row--) {
1201+
vlistNodes.push({
1202+
type: "elem",
1203+
elem: columns[col][row],
1204+
shift: currPos - rowDepths[row]
1205+
});
1206+
currPos -= rowDepths[row] + rowHeights[row] + rowSpacing[row];
1207+
}
1208+
1209+
var vlist = buildCommon.makeVList(
1210+
vlistNodes, "individualShift", null, options);
1211+
1212+
// Figure out the appropriate alignment for the column
1213+
var colAlign = envData.alignment(group.value, col);
1214+
1215+
var colElem = makeSpan(
1216+
["column", "column-align-" + colAlign], [vlist]);
1217+
1218+
var leftSpacing = envData.spacing(group.value, col);
1219+
colElem.style.marginLeft = leftSpacing + "em";
1220+
1221+
colElems.push(colElem);
1222+
}
1223+
1224+
var environment = makeSpan(["environment", "mord"], colElems);
1225+
1226+
// Add in any extra vertical space around environments
1227+
var verticalSpace = envData.verticalSpace(group.value);
1228+
environment.height += verticalSpace.top;
1229+
environment.depth += verticalSpace.bottom;
1230+
1231+
return environment;
10801232
}
10811233
};
10821234

@@ -1116,6 +1268,9 @@ var buildGroup = function(group, options, prev) {
11161268
}
11171269

11181270
return groupNode;
1271+
} else if (group.type === "envseparator") {
1272+
throw new ParseError(
1273+
"Got environment separator outside of an environment");
11191274
} else {
11201275
throw new ParseError(
11211276
"Got group of unknown type: '" + group.type + "'");

src/buildMathML.js

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,9 +376,27 @@ var groupTypes = {
376376
return node;
377377
},
378378

379-
phantom: function(group, options, prev) {
379+
phantom: function(group) {
380380
var inner = buildExpression(group.value.value);
381381
return new mathMLTree.MathNode("mphantom", inner);
382+
},
383+
384+
environment: function(group) {
385+
var tableElems = [];
386+
387+
for (var i = 0; i < group.value.rows.length; i++) {
388+
var row = group.value.rows[i];
389+
var rowElems = [];
390+
391+
for (var j = 0; j < row.length; j++) {
392+
var inner = buildExpression(row[j]);
393+
rowElems.push(new mathMLTree.MathNode("mtd", inner));
394+
}
395+
396+
tableElems.push(new mathMLTree.MathNode("mtr", rowElems));
397+
}
398+
399+
return new mathMLTree.MathNode("mtable", tableElems);
382400
}
383401
};
384402

@@ -408,6 +426,9 @@ var buildGroup = function(group) {
408426
if (groupTypes[group.type]) {
409427
// Call the groupTypes function
410428
return groupTypes[group.type](group);
429+
} else if (group.type === "envseparator") {
430+
throw new ParseError(
431+
"Got environment separator outside of an environment");
411432
} else {
412433
throw new ParseError(
413434
"Got group of unknown type: '" + group.type + "'");

0 commit comments

Comments
 (0)