diff --git a/docs/support_table.md b/docs/support_table.md index 928adba9..25762bf6 100644 --- a/docs/support_table.md +++ b/docs/support_table.md @@ -474,7 +474,7 @@ table td { |\impliedby|$P\impliedby Q$|`P\impliedby Q`| |\implies|$P\implies Q$|`P\implies Q`| |\in|$\in$|| -|\includegraphics|Not supported|[Issue #898](https://github.com/Khan/KaTeX/issues/898)| +|\includegraphics|$\includegraphics[height=0.8em, totalheight=0.9em, width=0.9em, alt=KA logo]{https://cdn.kastatic.org/images/apple-touch-icon-57x57-precomposed.new.png}$|`\includegraphics[height=0.8em, totalheight=0.9em, width=0.9em, alt=KA logo]{https://cdn.kastatic.org/images/apple-touch-icon-57x57-precomposed.new.png}`| |\inf|$\inf$|| |\infin|$\infin$|| |\infty|$\infty$|| diff --git a/docs/supported.md b/docs/supported.md index 2d69441c..cbbcbbf4 100644 --- a/docs/supported.md +++ b/docs/supported.md @@ -108,6 +108,10 @@ The `{array}` environment does not yet support `\cline` or `\multicolumn`. |:----------------|:-------------------| | $\href{https://katex.org/}{\KaTeX}$ | `\href{https://katex.org/}{\KaTeX}` | | $\url{https://katex.org/}$ | `\url{https://katex.org/}` | +| $\includegraphics[height=0.8em, totalheight=0.9em, width=0.9em, alt=KA logo]{https://cdn.kastatic.org/images/apple-touch-icon-57x57-precomposed.new.png}$ | `\includegraphics[height=0.8em, totalheight=0.9em, width=0.9em, alt=KA logo]{https://cdn.kastatic.org/images/apple-touch-icon-57x57-precomposed.new.png}` | + +`\includegraphics` supports`height`, `width`, `totalheight`, and `alt` in it's first argument. `height` is required. + ## Letters and Unicode diff --git a/src/Parser.js b/src/Parser.js index 9616bc44..2b42d386 100644 --- a/src/Parser.js +++ b/src/Parser.js @@ -527,6 +527,18 @@ export default class Parser { case "math": case "text": return this.parseGroup(name, optional, greediness, undefined, type); + case "raw": { + const token = this.parseStringGroup("raw", optional, true); + if (token) { + return { + type: "raw", + mode: "text", + string: token.text, + }; + } else { + throw new ParseError("Expected raw group", this.nextToken); + } + } case "original": case null: case undefined: diff --git a/src/domTree.js b/src/domTree.js index 426d5562..38ffd912 100644 --- a/src/domTree.js +++ b/src/domTree.js @@ -264,6 +264,69 @@ export class Anchor implements HtmlDomNode { } } +/** + * This node represents an image embed () element. + */ +export class Img implements VirtualNode { + src: string; + alt: string; + classes: string[]; + height: number; + depth: number; + maxFontSize: number; + style: CssStyle; + + constructor( + src: string, + alt: string, + style: CssStyle, + ) { + this.alt = alt; + this.src = src; + this.classes = ["mord"]; + this.style = style; + } + + hasClass(className: string): boolean { + return utils.contains(this.classes, className); + } + + toNode(): Node { + const node = document.createElement("img"); + node.src = this.src; + node.alt = this.alt; + node.className = "mord"; + + // Apply inline styles + for (const style in this.style) { + if (this.style.hasOwnProperty(style)) { + // $FlowFixMe + node.style[style] = this.style[style]; + } + } + + return node; + } + + toMarkup(): string { + let markup = `${this.alt} { + let width = {number: 0, unit: "em"}; + let height = {number: 0.9, unit: "em"}; // sorta character sized. + let totalheight = {number: 0, unit: "em"}; + let alt = ""; + + if (optArgs[0]) { + const attributeStr = assertNodeType(optArgs[0], "raw").string; + + // Parser.js does not parse key/value pairs. We get a string. + const attributes = attributeStr.split(","); + for (let i = 0; i < attributes.length; i++) { + const keyVal = attributes[i].split("="); + if (keyVal.length === 2) { + const str = keyVal[1].trim(); + switch (keyVal[0].trim()) { + case "alt": + alt = str; + break; + case "width": + width = sizeData(str); + break; + case "height": + height = sizeData(str); + break; + case "totalheight": + totalheight = sizeData(str); + break; + default: + throw new ParseError("Invalid key: '" + keyVal[0] + + "' in \\includegraphics."); + } + } + } + } + + const src = assertNodeType(args[0], "url").url; + + if (alt === "") { + // No alt given. Use the file name. Strip away the path. + alt = src; + alt = alt.replace(/^.*[\\/]/, ''); + alt = alt.substring(0, alt.lastIndexOf('.')); + } + + return { + type: "includegraphics", + mode: parser.mode, + alt: alt, + width: width, + height: height, + totalheight: totalheight, + src: src, + }; + }, + htmlBuilder: (group, options) => { + const height = calculateSize(group.height, options); + let depth = 0; + + if (group.totalheight.number > 0) { + depth = calculateSize(group.totalheight, options) - height; + depth = Number(depth.toFixed(2)); + } + + let width = 0; + if (group.width.number > 0) { + width = calculateSize(group.width, options); + } + + const style: CssStyle = {height: height + depth + "em"}; + if (width > 0) { + style.width = width + "em"; + } + if (depth > 0) { + style.verticalAlign = -depth + "em"; + } + + const node = new Img(group.src, group.alt, style); + node.height = height; + node.depth = depth; + + return node; + }, + mathmlBuilder: (group, options) => { + const node = new mathMLTree.MathNode("mglyph", []); + node.setAttribute("alt", group.alt); + + const height = calculateSize(group.height, options); + let depth = 0; + if (group.totalheight.number > 0) { + depth = calculateSize(group.totalheight, options) - height; + depth = depth.toFixed(2); + node.setAttribute("valign", "-" + depth + "em"); + } + node.setAttribute("height", height + depth + "em"); + + if (group.width.number > 0) { + const width = calculateSize(group.width, options); + node.setAttribute("width", width + "em"); + } + node.setAttribute("src", group.src); + return node; + }, +}); diff --git a/src/katex.less b/src/katex.less index c26e1f9c..aaebebf9 100644 --- a/src/katex.less +++ b/src/katex.less @@ -464,6 +464,14 @@ stroke-opacity: 1; } + img { + border-style: none; + min-width: 0; + min-height: 0; + max-width: none; + max-height: none; + } + // Define CSS for image whose width will match its span width. .stretchy { width: 100%; diff --git a/src/mathMLTree.js b/src/mathMLTree.js index 971d1a99..69ecc849 100644 --- a/src/mathMLTree.js +++ b/src/mathMLTree.js @@ -25,7 +25,7 @@ export type MathNodeType = "mfrac" | "mroot" | "msqrt" | "mtable" | "mtr" | "mtd" | "mlabeledtr" | "mrow" | "menclose" | - "mstyle" | "mpadded" | "mphantom"; + "mstyle" | "mpadded" | "mphantom" | "mglyph"; export interface MathDomNode extends VirtualNode { toText(): string; diff --git a/src/parseNode.js b/src/parseNode.js index b6e8897e..87b9c919 100644 --- a/src/parseNode.js +++ b/src/parseNode.js @@ -49,6 +49,12 @@ type ParseNodeTypes = { loc?: ?SourceLocation, color: string, |}, + "keyVals": {| + type: "keyVals", + mode: Mode, + loc?: ?SourceLocation, + keyVals: string, + |}, // To avoid requiring run-time type assertions, this more carefully captures // the requirements on the fields per the op.js htmlBuilder logic: // - `body` and `value` are NEVER set simultanouesly. @@ -271,6 +277,16 @@ type ParseNodeTypes = { html: AnyParseNode[], mathml: AnyParseNode[], |}, + "includegraphics": {| + type: "includegraphics", + mode: Mode, + loc?: ?SourceLocation, + alt: string, + width: Measurement, + height: Measurement, + totalheight: Measurement, + src: string, + |}, "infix": {| type: "infix", mode: Mode, diff --git a/test/katex-spec.js b/test/katex-spec.js index 773b48a6..b5804e42 100644 --- a/test/katex-spec.js +++ b/test/katex-spec.js @@ -1947,6 +1947,17 @@ describe("A MathML font tree-builder", function() { }); }); +describe("An includegraphics builder", function() { + const img = "\\includegraphics[height=0.9em, totalheight=0.9em, width=0.9em, alt=KA logo]{https://cdn.kastatic.org/images/apple-touch-icon-57x57-precomposed.new.png}"; + it("should not fail", function() { + expect(img).toBuild(); + }); + + it("should produce mords", function() { + expect(getBuilt(img)[0].classes).toContain("mord"); + }); +}); + describe("A bin builder", function() { it("should create mbins normally", function() { const built = getBuilt`x + y`; @@ -2647,6 +2658,15 @@ describe("href and url commands", function() { }); }); +describe("A raw text parser", function() { + it("should not not parse a mal-formed string", function() { + // In the next line, the first character passed to \includegraphics is a + // Unicode combining character. So this is a test that the parser will catch a bad string. + expect("\\includegraphics[\u030aheight=0.8em, totalheight=0.9em, width=0.9em]{" + "https://cdn.kastatic.org/images/apple-touch-icon-57x57-precomposed.new.png}").not.toParse(); + }); +}); + + describe("A parser that does not throw on unsupported commands", function() { // The parser breaks on unsupported commands unless it is explicitly // told not to diff --git a/test/screenshotter/images/Includegraphics-chrome.png b/test/screenshotter/images/Includegraphics-chrome.png new file mode 100644 index 00000000..038a3862 Binary files /dev/null and b/test/screenshotter/images/Includegraphics-chrome.png differ diff --git a/test/screenshotter/images/Includegraphics-firefox.png b/test/screenshotter/images/Includegraphics-firefox.png new file mode 100644 index 00000000..6fb6ba2e Binary files /dev/null and b/test/screenshotter/images/Includegraphics-firefox.png differ diff --git a/test/screenshotter/ss_data.yaml b/test/screenshotter/ss_data.yaml index 34e55889..8d9b9d5b 100644 --- a/test/screenshotter/ss_data.yaml +++ b/test/screenshotter/ss_data.yaml @@ -137,6 +137,13 @@ GroupMacros: \endExp: \egroup tex: \startExp a+b\endExp HorizontalBraces: \overbrace{\displaystyle{\oint_S{\vec E\cdot\hat n\,\mathrm d a}}}^\text{emf} = \underbrace{\frac{q_{\text{enc}}}{\varepsilon_0}}_{\text{charge}} +Includegraphics: | + \def\logo{\includegraphics[height=0.8em, totalheight=0.9em, width=0.9em, alt=KA logo]{https://cdn.kastatic.org/images/apple-touch-icon-57x57-precomposed.new.png}} + \def\logoB{\includegraphics[height=0.4em, totalheight=0.9em, width=0.9em, alt=KA logo]{https://cdn.kastatic.org/images/apple-touch-icon-57x57-precomposed.new.png}} + \begin{array}{l} + \underline{A\logo} + \sqrt{\logo} + \tfrac{A\logo}{\logo}\\[1em] + \underline{A\logoB} + \sqrt{x\logoB} + \tfrac{A\logoB}{\logoB} + \end{array} Integrands: | \begin{array}{l} \displaystyle \int + \oint + \iint + \oiint_i^n \\[0.6em]