mirror of
https://github.com/Smaug123/KaTeX
synced 2025-10-05 19:28:39 +00:00
feat: \nonumber/\notag support, \tag per row of {align} (#2952)
* feat: \nonumber and \notag support Support `\nonumber` (and equivalent `\notag`) using a global macro `\@eqnsw` to track whether one occurs in each row, similar to how amsmath uses global `\@eqnswtrue`/`\@eqnswfalse`. Fix #2950 * Remove duplicate mention of align* * Working version of \tag within {align} * Simpler subparse mechanism * Fix flow errors, clarifying set-to-undefined * Document that \tag works in rows * Add screenshot tests * Add Jest tests * Add Safari screenshot * Commit message about fixing \tag Fixes #2379 * Apply suggestions from code review Co-authored-by: ylemkimon <y@ylem.kim> * Revise and move getAutoTag * Fix handling of split * Remove unnecessary feedTokens Co-authored-by: ylemkimon <y@ylem.kim> Co-authored-by: ylemkimon <y@ylem.kim>
This commit is contained in:
@@ -748,10 +748,11 @@ use `\ce` instead|
|
||||
|\nobreakspace|$a\nobreakspace b$|`a\nobreakspace b`|
|
||||
|\noexpand|||
|
||||
|\nolimits|$\lim\nolimits_x$|`\lim\nolimits_x`|
|
||||
|\nonumber|$$\begin{align}a&=b+c\nonumber\\d+e&=f\end{align}$$|`\begin{align}`<br> `a&=b+c \nonumber\\`<br> `d+e&=f`<br>`\end{align}`|
|
||||
|\normalfont|<span style="color:firebrick;">Not supported</span>||
|
||||
|\normalsize|$\normalsize normalsize$|`\normalsize normalsize`|
|
||||
|\not|$\not =$|`\not =`|
|
||||
|\notag|<span style="color:firebrick;">Not supported</span>||
|
||||
|\notag|$$\begin{align}a&=b+c\notag\\d+e&=f\end{align}$$|`\begin{align}`<br> `a&=b+c \notag\\`<br> `d+e&=f`<br>`\end{align}`|
|
||||
|\notin|$\notin$||
|
||||
|\notni|$\notni$||
|
||||
|\nparallel|$\nparallel$||
|
||||
|
@@ -106,7 +106,7 @@ The auto-render extension will render the following environments even if they ar
|
||||
|:-----------------------------------------------|:------------------|
|
||||
| `darray`, `dcases`, `drcases` | … apply `displaystyle` |
|
||||
| `matrix*`, `pmatrix*`, `bmatrix*`<br>`Bmatrix*`, `vmatrix*`, `Vmatrix*` | … take an optional argument to set column<br>alignment, as in `\begin{matrix*}[r]`
|
||||
| `equation*`, `gather*`<br>`align*`, `alignat*` | … have no automatic numbering. |
|
||||
| `equation*`, `gather*`<br>`align*`, `alignat*` | … have no automatic numbering. Alternatively, you can use `\nonumber` or `\notag` to omit the numbering for a specific row of the equation. |
|
||||
| `gathered`, `aligned`, `alignedat` | … do not need to be in display mode.<br> … have no automatic numbering.<br> … must be inside math delimiters in<br>order to be rendered by the auto-render<br>extension. |
|
||||
|
||||
</div>
|
||||
@@ -117,7 +117,8 @@ The `{array}` environment supports `|` and `:` vertical separators.
|
||||
|
||||
The `{array}` environment does not yet support `\cline` or `\multicolumn`.
|
||||
|
||||
`\tag` can not yet be applied to individual environment rows.
|
||||
`\tag` can be applied to individual rows of top-level environments
|
||||
(`align`, `align*`, `alignat`, `alignat*`, `gather`, `gather*`).
|
||||
|
||||
<div class="katex-hopscotch">
|
||||
|
||||
|
@@ -15,7 +15,7 @@ export type Mapping<Value> = {[string]: Value};
|
||||
export default class Namespace<Value> {
|
||||
current: Mapping<Value>;
|
||||
builtins: Mapping<Value>;
|
||||
undefStack: Mapping<Value>[];
|
||||
undefStack: Mapping<?Value>[];
|
||||
|
||||
/**
|
||||
* Both arguments are optional. The first argument is an object of
|
||||
@@ -48,7 +48,7 @@ export default class Namespace<Value> {
|
||||
const undefs = this.undefStack.pop();
|
||||
for (const undef in undefs) {
|
||||
if (undefs.hasOwnProperty(undef)) {
|
||||
if (undefs[undef] === undefined) {
|
||||
if (undefs[undef] == null) {
|
||||
delete this.current[undef];
|
||||
} else {
|
||||
this.current[undef] = undefs[undef];
|
||||
@@ -97,8 +97,9 @@ export default class Namespace<Value> {
|
||||
* Local set() sets the current value and (when appropriate) adds an undo
|
||||
* operation to the undo stack. Global set() may change the undo
|
||||
* operation at every level, so takes time linear in their number.
|
||||
* A value of undefined means to delete existing definitions.
|
||||
*/
|
||||
set(name: string, value: Value, global: boolean = false) {
|
||||
set(name: string, value: ?Value, global: boolean = false) {
|
||||
if (global) {
|
||||
// Global set is equivalent to setting in all groups. Simulate this
|
||||
// by destroying any undos currently scheduled for this name,
|
||||
@@ -119,6 +120,10 @@ export default class Namespace<Value> {
|
||||
top[name] = this.current[name];
|
||||
}
|
||||
}
|
||||
this.current[name] = value;
|
||||
if (value == null) {
|
||||
delete this.current[name];
|
||||
} else {
|
||||
this.current[name] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -150,6 +150,27 @@ export default class Parser {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully parse a separate sequence of tokens as a separate job.
|
||||
* Tokens should be specified in reverse order, as in a MacroDefinition.
|
||||
*/
|
||||
subparse(tokens: Token[]): AnyParseNode[] {
|
||||
// Save the next token from the current job.
|
||||
const oldToken = this.nextToken;
|
||||
this.consume();
|
||||
|
||||
// Run the new job, terminating it with an excess '}'
|
||||
this.gullet.pushToken(new Token("}"));
|
||||
this.gullet.pushTokens(tokens);
|
||||
const parse = this.parseExpression(false);
|
||||
this.expect("}");
|
||||
|
||||
// Restore the next token from the current job.
|
||||
this.nextToken = oldToken;
|
||||
|
||||
return parse;
|
||||
}
|
||||
|
||||
static endOfExpression: string[] = ["}", "\\endgroup", "\\end", "\\right", "&"];
|
||||
|
||||
/**
|
||||
|
@@ -4,10 +4,12 @@ import Style from "../Style";
|
||||
import defineEnvironment from "../defineEnvironment";
|
||||
import {parseCD} from "./cd";
|
||||
import defineFunction from "../defineFunction";
|
||||
import defineMacro from "../defineMacro";
|
||||
import mathMLTree from "../mathMLTree";
|
||||
import ParseError from "../ParseError";
|
||||
import {assertNodeType, assertSymbolNodeType} from "../parseNode";
|
||||
import {checkSymbolNodeType} from "../parseNode";
|
||||
import {Token} from "../Token";
|
||||
import {calculateSize, makeEm} from "../units";
|
||||
import utils from "../utils";
|
||||
|
||||
@@ -54,6 +56,18 @@ const validateAmsEnvironmentContext = context => {
|
||||
}
|
||||
};
|
||||
|
||||
// autoTag (an argument to parseArray) can be one of three values:
|
||||
// * undefined: Regular (not-top-level) array; no tags on each row
|
||||
// * true: Automatic equation numbering, overridable by \tag
|
||||
// * false: Tags allowed on each row, but no automatic numbering
|
||||
// This function *doesn't* work with the "split" environment name.
|
||||
function getAutoTag(name): ?boolean {
|
||||
if (name.indexOf("ed") === -1) {
|
||||
return name.indexOf("*") === -1;
|
||||
}
|
||||
// return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the body of the environment, with rows delimited by \\ and
|
||||
* columns delimited by &, and create a nested list in row-major order
|
||||
@@ -68,7 +82,7 @@ function parseArray(
|
||||
cols,
|
||||
arraystretch,
|
||||
colSeparationType,
|
||||
addEqnNum,
|
||||
autoTag,
|
||||
singleRow,
|
||||
emptySingleRow,
|
||||
maxNumCols,
|
||||
@@ -79,7 +93,7 @@ function parseArray(
|
||||
cols?: AlignSpec[],
|
||||
arraystretch?: number,
|
||||
colSeparationType?: ColSeparationType,
|
||||
addEqnNum?: boolean,
|
||||
autoTag?: ?boolean,
|
||||
singleRow?: boolean,
|
||||
emptySingleRow?: boolean,
|
||||
maxNumCols?: number,
|
||||
@@ -116,6 +130,29 @@ function parseArray(
|
||||
const rowGaps = [];
|
||||
const hLinesBeforeRow = [];
|
||||
|
||||
const tags = (autoTag != null ? [] : undefined);
|
||||
|
||||
// amsmath uses \global\@eqnswtrue and \global\@eqnswfalse to represent
|
||||
// whether this row should have an equation number. Simulate this with
|
||||
// a \@eqnsw macro set to 1 or 0.
|
||||
function beginRow() {
|
||||
if (autoTag) {
|
||||
parser.gullet.macros.set("\\@eqnsw", "1", true);
|
||||
}
|
||||
}
|
||||
function endRow() {
|
||||
if (tags) {
|
||||
if (parser.gullet.macros.get("\\df@tag")) {
|
||||
tags.push(parser.subparse([new Token("\\df@tag")]));
|
||||
parser.gullet.macros.set("\\df@tag", undefined, true);
|
||||
} else {
|
||||
tags.push(Boolean(autoTag) &&
|
||||
parser.gullet.macros.get("\\@eqnsw") === "1");
|
||||
}
|
||||
}
|
||||
}
|
||||
beginRow();
|
||||
|
||||
// Test for \hline at the top of the array.
|
||||
hLinesBeforeRow.push(getHLines(parser));
|
||||
|
||||
@@ -154,6 +191,7 @@ function parseArray(
|
||||
}
|
||||
parser.consume();
|
||||
} else if (next === "\\end") {
|
||||
endRow();
|
||||
// Arrays terminate newlines with `\crcr` which consumes a `\cr` if
|
||||
// the last line is empty. However, AMS environments keep the
|
||||
// empty row if it's the only one.
|
||||
@@ -179,12 +217,14 @@ function parseArray(
|
||||
size = parser.parseSizeGroup(true);
|
||||
}
|
||||
rowGaps.push(size ? size.value : null);
|
||||
endRow();
|
||||
|
||||
// check for \hline(s) following the row separator
|
||||
hLinesBeforeRow.push(getHLines(parser));
|
||||
|
||||
row = [];
|
||||
body.push(row);
|
||||
beginRow();
|
||||
} else {
|
||||
throw new ParseError("Expected & or \\\\ or \\cr or \\end",
|
||||
parser.nextToken);
|
||||
@@ -207,7 +247,7 @@ function parseArray(
|
||||
hskipBeforeAndAfter,
|
||||
hLinesBeforeRow,
|
||||
colSeparationType,
|
||||
addEqnNum,
|
||||
tags,
|
||||
leqno,
|
||||
};
|
||||
}
|
||||
@@ -339,17 +379,27 @@ const htmlBuilder: HtmlBuilder<"array"> = function(group, options) {
|
||||
let colSep;
|
||||
let colDescrNum;
|
||||
|
||||
const eqnNumSpans = [];
|
||||
if (group.addEqnNum) {
|
||||
// An environment with automatic equation numbers.
|
||||
// Create node(s) that will trigger CSS counter increment.
|
||||
const tagSpans = [];
|
||||
if (group.tags && group.tags.some((tag) => tag)) {
|
||||
// An environment with manual tags and/or automatic equation numbers.
|
||||
// Create node(s), the latter of which trigger CSS counter increment.
|
||||
for (r = 0; r < nr; ++r) {
|
||||
const rw = body[r];
|
||||
const shift = rw.pos - offset;
|
||||
const eqnTag = buildCommon.makeSpan(["eqn-num"], [], options);
|
||||
eqnTag.depth = rw.depth;
|
||||
eqnTag.height = rw.height;
|
||||
eqnNumSpans.push({type: "elem", elem: eqnTag, shift});
|
||||
const tag = group.tags[r];
|
||||
let tagSpan;
|
||||
if (tag === true) { // automatic numbering
|
||||
tagSpan = buildCommon.makeSpan(["eqn-num"], [], options);
|
||||
} else if (tag === false) {
|
||||
// \nonumber/\notag or starred environment
|
||||
tagSpan = buildCommon.makeSpan([], [], options);
|
||||
} else { // manual \tag
|
||||
tagSpan = buildCommon.makeSpan([],
|
||||
html.buildExpression(tag, options, true), options);
|
||||
}
|
||||
tagSpan.depth = rw.depth;
|
||||
tagSpan.height = rw.height;
|
||||
tagSpans.push({type: "elem", elem: tagSpan, shift});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,12 +515,12 @@ const htmlBuilder: HtmlBuilder<"array"> = function(group, options) {
|
||||
}, options);
|
||||
}
|
||||
|
||||
if (!group.addEqnNum) {
|
||||
if (tagSpans.length === 0) {
|
||||
return buildCommon.makeSpan(["mord"], [body], options);
|
||||
} else {
|
||||
let eqnNumCol = buildCommon.makeVList({
|
||||
positionType: "individualShift",
|
||||
children: eqnNumSpans,
|
||||
children: tagSpans,
|
||||
}, options);
|
||||
eqnNumCol = buildCommon.makeSpan(["tag"], [eqnNumCol], options);
|
||||
return buildCommon.makeFragment([body, eqnNumCol]);
|
||||
@@ -494,7 +544,7 @@ const mathmlBuilder: MathMLBuilder<"array"> = function(group, options) {
|
||||
row.push(new mathMLTree.MathNode("mtd",
|
||||
[mml.buildGroup(rw[j], options)]));
|
||||
}
|
||||
if (group.addEqnNum) {
|
||||
if (group.tags && group.tags[i]) {
|
||||
row.unshift(glue);
|
||||
row.push(glue);
|
||||
if (group.leqno) {
|
||||
@@ -631,14 +681,15 @@ const alignedHandler = function(context, args) {
|
||||
}
|
||||
const cols = [];
|
||||
const separationType = context.envName.indexOf("at") > -1 ? "alignat" : "align";
|
||||
const isSplit = context.envName === "split";
|
||||
const res = parseArray(context.parser,
|
||||
{
|
||||
cols,
|
||||
addJot: true,
|
||||
addEqnNum: context.envName === "align" || context.envName === "alignat",
|
||||
autoTag: isSplit ? undefined : getAutoTag(context.envName),
|
||||
emptySingleRow: true,
|
||||
colSeparationType: separationType,
|
||||
maxNumCols: context.envName === "split" ? 2 : undefined,
|
||||
maxNumCols: isSplit ? 2 : undefined,
|
||||
leqno: context.parser.settings.leqno,
|
||||
},
|
||||
"display"
|
||||
@@ -984,7 +1035,7 @@ defineEnvironment({
|
||||
}],
|
||||
addJot: true,
|
||||
colSeparationType: "gather",
|
||||
addEqnNum: context.envName === "gather",
|
||||
autoTag: getAutoTag(context.envName),
|
||||
emptySingleRow: true,
|
||||
leqno: context.parser.settings.leqno,
|
||||
};
|
||||
@@ -1017,7 +1068,7 @@ defineEnvironment({
|
||||
handler(context) {
|
||||
validateAmsEnvironmentContext(context);
|
||||
const res = {
|
||||
addEqnNum: context.envName === "equation",
|
||||
autoTag: getAutoTag(context.envName),
|
||||
emptySingleRow: true,
|
||||
singleRow: true,
|
||||
maxNumCols: 1,
|
||||
@@ -1043,6 +1094,9 @@ defineEnvironment({
|
||||
mathmlBuilder,
|
||||
});
|
||||
|
||||
defineMacro("\\nonumber", "\\gdef\\@eqnsw{0}");
|
||||
defineMacro("\\notag", "\\nonumber");
|
||||
|
||||
// Catch \hline outside array environment
|
||||
defineFunction({
|
||||
type: "text", // Doesn't matter what this is.
|
||||
|
@@ -39,7 +39,8 @@ type ParseNodeTypes = {
|
||||
body: AnyParseNode[][], // List of rows in the (2D) array.
|
||||
rowGaps: (?Measurement)[],
|
||||
hLinesBeforeRow: Array<boolean[]>,
|
||||
addEqnNum?: boolean,
|
||||
// Whether each row should be automatically numbered, or an explicit tag
|
||||
tags?: (boolean | AnyParseNode[])[],
|
||||
leqno?: boolean,
|
||||
isCD?: boolean,
|
||||
|},
|
||||
|
@@ -6,6 +6,7 @@
|
||||
|
||||
import Parser from "./Parser";
|
||||
import ParseError from "./ParseError";
|
||||
import {Token} from "./Token";
|
||||
|
||||
import type Settings from "./Settings";
|
||||
import type {AnyParseNode} from "./parseNode";
|
||||
@@ -34,12 +35,11 @@ const parseTree = function(toParse: string, settings: Settings): AnyParseNode[]
|
||||
if (!settings.displayMode) {
|
||||
throw new ParseError("\\tag works only in display equations");
|
||||
}
|
||||
parser.gullet.feed("\\df@tag");
|
||||
tree = [{
|
||||
type: "tag",
|
||||
mode: "text",
|
||||
body: tree,
|
||||
tag: parser.parse(),
|
||||
tag: parser.subparse([new Token("\\df@tag")]),
|
||||
}];
|
||||
}
|
||||
|
||||
|
@@ -3606,6 +3606,19 @@ describe("\\tag support", function() {
|
||||
expect`\tag{1}\tag{2}x+y`.not.toParse(displayMode);
|
||||
});
|
||||
|
||||
it("should fail with multiple tags in one row", () => {
|
||||
expect`\begin{align}\tag{1}x+y\tag{2}\end{align}`.not.toParse(displayMode);
|
||||
});
|
||||
|
||||
it("should work with one tag per row", () => {
|
||||
expect`\begin{align}\tag{1}x\\&+y\tag{2}\end{align}`.toParse(displayMode);
|
||||
});
|
||||
|
||||
it("should work with \\nonumber/\\notag", () => {
|
||||
expect`\begin{align}\tag{1}\nonumber x\\&+y\notag\end{align}`
|
||||
.toParseLike(r`\begin{align}\tag{1}x\\&+y\nonumber\end{align}`, displayMode);
|
||||
});
|
||||
|
||||
it("should build", () => {
|
||||
expect`\tag{hi}x+y`.toBuild(displayMode);
|
||||
});
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 18 KiB |
Binary file not shown.
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 17 KiB |
Binary file not shown.
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 17 KiB |
@@ -23,7 +23,13 @@ AccentsText: |
|
||||
\text{\.I İ} & \text{\H e e̋} & \text{\i ı}
|
||||
\end{array}
|
||||
Align:
|
||||
tex: \begin{align}a &= 1 & b &= 2 \\ 3a &= 3 & 17b &= 34\end{align}
|
||||
tex: |
|
||||
\begin{align}
|
||||
a &= 1 & b &= 2 \\
|
||||
3a &= 3 & 17b &= 34 \\
|
||||
\tag{$\ast$} a &\neq b \\
|
||||
\nonumber a &={}? & b &={}? \\
|
||||
\end{align}
|
||||
display: 1
|
||||
Aligned: |
|
||||
\begin{aligned}
|
||||
|
Reference in New Issue
Block a user