diff --git a/.eslintrc.json b/.eslintrc.json
index 24e43ec51a..563180173d 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -114,7 +114,8 @@
"user_events": false,
"Plotly": false,
"emoji_codes": false,
- "drafts": false
+ "drafts": false,
+ "katex": false
},
"rules": {
"no-restricted-syntax": 0,
diff --git a/frontend_tests/node_tests/echo.js b/frontend_tests/node_tests/echo.js
index d7cf64dc52..e105189eff 100644
--- a/frontend_tests/node_tests/echo.js
+++ b/frontend_tests/node_tests/echo.js
@@ -37,6 +37,7 @@ add_dependencies({
hash_util: 'js/hash_util',
hashchange: 'js/hashchange',
fenced_code: 'js/fenced_code.js',
+ katex: 'node_modules/katex/dist/katex.min.js',
});
var doc = "";
diff --git a/package.json b/package.json
index f7cc3ff0b4..2e462c8870 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"i18next-localstorage-cache": "0.3.0",
"jquery": "1.12.1",
"jquery-validation": "1.16.0",
+ "katex": "0.7.1",
"plotly.js": "1.19.2",
"string.prototype.codepointat": "0.2.0",
"underscore": "1.8.3",
diff --git a/static/images/help/display-mode-tex-screenshot.png b/static/images/help/display-mode-tex-screenshot.png
new file mode 100644
index 0000000000..eec72f23f7
Binary files /dev/null and b/static/images/help/display-mode-tex-screenshot.png differ
diff --git a/static/images/help/inline-tex-screenshot.png b/static/images/help/inline-tex-screenshot.png
new file mode 100644
index 0000000000..efc4a72175
Binary files /dev/null and b/static/images/help/inline-tex-screenshot.png differ
diff --git a/static/js/echo.js b/static/js/echo.js
index 7c321a7390..1243a5a5f0 100644
--- a/static/js/echo.js
+++ b/static/js/echo.js
@@ -373,6 +373,14 @@ function handleRealmFilter(pattern, matches) {
return url;
}
+function handleTex(tex, fullmatch) {
+ try {
+ return katex.renderToString(tex);
+ } catch (ex) {
+ return '' + escape(fullmatch) + '';
+ }
+}
+
function python_to_js_filter(pattern, url) {
// Converts a python named-group regex to a javascript-compatible numbered
// group regex... with a regex!
@@ -514,6 +522,7 @@ $(function () {
unicodeEmojiHandler: handleUnicodeEmoji,
streamHandler: handleStream,
realmFilterHandler: handleRealmFilter,
+ texHandler: handleTex,
renderer: r,
preprocessors: [preprocess_code_blocks],
});
diff --git a/static/js/fenced_code.js b/static/js/fenced_code.js
index 2c045f8c01..3daac4929c 100644
--- a/static/js/fenced_code.js
+++ b/static/js/fenced_code.js
@@ -52,6 +52,16 @@ function wrap_quote(text) {
return quoted_paragraphs.join('\n\n');
}
+function wrap_tex(tex) {
+ try {
+ return katex.renderToString(tex, {
+ displayMode: true,
+ });
+ } catch (ex) {
+ return '' + escape_func(tex) + '';
+ }
+}
+
exports.set_stash_func = function (stash_handler) {
stash_func = stash_handler;
};
@@ -90,6 +100,28 @@ exports.process_fenced_code = function (content) {
},
};
}
+
+ if (lang === 'math' || lang === 'tex' || lang === 'latex') {
+ return {
+ handle_line: function (line) {
+ if (line === fence) {
+ this.done();
+ } else {
+ consume_line(lines, line);
+ }
+ },
+
+ done: function () {
+ var text = wrap_tex(lines.join('\n'));
+ var placeholder = stash_func(text, true);
+ output_lines.push('');
+ output_lines.push(placeholder);
+ output_lines.push('');
+ handler_stack.pop();
+ },
+ };
+ }
+
return {
handle_line: function (line) {
if (line === fence) {
diff --git a/static/styles/zulip.css b/static/styles/zulip.css
index b16e74f4f2..1372e636c3 100644
--- a/static/styles/zulip.css
+++ b/static/styles/zulip.css
@@ -1931,6 +1931,15 @@ div.floating_recipient {
font-weight: bold;
}
+.katex-html {
+ line-height: initial;
+ white-space: initial;
+}
+
+.tex-error {
+ color: red;
+}
+
.popover {
width: auto;
}
diff --git a/static/third/katex/cli.js b/static/third/katex/cli.js
new file mode 100644
index 0000000000..b99f825626
--- /dev/null
+++ b/static/third/katex/cli.js
@@ -0,0 +1,70 @@
+#!/usr/bin/env node
+
+/*
+The MIT License (MIT)
+
+Copyright (c) 2015 Khan Academy
+
+This software also uses portions of the underscore.js project, which is
+MIT licensed with the following copyright:
+
+Copyright (c) 2009-2015 Jeremy Ashkenas, DocumentCloud and Investigative
+Reporters & Editors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+*/
+
+// Simple CLI for KaTeX.
+// Reads TeX from stdin, outputs HTML to stdout.
+
+let katex;
+try {
+ // Attempt to import KaTeX from the production bundle
+ katex = require("../../serve/min/katex.js");
+} catch (ex) {
+ // Import KaTeX from node_modules (development environment) otherwise
+ katex = require("../../node_modules/katex/katex.js");
+}
+
+let input = "";
+
+// Skip the first two args, which are just "node" and "cli.js"
+const args = process.argv.slice(2);
+
+if (args.indexOf("--help") !== -1) {
+ console.log(process.argv[0] + " " + process.argv[1] +
+ " [ --help ]" +
+ " [ --display-mode ]");
+
+ console.log("\n" +
+ "Options:");
+ console.log(" --help Display this help message");
+ console.log(" --display-mode Render in display mode (not inline mode)");
+ process.exit();
+}
+
+process.stdin.on("data", function(chunk) {
+ input += chunk.toString();
+});
+
+process.stdin.on("end", function() {
+ var options = { displayMode: args.indexOf("--display-mode") !== -1 };
+ var output = katex.renderToString(input, options);
+ console.log(output);
+});
diff --git a/static/third/marked/lib/marked.js b/static/third/marked/lib/marked.js
index bd7acf3ba9..2189ecb612 100644
--- a/static/third/marked/lib/marked.js
+++ b/static/third/marked/lib/marked.js
@@ -479,9 +479,10 @@ var inline = {
usermention: noop,
stream: noop,
avatar: noop,
+ tex: noop,
gravatar: noop,
realm_filters: [],
- text: /^[\s\S]+?(?=[\\!_stream_subscribe_button(streamname)
1⊕0=1
", + "bugdown_matches_marked": false + }, + { + "name": "tex_complex", + "input": "$$\\Phi_E = \\oint E \\cdot dA$$", + "expected_output": "ΦE=∮E⋅dA
", + "bugdown_matches_marked": false + }, + { + "name": "tex_escaped", + "input": "`$$a$$`", + "expected_output": "$$a$$
a2=b2+c2
", + "bugdown_matches_marked": false + }, + { + "name": "tex_fenced_tex", + "input": "```tex\n\n\\pi \\textbf{ is not } 3.14\n```", + "expected_output": "π is not 3.14
", + "bugdown_matches_marked": false + }, + { + "name": "tex_fenced_latex", + "input": "```latex\n\n\\frac{1}{\\sqrt{2}}\n\\begin{bmatrix}\n1 & 1 \\\\\n1 & -1\n\\end{bmatrix}\n```", + "expected_output": "√21[111−1]
", + "bugdown_matches_marked": false + }, + { + "name": "tex_money", + "input": "Tickets are $5 to $20 for youth, $10-$30 for adults, so we are hoping to bring in $500 from the event ($$x \\approx 500\\$$$)", + "expected_output": "Tickets are $5 to $20 for youth, $10-$30 for adults, so we are hoping to bring in $500 from the event (x≈500$)
", + "bugdown_matches_marked": false + }, + { + "name": "tex_dollar_smiley", + "input": "$$_$$ is a fun money-related smiley!", + "expected_output": "$$_$$ is a fun money-related smiley!
", + "bugdown_matches_marked": true + }, + { + "name": "tex_multiple_dollars", + "input": "We are going to make some $$$ or maybe even $$$$!", + "expected_output": "We are going to make some $$$ or maybe even $$$$!
", + "bugdown_matches_marked": true + }, + { + "name": "tex_non_matching_dollar_count", + "input": "$foo is$$", + "expected_output": "$foo is$$
", + "bugdown_matches_marked": true + }, + { + "name": "tex_safe_script_tag", + "input": "$$$$\n\n~~~math\n\n~~~", + "expected_output": "<scripttype=′text/javascript′>alert(′xss′);</script>
\n<scripttype=′text/javascript′>alert(′xss′);</script>
", + "bugdown_matches_marked": false + }, + { + "name": "tex_error_safe_script_tag", + "input": "$$\\$$\n\n~~~math\n\\\n~~~", + "expected_output": "$$\\<script type='text/javascript'>alert('xss');</script>$$
\n\\<script type='text/javascript'>alert('xss');</script>
", + "bugdown_matches_marked": false } ], "linkify_tests": [ diff --git a/zerver/lib/bugdown/__init__.py b/zerver/lib/bugdown/__init__.py index a6cacf534a..0c89810360 100644 --- a/zerver/lib/bugdown/__init__.py +++ b/zerver/lib/bugdown/__init__.py @@ -1,4 +1,5 @@ from __future__ import absolute_import +import subprocess # Zulip's main markdown implementation. See docs/markdown.md for # detailed documentation on our markdown syntax. from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Text, Tuple, TypeVar, Union @@ -40,7 +41,8 @@ from zerver.lib.url_preview import preview as link_preview from zerver.models import Message, Realm, UserProfile, get_user_profile_by_email import zerver.lib.alert_words as alert_words import zerver.lib.mention as mention -from zerver.lib.str_utils import force_text, force_str +from zerver.lib.str_utils import force_str, force_text +from zerver.lib.tex import render_tex import six from six.moves import range, html_parser from typing import Text @@ -762,6 +764,18 @@ class ModalLink(markdown.inlinepatterns.Pattern): return a_tag +class Tex(markdown.inlinepatterns.Pattern): + def handleMatch(self, match): + # type: (Match[Text]) -> Element + rendered = render_tex(match.group('body'), is_inline=True) + if rendered is not None: + return etree.fromstring(rendered.encode('utf-8')) + else: # Something went wrong while rendering + span = markdown.util.etree.Element('span') + span.set('class', 'tex-error') + span.text = '$$' + match.group('body') + '$$' + return span + upload_title_re = re.compile(u"^(https?://[^/]*)?(/user_uploads/\\d+)(/[^/]*)?/[^/]*/(?P