Implemented `\href' command (#923)

* Implements `\href' command.

* Added `functions/href.js`.
* Added `domTree.anchor` and `buildCommon.makeAnchor` functions.
* Make `buildHTML.getTypeOfDomTree` exported.

* Reflects the code reviews

* Create new argType "url" to treat link string in math appropriately
* Stop using too shorten variable names
* Start using template strings

* Adopts template literal

* Elaborates on glueing

* If-clause restructuring

* Solved confusing explanation

* Allow balanced braces in url

* Adds unit-test for \href

* Adds snapshot tests
This commit is contained in:
Hiromi Ishii
2017-11-24 13:23:35 +09:00
committed by Kevin Barabash
parent 75af19c5bb
commit fd82c4fad0
10 changed files with 335 additions and 2 deletions

View File

@@ -759,6 +759,9 @@ export default class Parser {
if (innerMode === "size") {
return this.parseSizeGroup(optional);
}
if (innerMode === "url") {
return this.parseUrlGroup(optional);
}
// By the time we get here, innerMode is one of "text" or "math".
// We switch the mode of the parser, recurse, then restore the old mode.
@@ -807,6 +810,52 @@ export default class Parser {
return firstToken.range(lastToken, str);
}
/**
* Parses a group, essentially returning the string formed by the
* brace-enclosed tokens plus some position information, possibly
* with nested braces.
*
* @param {string} modeName Used to describe the mode in error messages
* @param {boolean=} optional Whether the group is optional or required
* @return {?Token}
*/
parseStringGroupWithBalancedBraces(modeName, optional) {
if (optional && this.nextToken.text !== "[") {
return null;
}
const outerMode = this.mode;
this.mode = "text";
this.expect(optional ? "[" : "{");
let str = "";
let nest = 0;
const firstToken = this.nextToken;
let lastToken = firstToken;
while (nest > 0 || this.nextToken.text !== (optional ? "]" : "}")) {
if (this.nextToken.text === "EOF") {
throw new ParseError(
"Unexpected end of input in " + modeName,
firstToken.range(this.nextToken, str));
}
lastToken = this.nextToken;
str += lastToken.text;
if (lastToken.text === "{") {
nest += 1;
} else if (lastToken.text === "}") {
if (nest <= 0) {
throw new ParseError(
"Unbalanced brace of input in " + modeName,
firstToken.range(this.nextToken, str));
} else {
nest -= 1;
}
}
this.consume();
}
this.mode = outerMode;
this.expect(optional ? "]" : "}");
return firstToken.range(lastToken, str);
}
/**
* Parses a regex-delimited group: the largest sequence of tokens
* whose concatenated strings match `regex`. Returns the string
@@ -852,6 +901,23 @@ export default class Parser {
return newArgument(new ParseNode("color", match[0], this.mode), res);
}
/**
* Parses a url string.
*/
parseUrlGroup(optional) {
const res = this.parseStringGroupWithBalancedBraces("url", optional);
if (!res) {
return null;
}
const raw = res.text;
// hyperref package allows backslashes alone in href, but doesn't generate
// valid links in such cases; we interpret this as "undefiend" behaviour,
// and keep them as-is. Some browser will replace backslashes with
// forward slashes.
const url = raw.replace(/\\([#$%&~_^{}])/g, '$1');
return newArgument(new ParseNode("url", url, this.mode), res);
}
/**
* Parses a size specification, consisting of magnitude and unit.
*/

View File

@@ -226,6 +226,18 @@ const makeSpan = function(classes, children, options) {
return span;
};
/**
* Makes an anchor with the given href, list of classes, list of children,
* and options.
*/
const makeAnchor = function(href, classes, children, options) {
const anchor = new domTree.anchor(href, classes, children, options);
sizeElementFromChildren(anchor);
return anchor;
};
/**
* Prepends the given children to the given span, updating height, depth, and
* maxFontSize.
@@ -511,6 +523,7 @@ export default {
makeSymbol: makeSymbol,
mathsym: mathsym,
makeSpan: makeSpan,
makeAnchor: makeAnchor,
makeFragment: makeFragment,
makeVList: makeVList,
makeOrd: makeOrd,

View File

@@ -158,7 +158,7 @@ export const buildExpression = function(expression, options, isRealGroup) {
};
// Return math atom class (mclass) of a domTree.
const getTypeOfDomTree = function(node) {
export const getTypeOfDomTree = function(node) {
if (node instanceof domTree.documentFragment) {
if (node.children.length) {
return getTypeOfDomTree(

View File

@@ -142,6 +142,127 @@ class span {
}
}
/**
* This node represents an anchor (<a>) element with a hyperlink, a list of classes,
* a list of children, and an inline style. It also contains information about its
* height, depth, and maxFontSize.
*/
class anchor {
constructor(href, classes, children, options) {
this.href = href || "";
this.classes = classes || [];
this.children = children || [];
this.height = 0;
this.depth = 0;
this.maxFontSize = 0;
this.style = {};
this.attributes = {};
if (options) {
if (options.style.isTight()) {
this.classes.push("mtight");
}
if (options.getColor()) {
this.style.color = options.getColor();
}
}
}
/**
* Sets an arbitrary attribute on the anchor. Warning: use this wisely. Not all
* browsers support attributes the same, and having too many custom attributes
* is probably bad.
*/
setAttribute(attribute, value) {
this.attributes[attribute] = value;
}
tryCombine(sibling) {
return false;
}
/**
* Convert the anchor into an HTML node
*/
toNode() {
const a = document.createElement("a");
// Apply the href
a.setAttribute('href', this.href);
// Apply the class
if (this.classes.length) {
a.className = createClass(this.classes);
}
// Apply inline styles
for (const style in this.style) {
if (Object.prototype.hasOwnProperty.call(this.style, style)) {
a.style[style] = this.style[style];
}
}
// Apply attributes
for (const attr in this.attributes) {
if (Object.prototype.hasOwnProperty.call(this.attributes, attr)) {
a.setAttribute(attr, this.attributes[attr]);
}
}
// Append the children, also as HTML nodes
for (let i = 0; i < this.children.length; i++) {
a.appendChild(this.children[i].toNode());
}
return a;
}
/**
* Convert the a into an HTML markup string
*/
toMarkup() {
let markup = "<a";
// Add the href
markup += `href="${markup += utils.escape(this.href)}"`;
// Add the class
if (this.classes.length) {
markup += ` class="${utils.escape(createClass(this.classes))}"`;
}
let styles = "";
// Add the styles, after hyphenation
for (const style in this.style) {
if (this.style.hasOwnProperty(style)) {
styles += utils.hyphenate(style) + ":" + this.style[style] + ";";
}
}
if (styles) {
markup += " style=\"" + utils.escape(styles) + "\"";
}
// Add the attributes
for (const attr in this.attributes) {
if (attr !== "href" &&
Object.prototype.hasOwnProperty.call(this.attributes, attr)) {
markup += ` ${attr}="${utils.escape(this.attributes[attr])}"`;
}
}
markup += ">";
// Add the markup of the children, also as markup
for (const child of this.children) {
markup += child.toMarkup();
}
markup += "</a>";
return markup;
}
}
/**
* This node represents a document fragment, which contains elements, but when
* placed into the DOM doesn't have any representation itself. Thus, it only
@@ -445,6 +566,7 @@ class lineNode {
export default {
span: span,
anchor: anchor,
documentFragment: documentFragment,
symbolNode: symbolNode,
svgNode: svgNode,

View File

@@ -579,5 +579,8 @@ defineFunction(["\\verb"], {
"\\verb ended by end of line instead of matching delimiter");
});
// Hyperlinks
import "./functions/href";
// MathChoice
import "./functions/mathchoice";

73
src/functions/href.js Normal file
View File

@@ -0,0 +1,73 @@
// @flow
import defineFunction, {ordargument} from "../defineFunction";
import buildCommon from "../buildCommon";
import mathMLTree from "../mathMLTree";
import * as html from "../buildHTML";
import * as mml from "../buildMathML";
defineFunction({
type: "href",
names: ["\\href"],
props: {
numArgs: 2,
argTypes: ["url", "original"],
},
handler: (context, args) => {
const body = args[1];
const href = args[0].value;
return {
type: "href",
href: href,
body: ordargument(body),
};
},
htmlBuilder: (group, options) => {
const elements = html.buildExpression(
group.value.body,
options,
false
);
const href = group.value.href;
/**
* Determining class for anchors.
* 1. if it has the only element, use its class;
* 2. if it has more than two elements, and the classes
* of its first and last elements coincide, then use it;
* 3. otherwise, we will inject an empty <span>s at both ends,
* with the same classes of both ends of elements, with the
* first span having the same class as the first element of body,
* and the second one the same as the last.
*/
let classes = []; // Default behaviour for Case 3.
let first; // mathtype of the first child
let last; // mathtype of the last child
// Invariants: both first and last must be non-null if classes is null.
if (elements.length === 1) { // Case 1
classes = elements[0].classes;
} else if (elements.length >= 2) {
first = html.getTypeOfDomTree(elements[0]) || 'mord';
last = html.getTypeOfDomTree(elements[elements.length - 1]) || 'mord';
if (first === last) { // Case 2 : type of both ends coincides
classes = [first];
} else { // Case 3: both ends have different types.
const anc = buildCommon.makeAnchor(href, [], elements, options);
return new buildCommon.makeFragment([
new buildCommon.makeSpan([first], [], options),
anc,
new buildCommon.makeSpan([last], [], options),
]);
}
}
return new buildCommon.makeAnchor(href, classes, elements, options);
},
mathmlBuilder: (group, options) => {
const inner = mml.buildExpression(group.value.body, options);
const math = new mathMLTree.MathNode("mrow", inner);
math.setAttribute("href", group.value.href);
return math;
},
});

View File

@@ -10,13 +10,15 @@ export type Mode = "math" | "text";
// LaTeX argument type.
// - "size": A size-like thing, such as "1em" or "5ex"
// - "color": An html color, like "#abc" or "blue"
// - "url": An url string, in which "\" will be ignored
// - if it precedes [#$%&~_^\{}]
// - "original": The same type as the environment that the
// function being parsed is in (e.g. used for the
// bodies of functions like \textcolor where the
// first argument is special and the second
// argument is parsed normally)
// - Mode: Node group parsed in given mode.
export type ArgType = "color" | "size" | "original" | Mode;
export type ArgType = "color" | "size" | "url" | "original" | Mode;
// LaTeX display style.
export type StyleStr = "text" | "display";

View File

@@ -220,6 +220,25 @@ exports[`A MathML builder should render mathchoice as if there was nothing 4`] =
`;
exports[`A MathML builder should set href attribute for href appropriately 1`] = `
<math>
<semantics>
<mrow>
<mrow href="http://example.org">
<mi>
α
</mi>
</mrow>
</mrow>
<annotation encoding="application/x-tex">
\\href{http://example.org}{\\alpha}
</annotation>
</semantics>
</math>
`;
exports[`A MathML builder should use <menclose> for colorbox 1`] = `
<math>

View File

@@ -2420,6 +2420,36 @@ describe("An aligned environment", function() {
});
});
describe("An href command", function() {
it("should parse its input", function() {
expect("\\href{http://example.com/}{example here}").toParse();
});
it("should allow letters [#$%&~_^] without escaping", function() {
const url = "http://example.org/~bar/#top?foo=$foo&bar=ba^r_boo%20baz";
const hash = getParsed(`\\href{${url}}{\\alpha}`)[0];
expect(hash.value.href).toBe(url);
});
it("should allow balanced braces in url", function() {
const url = "http://example.org/{too}";
const hash = getParsed(`\\href{${url}}{\\alpha}`)[0];
expect(hash.value.href).toBe(url);
});
it("should not allow unbalanced brace(s) in url", function() {
expect("\\href{http://example.com/{a}{bar}").toNotParse();
expect("\\href{http://example.com/}a}{bar}").toNotParse();
});
it("should allow escape for letters [#$%&~_^{}]", function() {
const url = "http://example.org/~bar/#top?foo=$}foo{&bar=bar^r_boo%20baz";
const input = url.replace(/([#$%&~_^{}])/g, '\\$1');
const ae = getParsed(`\\href{${input}}{\\alpha}`)[0];
expect(ae.value.href).toBe(url);
});
});
describe("A parser that does not throw on unsupported commands", function() {
// The parser breaks on unsupported commands unless it is explicitly
// told not to

View File

@@ -59,6 +59,11 @@ describe("A MathML builder", function() {
expect(getMathML("\\colorbox{red}{b}")).toMatchSnapshot();
});
it('should set href attribute for href appropriately', () => {
expect(getMathML("\\href{http://example.org}{\\alpha}")).toMatchSnapshot();
expect(getMathML("p \\Vdash \\beta \\href{http://example.org}{+ \\alpha} \\times \\gamma"));
});
it('should render mathchoice as if there was nothing', () => {
const cmd = "\\sum_{k = 0}^{\\infty} x^k";
expect(getMathML(`\\displaystyle\\mathchoice{${cmd}}{T}{S}{SS}`))