From e5333ad04d77b0d6affa277c98b1ac228e33d15d Mon Sep 17 00:00:00 2001 From: ylemkimon Date: Mon, 2 Dec 2019 07:49:28 +0900 Subject: [PATCH] Add HTML extension (#2082) * Add html extension * Fix flow error * Update documentation * Add tests * Call buildA11yStrings for "html" node * Throw ParseError when parsing \htmlData fails * Improve documentation * Add a screenshotter test * Add dummy screenshot * Update screenshots --- .../render-a11y-string/render-a11y-string.js | 5 + docs/issues.md | 2 + docs/options.md | 6 + docs/support_table.md | 4 + docs/supported.md | 6 + src/Settings.js | 16 ++ src/buildHTML.js | 7 +- src/functions.js | 1 + src/functions/html.js | 102 ++++++++ src/parseNode.js | 7 + test/__snapshots__/katex-spec.js.snap | 220 ++++++++++++++++++ test/katex-spec.js | 30 +++ test/screenshotter/images/HTML-chrome.png | Bin 0 -> 8362 bytes test/screenshotter/images/HTML-firefox.png | Bin 0 -> 8323 bytes test/screenshotter/ss_data.yaml | 3 + test/screenshotter/test.html | 1 + website/lib/remarkable-katex.js | 2 +- 17 files changed, 408 insertions(+), 4 deletions(-) create mode 100644 src/functions/html.js create mode 100644 test/screenshotter/images/HTML-chrome.png create mode 100644 test/screenshotter/images/HTML-firefox.png diff --git a/contrib/render-a11y-string/render-a11y-string.js b/contrib/render-a11y-string/render-a11y-string.js index 3cd7092e..fbc020ce 100644 --- a/contrib/render-a11y-string/render-a11y-string.js +++ b/contrib/render-a11y-string/render-a11y-string.js @@ -655,6 +655,11 @@ const handleObject = ( break; } + case "html": { + buildA11yStrings(tree.body, a11yStrings, atomType); + break; + } + default: (tree.type: empty); throw new Error("KaTeX a11y un-recognized type: " + tree.type); diff --git a/docs/issues.md b/docs/issues.md index 2bc23731..b3ca0ee0 100644 --- a/docs/issues.md +++ b/docs/issues.md @@ -22,6 +22,8 @@ title: Common Issues - MathJax defines `\color` to be like `\textcolor` by default; set KaTeX's `colorIsTextColor` option to `true` for this behavior. KaTeX's default behavior matches MathJax with its `color.js` extension enabled. +- Equivalents of MathJax `\class`, `\cssId`, and `\style` are `\htmlClass`, + `\htmlId`, and `\htmlStyle`, respectively, to avoid ambiguity. ## Troubleshooting diff --git a/docs/options.md b/docs/options.md index 4abe4b8d..3677e12a 100644 --- a/docs/options.md +++ b/docs/options.md @@ -28,6 +28,8 @@ You can provide an object of options as the last argument to [`katex.render` and - `"commentAtEnd"`: Use of `%` comment without a terminating newline. LaTeX would thereby comment out the end of math mode (e.g. `$`), causing an error. + - `"htmlExtension"`: Use of HTML extension (`\html`-prefixed) commands, + which are provieded for HTML manipulation. A second category of `errorCode`s never throw errors, but their strictness affects the behavior of KaTeX: @@ -41,6 +43,10 @@ You can provide an object of options as the last argument to [`katex.render` and - `{command: "\\url", url, protocol}` - `{command: "\\href", url, protocol}` - `{command: "\\includegraphics", url, protocol}` + - `{command: "\\htmlClass", class}` + - `{command: "\\htmlId", id}` + - `{command: "\\htmlStyle", style}` + - `{command: "\\htmlData", attributes}` Here are some sample trust settings: diff --git a/docs/support_table.md b/docs/support_table.md index 8db08e45..fabe3835 100644 --- a/docs/support_table.md +++ b/docs/support_table.md @@ -458,6 +458,10 @@ use `\ce` instead| |\hskip|$w\hskip1em i\hskip2em d$|`w\hskip1em i\hskip2em d`| |\hslash|$\hslash$|| |\hspace|$s\hspace7ex k$|`s\hspace7ex k`| +|\htmlClass|$\htmlClass{foo}{x}$|`\htmlClass{foo}{x}` Must enable `trust` and disable `strict` [option](options.md)| +|\htmlData|$\htmlData{foo=a, bar=b}{x}$|`\htmlData{foo=a, bar=b}{x}` Must enable `trust` and disable `strict` [option](options.md)| +|\htmlId|$\htmlId{bar}{x}$|`\htmlId{bar}{x}` Must enable `trust` and disable `strict` [option](options.md)| +|\htmlStyle|$\htmlStyle{color: red;}{x}$|`\htmlStyle{color: red;}{x}` Must enable `trust` and disable `strict` [option](options.md)| |\huge|$\huge huge$|`\huge huge`| |\Huge|$\Huge Huge$|`\Huge Huge`| diff --git a/docs/supported.md b/docs/supported.md index 10870350..b18686f7 100644 --- a/docs/supported.md +++ b/docs/supported.md @@ -113,9 +113,15 @@ or for just some URLs via the `trust` [option](options.md). | $\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://katex.org/img/khan-academy.png}$ | `\includegraphics[height=0.8em, totalheight=0.9em, width=0.9em, alt=KA logo]{https://katex.org/img/khan-academy.png}` | +| $\htmlId{bar}{x}$ | `\htmlId{bar}{x}` | +| $\htmlClass{foo}{x}$ | `\htmlClass{foo}{x}` | +| $\htmlStyle{color: red;}{x}$ | `\htmlStyle{color: red;}{x}` | +| $\htmlData{foo=a, bar=b}{x}$ | `\htmlData{foo=a, bar=b}{x}` | `\includegraphics` supports `height`, `width`, `totalheight`, and `alt` in its first argument. `height` is required. +HTML extension (`\html`-prefixed) commands are non-standard, so loosening `strict` option for `htmlExtension` is required. + ## Letters and Unicode diff --git a/src/Settings.js b/src/Settings.js index e1707d71..4e0a2ec6 100644 --- a/src/Settings.js +++ b/src/Settings.js @@ -32,6 +32,22 @@ export type TrustContextTypes = { url: string, protocol?: string, |}, + "\\htmlClass": {| + command: "\\htmlClass", + class: string, + |}, + "\\htmlId": {| + command: "\\htmlId", + id: string, + |}, + "\\htmlStyle": {| + command: "\\htmlStyle", + style: string, + |}, + "\\htmlData": {| + command: "\\htmlData", + attributes: {[string]: string}, + |}, }; export type AnyTrustContext = $Values; export type TrustFunction = (context: AnyTrustContext) => ?boolean; diff --git a/src/buildHTML.js b/src/buildHTML.js index 541b46f7..56186e19 100644 --- a/src/buildHTML.js +++ b/src/buildHTML.js @@ -9,7 +9,7 @@ import ParseError from "./ParseError"; import Style from "./Style"; import buildCommon from "./buildCommon"; -import {Anchor} from "./domTree"; +import {Span, Anchor} from "./domTree"; import utils from "./utils"; import {spacings, tightSpacings} from "./spacingData"; import {_htmlGroupBuilders as groupBuilders} from "./defineFunction"; @@ -185,8 +185,9 @@ const traverseNonSpaceNodes = function( // Check if given node is a partial group, i.e., does not affect spacing around. const checkPartialGroup = function( node: HtmlDomNode, -): ?(DocumentFragment | Anchor) { - if (node instanceof DocumentFragment || node instanceof Anchor) { +): ?(DocumentFragment | Anchor | DomSpan) { + if (node instanceof DocumentFragment || node instanceof Anchor + || (node instanceof Span && node.hasClass("enclosing"))) { return node; } return null; diff --git a/src/functions.js b/src/functions.js index 5bfae0eb..acb273e9 100644 --- a/src/functions.js +++ b/src/functions.js @@ -20,6 +20,7 @@ import "./functions/font"; import "./functions/genfrac"; import "./functions/horizBrace"; import "./functions/href"; +import "./functions/html"; import "./functions/htmlmathml"; import "./functions/includegraphics"; import "./functions/kern"; diff --git a/src/functions/html.js b/src/functions/html.js new file mode 100644 index 00000000..c8d316aa --- /dev/null +++ b/src/functions/html.js @@ -0,0 +1,102 @@ +// @flow +import defineFunction, {ordargument} from "../defineFunction"; +import buildCommon from "../buildCommon"; +import {assertNodeType} from "../parseNode"; +import ParseError from "../ParseError"; + +import * as html from "../buildHTML"; +import * as mml from "../buildMathML"; + +defineFunction({ + type: "html", + names: ["\\htmlClass", "\\htmlId", "\\htmlStyle", "\\htmlData"], + props: { + numArgs: 2, + argTypes: ["raw", "original"], + allowedInText: true, + }, + handler: ({parser, funcName, token}, args) => { + const value = assertNodeType(args[0], "raw").string; + const body = args[1]; + + if (parser.settings.strict) { + parser.settings.reportNonstrict("htmlExtension", + "HTML extension is disabled on strict mode"); + } + + let trustContext; + const attributes = {}; + + switch (funcName) { + case "\\htmlClass": + attributes.class = value; + trustContext = { + command: "\\htmlClass", + class: value, + }; + break; + case "\\htmlId": + attributes.id = value; + trustContext = { + command: "\\htmlId", + id: value, + }; + break; + case "\\htmlStyle": + attributes.style = value; + trustContext = { + command: "\\htmlStyle", + style: value, + }; + break; + case "\\htmlData": { + const data = value.split(","); + for (let i = 0; i < data.length; i++) { + const keyVal = data[i].split("="); + if (keyVal.length !== 2) { + throw new ParseError( + "Error parsing key-value for \\htmlData"); + } + attributes["data-" + keyVal[0].trim()] = keyVal[1].trim(); + } + + trustContext = { + command: "\\htmlData", + attributes, + }; + break; + } + default: + throw new Error("Unrecognized html command"); + } + + if (!parser.settings.isTrusted(trustContext)) { + return parser.formatUnsupportedCmd(funcName); + } + return { + type: "html", + mode: parser.mode, + attributes, + body: ordargument(body), + }; + }, + htmlBuilder: (group, options) => { + const elements = html.buildExpression(group.body, options, false); + + const classes = ["enclosing"]; + if (group.attributes.class) { + classes.push(...group.attributes.class.trim().split(/\s+/)); + } + + const span = buildCommon.makeSpan(classes, elements, options); + for (const attr in group.attributes) { + if (attr !== "class" && group.attributes.hasOwnProperty(attr)) { + span.setAttribute(attr, group.attributes[attr]); + } + } + return span; + }, + mathmlBuilder: (group, options) => { + return mml.buildExpressionRow(group.body, options); + }, +}); diff --git a/src/parseNode.js b/src/parseNode.js index b83d3dde..74852dd1 100644 --- a/src/parseNode.js +++ b/src/parseNode.js @@ -270,6 +270,13 @@ type ParseNodeTypes = { href: string, body: AnyParseNode[], |}, + "html": {| + type: "html", + mode: Mode, + loc?: ?SourceLocation, + attributes: {[string]: string}, + body: AnyParseNode[], + |}, "htmlmathml": {| type: "htmlmathml", mode: Mode, diff --git a/test/__snapshots__/katex-spec.js.snap b/test/__snapshots__/katex-spec.js.snap index e3f30ccf..d8b2cd71 100755 --- a/test/__snapshots__/katex-spec.js.snap +++ b/test/__snapshots__/katex-spec.js.snap @@ -624,6 +624,226 @@ exports[`A parser that does not throw on unsupported commands should properly es `; +exports[`An HTML extension builder should not affect spacing 1`] = ` +[ + { + "attributes": { + "id": "a" + }, + "children": [ + { + "classes": [ + "mord", + "mathdefault" + ], + "depth": 0, + "height": 0.43056, + "italic": 0, + "maxFontSize": 1, + "skew": 0.02778, + "style": { + }, + "text": "x", + "width": 0.57153 + }, + { + "attributes": { + }, + "children": [ + ], + "classes": [ + "mspace" + ], + "depth": 0, + "height": 0, + "maxFontSize": 0, + "style": { + "marginRight": "0.2222222222222222em" + } + }, + { + "classes": [ + "mbin" + ], + "depth": 0.08333, + "height": 0.58333, + "italic": 0, + "maxFontSize": 1, + "skew": 0, + "style": { + }, + "text": "+", + "width": 0.77778 + }, + { + "attributes": { + }, + "children": [ + ], + "classes": [ + "mspace" + ], + "depth": 0, + "height": 0, + "maxFontSize": 0, + "style": { + "marginRight": "0.2222222222222222em" + } + } + ], + "classes": [ + "enclosing" + ], + "depth": 0.08333, + "height": 0.58333, + "maxFontSize": 1, + "style": { + } + }, + { + "classes": [ + "mord", + "mathdefault" + ], + "depth": 0.19444, + "height": 0.43056, + "italic": 0.03588, + "maxFontSize": 1, + "skew": 0.05556, + "style": { + }, + "text": "y", + "width": 0.49028 + } +] +`; + +exports[`An HTML extension builder should render with trust and strict setting 1`] = ` +[ + { + "attributes": { + "id": "bar" + }, + "children": [ + { + "classes": [ + "mord", + "mathdefault" + ], + "depth": 0, + "height": 0.43056, + "italic": 0, + "maxFontSize": 1, + "skew": 0.02778, + "style": { + }, + "text": "x", + "width": 0.57153 + } + ], + "classes": [ + "enclosing" + ], + "depth": 0, + "height": 0.43056, + "maxFontSize": 1, + "style": { + } + }, + { + "attributes": { + }, + "children": [ + { + "classes": [ + "mord", + "mathdefault" + ], + "depth": 0, + "height": 0.43056, + "italic": 0, + "maxFontSize": 1, + "skew": 0.02778, + "style": { + }, + "text": "x", + "width": 0.57153 + } + ], + "classes": [ + "enclosing", + "foo" + ], + "depth": 0, + "height": 0.43056, + "maxFontSize": 1, + "style": { + } + }, + { + "attributes": { + "style": "color: red;" + }, + "children": [ + { + "classes": [ + "mord", + "mathdefault" + ], + "depth": 0, + "height": 0.43056, + "italic": 0, + "maxFontSize": 1, + "skew": 0.02778, + "style": { + }, + "text": "x", + "width": 0.57153 + } + ], + "classes": [ + "enclosing" + ], + "depth": 0, + "height": 0.43056, + "maxFontSize": 1, + "style": { + } + }, + { + "attributes": { + "data-bar": "b", + "data-foo": "a" + }, + "children": [ + { + "classes": [ + "mord", + "mathdefault" + ], + "depth": 0, + "height": 0.43056, + "italic": 0, + "maxFontSize": 1, + "skew": 0.02778, + "style": { + }, + "text": "x", + "width": 0.57153 + } + ], + "classes": [ + "enclosing" + ], + "depth": 0, + "height": 0.43056, + "maxFontSize": 1, + "style": { + } + } +] +`; + exports[`An implicit group parser within optional groups should work style commands \\sqrt[\\textstyle 3]{x} 1`] = ` [ { diff --git a/test/katex-spec.js b/test/katex-spec.js index 7f474eb4..da1b6430 100644 --- a/test/katex-spec.js +++ b/test/katex-spec.js @@ -2011,6 +2011,36 @@ describe("An includegraphics builder", function() { }); }); +describe("An HTML extension builder", function() { + const html = + "\\htmlId{bar}{x}\\htmlClass{foo}{x}\\htmlStyle{color: red;}{x}\\htmlData{foo=a, bar=b}{x}"; + const trustNonStrictSettings = new Settings({trust: true, strict: false}); + it("should not fail", function() { + expect(html).toBuild(trustNonStrictSettings); + }); + + it("should set HTML attributes", function() { + const built = getBuilt(html, trustNonStrictSettings); + expect(built[0].attributes.id).toMatch("bar"); + expect(built[1].classes).toContain("foo"); + expect(built[2].attributes.style).toMatch("color: red"); + expect(built[3].attributes).toEqual({ + "data-bar": "b", + "data-foo": "a", + }); + }); + + it("should not affect spacing", function() { + const built = getBuilt("\\htmlId{a}{x+}y", trustNonStrictSettings); + expect(built).toMatchSnapshot(); + }); + + it("should render with trust and strict setting", function() { + const built = getBuilt(html, trustNonStrictSettings); + expect(built).toMatchSnapshot(); + }); +}); + describe("A bin builder", function() { it("should create mbins normally", function() { const built = getBuilt`x + y`; diff --git a/test/screenshotter/images/HTML-chrome.png b/test/screenshotter/images/HTML-chrome.png new file mode 100644 index 0000000000000000000000000000000000000000..52afcd6e7eb7f75021e56fd75fa7651d73fe35ed GIT binary patch literal 8362 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 C9`Kw1 literal 0 HcmV?d00001 diff --git a/test/screenshotter/images/HTML-firefox.png b/test/screenshotter/images/HTML-firefox.png new file mode 100644 index 0000000000000000000000000000000000000000..e1646ea31a1ab70f0abd82ee3785658f4b6f6ef8 GIT binary patch literal 8323 zcmeAS@N?(olHy`uVBq!ia0y~yU}0cjU}oT8Vqjn}z0nlKz@Xse>EaktG3V{w$_aB) z8;=*NsWVQRprFI~kVBENbGE{vY3nwIEji+JAan@}Yu8f|o&cF94UOsCp&V;66cU`A zv=}`#Lq0I*axPl4vengr^LUcQCIxq;4J`#nTgt8ZM323j=aarHeb?;!&x1d^�XL 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