Line breaks for inline formulas (#1287)

* Line breaks for inline formulas

* Basic support for \allowbreak and \nobreak

* Fix spacing around \nobreak, and add documentation

* Backwards-compatibility _getBuilt to fix tests

* Put operator spacing on same line as operator

* One approach to ~

* Simplify \allowbreak/\nobreak, make ~/\nobreakspace prevent line breaks

* Adapt to #1295

* Prevent wrapping within a .base

* Implement \hspace* properly

* Fix flow error

* Update comment for regularSpace

* Update screenshots

* Move `width: min-content` from .katex into .base

* Fix screenshot

* Add min-width rule to .vlist-s

* Factor out hasClass method

* Cleanup nobreak test

* Pull out buildHTMLUnbreakable

* Fix \hspace* test (no longer the same as \hspace)

* Fix \nobreak handling

* Add screenshot test
This commit is contained in:
Erik Demaine
2018-05-10 19:44:26 -04:00
committed by Kevin Barabash
parent 34e6458245
commit 523df299e5
32 changed files with 149 additions and 43 deletions

View File

@@ -679,16 +679,31 @@ const spacingFunctions: {[string]: {| size: string, className: string |}} = {
size: "-0.16667em", size: "-0.16667em",
className: "negativethinspace", className: "negativethinspace",
}, },
"\\nobreak": {
size: "0em",
className: "nobreak",
},
"\\allowbreak": {
size: "0em",
className: "allowbreak",
},
}; };
// A lookup table to determine whether a spacing function/symbol should be // A lookup table to determine whether a spacing function/symbol should be
// treated like a regular space character. // treated like a regular space character. If a symbol or command is a key
const regularSpace: {[string]: boolean} = { // in this table, then it should be a regular space character. Furthermore,
" ": true, // the associated value may have a `className` specifying an extra CSS class
"\\ ": true, // to add to the created `span`.
"~": true, const regularSpace: {[string]: { className?: string }} = {
"\\space": true, " ": {},
"\\nobreakspace": true, "\\ ": {},
"~": {
className: "nobreak",
},
"\\space": {},
"\\nobreakspace": {
className: "nobreak",
},
}; };
/** /**

View File

@@ -205,7 +205,7 @@ export const getTypeOfDomTree = function(node, side = "right") {
// 'mtight' indicates that the node is script or scriptscript style. // 'mtight' indicates that the node is script or scriptscript style.
export const isLeftTight = function(node) { export const isLeftTight = function(node) {
node = getOutermostNode(node, "left"); node = getOutermostNode(node, "left");
return utils.contains(node.classes, "mtight"); return node.hasClass("mtight");
}; };
/** /**
@@ -395,13 +395,16 @@ export const groupTypes = {
spacing(group, options) { spacing(group, options) {
if (buildCommon.regularSpace.hasOwnProperty(group.value)) { if (buildCommon.regularSpace.hasOwnProperty(group.value)) {
const className = buildCommon.regularSpace[group.value].className;
// Spaces are generated by adding an actual space. Each of these // Spaces are generated by adding an actual space. Each of these
// things has an entry in the symbols table, so these will be turned // things has an entry in the symbols table, so these will be turned
// into appropriate outputs. // into appropriate outputs.
if (group.mode === "text") { if (group.mode === "text") {
return buildCommon.makeOrd(group, options, "textord"); const ord = buildCommon.makeOrd(group, options, "textord");
ord.classes.push(className);
return ord;
} else { } else {
return makeSpan(["mspace"], return makeSpan(["mspace", className],
[buildCommon.mathsym(group.value, group.mode, options)], [buildCommon.mathsym(group.value, group.mode, options)],
options); options);
} }
@@ -629,21 +632,18 @@ export const buildGroup = function(group, options, baseOptions) {
}; };
/** /**
* Take an entire parse tree, and build it into an appropriate set of HTML * Combine an array of HTML DOM nodes (e.g., the output of `buildExpression`)
* nodes. * into an unbreakable HTML node of class .base, with proper struts to
* guarantee correct vertical extent. `buildHTML` calls this repeatedly to
* make up the entire expression as a sequence of unbreakable units.
*/ */
export default function buildHTML(tree, options) { function buildHTMLUnbreakable(children, options) {
// buildExpression is destructive, so we need to make a clone // Compute height and depth of this chunk.
// of the incoming tree so that it isn't accidentally changed const body = makeSpan(["base"], children, options);
tree = JSON.parse(JSON.stringify(tree));
// Build the expression contained in the tree // Add struts, which ensure that the top of the HTML element falls at
const expression = buildExpression(tree, options, true); // the height of the expression, and the bottom of the HTML element
const body = makeSpan(["base"], expression, options); // falls at the depth of the expression.
// Add struts, which ensure that the top of the HTML element falls at the
// height of the expression, and the bottom of the HTML element falls at the
// depth of the expression.
const topStrut = makeSpan(["strut"]); const topStrut = makeSpan(["strut"]);
const bottomStrut = makeSpan(["strut", "bottom"]); const bottomStrut = makeSpan(["strut", "bottom"]);
@@ -654,10 +654,60 @@ export default function buildHTML(tree, options) {
// normal place) so we use an absolute value for vertical-align instead // normal place) so we use an absolute value for vertical-align instead
bottomStrut.style.verticalAlign = -body.depth + "em"; bottomStrut.style.verticalAlign = -body.depth + "em";
// Wrap the struts and body together body.children.unshift(topStrut, bottomStrut);
const htmlNode = makeSpan(["katex-html"], [topStrut, bottomStrut, body]);
return body;
}
/**
* Take an entire parse tree, and build it into an appropriate set of HTML
* nodes.
*/
export default function buildHTML(tree, options) {
// buildExpression is destructive, so we need to make a clone
// of the incoming tree so that it isn't accidentally changed
tree = JSON.parse(JSON.stringify(tree));
// Build the expression contained in the tree
const expression = buildExpression(tree, options, true);
const htmlNode = makeSpan(["katex-html"], []);
htmlNode.setAttribute("aria-hidden", "true"); htmlNode.setAttribute("aria-hidden", "true");
// Create one base node for each chunk between potential line breaks.
// The TeXBook [p.173] says "A formula will be broken only after a
// relation symbol like $=$ or $<$ or $\rightarrow$, or after a binary
// operation symbol like $+$ or $-$ or $\times$, where the relation or
// binary operation is on the ``outer level'' of the formula (i.e., not
// enclosed in {...} and not part of an \over construction)."
let parts = [];
for (let i = 0; i < expression.length; i++) {
parts.push(expression[i]);
if (expression[i].hasClass("mbin") ||
expression[i].hasClass("mrel") ||
expression[i].hasClass("allowbreak")) {
// Put any post-operator glue on same line as operator.
// Watch for \nobreak along the way.
let nobreak = false;
while (i < expression.length - 1 &&
expression[i + 1].hasClass("mspace")) {
i++;
parts.push(expression[i]);
if (expression[i].hasClass("nobreak")) {
nobreak = true;
}
}
// Don't allow break if \nobreak among the post-operator glue.
if (!nobreak) {
htmlNode.children.push(buildHTMLUnbreakable(parts, options));
parts = [];
}
}
}
if (parts.length > 0) {
htmlNode.children.push(buildHTMLUnbreakable(parts, options));
}
return htmlNode; return htmlNode;
} }

View File

@@ -40,6 +40,7 @@ export interface HtmlDomNode extends VirtualNodeInterface {
depth: number; depth: number;
maxFontSize: number; maxFontSize: number;
hasClass(className: string): boolean;
tryCombine(sibling: HtmlDomNode): boolean; tryCombine(sibling: HtmlDomNode): boolean;
} }
@@ -104,6 +105,10 @@ class span<ChildType: VirtualNodeInterface> implements HtmlDomNode {
this.attributes[attribute] = value; this.attributes[attribute] = value;
} }
hasClass(className: string): boolean {
return utils.contains(this.classes, className);
}
tryCombine(sibling: HtmlDomNode): boolean { tryCombine(sibling: HtmlDomNode): boolean {
return false; return false;
} }
@@ -235,6 +240,10 @@ class anchor implements HtmlDomNode {
this.attributes[attribute] = value; this.attributes[attribute] = value;
} }
hasClass(className: string): boolean {
return utils.contains(this.classes, className);
}
tryCombine(sibling: HtmlDomNode): boolean { tryCombine(sibling: HtmlDomNode): boolean {
return false; return false;
} }
@@ -344,6 +353,10 @@ class documentFragment implements HtmlDomNode {
this.maxFontSize = 0; this.maxFontSize = 0;
} }
hasClass(className: string): boolean {
return utils.contains(this.classes, className);
}
tryCombine(sibling: HtmlDomNode): boolean { tryCombine(sibling: HtmlDomNode): boolean {
return false; return false;
} }
@@ -439,6 +452,10 @@ class symbolNode implements HtmlDomNode {
} }
} }
hasClass(className: string): boolean {
return utils.contains(this.classes, className);
}
tryCombine(sibling: HtmlDomNode): boolean { tryCombine(sibling: HtmlDomNode): boolean {
if (!sibling if (!sibling
|| !(sibling instanceof symbolNode) || !(sibling instanceof symbolNode)

View File

@@ -25,13 +25,17 @@
> .katex { > .katex {
display: inline-block; display: inline-block;
text-align: initial; text-align: initial;
white-space: nowrap;
> .katex-html {
display: inline-block;
}
} }
} }
.katex { .katex {
font: normal 1.21em KaTeX_Main, Times New Roman, serif; font: normal 1.21em KaTeX_Main, Times New Roman, serif;
line-height: 1.2; line-height: 1.2;
white-space: nowrap;
// Protect elements inside .katex from inheriting text-indent. // Protect elements inside .katex from inheriting text-indent.
text-indent: 0; text-indent: 0;
@@ -39,20 +43,10 @@
// Prevent a rendering bug that misplaces \vec in Chrome. // Prevent a rendering bug that misplaces \vec in Chrome.
text-rendering: auto; text-rendering: auto;
// Fix width of containers of negative spaces, working around Chrome bug.
width: min-content;
// Prevent background resetting on elements in Windows's high-contrast // Prevent background resetting on elements in Windows's high-contrast
// mode, while still allowing background/foreground setting on root .katex // mode, while still allowing background/foreground setting on root .katex
* { -ms-high-contrast-adjust: none !important; } * { -ms-high-contrast-adjust: none !important; }
.katex-html {
// Making .katex inline-block allows line breaks before and after,
// which is undesireable ("to $x$,"). Instead, adjust the .katex-html
// style and put nowrap on the inline .katex element.
display: inline-block;
}
.katex-mathml { .katex-mathml {
// Accessibility hack to only show to screen readers // Accessibility hack to only show to screen readers
// Found at: http://a11yproject.com/posts/how-to-hide-content/ // Found at: http://a11yproject.com/posts/how-to-hide-content/
@@ -68,6 +62,10 @@
.base { .base {
position: relative; position: relative;
display: inline-block; display: inline-block;
white-space: nowrap;
// Fix width of containers of negative spaces, working around Chrome bug.
width: min-content;
} }
.strut { .strut {
@@ -206,11 +204,13 @@
// issues; absolute units prevent user font-size overrides from breaking // issues; absolute units prevent user font-size overrides from breaking
// rendering. Safari refuses to make the box zero-width, so we give it // rendering. Safari refuses to make the box zero-width, so we give it
// a known width and compensate with negative right margin on the // a known width and compensate with negative right margin on the
// inline-table. // inline-table. To prevent the "width: min-content" Chrome workaround
// from shrinking this box, we also set min-width.
display: table-cell; display: table-cell;
vertical-align: bottom; vertical-align: bottom;
font-size: 1px; font-size: 1px;
width: 2px; width: 2px;
min-width: 2px;
} }
.msupsub { .msupsub {

View File

@@ -390,8 +390,11 @@ defineMacro("\\KaTeX",
// \DeclareRobustCommand\hspace{\@ifstar\@hspacer\@hspace} // \DeclareRobustCommand\hspace{\@ifstar\@hspacer\@hspace}
// \def\@hspace#1{\hskip #1\relax} // \def\@hspace#1{\hskip #1\relax}
// KaTeX doesn't do line breaks, so \hspace and \hspace* are the same as \kern // \def\@hspacer#1{\vrule \@width\z@\nobreak
defineMacro("\\hspace", "\\@ifstar\\kern\\kern"); // \hskip #1\hskip \z@skip}
defineMacro("\\hspace", "\\@ifstar\\@hspacer\\@hspace");
defineMacro("\\@hspace", "\\hskip #1\\relax");
defineMacro("\\@hspacer", "\\rule{0pt}{0pt}\\hskip #1\\relax");
////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////
// mathtools.sty // mathtools.sty

View File

@@ -594,6 +594,8 @@ defineSymbol(text, main, spacing, null, "\\qquad");
defineSymbol(text, main, spacing, null, "\\quad"); defineSymbol(text, main, spacing, null, "\\quad");
defineSymbol(text, main, spacing, "\u00a0", "\\space"); defineSymbol(text, main, spacing, "\u00a0", "\\space");
defineSymbol(text, main, spacing, "\u00a0", "\\nobreakspace"); defineSymbol(text, main, spacing, "\u00a0", "\\nobreakspace");
defineSymbol(math, main, spacing, null, "\\nobreak");
defineSymbol(math, main, spacing, null, "\\allowbreak");
defineSymbol(math, main, punct, ",", ","); defineSymbol(math, main, punct, ",", ",");
defineSymbol(math, main, punct, ";", ";"); defineSymbol(math, main, punct, ";", ";");
defineSymbol(math, main, punct, ":", "\\colon"); defineSymbol(math, main, punct, ":", "\\colon");

View File

@@ -53,8 +53,13 @@ const _getBuilt = function(expr, settings) {
// grab the root node of the HTML rendering // grab the root node of the HTML rendering
const builtHTML = rootNode.children[1]; const builtHTML = rootNode.children[1];
// Remove the outer .katex and .katex-inner layers // combine the non-strut children of all base spans
return builtHTML.children[2].children; const children = [];
for (let i = 0; i < builtHTML.children.length; i++) {
children.push(...builtHTML.children[i].children.filter(
(node) => node.classes.indexOf("strut") < 0));
}
return children;
}; };
/** /**
@@ -2889,9 +2894,9 @@ describe("A macro expander", function() {
// This may change in the future, if we support the extra features of // This may change in the future, if we support the extra features of
// \hspace. // \hspace.
it("should treat \\hspace, \\hspace*, \\hskip like \\kern", function() { it("should treat \\hspace, \\hskip like \\kern", function() {
expect("\\hspace{1em}").toParseLike("\\kern1em"); expect("\\hspace{1em}").toParseLike("\\kern1em");
expect("\\hspace*{1em}").toParseLike("\\kern1em"); expect("\\hskip{1em}").toParseLike("\\kern1em");
}); });
it("should expand \\limsup as expected", () => { it("should expand \\limsup as expected", () => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -144,6 +144,20 @@ LeftRightStyleSizing: |
LimitControls: | LimitControls: |
\displaystyle\int\limits_2^3 3x^2\,dx + \sum\nolimits^n_{i=1}i + \displaystyle\int\limits_2^3 3x^2\,dx + \sum\nolimits^n_{i=1}i +
\textstyle\int\limits_x^y z \textstyle\int\limits_x^y z
LineBreak: |
\frac{x^2}{y^2} + z^2 =
z^2 + \frac{x^2}{y^2} =
\frac{x^2}{y^2} +\nobreak z^2 =
z^2 + \frac{x^2}{y^2} =
\frac{x^2}{y^2} + ~ z^2 =
z^2 + \frac{x^2}{y^2} =
\frac{x^2}{y^2} + \hspace{1em} z^2 =
z^2 + \frac{x^2}{y^2} =
\frac{x^2}{y^2} + z^2 = \hspace*{1em}
z^2 + \frac{x^2}{y^2} =
\frac{x^2}{y^2} + z^2 =
hi \allowbreak there =
hi \allowbreak there
LowerAccent: | LowerAccent: |
\begin{matrix} \begin{matrix}
\underleftarrow{AB} \quad \underrightarrow{AB} \quad \underleftrightarrow{AB} \quad \undergroup{AB} \\ \underleftarrow{AB} \quad \underrightarrow{AB} \quad \underleftrightarrow{AB} \quad \undergroup{AB} \\