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:
Erik Demaine
2021-10-31 17:40:06 -04:00
committed by GitHub
parent a59135fd77
commit 52c4778b15
12 changed files with 131 additions and 29 deletions

View File

@@ -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>&nbsp;&nbsp;&nbsp;`a&=b+c \nonumber\\`<br>&nbsp;&nbsp;&nbsp;`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>&nbsp;&nbsp;&nbsp;`a&=b+c \notag\\`<br>&nbsp;&nbsp;&nbsp;`d+e&=f`<br>`\end{align}`|
|\notin|$\notin$||
|\notni|$\notni$||
|\nparallel|$\nparallel$||

View File

@@ -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">

View File

@@ -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];
}
}
if (value == null) {
delete this.current[name];
} else {
this.current[name] = value;
}
}
}

View File

@@ -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", "&"];
/**

View File

@@ -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.

View File

@@ -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,
|},

View File

@@ -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")]),
}];
}

View File

@@ -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

View File

@@ -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}