UNPKG

25.2 kBJavaScriptView Raw
1// @flow
2import buildCommon from "../buildCommon";
3import defineEnvironment from "../defineEnvironment";
4import defineFunction from "../defineFunction";
5import mathMLTree from "../mathMLTree";
6import ParseError from "../ParseError";
7import {assertNodeType, assertSymbolNodeType} from "../parseNode";
8import {checkNodeType, checkSymbolNodeType} from "../parseNode";
9import {calculateSize} from "../units";
10import utils from "../utils";
11
12import * as html from "../buildHTML";
13import * as mml from "../buildMathML";
14
15import type Parser from "../Parser";
16import type {ParseNode, AnyParseNode} from "../parseNode";
17import type {StyleStr} from "../types";
18import type {HtmlBuilder, MathMLBuilder} from "../defineFunction";
19
20// Data stored in the ParseNode associated with the environment.
21export type AlignSpec = { type: "separator", separator: string } | {
22 type: "align",
23 align: string,
24 pregap?: number,
25 postgap?: number,
26};
27
28// Type to indicate column separation in MathML
29export type ColSeparationType = "align" | "alignat";
30
31function getHLines(parser: Parser): boolean[] {
32 // Return an array. The array length = number of hlines.
33 // Each element in the array tells if the line is dashed.
34 const hlineInfo = [];
35 parser.consumeSpaces();
36 let nxt = parser.nextToken.text;
37 while (nxt === "\\hline" || nxt === "\\hdashline") {
38 parser.consume();
39 hlineInfo.push(nxt === "\\hdashline");
40 parser.consumeSpaces();
41 nxt = parser.nextToken.text;
42 }
43 return hlineInfo;
44}
45
46/**
47 * Parse the body of the environment, with rows delimited by \\ and
48 * columns delimited by &, and create a nested list in row-major order
49 * with one group per cell. If given an optional argument style
50 * ("text", "display", etc.), then each cell is cast into that style.
51 */
52function parseArray(
53 parser: Parser,
54 {hskipBeforeAndAfter, addJot, cols, arraystretch, colSeparationType}: {|
55 hskipBeforeAndAfter?: boolean,
56 addJot?: boolean,
57 cols?: AlignSpec[],
58 arraystretch?: number,
59 colSeparationType?: ColSeparationType,
60 |},
61 style: StyleStr,
62): ParseNode<"array"> {
63 // Parse body of array with \\ temporarily mapped to \cr
64 parser.gullet.beginGroup();
65 parser.gullet.macros.set("\\\\", "\\cr");
66
67 // Get current arraystretch if it's not set by the environment
68 if (!arraystretch) {
69 const stretch = parser.gullet.expandMacroAsText("\\arraystretch");
70 if (stretch == null) {
71 // Default \arraystretch from lttab.dtx
72 arraystretch = 1;
73 } else {
74 arraystretch = parseFloat(stretch);
75 if (!arraystretch || arraystretch < 0) {
76 throw new ParseError(`Invalid \\arraystretch: ${stretch}`);
77 }
78 }
79 }
80
81 let row = [];
82 const body = [row];
83 const rowGaps = [];
84 const hLinesBeforeRow = [];
85
86 // Test for \hline at the top of the array.
87 hLinesBeforeRow.push(getHLines(parser));
88
89 while (true) { // eslint-disable-line no-constant-condition
90 let cell = parser.parseExpression(false, "\\cr");
91 cell = {
92 type: "ordgroup",
93 mode: parser.mode,
94 body: cell,
95 };
96 if (style) {
97 cell = {
98 type: "styling",
99 mode: parser.mode,
100 style,
101 body: [cell],
102 };
103 }
104 row.push(cell);
105 const next = parser.nextToken.text;
106 if (next === "&") {
107 parser.consume();
108 } else if (next === "\\end") {
109 // Arrays terminate newlines with `\crcr` which consumes a `\cr` if
110 // the last line is empty.
111 // NOTE: Currently, `cell` is the last item added into `row`.
112 if (row.length === 1 && cell.type === "styling" &&
113 cell.body[0].body.length === 0) {
114 body.pop();
115 }
116 if (hLinesBeforeRow.length < body.length + 1) {
117 hLinesBeforeRow.push([]);
118 }
119 break;
120 } else if (next === "\\cr") {
121 const cr = assertNodeType(parser.parseFunction(), "cr");
122 rowGaps.push(cr.size);
123
124 // check for \hline(s) following the row separator
125 hLinesBeforeRow.push(getHLines(parser));
126
127 row = [];
128 body.push(row);
129 } else {
130 throw new ParseError("Expected & or \\\\ or \\cr or \\end",
131 parser.nextToken);
132 }
133 }
134 parser.gullet.endGroup();
135 return {
136 type: "array",
137 mode: parser.mode,
138 addJot,
139 arraystretch,
140 body,
141 cols,
142 rowGaps,
143 hskipBeforeAndAfter,
144 hLinesBeforeRow,
145 colSeparationType,
146 };
147}
148
149
150// Decides on a style for cells in an array according to whether the given
151// environment name starts with the letter 'd'.
152function dCellStyle(envName): StyleStr {
153 if (envName.substr(0, 1) === "d") {
154 return "display";
155 } else {
156 return "text";
157 }
158}
159
160type Outrow = {
161 [idx: number]: *,
162 height: number,
163 depth: number,
164 pos: number,
165};
166
167const htmlBuilder: HtmlBuilder<"array"> = function(group, options) {
168 let r;
169 let c;
170 const nr = group.body.length;
171 const hLinesBeforeRow = group.hLinesBeforeRow;
172 let nc = 0;
173 let body = new Array(nr);
174 const hlines = [];
175
176 // Horizontal spacing
177 const pt = 1 / options.fontMetrics().ptPerEm;
178 const arraycolsep = 5 * pt; // \arraycolsep in article.cls
179
180 // Vertical spacing
181 const baselineskip = 12 * pt; // see size10.clo
182 // Default \jot from ltmath.dtx
183 // TODO(edemaine): allow overriding \jot via \setlength (#687)
184 const jot = 3 * pt;
185 const arrayskip = group.arraystretch * baselineskip;
186 const arstrutHeight = 0.7 * arrayskip; // \strutbox in ltfsstrc.dtx and
187 const arstrutDepth = 0.3 * arrayskip; // \@arstrutbox in lttab.dtx
188
189 let totalHeight = 0;
190
191 // Set a position for \hline(s) at the top of the array, if any.
192 function setHLinePos(hlinesInGap: boolean[]) {
193 for (let i = 0; i < hlinesInGap.length; ++i) {
194 if (i > 0) {
195 totalHeight += 0.25;
196 }
197 hlines.push({pos: totalHeight, isDashed: hlinesInGap[i]});
198 }
199 }
200 setHLinePos(hLinesBeforeRow[0]);
201
202 for (r = 0; r < group.body.length; ++r) {
203 const inrow = group.body[r];
204 let height = arstrutHeight; // \@array adds an \@arstrut
205 let depth = arstrutDepth; // to each tow (via the template)
206
207 if (nc < inrow.length) {
208 nc = inrow.length;
209 }
210
211 const outrow: Outrow = (new Array(inrow.length): any);
212 for (c = 0; c < inrow.length; ++c) {
213 const elt = html.buildGroup(inrow[c], options);
214 if (depth < elt.depth) {
215 depth = elt.depth;
216 }
217 if (height < elt.height) {
218 height = elt.height;
219 }
220 outrow[c] = elt;
221 }
222
223 const rowGap = group.rowGaps[r];
224 let gap = 0;
225 if (rowGap) {
226 gap = calculateSize(rowGap, options);
227 if (gap > 0) { // \@argarraycr
228 gap += arstrutDepth;
229 if (depth < gap) {
230 depth = gap; // \@xargarraycr
231 }
232 gap = 0;
233 }
234 }
235 // In AMS multiline environments such as aligned and gathered, rows
236 // correspond to lines that have additional \jot added to the
237 // \baselineskip via \openup.
238 if (group.addJot) {
239 depth += jot;
240 }
241
242 outrow.height = height;
243 outrow.depth = depth;
244 totalHeight += height;
245 outrow.pos = totalHeight;
246 totalHeight += depth + gap; // \@yargarraycr
247 body[r] = outrow;
248
249 // Set a position for \hline(s), if any.
250 setHLinePos(hLinesBeforeRow[r + 1]);
251 }
252
253 const offset = totalHeight / 2 + options.fontMetrics().axisHeight;
254 const colDescriptions = group.cols || [];
255 const cols = [];
256 let colSep;
257 let colDescrNum;
258 for (c = 0, colDescrNum = 0;
259 // Continue while either there are more columns or more column
260 // descriptions, so trailing separators don't get lost.
261 c < nc || colDescrNum < colDescriptions.length;
262 ++c, ++colDescrNum) {
263
264 let colDescr = colDescriptions[colDescrNum] || {};
265
266 let firstSeparator = true;
267 while (colDescr.type === "separator") {
268 // If there is more than one separator in a row, add a space
269 // between them.
270 if (!firstSeparator) {
271 colSep = buildCommon.makeSpan(["arraycolsep"], []);
272 colSep.style.width =
273 options.fontMetrics().doubleRuleSep + "em";
274 cols.push(colSep);
275 }
276
277 if (colDescr.separator === "|") {
278 const separator = buildCommon.makeSpan(
279 ["vertical-separator"], [], options
280 );
281 separator.style.height = totalHeight + "em";
282 separator.style.verticalAlign =
283 -(totalHeight - offset) + "em";
284
285 cols.push(separator);
286 } else if (colDescr.separator === ":") {
287 const separator = buildCommon.makeSpan(
288 ["vertical-separator", "vs-dashed"], [], options
289 );
290 separator.style.height = totalHeight + "em";
291 separator.style.verticalAlign =
292 -(totalHeight - offset) + "em";
293
294 cols.push(separator);
295 } else {
296 throw new ParseError(
297 "Invalid separator type: " + colDescr.separator);
298 }
299
300 colDescrNum++;
301 colDescr = colDescriptions[colDescrNum] || {};
302 firstSeparator = false;
303 }
304
305 if (c >= nc) {
306 continue;
307 }
308
309 let sepwidth;
310 if (c > 0 || group.hskipBeforeAndAfter) {
311 sepwidth = utils.deflt(colDescr.pregap, arraycolsep);
312 if (sepwidth !== 0) {
313 colSep = buildCommon.makeSpan(["arraycolsep"], []);
314 colSep.style.width = sepwidth + "em";
315 cols.push(colSep);
316 }
317 }
318
319 let col = [];
320 for (r = 0; r < nr; ++r) {
321 const row = body[r];
322 const elem = row[c];
323 if (!elem) {
324 continue;
325 }
326 const shift = row.pos - offset;
327 elem.depth = row.depth;
328 elem.height = row.height;
329 col.push({type: "elem", elem: elem, shift: shift});
330 }
331
332 col = buildCommon.makeVList({
333 positionType: "individualShift",
334 children: col,
335 }, options);
336 col = buildCommon.makeSpan(
337 ["col-align-" + (colDescr.align || "c")],
338 [col]);
339 cols.push(col);
340
341 if (c < nc - 1 || group.hskipBeforeAndAfter) {
342 sepwidth = utils.deflt(colDescr.postgap, arraycolsep);
343 if (sepwidth !== 0) {
344 colSep = buildCommon.makeSpan(["arraycolsep"], []);
345 colSep.style.width = sepwidth + "em";
346 cols.push(colSep);
347 }
348 }
349 }
350 body = buildCommon.makeSpan(["mtable"], cols);
351
352 // Add \hline(s), if any.
353 if (hlines.length > 0) {
354 const line = buildCommon.makeLineSpan("hline", options, 0.05);
355 const dashes = buildCommon.makeLineSpan("hdashline", options, 0.05);
356 const vListElems = [{type: "elem", elem: body, shift: 0}];
357 while (hlines.length > 0) {
358 const hline = hlines.pop();
359 const lineShift = hline.pos - offset;
360 if (hline.isDashed) {
361 vListElems.push({type: "elem", elem: dashes, shift: lineShift});
362 } else {
363 vListElems.push({type: "elem", elem: line, shift: lineShift});
364 }
365 }
366 body = buildCommon.makeVList({
367 positionType: "individualShift",
368 children: vListElems,
369 }, options);
370 }
371
372 return buildCommon.makeSpan(["mord"], [body], options);
373};
374
375const alignMap = {
376 c: "center ",
377 l: "left ",
378 r: "right ",
379};
380
381const mathmlBuilder: MathMLBuilder<"array"> = function(group, options) {
382 const table = new mathMLTree.MathNode(
383 "mtable", group.body.map(function(row) {
384 return new mathMLTree.MathNode(
385 "mtr", row.map(function(cell) {
386 return new mathMLTree.MathNode(
387 "mtd", [mml.buildGroup(cell, options)]);
388 }));
389 }));
390
391 // Set column alignment, row spacing, column spacing, and
392 // array lines by setting attributes on the table element.
393
394 // Set the row spacing. In MathML, we specify a gap distance.
395 // We do not use rowGap[] because MathML automatically increases
396 // cell height with the height/depth of the element content.
397
398 // LaTeX \arraystretch multiplies the row baseline-to-baseline distance.
399 // We simulate this by adding (arraystretch - 1)em to the gap. This
400 // does a reasonable job of adjusting arrays containing 1 em tall content.
401
402 // The 0.16 and 0.09 values are found emprically. They produce an array
403 // similar to LaTeX and in which content does not interfere with \hines.
404 const gap = 0.16 + group.arraystretch - 1 + (group.addJot ? 0.09 : 0);
405 table.setAttribute("rowspacing", gap + "em");
406
407 // MathML table lines go only between cells.
408 // To place a line on an edge we'll use <menclose>, if necessary.
409 let menclose = "";
410 let align = "";
411
412 if (group.cols) {
413 // Find column alignment, column spacing, and vertical lines.
414 const cols = group.cols;
415 let columnLines = "";
416 let prevTypeWasAlign = false;
417 let iStart = 0;
418 let iEnd = cols.length;
419
420 if (cols[0].type === "separator") {
421 menclose += "top ";
422 iStart = 1;
423 }
424 if (cols[cols.length - 1].type === "separator") {
425 menclose += "bottom ";
426 iEnd -= 1;
427 }
428
429 for (let i = iStart; i < iEnd; i++) {
430 if (cols[i].type === "align") {
431 align += alignMap[cols[i].align];
432
433 if (prevTypeWasAlign) {
434 columnLines += "none ";
435 }
436 prevTypeWasAlign = true;
437 } else if (cols[i].type === "separator") {
438 // MathML accepts only single lines between cells.
439 // So we read only the first of consecutive separators.
440 if (prevTypeWasAlign) {
441 columnLines += cols[i].separator === "|"
442 ? "solid "
443 : "dashed ";
444 prevTypeWasAlign = false;
445 }
446 }
447 }
448
449 table.setAttribute("columnalign", align.trim());
450
451 if (/[sd]/.test(columnLines)) {
452 table.setAttribute("columnlines", columnLines.trim());
453 }
454 }
455
456 // Set column spacing.
457 if (group.colSeparationType === "align") {
458 const cols = group.cols || [];
459 let spacing = "";
460 for (let i = 1; i < cols.length; i++) {
461 spacing += i % 2 ? "0em " : "1em ";
462 }
463 table.setAttribute("columnspacing", spacing.trim());
464 } else if (group.colSeparationType === "alignat") {
465 table.setAttribute("columnspacing", "0em");
466 } else {
467 table.setAttribute("columnspacing", "1em");
468 }
469
470 // Address \hline and \hdashline
471 let rowLines = "";
472 const hlines = group.hLinesBeforeRow;
473
474 menclose += hlines[0].length > 0 ? "left " : "";
475 menclose += hlines[hlines.length - 1].length > 0 ? "right " : "";
476
477 for (let i = 1; i < hlines.length - 1; i++) {
478 rowLines += (hlines[i].length === 0)
479 ? "none "
480 // MathML accepts only a single line between rows. Read one element.
481 : hlines[i][0] ? "dashed " : "solid ";
482 }
483 if (/[sd]/.test(rowLines)) {
484 table.setAttribute("rowlines", rowLines.trim());
485 }
486
487 if (menclose === "") {
488 return table;
489 } else {
490 const wrapper = new mathMLTree.MathNode("menclose", [table]);
491 wrapper.setAttribute("notation", menclose.trim());
492 return wrapper;
493 }
494};
495
496// Convenience function for aligned and alignedat environments.
497const alignedHandler = function(context, args) {
498 const cols = [];
499 const res = parseArray(context.parser, {cols, addJot: true}, "display");
500
501 // Determining number of columns.
502 // 1. If the first argument is given, we use it as a number of columns,
503 // and makes sure that each row doesn't exceed that number.
504 // 2. Otherwise, just count number of columns = maximum number
505 // of cells in each row ("aligned" mode -- isAligned will be true).
506 //
507 // At the same time, prepend empty group {} at beginning of every second
508 // cell in each row (starting with second cell) so that operators become
509 // binary. This behavior is implemented in amsmath's \start@aligned.
510 let numMaths;
511 let numCols = 0;
512 const emptyGroup = {
513 type: "ordgroup",
514 mode: context.mode,
515 body: [],
516 };
517 const ordgroup = checkNodeType(args[0], "ordgroup");
518 if (ordgroup) {
519 let arg0 = "";
520 for (let i = 0; i < ordgroup.body.length; i++) {
521 const textord = assertNodeType(ordgroup.body[i], "textord");
522 arg0 += textord.text;
523 }
524 numMaths = Number(arg0);
525 numCols = numMaths * 2;
526 }
527 const isAligned = !numCols;
528 res.body.forEach(function(row) {
529 for (let i = 1; i < row.length; i += 2) {
530 // Modify ordgroup node within styling node
531 const styling = assertNodeType(row[i], "styling");
532 const ordgroup = assertNodeType(styling.body[0], "ordgroup");
533 ordgroup.body.unshift(emptyGroup);
534 }
535 if (!isAligned) { // Case 1
536 const curMaths = row.length / 2;
537 if (numMaths < curMaths) {
538 throw new ParseError(
539 "Too many math in a row: " +
540 `expected ${numMaths}, but got ${curMaths}`,
541 row[0]);
542 }
543 } else if (numCols < row.length) { // Case 2
544 numCols = row.length;
545 }
546 });
547
548 // Adjusting alignment.
549 // In aligned mode, we add one \qquad between columns;
550 // otherwise we add nothing.
551 for (let i = 0; i < numCols; ++i) {
552 let align = "r";
553 let pregap = 0;
554 if (i % 2 === 1) {
555 align = "l";
556 } else if (i > 0 && isAligned) { // "aligned" mode.
557 pregap = 1; // add one \quad
558 }
559 cols[i] = {
560 type: "align",
561 align: align,
562 pregap: pregap,
563 postgap: 0,
564 };
565 }
566 res.colSeparationType = isAligned ? "align" : "alignat";
567 return res;
568};
569
570// Arrays are part of LaTeX, defined in lttab.dtx so its documentation
571// is part of the source2e.pdf file of LaTeX2e source documentation.
572// {darray} is an {array} environment where cells are set in \displaystyle,
573// as defined in nccmath.sty.
574defineEnvironment({
575 type: "array",
576 names: ["array", "darray"],
577 props: {
578 numArgs: 1,
579 },
580 handler(context, args) {
581 // Since no types are specified above, the two possibilities are
582 // - The argument is wrapped in {} or [], in which case Parser's
583 // parseGroup() returns an "ordgroup" wrapping some symbol node.
584 // - The argument is a bare symbol node.
585 const symNode = checkSymbolNodeType(args[0]);
586 const colalign: AnyParseNode[] =
587 symNode ? [args[0]] : assertNodeType(args[0], "ordgroup").body;
588 const cols = colalign.map(function(nde) {
589 const node = assertSymbolNodeType(nde);
590 const ca = node.text;
591 if ("lcr".indexOf(ca) !== -1) {
592 return {
593 type: "align",
594 align: ca,
595 };
596 } else if (ca === "|") {
597 return {
598 type: "separator",
599 separator: "|",
600 };
601 } else if (ca === ":") {
602 return {
603 type: "separator",
604 separator: ":",
605 };
606 }
607 throw new ParseError("Unknown column alignment: " + ca, nde);
608 });
609 const res = {
610 cols,
611 hskipBeforeAndAfter: true, // \@preamble in lttab.dtx
612 };
613 return parseArray(context.parser, res, dCellStyle(context.envName));
614 },
615 htmlBuilder,
616 mathmlBuilder,
617});
618
619// The matrix environments of amsmath builds on the array environment
620// of LaTeX, which is discussed above.
621defineEnvironment({
622 type: "array",
623 names: [
624 "matrix",
625 "pmatrix",
626 "bmatrix",
627 "Bmatrix",
628 "vmatrix",
629 "Vmatrix",
630 ],
631 props: {
632 numArgs: 0,
633 },
634 handler(context) {
635 const delimiters = {
636 "matrix": null,
637 "pmatrix": ["(", ")"],
638 "bmatrix": ["[", "]"],
639 "Bmatrix": ["\\{", "\\}"],
640 "vmatrix": ["|", "|"],
641 "Vmatrix": ["\\Vert", "\\Vert"],
642 }[context.envName];
643 // \hskip -\arraycolsep in amsmath
644 const payload = {hskipBeforeAndAfter: false};
645 const res: ParseNode<"array"> =
646 parseArray(context.parser, payload, dCellStyle(context.envName));
647 return delimiters ? {
648 type: "leftright",
649 mode: context.mode,
650 body: [res],
651 left: delimiters[0],
652 right: delimiters[1],
653 } : res;
654 },
655 htmlBuilder,
656 mathmlBuilder,
657});
658
659// A cases environment (in amsmath.sty) is almost equivalent to
660// \def\arraystretch{1.2}%
661// \left\{\begin{array}{@{}l@{\quad}l@{}} … \end{array}\right.
662// {dcases} is a {cases} environment where cells are set in \displaystyle,
663// as defined in mathtools.sty.
664defineEnvironment({
665 type: "array",
666 names: [
667 "cases",
668 "dcases",
669 ],
670 props: {
671 numArgs: 0,
672 },
673 handler(context) {
674 const payload = {
675 arraystretch: 1.2,
676 cols: [{
677 type: "align",
678 align: "l",
679 pregap: 0,
680 // TODO(kevinb) get the current style.
681 // For now we use the metrics for TEXT style which is what we were
682 // doing before. Before attempting to get the current style we
683 // should look at TeX's behavior especially for \over and matrices.
684 postgap: 1.0, /* 1em quad */
685 }, {
686 type: "align",
687 align: "l",
688 pregap: 0,
689 postgap: 0,
690 }],
691 };
692 const res: ParseNode<"array"> =
693 parseArray(context.parser, payload, dCellStyle(context.envName));
694 return {
695 type: "leftright",
696 mode: context.mode,
697 body: [res],
698 left: "\\{",
699 right: ".",
700 };
701 },
702 htmlBuilder,
703 mathmlBuilder,
704});
705
706// An aligned environment is like the align* environment
707// except it operates within math mode.
708// Note that we assume \nomallineskiplimit to be zero,
709// so that \strut@ is the same as \strut.
710defineEnvironment({
711 type: "array",
712 names: ["aligned"],
713 props: {
714 numArgs: 0,
715 },
716 handler: alignedHandler,
717 htmlBuilder,
718 mathmlBuilder,
719});
720
721// A gathered environment is like an array environment with one centered
722// column, but where rows are considered lines so get \jot line spacing
723// and contents are set in \displaystyle.
724defineEnvironment({
725 type: "array",
726 names: ["gathered"],
727 props: {
728 numArgs: 0,
729 },
730 handler(context) {
731 const res = {
732 cols: [{
733 type: "align",
734 align: "c",
735 }],
736 addJot: true,
737 };
738 return parseArray(context.parser, res, "display");
739 },
740 htmlBuilder,
741 mathmlBuilder,
742});
743
744// alignat environment is like an align environment, but one must explicitly
745// specify maximum number of columns in each row, and can adjust spacing between
746// each columns.
747defineEnvironment({
748 type: "array",
749 names: ["alignedat"],
750 // One for numbered and for unnumbered;
751 // but, KaTeX doesn't supports math numbering yet,
752 // they make no difference for now.
753 props: {
754 numArgs: 1,
755 },
756 handler: alignedHandler,
757 htmlBuilder,
758 mathmlBuilder,
759});
760
761// Catch \hline outside array environment
762defineFunction({
763 type: "text", // Doesn't matter what this is.
764 names: ["\\hline", "\\hdashline"],
765 props: {
766 numArgs: 0,
767 allowedInText: true,
768 allowedInMath: true,
769 },
770 handler(context, args) {
771 throw new ParseError(
772 `${context.funcName} valid only within array environment`);
773 },
774});