Add \smash, laps, spaces, and phantoms (#833)
* Add \smash, laps, spaces, and phantoms 1. Support `\smash`, including the optional argument from AMS. 2. Change `\llap` and `\rlap` so that they render in text style. Repeat: This *changes* KaTeX behavior. 3. Add `\mathllap` and `\mathrlap`. These will act as they do in `mathtools` and as in previous KaTeX versions of `\llap` and `\rlap`. 4. Add `\mathclap` and `\clap`. 5. Add `\hphantom` and \vphantom`. 6. Add `\thinspace`, `\medspace`, `\thickspace` 7. Add `\hspace`. This work will resolve issue #270 and parts of #50 and #164. A. Perlis has written a [concise description](https://www.math.lsu.edu/~aperlis/publications/mathclap/perlis_mathclap_24Jun2003.pdf) of items 1 thru 5. Except for `\smash`'s optional argument. It's described in the [AMS User's Guide](http://texdoc.net/texmf-dist/doc/latex/amsmath/amsldoc.pdf). Item 6 also comes from the AMS User's Guide. * Fix test spec * Exploit makeVList for smash * update smash and phantom screenshots for chrome * Pick up review comments * Change test from \llap to \mathlap \llap is fundamentally a text-mode function. We should not expect it to work correctly when given math-mode arguments. So test \mathllap instead. * Correct \llap macro A correction. The previous macro returned an error if given an argument with math-mode content, such as x^2. The corrected macro will not return an error. It will instead return well rendered math, but letters are in `\mathrm` font. * update \llap, \rlap, \clap macros to use \textrm * update Lap screenshots
@@ -762,20 +762,57 @@ groupTypes.spacing = function(group, options) {
|
||||
}
|
||||
};
|
||||
|
||||
groupTypes.llap = function(group, options) {
|
||||
const inner = makeSpan(
|
||||
["inner"], [buildGroup(group.value.body, options)]);
|
||||
groupTypes.lap = function(group, options) {
|
||||
// mathllap, mathrlap, mathclap
|
||||
let inner;
|
||||
if (group.value.alignment === "clap") {
|
||||
// ref: https://www.math.lsu.edu/~aperlis/publications/mathclap/
|
||||
inner = makeSpan([], [buildGroup(group.value.body, options)]);
|
||||
// wrap, since CSS will center a .clap > .inner > span
|
||||
inner = makeSpan(["inner"], [inner], options);
|
||||
} else {
|
||||
inner = makeSpan(
|
||||
["inner"], [buildGroup(group.value.body, options)]);
|
||||
}
|
||||
const fix = makeSpan(["fix"], []);
|
||||
return makeSpan(
|
||||
["mord", "llap"], [inner, fix], options);
|
||||
["mord", group.value.alignment], [inner, fix], options);
|
||||
};
|
||||
|
||||
groupTypes.rlap = function(group, options) {
|
||||
const inner = makeSpan(
|
||||
["inner"], [buildGroup(group.value.body, options)]);
|
||||
const fix = makeSpan(["fix"], []);
|
||||
return makeSpan(
|
||||
["mord", "rlap"], [inner, fix], options);
|
||||
groupTypes.smash = function(group, options) {
|
||||
const node = makeSpan(["mord"], [buildGroup(group.value.body, options)]);
|
||||
|
||||
if (!group.value.smashHeight && !group.value.smashDepth) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (group.value.smashHeight) {
|
||||
node.height = 0;
|
||||
// In order to influence makeVList, we have to reset the children.
|
||||
if (node.children) {
|
||||
for (let i = 0; i < node.children.length; i++) {
|
||||
node.children[i].height = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (group.value.smashDepth) {
|
||||
node.depth = 0;
|
||||
if (node.children) {
|
||||
for (let i = 0; i < node.children.length; i++) {
|
||||
node.children[i].depth = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// At this point, we've reset the TeX-like height and depth values.
|
||||
// But the span still has an HTML line height.
|
||||
// makeVList applies "display: table-cell", which prevents the browser
|
||||
// from acting on that line height. So we'll call makeVList now.
|
||||
|
||||
return buildCommon.makeVList([
|
||||
{type: "elem", elem: node},
|
||||
], "firstBaseline", null, options);
|
||||
};
|
||||
|
||||
groupTypes.op = function(group, options) {
|
||||
@@ -1698,6 +1735,34 @@ groupTypes.phantom = function(group, options) {
|
||||
return new buildCommon.makeFragment(elements);
|
||||
};
|
||||
|
||||
groupTypes.hphantom = function(group, options) {
|
||||
let node = makeSpan(
|
||||
[], [buildGroup(group.value.body, options.withPhantom())]);
|
||||
node.height = 0;
|
||||
node.depth = 0;
|
||||
if (node.children) {
|
||||
for (let i = 0; i < node.children.length; i++) {
|
||||
node.children[i].height = 0;
|
||||
node.children[i].depth = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// See smash for comment re: use of makeVList
|
||||
node = buildCommon.makeVList([
|
||||
{type: "elem", elem: node},
|
||||
], "firstBaseline", null, options);
|
||||
|
||||
return node;
|
||||
};
|
||||
|
||||
groupTypes.vphantom = function(group, options) {
|
||||
const inner = makeSpan(
|
||||
["inner"], [buildGroup(group.value.body, options.withPhantom())]);
|
||||
const fix = makeSpan(["fix"], []);
|
||||
return makeSpan(
|
||||
["mord", "rlap"], [inner, fix], options);
|
||||
};
|
||||
|
||||
groupTypes.mclass = function(group, options) {
|
||||
const elements = buildExpression(group.value.value, options, true);
|
||||
|
||||
|
@@ -621,21 +621,31 @@ groupTypes.kern = function(group) {
|
||||
return node;
|
||||
};
|
||||
|
||||
groupTypes.llap = function(group, options) {
|
||||
groupTypes.lap = function(group, options) {
|
||||
// mathllap, mathrlap, mathclap
|
||||
const node = new mathMLTree.MathNode(
|
||||
"mpadded", [buildGroup(group.value.body, options)]);
|
||||
|
||||
node.setAttribute("lspace", "-1width");
|
||||
if (group.value.alignment !== "rlap") {
|
||||
const offset = (group.value.alignment === "llap" ? "-1" : "-0.5");
|
||||
node.setAttribute("lspace", offset + "width");
|
||||
}
|
||||
node.setAttribute("width", "0px");
|
||||
|
||||
return node;
|
||||
};
|
||||
|
||||
groupTypes.rlap = function(group, options) {
|
||||
groupTypes.smash = function(group, options) {
|
||||
const node = new mathMLTree.MathNode(
|
||||
"mpadded", [buildGroup(group.value.body, options)]);
|
||||
|
||||
node.setAttribute("width", "0px");
|
||||
if (group.value.smashHeight) {
|
||||
node.setAttribute("height", "0px");
|
||||
}
|
||||
|
||||
if (group.value.smashDepth) {
|
||||
node.setAttribute("depth", "0px");
|
||||
}
|
||||
|
||||
return node;
|
||||
};
|
||||
@@ -645,6 +655,20 @@ groupTypes.phantom = function(group, options) {
|
||||
return new mathMLTree.MathNode("mphantom", inner);
|
||||
};
|
||||
|
||||
groupTypes.hphantom = function(group, options) {
|
||||
const inner = buildExpression(group.value.value, options);
|
||||
const node = new mathMLTree.MathNode("mphantom", inner);
|
||||
node.setAttribute("height", "0px");
|
||||
return node;
|
||||
};
|
||||
|
||||
groupTypes.vphantom = function(group, options) {
|
||||
const inner = buildExpression(group.value.value, options);
|
||||
const node = new mathMLTree.MathNode("mphantom", inner);
|
||||
node.setAttribute("width", "0px");
|
||||
return node;
|
||||
};
|
||||
|
||||
groupTypes.mclass = function(group, options) {
|
||||
const inner = buildExpression(group.value.value, options);
|
||||
return new mathMLTree.MathNode("mstyle", inner);
|
||||
|
@@ -234,13 +234,14 @@ defineFunction("\\KaTeX", {
|
||||
};
|
||||
});
|
||||
|
||||
defineFunction("\\phantom", {
|
||||
defineFunction(["\\phantom", "\\hphantom", "\\vphantom"], {
|
||||
numArgs: 1,
|
||||
}, function(context, args) {
|
||||
const body = args[0];
|
||||
return {
|
||||
type: "phantom",
|
||||
type: context.funcName.slice(1),
|
||||
value: ordargument(body),
|
||||
body: body,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -516,18 +517,59 @@ defineFunction([
|
||||
};
|
||||
});
|
||||
|
||||
// Left and right overlap functions
|
||||
defineFunction(["\\llap", "\\rlap"], {
|
||||
// Horizontal overlap functions
|
||||
defineFunction(["\\mathllap", "\\mathrlap", "\\mathclap"], {
|
||||
numArgs: 1,
|
||||
allowedInText: true,
|
||||
}, function(context, args) {
|
||||
const body = args[0];
|
||||
return {
|
||||
type: context.funcName.slice(1),
|
||||
type: "lap",
|
||||
alignment: context.funcName.slice(5),
|
||||
body: body,
|
||||
};
|
||||
});
|
||||
|
||||
// smash, with optional [tb], as in AMS
|
||||
defineFunction("\\smash", {
|
||||
numArgs: 1,
|
||||
numOptionalArgs: 1,
|
||||
allowedInText: true,
|
||||
}, function(context, args) {
|
||||
let smashHeight = false;
|
||||
let smashDepth = false;
|
||||
const tbArg = args[0];
|
||||
if (tbArg) {
|
||||
// Optional [tb] argument is engaged.
|
||||
// ref: amsmath: \renewcommand{\smash}[1][tb]{%
|
||||
// def\mb@t{\ht}\def\mb@b{\dp}\def\mb@tb{\ht\z@\z@\dp}%
|
||||
let letter = "";
|
||||
for (let i = 0; i < tbArg.value.length; ++i) {
|
||||
letter = tbArg.value[i].value;
|
||||
if (letter === "t") {
|
||||
smashHeight = true;
|
||||
} else if (letter === "b") {
|
||||
smashDepth = true;
|
||||
} else {
|
||||
smashHeight = false;
|
||||
smashDepth = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
smashHeight = true;
|
||||
smashDepth = true;
|
||||
}
|
||||
|
||||
const body = args[1];
|
||||
return {
|
||||
type: "smash",
|
||||
body: body,
|
||||
smashHeight: smashHeight,
|
||||
smashDepth: smashDepth,
|
||||
};
|
||||
});
|
||||
|
||||
// Delimiter functions
|
||||
const checkDelimiter = function(delim, context) {
|
||||
if (utils.contains(delimiters, delim.value)) {
|
||||
|
@@ -19,6 +19,11 @@ defineMacro("\\endgroup", "}");
|
||||
// (In TeX, the mu unit works only with \mkern.)
|
||||
defineMacro("\\mkern", "\\kern");
|
||||
|
||||
// \llap and \rlap render their contents in text mode
|
||||
defineMacro("\\llap", "\\mathllap{\\textrm{#1}}");
|
||||
defineMacro("\\rlap", "\\mathrlap{\\textrm{#1}}");
|
||||
defineMacro("\\clap", "\\mathclap{\\textrm{#1}}");
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// amsmath.sty
|
||||
|
||||
@@ -38,6 +43,19 @@ defineMacro("\\iff", "\\;\\Longleftrightarrow\\;");
|
||||
defineMacro("\\implies", "\\;\\Longrightarrow\\;");
|
||||
defineMacro("\\impliedby", "\\;\\Longleftarrow\\;");
|
||||
|
||||
// http://texdoc.net/texmf-dist/doc/latex/amsmath/amsmath.pdf
|
||||
defineMacro("\\thinspace", "\\,"); // \let\thinspace\,
|
||||
defineMacro("\\medspace", "\\:"); // \let\medspace\:
|
||||
defineMacro("\\thickspace", "\\;"); // \let\thickspace\;
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// LaTeX source2e
|
||||
|
||||
// \DeclareRobustCommand\hspace{\@ifstar\@hspacer\@hspace}
|
||||
// \def\@hspace#1{\hskip #1\relax}
|
||||
// KaTeX doesn't do line breaks, so \hspace is the same as \kern
|
||||
defineMacro("\\hspace", "\\kern{#1}");
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// mathtools.sty
|
||||
|
||||
|
@@ -350,7 +350,8 @@
|
||||
}
|
||||
|
||||
.llap,
|
||||
.rlap {
|
||||
.rlap,
|
||||
.clap {
|
||||
width: 0;
|
||||
position: relative;
|
||||
|
||||
@@ -367,10 +368,16 @@
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.rlap > .inner {
|
||||
.rlap > .inner,
|
||||
.clap > .inner {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.clap > .inner > span {
|
||||
margin-left: -50%;
|
||||
margin-right: 50%;
|
||||
}
|
||||
|
||||
.katex-logo {
|
||||
.a {
|
||||
font-size: 0.75em;
|
||||
|
@@ -1253,15 +1253,15 @@ describe("A TeX-compliant parser", function() {
|
||||
"\\frac x \\frac y z",
|
||||
"\\frac \\sqrt x y",
|
||||
"\\frac x \\sqrt y",
|
||||
"\\frac \\llap x y",
|
||||
"\\frac x \\llap y",
|
||||
"\\frac \\mathllap x y",
|
||||
"\\frac x \\mathllap y",
|
||||
// This actually doesn't work in real TeX, but it is suprisingly
|
||||
// hard to get this to correctly work. So, we take hit of very small
|
||||
// amounts of non-compatiblity in order for the rest of the tests to
|
||||
// work
|
||||
// "\\llap \\frac x y",
|
||||
"\\llap \\llap x",
|
||||
"\\sqrt \\llap x",
|
||||
"\\mathllap \\mathllap x",
|
||||
"\\sqrt \\mathllap x",
|
||||
];
|
||||
|
||||
for (let i = 0; i < badArguments.length; i++) {
|
||||
@@ -1275,11 +1275,11 @@ describe("A TeX-compliant parser", function() {
|
||||
"\\frac x {\\frac y z}",
|
||||
"\\frac {\\sqrt x} y",
|
||||
"\\frac x {\\sqrt y}",
|
||||
"\\frac {\\llap x} y",
|
||||
"\\frac x {\\llap y}",
|
||||
"\\llap {\\frac x y}",
|
||||
"\\llap {\\llap x}",
|
||||
"\\sqrt {\\llap x}",
|
||||
"\\frac {\\mathllap x} y",
|
||||
"\\frac x {\\mathllap y}",
|
||||
"\\mathllap {\\frac x y}",
|
||||
"\\mathllap {\\mathllap x}",
|
||||
"\\sqrt {\\mathllap x}",
|
||||
];
|
||||
|
||||
for (let i = 0; i < goodArguments.length; i++) {
|
||||
@@ -1290,9 +1290,9 @@ describe("A TeX-compliant parser", function() {
|
||||
it("should fail when sup/subscripts require arguments", function() {
|
||||
const badSupSubscripts = [
|
||||
"x^\\sqrt x",
|
||||
"x^\\llap x",
|
||||
"x^\\mathllap x",
|
||||
"x_\\sqrt x",
|
||||
"x_\\llap x",
|
||||
"x_\\mathllap x",
|
||||
];
|
||||
|
||||
for (let i = 0; i < badSupSubscripts.length; i++) {
|
||||
@@ -1303,9 +1303,9 @@ describe("A TeX-compliant parser", function() {
|
||||
it("should work when sup/subscripts arguments have braces", function() {
|
||||
const goodSupSubscripts = [
|
||||
"x^{\\sqrt x}",
|
||||
"x^{\\llap x}",
|
||||
"x^{\\mathllap x}",
|
||||
"x_{\\sqrt x}",
|
||||
"x_{\\llap x}",
|
||||
"x_{\\mathllap x}",
|
||||
];
|
||||
|
||||
for (let i = 0; i < goodSupSubscripts.length; i++) {
|
||||
@@ -1341,7 +1341,7 @@ describe("A TeX-compliant parser", function() {
|
||||
const badLeftArguments = [
|
||||
"\\frac \\left( x \\right) y",
|
||||
"\\frac x \\left( y \\right)",
|
||||
"\\llap \\left( x \\right)",
|
||||
"\\mathllap \\left( x \\right)",
|
||||
"\\sqrt \\left( x \\right)",
|
||||
"x^\\left( x \\right)",
|
||||
];
|
||||
@@ -1355,7 +1355,7 @@ describe("A TeX-compliant parser", function() {
|
||||
const goodLeftArguments = [
|
||||
"\\frac {\\left( x \\right)} y",
|
||||
"\\frac x {\\left( y \\right)}",
|
||||
"\\llap {\\left( x \\right)}",
|
||||
"\\mathllap {\\left( x \\right)}",
|
||||
"\\sqrt {\\left( x \\right)}",
|
||||
"x^{\\left( x \\right)}",
|
||||
];
|
||||
@@ -2088,6 +2088,10 @@ describe("A phantom parser", function() {
|
||||
expect("\\phantom{x^2}").toParse();
|
||||
expect("\\phantom{x}^2").toParse();
|
||||
expect("\\phantom x").toParse();
|
||||
expect("\\hphantom{x}").toParse();
|
||||
expect("\\hphantom{x^2}").toParse();
|
||||
expect("\\hphantom{x}^2").toParse();
|
||||
expect("\\hphantom x").toParse();
|
||||
});
|
||||
|
||||
it("should build a phantom node", function() {
|
||||
@@ -2104,6 +2108,11 @@ describe("A phantom builder", function() {
|
||||
expect("\\phantom{x^2}").toBuild();
|
||||
expect("\\phantom{x}^2").toBuild();
|
||||
expect("\\phantom x").toBuild();
|
||||
|
||||
expect("\\hphantom{x}").toBuild();
|
||||
expect("\\hphantom{x^2}").toBuild();
|
||||
expect("\\hphantom{x}^2").toBuild();
|
||||
expect("\\hphantom x").toBuild();
|
||||
});
|
||||
|
||||
it("should make the children transparent", function() {
|
||||
@@ -2121,6 +2130,45 @@ describe("A phantom builder", function() {
|
||||
});
|
||||
});
|
||||
|
||||
describe("A smash parser", function() {
|
||||
it("should not fail", function() {
|
||||
expect("\\smash{x}").toParse();
|
||||
expect("\\smash{x^2}").toParse();
|
||||
expect("\\smash{x}^2").toParse();
|
||||
expect("\\smash x").toParse();
|
||||
|
||||
expect("\\smash[b]{x}").toParse();
|
||||
expect("\\smash[b]{x^2}").toParse();
|
||||
expect("\\smash[b]{x}^2").toParse();
|
||||
expect("\\smash[b] x").toParse();
|
||||
|
||||
expect("\\smash[]{x}").toParse();
|
||||
expect("\\smash[]{x^2}").toParse();
|
||||
expect("\\smash[]{x}^2").toParse();
|
||||
expect("\\smash[] x").toParse();
|
||||
});
|
||||
|
||||
it("should build a smash node", function() {
|
||||
const parse = getParsed("\\smash{x}")[0];
|
||||
|
||||
expect(parse.type).toEqual("smash");
|
||||
});
|
||||
});
|
||||
|
||||
describe("A smash builder", function() {
|
||||
it("should not fail", function() {
|
||||
expect("\\smash{x}").toBuild();
|
||||
expect("\\smash{x^2}").toBuild();
|
||||
expect("\\smash{x}^2").toBuild();
|
||||
expect("\\smash x").toBuild();
|
||||
|
||||
expect("\\smash[b]{x}").toBuild();
|
||||
expect("\\smash[b]{x^2}").toBuild();
|
||||
expect("\\smash[b]{x}^2").toBuild();
|
||||
expect("\\smash[b] x").toBuild();
|
||||
});
|
||||
});
|
||||
|
||||
describe("A parser error", function() {
|
||||
it("should report the position of an error", function() {
|
||||
try {
|
||||
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 19 KiB |
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 17 KiB |
BIN
test/screenshotter/images/Smash-chrome.png
Normal file
After Width: | Height: | Size: 6.4 KiB |
BIN
test/screenshotter/images/Smash-firefox.png
Normal file
After Width: | Height: | Size: 6.7 KiB |
@@ -107,7 +107,7 @@ KaTeX: \KaTeX
|
||||
Kern:
|
||||
tex: \frac{a\kern{1em}b}{c}a\kern{1em}b\kern{1ex}c\kern{-0.25em}d
|
||||
nolatex: LaTeX fails to typeset this, “Missing number, treated as zero.”
|
||||
Lap: ab\llap{f}cd\rlap{g}h
|
||||
Lap: ab\mathllap{f}cd\mathrlap{g}hij\mathclap{k}lm \; ab\llap{f}cd\rlap{g}hij\clap{k}lm
|
||||
LargeRuleNumerator: \frac{\textcolor{blue}{\rule{1em}{2em}}}{x}
|
||||
LeftRight: \left( x^2 \right) \left\{ x^{x^{x^{x^x}}} \right.
|
||||
LeftRightListStyling: a+\left(x+y\right)-x
|
||||
@@ -178,7 +178,7 @@ OverUnderset: |
|
||||
a+b+c+d\overset{b+c=0}\longrightarrow a+d\\
|
||||
\overset { x = y } { \sqrt { a b } }
|
||||
\end{array}
|
||||
Phantom: \dfrac{1+\phantom{x^{\blue{2}}} = x}{1+x^{\blue{2}} = x}
|
||||
Phantom: \dfrac{1+\phantom{x^{\blue{2}}} = x}{1+x^{\blue{2}} = x} \left(\vphantom{\int_t} zzz \right) \left( X \hphantom{\frac{\frac X X}{X}} \right)
|
||||
PrimeSpacing: f'+f_2'+f^{f'}
|
||||
PrimeSuper: x'^2+x'''^2+x'^2_3+x_3'^2
|
||||
RelativeUnits: |
|
||||
@@ -190,7 +190,7 @@ RelativeUnits: |
|
||||
\rule{1em}{1em}^{\rule{1em}{1em}}\rule{18mu}{18mu}^{\rule{18mu}{18mu}} &
|
||||
{\footnotesize\rule{1em}{1em}^{\rule{1em}{1em}}\rule{18mu}{18mu}^{\rule{18mu}{18mu}}}
|
||||
\end{array}
|
||||
RlapBug: \frac{\rlap{x}}{2}
|
||||
RlapBug: \frac{\mathrlap{x}}{2}
|
||||
Rule: \rule{1em}{0.5em}\rule{1ex}{2ex}\rule{1em}{1ex}\rule{1em}{0.431ex}
|
||||
SizingBaseline:
|
||||
tex: '{\tiny a+b}a+b{\Huge a+b}'
|
||||
@@ -198,6 +198,7 @@ SizingBaseline:
|
||||
post: M
|
||||
Sizing: |
|
||||
{\Huge x}{\LARGE y}{\normalsize z}{\scriptsize w}
|
||||
Smash: \left( X^{\smash 2} \right) \sqrt{\smash[b]{y}}
|
||||
Spacing: ^3+[-1][1-1]1=1(=1)\lvert a\rvert~b
|
||||
Sqrt: |
|
||||
\sqrt{\sqrt{\sqrt{x}}}_{\sqrt{\sqrt{x}}}^{\sqrt{\sqrt{\sqrt{x}}}
|
||||
|