Use JS for spacing between atoms instead of CSS (#1070)

* Use JS for spacing between atoms instead of CSS

Summary:
This is the first step towards creating an intermediate representation
that can be used to generate HTML, SVG, and Canvas commands for rendering.
By generating spans that contain the width of the spaces instead of
relying on CSS sibling rules we'll be able to one day replaces the spans
with intermeidate 'Glue' nodes (in a later PR).

An added benefit of this approach is that is enables us to programmatically
change the values for thinspace, mediumspace, and thickspace which will
allow us to implement the \setlength command.

Test Plan:
- npm test
- dockers/Screenshotter/screenshotter.sh --verify

* fixed failures in BinCancellation, BoldSymbol, and OperatorName

* update screenshots

* don't use current size when determining size of spaces, update more screenshots

* fix spacing in SizingBaseline and StyleSwitching

* actually do the right thing for sizing groups

* fix \not for Chrome and Firefox

* do TODOs

* address feedback from the code review

* fix issue in delimsizing.js

* add TODO to think about a better solution in href.js

* fix typos, simplify href, be honest about paddingLeft for \not
This commit is contained in:
Kevin Barabash
2018-01-24 23:03:36 -05:00
committed by GitHub
parent d82424c618
commit 63f541b6e6
24 changed files with 252 additions and 232 deletions

View File

@@ -47,6 +47,7 @@ export type OptionsData = {
fontFamily?: string | void;
fontWeight?: string;
fontShape?: string;
sizeMultiplier?: number;
maxSize: number;
};
@@ -150,6 +151,7 @@ class Options {
style: this.style.text(),
size: size,
textSize: size,
sizeMultiplier: sizeMultipliers[size - 1],
});
}
}

View File

@@ -10,12 +10,14 @@ import fontMetrics from "./fontMetrics";
import symbols from "./symbols";
import utils from "./utils";
import stretchy from "./stretchy";
import {calculateSize} from "./units";
import type Options from "./Options";
import type ParseNode from "./ParseNode";
import type {CharacterMetrics} from "./fontMetrics";
import type {Mode} from "./types";
import type {DomChildNode, CombinableDomNode, CssStyle} from "./domTree";
import type {Measurement} from "./units";
// The following have to be loaded from Main-Italic font, using class mainit
const mainitLetters = [
@@ -359,19 +361,6 @@ const makeAnchor = function(
return anchor;
};
/**
* Prepends the given children to the given span, updating height, depth, and
* maxFontSize.
*/
const prependChildren = function(
span: domTree.span,
children: DomChildNode[],
) {
span.children = children.concat(span.children);
sizeElementFromChildren(span);
};
/**
* Makes a document fragment with the given list of children.
*/
@@ -596,6 +585,17 @@ const makeVerb = function(group: ParseNode, options: Options): string {
return text;
};
// Glue is a concept from TeX which is a flexible space between elements in
// either a vertical or horizontal list. In KaTeX, at least for now, it's
// static space between elements in a horizontal layout.
const makeGlue = (measurement: Measurement, options: Options): domTree.span => {
// Make an empty span for the rule
const rule = makeSpan(["mord", "rule"], [], options);
const size = calculateSize(measurement, options);
rule.style.marginRight = `${size}em`;
return rule;
};
// Takes an Options object, and returns the appropriate fontLookup
const retrieveTextFontName = function(
fontFamily: string,
@@ -765,9 +765,9 @@ export default {
makeVList,
makeOrd,
makeVerb,
makeGlue,
staticSvg,
svgData,
tryCombineChars,
prependChildren,
spacingFunctions,
};

View File

@@ -15,6 +15,7 @@ import domTree from "./domTree";
import { calculateSize } from "./units";
import utils from "./utils";
import stretchy from "./stretchy";
import {spacings, tightSpacings} from "./spacingData";
const makeSpan = buildCommon.makeSpan;
@@ -66,6 +67,13 @@ export const spliceSpaces = function(children, i) {
}
};
const styleMap = {
"display": Style.DISPLAY,
"text": Style.TEXT,
"script": Style.SCRIPT,
"scriptscript": Style.SCRIPTSCRIPT,
};
/**
* Take a list of nodes, build them in order, and return a list of the built
* nodes. documentFragments are flattened into their contents, so the
@@ -75,81 +83,91 @@ export const spliceSpaces = function(children, i) {
*/
export const buildExpression = function(expression, options, isRealGroup) {
// Parse expressions into `groups`.
const groups = [];
const rawGroups = [];
for (let i = 0; i < expression.length; i++) {
const group = expression[i];
const output = buildGroup(group, options);
if (output instanceof domTree.documentFragment) {
Array.prototype.push.apply(groups, output.children);
rawGroups.push(...output.children);
} else {
groups.push(output);
rawGroups.push(output);
}
}
// At this point `groups` consists entirely of `symbolNode`s and `span`s.
// At this point `rawGroups` consists entirely of `symbolNode`s and `span`s.
// Explicit spaces (e.g., \;, \,) should be ignored with respect to atom
// spacing (e.g., "add thick space between mord and mrel"). Since CSS
// adjacency rules implement atom spacing, spaces should be invisible to
// CSS. So we splice them out of `groups` and into the atoms themselves.
for (let i = 0; i < groups.length; i++) {
const spaces = spliceSpaces(groups, i);
if (spaces) {
// Splicing of spaces may have removed all remaining groups.
if (i < groups.length) {
// If there is a following group, move space within it.
if (groups[i] instanceof domTree.symbolNode) {
groups[i] = makeSpan([].concat(groups[i].classes),
[groups[i]]);
}
buildCommon.prependChildren(groups[i], spaces);
} else {
// Otherwise, put any spaces back at the end of the groups.
Array.prototype.push.apply(groups, spaces);
break;
}
}
}
// Ignore explicit spaces (e.g., \;, \,) when determining what implicit
// spacing should go between atoms of different classes.
const nonSpaces =
rawGroups.filter(group => group && group.classes[0] !== "mspace");
// Before determining what spaces to insert, perform bin cancellation.
// Binary operators change to ordinary symbols in some contexts.
for (let i = 0; i < groups.length; i++) {
if (isBin(groups[i])
&& (isBinLeftCanceller(groups[i - 1], isRealGroup)
|| isBinRightCanceller(groups[i + 1], isRealGroup))) {
groups[i].classes[0] = "mord";
for (let i = 0; i < nonSpaces.length; i++) {
if (isBin(nonSpaces[i])) {
if (isBinLeftCanceller(nonSpaces[i - 1], isRealGroup)
|| isBinRightCanceller(nonSpaces[i + 1], isRealGroup)) {
nonSpaces[i].classes[0] = "mord";
}
}
}
const groups = [];
let j = 0;
for (let i = 0; i < rawGroups.length; i++) {
groups.push(rawGroups[i]);
// For any group that is not a space, get the next non-space. Then
// lookup what implicit space should be placed between those atoms and
// add it to groups.
if (rawGroups[i].classes[0] !== "mspace" && j < nonSpaces.length - 1) {
// Get the type of the current non-space node. If it's a document
// fragment, get the type of the rightmost node in the fragment.
const left = getTypeOfDomTree(nonSpaces[j], "right");
// Get the type of the next non-space node. If it's a document
// fragment, get the type of the leftmost node in the fragment.
const right = getTypeOfDomTree(nonSpaces[j + 1], "left");
// We use buildExpression inside of sizingGroup, but it returns a
// document fragment of elements. sizingGroup sets `isRealGroup`
// to false to avoid processing spans multiple times.
if (left && right && isRealGroup) {
const space = isLeftTight(nonSpaces[j + 1])
? tightSpacings[left][right]
: spacings[left][right];
if (space) {
let glueOptions = options;
if (expression.length === 1) {
if (expression[0].type === "sizing") {
glueOptions = options.havingSize(
expression[0].value.size);
} else if (expression[0].type === "styling") {
glueOptions = options.havingStyle(
styleMap[expression[0].value.style]);
}
}
const glue = buildCommon.makeGlue(
spacings[left][right], glueOptions);
groups.push(glue);
}
}
j++;
}
}
// Process \\not commands within the group.
// TODO(kevinb): Handle multiple \\not commands in a row.
// TODO(kevinb): Handle \\not{abc} correctly. The \\not should appear over
// the 'a' instead of the 'c'.
for (let i = 0; i < groups.length; i++) {
if (groups[i].value === "\u0338" && i + 1 < groups.length) {
const children = groups.slice(i, i + 2);
children[0].classes = ["mainrm"];
// \u0338 is a combining glyph so we could reorder the children so
// that it comes after the other glyph. This works correctly on
// most browsers except for Safari. Instead we absolutely position
// the glyph and set its right side to match that of the other
// glyph which is visually equivalent.
children[0].style.position = "absolute";
children[0].style.right = "0";
// Copy the classes from the second glyph to the new container.
// This is so it behaves the same as though there was no \\not.
const classes = groups[i + 1].classes;
const container = makeSpan(classes, children);
// LaTeX adds a space between ords separated by a \\not.
if (classes.indexOf("mord") !== -1) {
// \glue(\thickmuskip) 2.77771 plus 2.77771
container.style.paddingLeft = "0.277771em";
}
// Ensure that the \u0338 is positioned relative to the container.
container.style.position = "relative";
groups.splice(i, 2, container);
if (groups[i].value === "\u0338") {
groups[i].style.position = "absolute";
// TODO(kevinb) fix this for Safari by switching to a non-combining
// character for \not.
// This value was determined empirically.
// TODO(kevinb) figure out the real math for this value.
groups[i].style.paddingLeft = "0.8em";
}
}
@@ -157,13 +175,21 @@ export const buildExpression = function(expression, options, isRealGroup) {
};
// Return math atom class (mclass) of a domTree.
export const getTypeOfDomTree = function(node) {
if (node instanceof domTree.documentFragment) {
export const getTypeOfDomTree = function(node, side = "right") {
if (node instanceof domTree.documentFragment ||
node instanceof domTree.anchor) {
if (node.children.length) {
if (side === "right") {
return getTypeOfDomTree(
node.children[node.children.length - 1]);
} else if (side === "left") {
return getTypeOfDomTree(
node.children[0]);
}
}
} else {
// This makes a lot of assumptions as to where the type of atom
// appears. We should do a better job of enforcing this.
if (utils.contains([
"mord", "mop", "mbin", "mrel", "mopen", "mclose",
"mpunct", "minner",
@@ -174,6 +200,21 @@ export const getTypeOfDomTree = function(node) {
return null;
};
// If `node` is an atom return whether it's been assigned the mtight class.
// If `node` is a document fragment, return the value of isLeftTight() for the
// leftmost node in the fragment.
// 'mtight' indicates that the node is script or scriptscript style.
export const isLeftTight = function(node) {
if (node instanceof domTree.documentFragment) {
if (node.children.length) {
return isLeftTight(node.children[0]);
}
} else {
return utils.contains(node.classes, "mtight");
}
return false;
};
/**
* Sometimes, groups perform special rules when they have superscripts or
* subscripts attached to them. This function lets the `supsub` group know that
@@ -440,15 +481,6 @@ groupTypes.sizing = function(group, options) {
groupTypes.styling = function(group, options) {
// Style changes are handled in the TeXbook on pg. 442, Rule 3.
// Figure out what style we're changing to.
const styleMap = {
"display": Style.DISPLAY,
"text": Style.TEXT,
"script": Style.SCRIPT,
"scriptscript": Style.SCRIPTSCRIPT,
};
const newStyle = styleMap[group.value.style];
const newOptions = options.havingStyle(newStyle);
return sizingGroup(group.value.value, newOptions, options);

View File

@@ -5,6 +5,8 @@ import delimiter from "../delimiter";
import mathMLTree from "../mathMLTree";
import ParseError from "../ParseError";
import utils from "../utils";
import { calculateSize } from "../units";
import { spacings, tightSpacings } from "../spacingData";
import * as html from "../buildHTML";
import * as mml from "../buildMathML";
@@ -204,13 +206,20 @@ defineFunction({
inner[i] = delimiter.leftRightDelim(
middleDelim.isMiddle.value, innerHeight, innerDepth,
middleDelim.isMiddle.options, group.mode, []);
// Add back spaces shifted into the delimiter
const spaces = html.spliceSpaces(middleDelim.children, 0);
if (spaces) {
buildCommon.prependChildren(inner[i], spaces);
}
}
}
const lastChildType = html.getTypeOfDomTree(inner[inner.length - 1]);
const activeSpacings = options.style.isTight() ? tightSpacings : spacings;
if (lastChildType && activeSpacings[lastChildType]["mclose"]) {
const glue =
buildCommon.makeSpan(["mord", "rule"], [], options);
const dimension =
calculateSize(activeSpacings[lastChildType]["mclose"], options);
glue.style.marginRight = `${dimension}em`;
inner.push(glue);
}
let rightDelim;

View File

@@ -54,6 +54,8 @@ defineFunction({
if (first === last) { // Case 2 : type of both ends coincides
classes = [first];
} else { // Case 3: both ends have different types.
// TODO(kevinb): figure out a better way to communicate this
// information to buildHTML.js#buildExpression.
const anc = buildCommon.makeAnchor(href, [], elements, options);
return new buildCommon.makeFragment([
new buildCommon.makeSpan([first], [], options),

View File

@@ -43,23 +43,13 @@ defineFunction({
};
},
htmlBuilder: (group, options) => {
// Make an empty span for the rule
const rule = buildCommon.makeSpan(["mord", "rule"], [], options);
if (group.value.dimension) {
const dimension = calculateSize(group.value.dimension, options);
rule.style.marginRight = dimension + "em";
}
return rule;
return buildCommon.makeGlue(group.value.dimension, options);
},
mathmlBuilder: (group, options) => {
const node = new mathMLTree.MathNode("mspace");
if (group.value.dimension) {
const dimension = calculateSize(group.value.dimension, options);
node.setAttribute("width", dimension + "em");
}
return node;
},

View File

@@ -29,6 +29,14 @@ defineFunction({
let letter = "";
let mode = "";
for (const child of group.value.value) {
// In the amsopn package, \newmcodes@ changes four
// characters, *-/:, from math operators back into text.
if ("*-/:".indexOf(child.value) !== -1) {
child.type = "textord";
}
}
// Consolidate Greek letter function names into symbol characters.
const temp = html.buildExpression(
group.value.value, options.withFontFamily("mathrm"), true);

90
src/spacingData.js Normal file
View File

@@ -0,0 +1,90 @@
/**
* Describes spaces between different classes of atoms.
*/
const thinspace = {
number: 3,
unit: "mu",
};
const mediumspace = {
number: 4,
unit: "mu",
};
const thickspace = {
number: 5,
unit: "mu",
};
// Spacing relationships for display and text styles
export const spacings = {
mord: {
mop: thinspace,
mbin: mediumspace,
mrel: thickspace,
minner: thinspace,
},
mop: {
mord: thinspace,
mop: thinspace,
mrel: thickspace,
minner: thinspace,
},
mbin: {
mord: mediumspace,
mop: mediumspace,
mopen: mediumspace,
minner: mediumspace,
},
mrel: {
mord: thickspace,
mop: thickspace,
mopen: thickspace,
minner: thickspace,
},
mopen: {},
mclose: {
mop: thinspace,
mbin: mediumspace,
mrel: thickspace,
minner: thinspace,
},
mpunct: {
mord: thinspace,
mop: thinspace,
mrel: thickspace,
mopen: thinspace,
mclose: thinspace,
mpunct: thinspace,
minner: thinspace,
},
minner: {
mord: thinspace,
mop: thinspace,
mbin: mediumspace,
mrel: thickspace,
mopen: thinspace,
mpunct: thinspace,
minner: thinspace,
},
};
// Spacing relationships for script and scriptscript styles
export const tightSpacings = {
mord: {
mop: thinspace,
},
mop: {
mord: thinspace,
mop: thinspace,
},
mbin: {},
mrel: {},
mopen: {},
mclose: {
mop: thinspace,
},
mpunct: {},
minner: {
mop: thinspace,
},
};

View File

@@ -149,122 +149,6 @@
@mediumspace: 0.22222em; // 4mu
@thickspace: 0.27778em; // 5mu
// These spacings apply in textstyle and displaystyle.
.mord {
& + .mord {}
& + .mop { margin-left: @thinspace; }
& + .mbin { margin-left: @mediumspace; }
& + .mrel { margin-left: @thickspace; }
& + .mopen {}
& + .mclose {}
& + .mpunct {}
& + .minner { margin-left: @thinspace; }
}
.mop {
& + .mord { margin-left: @thinspace; }
& + .mop { margin-left: @thinspace; }
& + .mbin {}
& + .mrel { margin-left: @thickspace; }
& + .mopen {}
& + .mclose {}
& + .mpunct {}
& + .minner { margin-left: @thinspace; }
}
.mbin {
& + .mord { margin-left: @mediumspace; }
& + .mop { margin-left: @mediumspace; }
& + .mbin {}
& + .mrel {}
& + .mopen { margin-left: @mediumspace; }
& + .mclose {}
& + .mpunct {}
& + .minner { margin-left: @mediumspace; }
}
.mrel {
& + .mord { margin-left: @thickspace; }
& + .mop { margin-left: @thickspace; }
& + .mbin {}
& + .mrel {}
& + .mopen { margin-left: @thickspace; }
& + .mclose {}
& + .mpunct {}
& + .minner { margin-left: @thickspace; }
}
.mopen {
& + .mord {}
& + .mop {}
& + .mbin {}
& + .mrel {}
& + .mopen {}
& + .mclose {}
& + .mpunct {}
& + .minner {}
}
.mclose {
& + .mord {}
& + .mop { margin-left: @thinspace; }
& + .mbin { margin-left: @mediumspace; }
& + .mrel { margin-left: @thickspace; }
& + .mopen {}
& + .mclose {}
& + .mpunct {}
& + .minner { margin-left: @thinspace; }
}
.mpunct {
& + .mord { margin-left: @thinspace; }
& + .mop { margin-left: @thinspace; }
& + .mbin {}
& + .mrel { margin-left: @thinspace; }
& + .mopen { margin-left: @thinspace; }
& + .mclose { margin-left: @thinspace; }
& + .mpunct { margin-left: @thinspace; }
& + .minner { margin-left: @thinspace; }
}
.minner {
& + .mord { margin-left: @thinspace; }
& + .mop { margin-left: @thinspace; }
& + .mbin { margin-left: @mediumspace; }
& + .mrel { margin-left: @thickspace; }
& + .mopen { margin-left: @thinspace; }
& + .mclose {}
& + .mpunct { margin-left: @thinspace; }
& + .minner { margin-left: @thinspace; }
}
// These tighter spacings apply in scriptstyle and scriptscriptstyle.
.mord.mtight { margin-left: 0; }
.mop.mtight { margin-left: 0; }
.mbin.mtight { margin-left: 0; }
.mrel.mtight { margin-left: 0; }
.mopen.mtight { margin-left: 0; }
.mclose.mtight { margin-left: 0; }
.mpunct.mtight { margin-left: 0; }
.minner.mtight { margin-left: 0; }
.mord {
& + .mop.mtight { margin-left: @thinspace; }
}
.mop {
& + .mord.mtight { margin-left: @thinspace; }
& + .mop.mtight { margin-left: @thinspace; }
}
.mclose {
& + .mop.mtight { margin-left: @thinspace; }
}
.minner {
& + .mop.mtight { margin-left: @thinspace; }
}
.vlist-t {
display: inline-table;
table-layout: fixed;

View File

@@ -1841,7 +1841,8 @@ describe("A bin builder", function() {
it("should create mbins normally", function() {
const built = getBuilt("x + y");
expect(built[1].classes).toContain("mbin");
// we add glue elements around the '+'
expect(built[2].classes).toContain("mbin");
});
it("should create ords when at the beginning of lists", function() {
@@ -1852,17 +1853,17 @@ describe("A bin builder", function() {
});
it("should create ords after some other objects", function() {
expect(getBuilt("x + + 2")[2].classes).toContain("mord");
expect(getBuilt("( + 2")[1].classes).toContain("mord");
expect(getBuilt("= + 2")[1].classes).toContain("mord");
expect(getBuilt("\\sin + 2")[1].classes).toContain("mord");
expect(getBuilt(", + 2")[1].classes).toContain("mord");
expect(getBuilt("x + + 2")[4].classes).toContain("mord");
expect(getBuilt("( + 2")[2].classes).toContain("mord");
expect(getBuilt("= + 2")[2].classes).toContain("mord");
expect(getBuilt("\\sin + 2")[2].classes).toContain("mord");
expect(getBuilt(", + 2")[2].classes).toContain("mord");
});
it("should correctly interact with color objects", function() {
expect(getBuilt("\\blue{x}+y")[1].classes).toContain("mbin");
expect(getBuilt("\\blue{x+}+y")[1].classes).toContain("mbin");
expect(getBuilt("\\blue{x+}+y")[2].classes).toContain("mord");
expect(getBuilt("\\blue{x}+y")[2].classes).toContain("mbin");
expect(getBuilt("\\blue{x+}+y")[2].classes).toContain("mbin");
expect(getBuilt("\\blue{x+}+y")[4].classes).toContain("mord");
});
});
@@ -2291,15 +2292,15 @@ describe("A phantom builder", function() {
it("should make the children transparent", function() {
const children = getBuilt("\\phantom{x+1}");
expect(children[0].style.color).toBe("transparent");
expect(children[1].style.color).toBe("transparent");
expect(children[2].style.color).toBe("transparent");
expect(children[4].style.color).toBe("transparent");
});
it("should make all descendants transparent", function() {
const children = getBuilt("\\phantom{x+\\blue{1}}");
expect(children[0].style.color).toBe("transparent");
expect(children[1].style.color).toBe("transparent");
expect(children[2].style.color).toBe("transparent");
expect(children[4].style.color).toBe("transparent");
});
});

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: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 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: 25 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 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: 9.9 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: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -5,6 +5,8 @@
\usepackage{eufrak}
\usepackage[papersize={133pt,100pt},margin=0.5pt]{geometry}
\usepackage{color}
\usepackage{cancel}
\usepackage[normalem]{ulem}
\usepackage{etoolbox}
\setlength{\parindent}{0pt}
\pagestyle{empty}