mirror of
https://github.com/Smaug123/KaTeX
synced 2025-10-08 20:48:41 +00:00
feat: Support {CD} (#2396)
* Support {CD} * Edit screenshotter test to fit on one page * Update screenshots * Remove bogus Safari screenshot * Edit documentation to avoid tag conflicts and explain delimiters * Add type annotations * Add bogus safari screenshot * Update with real Safari screenshot * Set label vertical alignment * Revise call to parseExpression() per PR 2085 changes to macro parsing * Update Firefox screenshot * Pick up review comments * Add unit tests and snapshot. * Tighten up label collection. * Better loop index * remove extra space * Picked up comments. Added a parse check. Added a test. Co-authored-by: ylemkimon <y@ylem.kim> Co-authored-by: Kevin Barabash <kevinb@khanacademy.org> Co-authored-by: Kevin Barabash <kevinb7@gmail.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
import buildCommon from "../buildCommon";
|
||||
import Style from "../Style";
|
||||
import defineEnvironment from "../defineEnvironment";
|
||||
import {parseCD} from "./cd";
|
||||
import defineFunction from "../defineFunction";
|
||||
import mathMLTree from "../mathMLTree";
|
||||
import ParseError from "../ParseError";
|
||||
@@ -27,7 +28,7 @@ export type AlignSpec = { type: "separator", separator: string } | {
|
||||
};
|
||||
|
||||
// Type to indicate column separation in MathML
|
||||
export type ColSeparationType = "align" | "alignat" | "gather" | "small";
|
||||
export type ColSeparationType = "align" | "alignat" | "gather" | "small" | "CD";
|
||||
|
||||
// Helper functions
|
||||
function getHLines(parser: Parser): boolean[] {
|
||||
@@ -256,7 +257,9 @@ const htmlBuilder: HtmlBuilder<"array"> = function(group, options) {
|
||||
}
|
||||
|
||||
// Vertical spacing
|
||||
const baselineskip = 12 * pt; // see size10.clo
|
||||
const baselineskip = group.colSeparationType === "CD"
|
||||
? calculateSize({number: 3, unit: "ex"}, options)
|
||||
: 12 * pt; // see size10.clo
|
||||
// Default \jot from ltmath.dtx
|
||||
// TODO(edemaine): allow overriding \jot via \setlength (#687)
|
||||
const jot = 3 * pt;
|
||||
@@ -516,7 +519,7 @@ const mathmlBuilder: MathMLBuilder<"array"> = function(group, options) {
|
||||
const gap = (group.arraystretch === 0.5)
|
||||
? 0.1 // {smallmatrix}, {subarray}
|
||||
: 0.16 + group.arraystretch - 1 + (group.addJot ? 0.09 : 0);
|
||||
table.setAttribute("rowspacing", gap + "em");
|
||||
table.setAttribute("rowspacing", gap.toFixed(4) + "em");
|
||||
|
||||
// MathML table lines go only between cells.
|
||||
// To place a line on an edge we'll use <menclose>, if necessary.
|
||||
@@ -580,6 +583,8 @@ const mathmlBuilder: MathMLBuilder<"array"> = function(group, options) {
|
||||
table.setAttribute("columnspacing", "0em");
|
||||
} else if (group.colSeparationType === "small") {
|
||||
table.setAttribute("columnspacing", "0.2778em");
|
||||
} else if (group.colSeparationType === "CD") {
|
||||
table.setAttribute("columnspacing", "0.5em");
|
||||
} else {
|
||||
table.setAttribute("columnspacing", "1em");
|
||||
}
|
||||
@@ -1016,6 +1021,20 @@ defineEnvironment({
|
||||
mathmlBuilder,
|
||||
});
|
||||
|
||||
defineEnvironment({
|
||||
type: "array",
|
||||
names: ["CD"],
|
||||
props: {
|
||||
numArgs: 0,
|
||||
},
|
||||
handler(context) {
|
||||
validateAmsEnvironmentContext(context);
|
||||
return parseCD(context.parser);
|
||||
},
|
||||
htmlBuilder,
|
||||
mathmlBuilder,
|
||||
});
|
||||
|
||||
// Catch \hline outside array environment
|
||||
defineFunction({
|
||||
type: "text", // Doesn't matter what this is.
|
||||
|
312
src/environments/cd.js
Normal file
312
src/environments/cd.js
Normal file
@@ -0,0 +1,312 @@
|
||||
// @flow
|
||||
import buildCommon from "../buildCommon";
|
||||
import defineFunction from "../defineFunction";
|
||||
import mathMLTree from "../mathMLTree";
|
||||
import * as html from "../buildHTML";
|
||||
import * as mml from "../buildMathML";
|
||||
import {assertSymbolNodeType} from "../parseNode";
|
||||
import ParseError from "../ParseError";
|
||||
|
||||
import type Parser from "../Parser";
|
||||
import type {ParseNode, AnyParseNode} from "../parseNode";
|
||||
|
||||
const cdArrowFunctionName = {
|
||||
">": "\\\\cdrightarrow",
|
||||
"<": "\\\\cdleftarrow",
|
||||
"=": "\\\\cdlongequal",
|
||||
"A": "\\uparrow",
|
||||
"V": "\\downarrow",
|
||||
"|": "\\Vert",
|
||||
".": "no arrow",
|
||||
};
|
||||
|
||||
const newCell = () => {
|
||||
// Create an empty cell, to be filled below with parse nodes.
|
||||
// The parseTree from this module must be constructed like the
|
||||
// one created by parseArray(), so an empty CD cell must
|
||||
// be a ParseNode<"styling">. And CD is always displaystyle.
|
||||
// So these values are fixed and flow can do implicit typing.
|
||||
return {type: "styling", body: [], mode: "math", style: "display"};
|
||||
};
|
||||
|
||||
const isStartOfArrow = (node: AnyParseNode) => {
|
||||
return (node.type === "textord" && node.text === "@");
|
||||
};
|
||||
|
||||
const isLabelEnd = (node: AnyParseNode, endChar: string): boolean => {
|
||||
return ((node.type === "mathord" || node.type === "atom") &&
|
||||
node.text === endChar);
|
||||
};
|
||||
|
||||
function cdArrow(
|
||||
arrowChar: string,
|
||||
labels: ParseNode<"ordgroup">[],
|
||||
parser: Parser
|
||||
): AnyParseNode {
|
||||
// Return a parse tree of an arrow and its labels.
|
||||
// This acts in a way similar to a macro expansion.
|
||||
const funcName = cdArrowFunctionName[arrowChar];
|
||||
switch (funcName) {
|
||||
case "\\\\cdrightarrow":
|
||||
case "\\\\cdleftarrow":
|
||||
return parser.callFunction(
|
||||
funcName, [labels[0]], [labels[1]]
|
||||
);
|
||||
case "\\uparrow":
|
||||
case "\\downarrow": {
|
||||
const leftLabel = parser.callFunction(
|
||||
"\\\\cdleft", [labels[0]], []
|
||||
);
|
||||
const bareArrow = {
|
||||
type: "atom",
|
||||
text: funcName,
|
||||
mode: "math",
|
||||
family: "rel",
|
||||
};
|
||||
const sizedArrow = parser.callFunction("\\Big", [bareArrow], []);
|
||||
const rightLabel = parser.callFunction(
|
||||
"\\\\cdright", [labels[1]], []
|
||||
);
|
||||
const arrowGroup = {
|
||||
type: "ordgroup",
|
||||
mode: "math",
|
||||
body: [leftLabel, sizedArrow, rightLabel],
|
||||
};
|
||||
return parser.callFunction("\\\\cdparent", [arrowGroup], []);
|
||||
}
|
||||
case "\\\\cdlongequal":
|
||||
return parser.callFunction("\\\\cdlongequal", [], []);
|
||||
case "\\Vert": {
|
||||
const arrow = {type: "textord", text: "\\Vert", mode: "math"};
|
||||
return parser.callFunction("\\Big", [arrow], []);
|
||||
}
|
||||
default:
|
||||
return {type: "textord", text: " ", mode: "math"};
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCD(parser: Parser): ParseNode<"array"> {
|
||||
// Get the array's parse nodes with \\ temporarily mapped to \cr.
|
||||
const parsedRows: AnyParseNode[][] = [];
|
||||
parser.gullet.beginGroup();
|
||||
parser.gullet.macros.set("\\cr", "\\\\\\relax");
|
||||
parser.gullet.beginGroup();
|
||||
while (true) { // eslint-disable-line no-constant-condition
|
||||
// Get the parse nodes for the next row.
|
||||
parsedRows.push(parser.parseExpression(false, "\\\\"));
|
||||
parser.gullet.endGroup();
|
||||
parser.gullet.beginGroup();
|
||||
const next = parser.fetch().text;
|
||||
if (next === "&" || next === "\\\\") {
|
||||
parser.consume();
|
||||
} else if (next === "\\end") {
|
||||
if (parsedRows[parsedRows.length - 1].length === 0) {
|
||||
parsedRows.pop(); // final row ended in \\
|
||||
}
|
||||
break;
|
||||
} else {
|
||||
throw new ParseError("Expected \\\\ or \\cr or \\end",
|
||||
parser.nextToken);
|
||||
}
|
||||
}
|
||||
|
||||
let row = [];
|
||||
const body = [row];
|
||||
|
||||
// Loop thru the parse nodes. Collect them into cells and arrows.
|
||||
for (let i = 0; i < parsedRows.length; i++) {
|
||||
// Start a new row.
|
||||
const rowNodes = parsedRows[i];
|
||||
// Create the first cell.
|
||||
let cell = newCell();
|
||||
|
||||
for (let j = 0; j < rowNodes.length; j++) {
|
||||
if (!isStartOfArrow(rowNodes[j])) {
|
||||
// If a parseNode is not an arrow, it goes into a cell.
|
||||
cell.body.push(rowNodes[j]);
|
||||
} else {
|
||||
// Parse node j is an "@", the start of an arrow.
|
||||
// Before starting on the arrow, push the cell into `row`.
|
||||
row.push(cell);
|
||||
|
||||
// Now collect parseNodes into an arrow.
|
||||
// The character after "@" defines the arrow type.
|
||||
j += 1;
|
||||
const arrowChar = assertSymbolNodeType(rowNodes[j]).text;
|
||||
|
||||
// Create two empty label nodes. We may or may not use them.
|
||||
const labels: ParseNode<"ordgroup">[] = new Array(2);
|
||||
labels[0] = {type: "ordgroup", mode: "math", body: []};
|
||||
labels[1] = {type: "ordgroup", mode: "math", body: []};
|
||||
|
||||
// Process the arrow.
|
||||
if ("=|.".indexOf(arrowChar) > -1) {
|
||||
// Three "arrows", ``@=`, `@|`, and `@.`, do not take labels.
|
||||
// Do nothing here.
|
||||
} else if ("<>AV".indexOf(arrowChar) > -1) {
|
||||
// Four arrows, `@>>>`, `@<<<`, `@AAA`, and `@VVV`, each take
|
||||
// two optional labels. E.g. the right-point arrow syntax is
|
||||
// really: @>{optional label}>{optional label}>
|
||||
// Collect parseNodes into labels.
|
||||
for (let labelNum = 0; labelNum < 2; labelNum++) {
|
||||
let inLabel = true;
|
||||
for (let k = j + 1; k < rowNodes.length; k++) {
|
||||
if (isLabelEnd(rowNodes[k], arrowChar)) {
|
||||
inLabel = false;
|
||||
j = k;
|
||||
break;
|
||||
}
|
||||
if (isStartOfArrow(rowNodes[k])) {
|
||||
throw new ParseError("Missing a " + arrowChar +
|
||||
" character to complete a CD arrow.", rowNodes[k]);
|
||||
}
|
||||
|
||||
labels[labelNum].body.push(rowNodes[k]);
|
||||
}
|
||||
if (inLabel) {
|
||||
// isLabelEnd never returned a true.
|
||||
throw new ParseError("Missing a " + arrowChar +
|
||||
" character to complete a CD arrow.", rowNodes[j]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new ParseError(`Expected one of "<>AV=|." after @`,
|
||||
rowNodes[j]);
|
||||
}
|
||||
|
||||
// Now join the arrow to its labels.
|
||||
const arrow: AnyParseNode = cdArrow(arrowChar, labels, parser);
|
||||
|
||||
// Wrap the arrow in ParseNode<"styling">.
|
||||
// This is done to match parseArray() behavior.
|
||||
const wrappedArrow = {
|
||||
type: "styling",
|
||||
body: [arrow],
|
||||
mode: "math",
|
||||
style: "display", // CD is always displaystyle.
|
||||
};
|
||||
row.push(wrappedArrow);
|
||||
// In CD's syntax, cells are implicit. That is, everything that
|
||||
// is not an arrow gets collected into a cell. So create an empty
|
||||
// cell now. It will collect upcoming parseNodes.
|
||||
cell = newCell();
|
||||
}
|
||||
}
|
||||
if (i % 2 === 0) {
|
||||
// Even-numbered rows consist of: cell, arrow, cell, arrow, ... cell
|
||||
// The last cell is not yet pushed into `row`, so:
|
||||
row.push(cell);
|
||||
} else {
|
||||
// Odd-numbered rows consist of: vert arrow, empty cell, ... vert arrow
|
||||
// Remove the empty cell that was placed at the beginning of `row`.
|
||||
row.shift();
|
||||
}
|
||||
row = [];
|
||||
body.push(row);
|
||||
}
|
||||
|
||||
// End row group
|
||||
parser.gullet.endGroup();
|
||||
// End array group defining \\
|
||||
parser.gullet.endGroup();
|
||||
|
||||
// define column separation.
|
||||
const cols = new Array(body[0].length).fill({
|
||||
type: "align",
|
||||
align: "c",
|
||||
pregap: 0.25, // CD package sets \enskip between columns.
|
||||
postgap: 0.25, // So pre and post each get half an \enskip, i.e. 0.25em.
|
||||
});
|
||||
|
||||
return {
|
||||
type: "array",
|
||||
mode: "math",
|
||||
body,
|
||||
arraystretch: 1,
|
||||
addJot: true,
|
||||
rowGaps: [null],
|
||||
cols,
|
||||
colSeparationType: "CD",
|
||||
hLinesBeforeRow: new Array(body.length + 1).fill([]),
|
||||
};
|
||||
}
|
||||
|
||||
// The functions below are not available for general use.
|
||||
// They are here only for internal use by the {CD} environment in placing labels
|
||||
// next to vertical arrows.
|
||||
|
||||
// We don't need any such functions for horizontal arrows because we can reuse
|
||||
// the functionality that already exists for extensible arrows.
|
||||
|
||||
defineFunction({
|
||||
type: "cdlabel",
|
||||
names: ["\\\\cdleft", "\\\\cdright"],
|
||||
props: {
|
||||
numArgs: 1,
|
||||
},
|
||||
handler({parser, funcName}, args) {
|
||||
return {
|
||||
type: "cdlabel",
|
||||
mode: parser.mode,
|
||||
side: funcName.slice(4),
|
||||
label: args[0],
|
||||
};
|
||||
},
|
||||
htmlBuilder(group, options) {
|
||||
const newOptions = options.havingStyle(options.style.sup());
|
||||
const label = buildCommon.wrapFragment(
|
||||
html.buildGroup(group.label, newOptions, options), options);
|
||||
label.classes.push("cd-label-" + group.side);
|
||||
label.style.bottom = (0.8 - label.depth) + "em";
|
||||
// Zero out label height & depth, so vertical align of arrow is set
|
||||
// by the arrow height, not by the label.
|
||||
label.height = 0;
|
||||
label.depth = 0;
|
||||
return label;
|
||||
},
|
||||
mathmlBuilder(group, options) {
|
||||
let label = new mathMLTree.MathNode("mrow",
|
||||
[mml.buildGroup(group.label, options)]);
|
||||
label = new mathMLTree.MathNode("mpadded", [label]);
|
||||
label.setAttribute("width", "0");
|
||||
if (group.side === "left") {
|
||||
label.setAttribute("lspace", "-1width");
|
||||
}
|
||||
// We have to guess at vertical alignment. We know the arrow is 1.8em tall,
|
||||
// But we don't know the height or depth of the label.
|
||||
label.setAttribute("voffset", "0.7em");
|
||||
label = new mathMLTree.MathNode("mstyle", [label]);
|
||||
label.setAttribute("displaystyle", "false");
|
||||
label.setAttribute("scriptlevel", "1");
|
||||
return label;
|
||||
},
|
||||
});
|
||||
|
||||
defineFunction({
|
||||
type: "cdlabelparent",
|
||||
names: ["\\\\cdparent"],
|
||||
props: {
|
||||
numArgs: 1,
|
||||
},
|
||||
handler({parser}, args) {
|
||||
return {
|
||||
type: "cdlabelparent",
|
||||
mode: parser.mode,
|
||||
fragment: args[0],
|
||||
};
|
||||
},
|
||||
htmlBuilder(group, options) {
|
||||
// Wrap the vertical arrow and its labels.
|
||||
// The parent gets position: relative. The child gets position: absolute.
|
||||
// So CSS can locate the label correctly.
|
||||
const parent = buildCommon.wrapFragment(
|
||||
html.buildGroup(group.fragment, options), options
|
||||
);
|
||||
parent.classes.push("cd-vert-arrow");
|
||||
return parent;
|
||||
},
|
||||
mathmlBuilder(group, options) {
|
||||
return new mathMLTree.MathNode("mrow",
|
||||
[mml.buildGroup(group.fragment, options)]);
|
||||
},
|
||||
});
|
@@ -10,6 +10,7 @@ export default functions;
|
||||
import "./functions/accent";
|
||||
import "./functions/accentunder";
|
||||
import "./functions/arrow";
|
||||
import "./environments/cd";
|
||||
import "./functions/char";
|
||||
import "./functions/color";
|
||||
import "./functions/cr";
|
||||
|
@@ -30,6 +30,8 @@ defineFunction({
|
||||
// The next 3 functions are here to support the mhchem extension.
|
||||
// Direct use of these functions is discouraged and may break someday.
|
||||
"\\xrightleftarrows", "\\xrightequilibrium", "\\xleftequilibrium",
|
||||
// The next 3 functions are here only to support the {CD} environment.
|
||||
"\\\\cdrightarrow", "\\\\cdleftarrow", "\\\\cdlongequal",
|
||||
],
|
||||
props: {
|
||||
numArgs: 1,
|
||||
@@ -57,7 +59,8 @@ defineFunction({
|
||||
let newOptions = options.havingStyle(style.sup());
|
||||
const upperGroup = buildCommon.wrapFragment(
|
||||
html.buildGroup(group.body, newOptions, options), options);
|
||||
upperGroup.classes.push("x-arrow-pad");
|
||||
const arrowPrefix = group.label.slice(0, 2) === "\\x" ? "x" : "cd";
|
||||
upperGroup.classes.push(arrowPrefix + "-arrow-pad");
|
||||
|
||||
let lowerGroup;
|
||||
if (group.below) {
|
||||
@@ -65,7 +68,7 @@ defineFunction({
|
||||
newOptions = options.havingStyle(style.sub());
|
||||
lowerGroup = buildCommon.wrapFragment(
|
||||
html.buildGroup(group.below, newOptions, options), options);
|
||||
lowerGroup.classes.push("x-arrow-pad");
|
||||
lowerGroup.classes.push(arrowPrefix + "-arrow-pad");
|
||||
}
|
||||
|
||||
const arrowBody = stretchy.svgSpan(group, options);
|
||||
@@ -112,6 +115,9 @@ defineFunction({
|
||||
},
|
||||
mathmlBuilder(group, options) {
|
||||
const arrowNode = stretchy.mathMLnode(group.label);
|
||||
arrowNode.setAttribute(
|
||||
"minsize", group.label.charAt(0) === "x" ? "1.75em" : "3.0em"
|
||||
);
|
||||
let node;
|
||||
|
||||
if (group.body) {
|
||||
|
@@ -555,6 +555,10 @@
|
||||
padding: 0 0.5em;
|
||||
}
|
||||
|
||||
.cd-arrow-pad {
|
||||
padding: 0 0.55556em 0 0.27778em; // \;{#1}\;\;
|
||||
}
|
||||
|
||||
.x-arrow,
|
||||
.mover,
|
||||
.munder {
|
||||
@@ -610,6 +614,25 @@
|
||||
.mtr-glue {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.cd-vert-arrow {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cd-label-left {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
right: calc(50% + 0.3em);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cd-label-right {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
left: calc(50% + 0.3em);
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.katex-display {
|
||||
|
@@ -41,6 +41,20 @@ type ParseNodeTypes = {
|
||||
hLinesBeforeRow: Array<boolean[]>,
|
||||
addEqnNum?: boolean,
|
||||
leqno?: boolean,
|
||||
isCD?: boolean,
|
||||
|},
|
||||
"cdlabel": {|
|
||||
type: "cdlabel",
|
||||
mode: Mode,
|
||||
loc?: ?SourceLocation,
|
||||
side: string,
|
||||
label: AnyParseNode,
|
||||
|},
|
||||
"cdlabelparent": {|
|
||||
type: "cdlabelparent",
|
||||
mode: Mode,
|
||||
loc?: ?SourceLocation,
|
||||
fragment: AnyParseNode,
|
||||
|},
|
||||
"color": {|
|
||||
type: "color",
|
||||
|
@@ -54,11 +54,14 @@ const stretchyCodePoint: {[string]: string} = {
|
||||
xrightleftarrows: "\u21c4",
|
||||
xrightequilibrium: "\u21cc", // Not a perfect match.
|
||||
xleftequilibrium: "\u21cb", // None better available.
|
||||
"\\\\cdrightarrow": "\u2192",
|
||||
"\\\\cdleftarrow": "\u2190",
|
||||
"\\\\cdlongequal": "=",
|
||||
};
|
||||
|
||||
const mathMLnode = function(label: string): mathMLTree.MathNode {
|
||||
const node = new mathMLTree.MathNode(
|
||||
"mo", [new mathMLTree.TextNode(stretchyCodePoint[label.substr(1)])]);
|
||||
"mo", [new mathMLTree.TextNode(stretchyCodePoint[label])]);
|
||||
node.setAttribute("stretchy", "true");
|
||||
return node;
|
||||
};
|
||||
@@ -118,7 +121,9 @@ const katexImagesData: {
|
||||
underrightarrow: [["rightarrow"], 0.888, 522, "xMaxYMin"],
|
||||
underleftarrow: [["leftarrow"], 0.888, 522, "xMinYMin"],
|
||||
xrightarrow: [["rightarrow"], 1.469, 522, "xMaxYMin"],
|
||||
"\\cdrightarrow": [["rightarrow"], 3.0, 522, "xMaxYMin"], // CD minwwidth2.5pc
|
||||
xleftarrow: [["leftarrow"], 1.469, 522, "xMinYMin"],
|
||||
"\\cdleftarrow": [["leftarrow"], 3.0, 522, "xMinYMin"],
|
||||
Overrightarrow: [["doublerightarrow"], 0.888, 560, "xMaxYMin"],
|
||||
xRightarrow: [["doublerightarrow"], 1.526, 560, "xMaxYMin"],
|
||||
xLeftarrow: [["doubleleftarrow"], 1.526, 560, "xMinYMin"],
|
||||
@@ -129,6 +134,7 @@ const katexImagesData: {
|
||||
xrightharpoonup: [["rightharpoon"], 0.888, 522, "xMaxYMin"],
|
||||
xrightharpoondown: [["rightharpoondown"], 0.888, 522, "xMaxYMin"],
|
||||
xlongequal: [["longequal"], 0.888, 334, "xMinYMin"],
|
||||
"\\cdlongequal": [["longequal"], 3.0, 334, "xMinYMin"],
|
||||
xtwoheadleftarrow: [["twoheadleftarrow"], 0.888, 334, "xMinYMin"],
|
||||
xtwoheadrightarrow: [["twoheadrightarrow"], 0.888, 334, "xMaxYMin"],
|
||||
|
||||
|
Reference in New Issue
Block a user