mirror of
https://github.com/Smaug123/KaTeX
synced 2025-10-05 19:28:39 +00:00
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:
committed by
Kevin Barabash
parent
75af19c5bb
commit
fd82c4fad0
@@ -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.
|
||||
*/
|
||||
|
@@ -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,
|
||||
|
@@ -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(
|
||||
|
122
src/domTree.js
122
src/domTree.js
@@ -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,
|
||||
|
@@ -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
73
src/functions/href.js
Normal 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;
|
||||
},
|
||||
});
|
@@ -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";
|
||||
|
@@ -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>
|
||||
|
@@ -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
|
||||
|
@@ -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}`))
|
||||
|
Reference in New Issue
Block a user