mirror of
https://github.com/Smaug123/KaTeX
synced 2025-10-05 19:28:39 +00:00
* Replace ParseNode<*> with a more accurate AnyParseNode and fix flow errors. * Allow "array" environment type spec to use any all symbol type. Before this commit, it was constrained to use "mathord" and "textord", but a recent change in HEAD resulted in a "rel" node being used in the spec for, e.g. \begin{array}{|l||c:r::}\end{array} * Address reviewer comments: rename `lastRow` to `row` in array.js.
651 lines
21 KiB
JavaScript
651 lines
21 KiB
JavaScript
// @flow
|
|
import buildCommon from "../buildCommon";
|
|
import defineEnvironment from "../defineEnvironment";
|
|
import mathMLTree from "../mathMLTree";
|
|
import ParseError from "../ParseError";
|
|
import ParseNode from "../ParseNode";
|
|
import {assertNodeType, assertSymbolNodeType} from "../ParseNode";
|
|
import {checkNodeType, checkSymbolNodeType} from "../ParseNode";
|
|
import {calculateSize} from "../units";
|
|
import utils from "../utils";
|
|
|
|
import * as html from "../buildHTML";
|
|
import * as mml from "../buildMathML";
|
|
|
|
import type Parser from "../Parser";
|
|
import type {AnyParseNode} from "../ParseNode";
|
|
import type {StyleStr} from "../types";
|
|
import type {HtmlBuilder, MathMLBuilder} from "../defineFunction";
|
|
|
|
// Data stored in the ParseNode associated with the environment.
|
|
type AlignSpec = { type: "separator", separator: string } | {
|
|
type: "align",
|
|
align: string,
|
|
pregap?: number,
|
|
postgap?: number,
|
|
};
|
|
|
|
export type ArrayEnvNodeData = {|
|
|
type: "array",
|
|
hskipBeforeAndAfter?: boolean,
|
|
arraystretch: number,
|
|
addJot?: boolean,
|
|
cols?: AlignSpec[],
|
|
body: AnyParseNode[][], // List of rows in the (2D) array.
|
|
rowGaps: (?ParseNode<"size">)[],
|
|
numHLinesBeforeRow: number[],
|
|
|};
|
|
// Same as above but with some fields not yet filled.
|
|
type ArrayEnvNodeDataIncomplete = {|
|
|
type: "array",
|
|
hskipBeforeAndAfter?: boolean,
|
|
arraystretch?: number,
|
|
addJot?: boolean,
|
|
cols?: AlignSpec[],
|
|
// Before these fields are filled.
|
|
body?: AnyParseNode[][],
|
|
rowGaps?: (?ParseNode<"size">)[],
|
|
numHLinesBeforeRow?: number[],
|
|
|};
|
|
|
|
function getNumHLines(parser: Parser): number {
|
|
let n = 0;
|
|
parser.consumeSpaces();
|
|
while (parser.nextToken.text === "\\hline") {
|
|
parser.consume();
|
|
n++;
|
|
parser.consumeSpaces();
|
|
}
|
|
return n;
|
|
}
|
|
|
|
/**
|
|
* 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. If given an optional argument style
|
|
* ("text", "display", etc.), then each cell is cast into that style.
|
|
*/
|
|
function parseArray(
|
|
parser: Parser,
|
|
result: ArrayEnvNodeDataIncomplete,
|
|
style: StyleStr,
|
|
): ParseNode<"array"> {
|
|
// Parse body of array with \\ temporarily mapped to \cr
|
|
parser.gullet.beginGroup();
|
|
parser.gullet.macros.set("\\\\", "\\cr");
|
|
|
|
// Get current arraystretch if it's not set by the environment
|
|
if (!result.arraystretch) {
|
|
const arraystretch = parser.gullet.expandMacroAsText("\\arraystretch");
|
|
if (arraystretch == null) {
|
|
// Default \arraystretch from lttab.dtx
|
|
result.arraystretch = 1;
|
|
} else {
|
|
result.arraystretch = parseFloat(arraystretch);
|
|
if (!result.arraystretch || result.arraystretch < 0) {
|
|
throw new ParseError(`Invalid \\arraystretch: ${arraystretch}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
let row = [];
|
|
const body = [row];
|
|
const rowGaps = [];
|
|
const numHLinesBeforeRow = [];
|
|
|
|
// Test for \hline at the top of the array.
|
|
numHLinesBeforeRow.push(getNumHLines(parser));
|
|
|
|
while (true) { // eslint-disable-line no-constant-condition
|
|
let cell = parser.parseExpression(false, "\\cr");
|
|
cell = new ParseNode("ordgroup", cell, parser.mode);
|
|
if (style) {
|
|
cell = new ParseNode("styling", {
|
|
type: "styling",
|
|
style: style,
|
|
value: [cell],
|
|
}, parser.mode);
|
|
}
|
|
row.push(cell);
|
|
const next = parser.nextToken.text;
|
|
if (next === "&") {
|
|
parser.consume();
|
|
} else if (next === "\\end") {
|
|
// Arrays terminate newlines with `\crcr` which consumes a `\cr` if
|
|
// the last line is empty.
|
|
// NOTE: Currently, `cell` is the last item added into `row`.
|
|
if (row.length === 1 && cell.value.value[0].value.length === 0) {
|
|
body.pop();
|
|
}
|
|
break;
|
|
} else if (next === "\\cr") {
|
|
const cr = parser.parseFunction();
|
|
if (!cr) {
|
|
throw new ParseError(`Failed to parse function after ${next}`);
|
|
}
|
|
rowGaps.push(assertNodeType(cr, "cr").value.size);
|
|
|
|
// check for \hline(s) following the row separator
|
|
numHLinesBeforeRow.push(getNumHLines(parser));
|
|
|
|
row = [];
|
|
body.push(row);
|
|
} else {
|
|
throw new ParseError("Expected & or \\\\ or \\cr or \\end",
|
|
parser.nextToken);
|
|
}
|
|
}
|
|
result.body = body;
|
|
result.rowGaps = rowGaps;
|
|
result.numHLinesBeforeRow = numHLinesBeforeRow;
|
|
// $FlowFixMe: The required fields were added immediately above.
|
|
const res: ArrayEnvNodeData = result;
|
|
parser.gullet.endGroup();
|
|
return new ParseNode("array", res, parser.mode);
|
|
}
|
|
|
|
|
|
// Decides on a style for cells in an array according to whether the given
|
|
// environment name starts with the letter 'd'.
|
|
function dCellStyle(envName): StyleStr {
|
|
if (envName.substr(0, 1) === "d") {
|
|
return "display";
|
|
} else {
|
|
return "text";
|
|
}
|
|
}
|
|
|
|
type Outrow = {
|
|
[idx: number]: *,
|
|
height: number,
|
|
depth: number,
|
|
pos: number,
|
|
};
|
|
|
|
const htmlBuilder: HtmlBuilder<"array"> = function(group, options) {
|
|
let r;
|
|
let c;
|
|
const nr = group.value.body.length;
|
|
const numHLinesBeforeRow = group.value.numHLinesBeforeRow;
|
|
let nc = 0;
|
|
let body = new Array(nr);
|
|
const hlinePos = [];
|
|
|
|
// Horizontal spacing
|
|
const pt = 1 / options.fontMetrics().ptPerEm;
|
|
const arraycolsep = 5 * pt; // \arraycolsep in article.cls
|
|
|
|
// Vertical spacing
|
|
const baselineskip = 12 * pt; // see size10.clo
|
|
// Default \jot from ltmath.dtx
|
|
// TODO(edemaine): allow overriding \jot via \setlength (#687)
|
|
const jot = 3 * pt;
|
|
const arrayskip = group.value.arraystretch * baselineskip;
|
|
const arstrutHeight = 0.7 * arrayskip; // \strutbox in ltfsstrc.dtx and
|
|
const arstrutDepth = 0.3 * arrayskip; // \@arstrutbox in lttab.dtx
|
|
|
|
let totalHeight = 0;
|
|
|
|
// Set a position for \hline(s) at the top of the array, if any.
|
|
for (let i = 1; i <= numHLinesBeforeRow[0]; i++) {
|
|
if (i > 1) { // The first \hline doesn't add to height.
|
|
totalHeight += 0.25;
|
|
}
|
|
hlinePos.push(totalHeight);
|
|
}
|
|
|
|
for (r = 0; r < group.value.body.length; ++r) {
|
|
const inrow = group.value.body[r];
|
|
let height = arstrutHeight; // \@array adds an \@arstrut
|
|
let depth = arstrutDepth; // to each tow (via the template)
|
|
|
|
if (nc < inrow.length) {
|
|
nc = inrow.length;
|
|
}
|
|
|
|
const outrow: Outrow = (new Array(inrow.length): any);
|
|
for (c = 0; c < inrow.length; ++c) {
|
|
const elt = html.buildGroup(inrow[c], options);
|
|
if (depth < elt.depth) {
|
|
depth = elt.depth;
|
|
}
|
|
if (height < elt.height) {
|
|
height = elt.height;
|
|
}
|
|
outrow[c] = elt;
|
|
}
|
|
|
|
const rowGap = group.value.rowGaps[r];
|
|
let gap = 0;
|
|
if (rowGap) {
|
|
gap = calculateSize(rowGap.value.value, options);
|
|
if (gap > 0) { // \@argarraycr
|
|
gap += arstrutDepth;
|
|
if (depth < gap) {
|
|
depth = gap; // \@xargarraycr
|
|
}
|
|
gap = 0;
|
|
}
|
|
}
|
|
// In AMS multiline environments such as aligned and gathered, rows
|
|
// correspond to lines that have additional \jot added to the
|
|
// \baselineskip via \openup.
|
|
if (group.value.addJot) {
|
|
depth += jot;
|
|
}
|
|
|
|
outrow.height = height;
|
|
outrow.depth = depth;
|
|
totalHeight += height;
|
|
outrow.pos = totalHeight;
|
|
totalHeight += depth + gap; // \@yargarraycr
|
|
body[r] = outrow;
|
|
|
|
// Set a position for \hline(s), if any.
|
|
for (let i = 1; i <= numHLinesBeforeRow[r + 1]; i++) {
|
|
if (i > 1) { // the first \hline doesn't add height
|
|
totalHeight += 0.25;
|
|
}
|
|
hlinePos.push(totalHeight);
|
|
}
|
|
}
|
|
|
|
const offset = totalHeight / 2 + options.fontMetrics().axisHeight;
|
|
const colDescriptions = group.value.cols || [];
|
|
const cols = [];
|
|
let colSep;
|
|
let colDescrNum;
|
|
for (c = 0, colDescrNum = 0;
|
|
// Continue while either there are more columns or more column
|
|
// descriptions, so trailing separators don't get lost.
|
|
c < nc || colDescrNum < colDescriptions.length;
|
|
++c, ++colDescrNum) {
|
|
|
|
let colDescr = colDescriptions[colDescrNum] || {};
|
|
|
|
let firstSeparator = true;
|
|
while (colDescr.type === "separator") {
|
|
// If there is more than one separator in a row, add a space
|
|
// between them.
|
|
if (!firstSeparator) {
|
|
colSep = buildCommon.makeSpan(["arraycolsep"], []);
|
|
colSep.style.width =
|
|
options.fontMetrics().doubleRuleSep + "em";
|
|
cols.push(colSep);
|
|
}
|
|
|
|
if (colDescr.separator === "|") {
|
|
const separator = buildCommon.makeSpan(
|
|
["vertical-separator"], [], options
|
|
);
|
|
separator.style.height = totalHeight + "em";
|
|
separator.style.verticalAlign =
|
|
-(totalHeight - offset) + "em";
|
|
|
|
cols.push(separator);
|
|
} else if (colDescr.separator === ":") {
|
|
const separator = buildCommon.makeSpan(
|
|
["vertical-separator", "vs-dashed"], [], options
|
|
);
|
|
separator.style.height = totalHeight + "em";
|
|
separator.style.verticalAlign =
|
|
-(totalHeight - offset) + "em";
|
|
|
|
cols.push(separator);
|
|
} else {
|
|
throw new ParseError(
|
|
"Invalid separator type: " + colDescr.separator);
|
|
}
|
|
|
|
colDescrNum++;
|
|
colDescr = colDescriptions[colDescrNum] || {};
|
|
firstSeparator = false;
|
|
}
|
|
|
|
if (c >= nc) {
|
|
continue;
|
|
}
|
|
|
|
let sepwidth;
|
|
if (c > 0 || group.value.hskipBeforeAndAfter) {
|
|
sepwidth = utils.deflt(colDescr.pregap, arraycolsep);
|
|
if (sepwidth !== 0) {
|
|
colSep = buildCommon.makeSpan(["arraycolsep"], []);
|
|
colSep.style.width = sepwidth + "em";
|
|
cols.push(colSep);
|
|
}
|
|
}
|
|
|
|
let col = [];
|
|
for (r = 0; r < nr; ++r) {
|
|
const row = body[r];
|
|
const elem = row[c];
|
|
if (!elem) {
|
|
continue;
|
|
}
|
|
const shift = row.pos - offset;
|
|
elem.depth = row.depth;
|
|
elem.height = row.height;
|
|
col.push({type: "elem", elem: elem, shift: shift});
|
|
}
|
|
|
|
col = buildCommon.makeVList({
|
|
positionType: "individualShift",
|
|
children: col,
|
|
}, options);
|
|
col = buildCommon.makeSpan(
|
|
["col-align-" + (colDescr.align || "c")],
|
|
[col]);
|
|
cols.push(col);
|
|
|
|
if (c < nc - 1 || group.value.hskipBeforeAndAfter) {
|
|
sepwidth = utils.deflt(colDescr.postgap, arraycolsep);
|
|
if (sepwidth !== 0) {
|
|
colSep = buildCommon.makeSpan(["arraycolsep"], []);
|
|
colSep.style.width = sepwidth + "em";
|
|
cols.push(colSep);
|
|
}
|
|
}
|
|
}
|
|
body = buildCommon.makeSpan(["mtable"], cols);
|
|
|
|
// Add \hline(s), if any.
|
|
if (hlinePos.length > 0) {
|
|
const line = buildCommon.makeLineSpan("hline", options, 0.05);
|
|
const vListChildren = [{type: "elem", elem: body, shift: 0}];
|
|
while (hlinePos.length > 0) {
|
|
const lineShift = hlinePos.pop() - offset;
|
|
vListChildren.push({type: "elem", elem: line, shift: lineShift});
|
|
}
|
|
body = buildCommon.makeVList({
|
|
positionType: "individualShift",
|
|
children: vListChildren,
|
|
}, options);
|
|
}
|
|
|
|
return buildCommon.makeSpan(["mord"], [body], options);
|
|
};
|
|
|
|
const mathmlBuilder: MathMLBuilder<"array"> = function(group, options) {
|
|
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", [mml.buildGroup(cell, options)]);
|
|
}));
|
|
}));
|
|
};
|
|
|
|
// Convenience function for aligned and alignedat environments.
|
|
const alignedHandler = function(context, args) {
|
|
const cols = [];
|
|
let res = {
|
|
type: "array",
|
|
cols,
|
|
addJot: true,
|
|
};
|
|
res = parseArray(context.parser, res, "display");
|
|
|
|
// Determining number of columns.
|
|
// 1. If the first argument is given, we use it as a number of columns,
|
|
// and makes sure that each row doesn't exceed that number.
|
|
// 2. Otherwise, just count number of columns = maximum number
|
|
// of cells in each row ("aligned" mode -- isAligned will be true).
|
|
//
|
|
// At the same time, prepend empty group {} at beginning of every second
|
|
// cell in each row (starting with second cell) so that operators become
|
|
// binary. This behavior is implemented in amsmath's \start@aligned.
|
|
let numMaths;
|
|
let numCols = 0;
|
|
const emptyGroup = new ParseNode("ordgroup", [], context.mode);
|
|
const ordgroup = checkNodeType(args[0], "ordgroup");
|
|
if (ordgroup) {
|
|
let arg0 = "";
|
|
for (let i = 0; i < ordgroup.value.length; i++) {
|
|
const textord = assertNodeType(ordgroup.value[i], "textord");
|
|
arg0 += textord.value;
|
|
}
|
|
numMaths = Number(arg0);
|
|
numCols = numMaths * 2;
|
|
}
|
|
const isAligned = !numCols;
|
|
res.value.body.forEach(function(row) {
|
|
for (let i = 1; i < row.length; i += 2) {
|
|
// Modify ordgroup node within styling node
|
|
const styling = assertNodeType(row[i], "styling");
|
|
const ordgroup = assertNodeType(styling.value.value[0], "ordgroup");
|
|
ordgroup.value.unshift(emptyGroup);
|
|
}
|
|
if (!isAligned) { // Case 1
|
|
const curMaths = row.length / 2;
|
|
if (numMaths < curMaths) {
|
|
throw new ParseError(
|
|
"Too many math in a row: " +
|
|
`expected ${numMaths}, but got ${curMaths}`,
|
|
row[0]);
|
|
}
|
|
} else if (numCols < row.length) { // Case 2
|
|
numCols = row.length;
|
|
}
|
|
});
|
|
|
|
// Adjusting alignment.
|
|
// In aligned mode, we add one \qquad between columns;
|
|
// otherwise we add nothing.
|
|
for (let i = 0; i < numCols; ++i) {
|
|
let align = "r";
|
|
let pregap = 0;
|
|
if (i % 2 === 1) {
|
|
align = "l";
|
|
} else if (i > 0 && isAligned) { // "aligned" mode.
|
|
pregap = 1; // add one \quad
|
|
}
|
|
cols[i] = {
|
|
type: "align",
|
|
align: align,
|
|
pregap: pregap,
|
|
postgap: 0,
|
|
};
|
|
}
|
|
return res;
|
|
};
|
|
|
|
// Arrays are part of LaTeX, defined in lttab.dtx so its documentation
|
|
// is part of the source2e.pdf file of LaTeX2e source documentation.
|
|
// {darray} is an {array} environment where cells are set in \displaystyle,
|
|
// as defined in nccmath.sty.
|
|
defineEnvironment({
|
|
type: "array",
|
|
names: ["array", "darray"],
|
|
props: {
|
|
numArgs: 1,
|
|
},
|
|
handler(context, args) {
|
|
// Since no types are specified above, the two possibilities are
|
|
// - The argument is wrapped in {} or [], in which case Parser's
|
|
// parseGroup() returns an "ordgroup" wrapping some symbol node.
|
|
// - The argument is a bare symbol node.
|
|
const symNode = checkSymbolNodeType(args[0]);
|
|
const colalign: AnyParseNode[] =
|
|
symNode ? [args[0]] : assertNodeType(args[0], "ordgroup").value;
|
|
const cols = colalign.map(function(nde) {
|
|
const node = assertSymbolNodeType(nde);
|
|
const ca = node.value;
|
|
if ("lcr".indexOf(ca) !== -1) {
|
|
return {
|
|
type: "align",
|
|
align: ca,
|
|
};
|
|
} else if (ca === "|") {
|
|
return {
|
|
type: "separator",
|
|
separator: "|",
|
|
};
|
|
} else if (ca === ":") {
|
|
return {
|
|
type: "separator",
|
|
separator: ":",
|
|
};
|
|
}
|
|
throw new ParseError("Unknown column alignment: " + ca, nde);
|
|
});
|
|
let res = {
|
|
type: "array",
|
|
cols: cols,
|
|
hskipBeforeAndAfter: true, // \@preamble in lttab.dtx
|
|
};
|
|
res = parseArray(context.parser, res, dCellStyle(context.envName));
|
|
return res;
|
|
},
|
|
htmlBuilder,
|
|
mathmlBuilder,
|
|
});
|
|
|
|
// The matrix environments of amsmath builds on the array environment
|
|
// of LaTeX, which is discussed above.
|
|
defineEnvironment({
|
|
type: "array",
|
|
names: [
|
|
"matrix",
|
|
"pmatrix",
|
|
"bmatrix",
|
|
"Bmatrix",
|
|
"vmatrix",
|
|
"Vmatrix",
|
|
],
|
|
props: {
|
|
numArgs: 0,
|
|
},
|
|
handler: function(context) {
|
|
const delimiters = {
|
|
"matrix": null,
|
|
"pmatrix": ["(", ")"],
|
|
"bmatrix": ["[", "]"],
|
|
"Bmatrix": ["\\{", "\\}"],
|
|
"vmatrix": ["|", "|"],
|
|
"Vmatrix": ["\\Vert", "\\Vert"],
|
|
}[context.envName];
|
|
let res = {
|
|
type: "array",
|
|
hskipBeforeAndAfter: false, // \hskip -\arraycolsep in amsmath
|
|
};
|
|
res = parseArray(context.parser, res, dCellStyle(context.envName));
|
|
if (delimiters) {
|
|
res = new ParseNode("leftright", {
|
|
type: "leftright",
|
|
body: [res],
|
|
left: delimiters[0],
|
|
right: delimiters[1],
|
|
}, context.mode);
|
|
}
|
|
return res;
|
|
},
|
|
htmlBuilder,
|
|
mathmlBuilder,
|
|
});
|
|
|
|
// A cases environment (in amsmath.sty) is almost equivalent to
|
|
// \def\arraystretch{1.2}%
|
|
// \left\{\begin{array}{@{}l@{\quad}l@{}} … \end{array}\right.
|
|
// {dcases} is a {cases} environment where cells are set in \displaystyle,
|
|
// as defined in mathtools.sty.
|
|
defineEnvironment({
|
|
type: "array",
|
|
names: [
|
|
"cases",
|
|
"dcases",
|
|
],
|
|
props: {
|
|
numArgs: 0,
|
|
},
|
|
handler: function(context) {
|
|
let res = {
|
|
type: "array",
|
|
arraystretch: 1.2,
|
|
cols: [{
|
|
type: "align",
|
|
align: "l",
|
|
pregap: 0,
|
|
// TODO(kevinb) get the current style.
|
|
// For now we use the metrics for TEXT style which is what we were
|
|
// doing before. Before attempting to get the current style we
|
|
// should look at TeX's behavior especially for \over and matrices.
|
|
postgap: 1.0, /* 1em quad */
|
|
}, {
|
|
type: "align",
|
|
align: "l",
|
|
pregap: 0,
|
|
postgap: 0,
|
|
}],
|
|
};
|
|
res = parseArray(context.parser, res, dCellStyle(context.envName));
|
|
res = new ParseNode("leftright", {
|
|
type: "leftright",
|
|
body: [res],
|
|
left: "\\{",
|
|
right: ".",
|
|
}, context.mode);
|
|
return res;
|
|
},
|
|
htmlBuilder,
|
|
mathmlBuilder,
|
|
});
|
|
|
|
// An aligned environment is like the align* environment
|
|
// except it operates within math mode.
|
|
// Note that we assume \nomallineskiplimit to be zero,
|
|
// so that \strut@ is the same as \strut.
|
|
defineEnvironment({
|
|
type: "array",
|
|
names: ["aligned"],
|
|
props: {
|
|
numArgs: 0,
|
|
},
|
|
handler: alignedHandler,
|
|
htmlBuilder,
|
|
mathmlBuilder,
|
|
});
|
|
|
|
// A gathered environment is like an array environment with one centered
|
|
// column, but where rows are considered lines so get \jot line spacing
|
|
// and contents are set in \displaystyle.
|
|
defineEnvironment({
|
|
type: "array",
|
|
names: ["gathered"],
|
|
props: {
|
|
numArgs: 0,
|
|
},
|
|
handler: function(context) {
|
|
let res = {
|
|
type: "array",
|
|
cols: [{
|
|
type: "align",
|
|
align: "c",
|
|
}],
|
|
addJot: true,
|
|
};
|
|
res = parseArray(context.parser, res, "display");
|
|
return res;
|
|
},
|
|
htmlBuilder,
|
|
mathmlBuilder,
|
|
});
|
|
|
|
// alignat environment is like an align environment, but one must explicitly
|
|
// specify maximum number of columns in each row, and can adjust spacing between
|
|
// each columns.
|
|
defineEnvironment({
|
|
type: "array",
|
|
names: ["alignedat"],
|
|
// One for numbered and for unnumbered;
|
|
// but, KaTeX doesn't supports math numbering yet,
|
|
// they make no difference for now.
|
|
props: {
|
|
numArgs: 1,
|
|
},
|
|
handler: alignedHandler,
|
|
htmlBuilder,
|
|
mathmlBuilder,
|
|
});
|