Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 8264661

Browse files
authored
Merge pull request #5244 from akissinger/katex
LaTeX rendering in element-web using KaTeX
2 parents e64b6b0 + 79baea9 commit 8264661

File tree

9 files changed

+107
-5
lines changed

9 files changed

+107
-5
lines changed

package.json

+2
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@
7676
"highlight.js": "^10.1.2",
7777
"html-entities": "^1.3.1",
7878
"is-ip": "^2.0.0",
79+
"katex": "^0.12.0",
80+
"cheerio": "^1.0.0-rc.3",
7981
"linkifyjs": "^2.1.9",
8082
"lodash": "^4.17.19",
8183
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",

src/HtmlUtils.tsx

+24-3
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,12 @@ import _linkifyString from 'linkifyjs/string';
2727
import classNames from 'classnames';
2828
import EMOJIBASE_REGEX from 'emojibase-regex';
2929
import url from 'url';
30+
import katex from 'katex';
31+
import { AllHtmlEntities } from 'html-entities';
32+
import SettingsStore from './settings/SettingsStore';
33+
import cheerio from 'cheerio';
3034

3135
import {MatrixClientPeg} from './MatrixClientPeg';
32-
import SettingsStore from './settings/SettingsStore';
3336
import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks";
3437
import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji";
3538
import ReplyThread from "./components/views/elements/ReplyThread";
@@ -240,7 +243,8 @@ const sanitizeHtmlParams: IExtendedSanitizeOptions = {
240243
allowedAttributes: {
241244
// custom ones first:
242245
font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix
243-
span: ['data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'style'], // custom to matrix
246+
span: ['data-mx-maths', 'data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'style'], // custom to matrix
247+
div: ['data-mx-maths'],
244248
a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix
245249
img: ['src', 'width', 'height', 'alt', 'title'],
246250
ol: ['start'],
@@ -414,6 +418,21 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts
414418
if (isHtmlMessage) {
415419
isDisplayedWithHtml = true;
416420
safeBody = sanitizeHtml(formattedBody, sanitizeParams);
421+
422+
if (SettingsStore.getValue("feature_latex_maths")) {
423+
const phtml = cheerio.load(safeBody,
424+
{ _useHtmlParser2: true, decodeEntities: false })
425+
phtml('div, span[data-mx-maths!=""]').replaceWith(function(i, e) {
426+
return katex.renderToString(
427+
AllHtmlEntities.decode(phtml(e).attr('data-mx-maths')),
428+
{
429+
throwOnError: false,
430+
displayMode: e.name == 'div',
431+
output: "htmlAndMathml",
432+
});
433+
});
434+
safeBody = phtml.html();
435+
}
417436
}
418437
} finally {
419438
delete sanitizeParams.textFilter;
@@ -515,7 +534,6 @@ export function checkBlockNode(node: Node) {
515534
case "H6":
516535
case "PRE":
517536
case "BLOCKQUOTE":
518-
case "DIV":
519537
case "P":
520538
case "UL":
521539
case "OL":
@@ -528,6 +546,9 @@ export function checkBlockNode(node: Node) {
528546
case "TH":
529547
case "TD":
530548
return true;
549+
case "DIV":
550+
// don't treat math nodes as block nodes for deserializing
551+
return !(node as HTMLElement).hasAttribute("data-mx-maths");
531552
default:
532553
return false;
533554
}

src/Markdown.js

+6
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,19 @@ const ALLOWED_HTML_TAGS = ['sub', 'sup', 'del', 'u'];
2323
const TEXT_NODES = ['text', 'softbreak', 'linebreak', 'paragraph', 'document'];
2424

2525
function is_allowed_html_tag(node) {
26+
if (node.literal != null &&
27+
node.literal.match('^<((div|span) data-mx-maths="[^"]*"|\/(div|span))>$') != null) {
28+
return true;
29+
}
30+
2631
// Regex won't work for tags with attrs, but we only
2732
// allow <del> anyway.
2833
const matches = /^<\/?(.*)>$/.exec(node.literal);
2934
if (matches && matches.length == 2) {
3035
const tag = matches[1];
3136
return ALLOWED_HTML_TAGS.indexOf(tag) > -1;
3237
}
38+
3339
return false;
3440
}
3541

src/editor/deserialize.ts

+18
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { walkDOMDepthFirst } from "./dom";
2121
import { checkBlockNode } from "../HtmlUtils";
2222
import { getPrimaryPermalinkEntity } from "../utils/permalinks/Permalinks";
2323
import { PartCreator } from "./parts";
24+
import SdkConfig from "../SdkConfig";
2425

2526
function parseAtRoomMentions(text: string, partCreator: PartCreator) {
2627
const ATROOM = "@room";
@@ -130,6 +131,23 @@ function parseElement(n: HTMLElement, partCreator: PartCreator, lastNode: HTMLEl
130131
}
131132
break;
132133
}
134+
case "DIV":
135+
case "SPAN": {
136+
// math nodes are translated back into delimited latex strings
137+
if (n.hasAttribute("data-mx-maths")) {
138+
const delimLeft = (n.nodeName == "SPAN") ?
139+
(SdkConfig.get()['latex_maths_delims'] || {})['inline_left'] || "$" :
140+
(SdkConfig.get()['latex_maths_delims'] || {})['display_left'] || "$$";
141+
const delimRight = (n.nodeName == "SPAN") ?
142+
(SdkConfig.get()['latex_maths_delims'] || {})['inline_right'] || "$" :
143+
(SdkConfig.get()['latex_maths_delims'] || {})['display_right'] || "$$";
144+
const tex = n.getAttribute("data-mx-maths");
145+
return partCreator.plain(delimLeft + tex + delimRight);
146+
} else if (!checkDescendInto(n)) {
147+
return partCreator.plain(n.textContent);
148+
}
149+
break;
150+
}
133151
case "OL":
134152
state.listIndex.push((<HTMLOListElement>n).start || 1);
135153
/* falls through */

src/editor/serialize.ts

+39-2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ limitations under the License.
1818
import Markdown from '../Markdown';
1919
import {makeGenericPermalink} from "../utils/permalinks/Permalinks";
2020
import EditorModel from "./model";
21+
import { AllHtmlEntities } from 'html-entities';
22+
import SettingsStore from '../settings/SettingsStore';
23+
import SdkConfig from '../SdkConfig';
24+
import cheerio from 'cheerio';
2125

2226
export function mdSerialize(model: EditorModel) {
2327
return model.parts.reduce((html, part) => {
@@ -38,10 +42,43 @@ export function mdSerialize(model: EditorModel) {
3842
}
3943

4044
export function htmlSerializeIfNeeded(model: EditorModel, {forceHTML = false} = {}) {
41-
const md = mdSerialize(model);
45+
let md = mdSerialize(model);
46+
47+
if (SettingsStore.getValue("feature_latex_maths")) {
48+
const displayPattern = (SdkConfig.get()['latex_maths_delims'] || {})['display_pattern'] ||
49+
"\\$\\$(([^$]|\\\\\\$)*)\\$\\$";
50+
const inlinePattern = (SdkConfig.get()['latex_maths_delims'] || {})['inline_pattern'] ||
51+
"\\$(([^$]|\\\\\\$)*)\\$";
52+
53+
md = md.replace(RegExp(displayPattern, "gm"), function(m, p1) {
54+
const p1e = AllHtmlEntities.encode(p1);
55+
return `<div data-mx-maths="${p1e}">\n\n</div>\n\n`;
56+
});
57+
58+
md = md.replace(RegExp(inlinePattern, "gm"), function(m, p1) {
59+
const p1e = AllHtmlEntities.encode(p1);
60+
return `<span data-mx-maths="${p1e}"></span>`;
61+
});
62+
63+
// make sure div tags always start on a new line, otherwise it will confuse
64+
// the markdown parser
65+
md = md.replace(/(.)<div/g, function(m, p1) { return `${p1}\n<div`; });
66+
}
67+
4268
const parser = new Markdown(md);
4369
if (!parser.isPlainText() || forceHTML) {
44-
return parser.toHTML();
70+
// feed Markdown output to HTML parser
71+
const phtml = cheerio.load(parser.toHTML(),
72+
{ _useHtmlParser2: true, decodeEntities: false })
73+
74+
// add fallback output for latex math, which should not be interpreted as markdown
75+
phtml('div, span').each(function(i, e) {
76+
const tex = phtml(e).attr('data-mx-maths')
77+
if (tex) {
78+
phtml(e).html(`<code>${tex}</code>`)
79+
}
80+
});
81+
return phtml.html();
4582
}
4683
// ensure removal of escape backslashes in non-Markdown messages
4784
if (md.indexOf("\\") > -1) {

src/i18n/strings/en_EN.json

+1
Original file line numberDiff line numberDiff line change
@@ -755,6 +755,7 @@
755755
"%(senderName)s: %(reaction)s": "%(senderName)s: %(reaction)s",
756756
"%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s",
757757
"Change notification settings": "Change notification settings",
758+
"Render LaTeX maths in messages": "Render LaTeX maths in messages",
758759
"Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.": "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.",
759760
"New spinner design": "New spinner design",
760761
"Message Pinning": "Message Pinning",

src/settings/Settings.ts

+6
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,12 @@ export interface ISetting {
117117
}
118118

119119
export const SETTINGS: {[setting: string]: ISetting} = {
120+
"feature_latex_maths": {
121+
isFeature: true,
122+
displayName: _td("Render LaTeX maths in messages"),
123+
supportedLevels: LEVELS_FEATURE,
124+
default: false,
125+
},
120126
"feature_communities_v2_prototypes": {
121127
isFeature: true,
122128
displayName: _td(

test/components/views/messages/TextualBody-test.js

+4
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ describe("<TextualBody />", () => {
3636
MatrixClientPeg.matrixClient = {
3737
getRoom: () => mkStubRoom("room_id"),
3838
getAccountData: () => undefined,
39+
isGuest: () => false,
3940
};
4041

4142
const ev = mkEvent({
@@ -59,6 +60,7 @@ describe("<TextualBody />", () => {
5960
MatrixClientPeg.matrixClient = {
6061
getRoom: () => mkStubRoom("room_id"),
6162
getAccountData: () => undefined,
63+
isGuest: () => false,
6264
};
6365

6466
const ev = mkEvent({
@@ -83,6 +85,7 @@ describe("<TextualBody />", () => {
8385
MatrixClientPeg.matrixClient = {
8486
getRoom: () => mkStubRoom("room_id"),
8587
getAccountData: () => undefined,
88+
isGuest: () => false,
8689
};
8790
});
8891

@@ -135,6 +138,7 @@ describe("<TextualBody />", () => {
135138
getHomeserverUrl: () => "https://my_server/",
136139
on: () => undefined,
137140
removeListener: () => undefined,
141+
isGuest: () => false,
138142
};
139143
});
140144

yarn.lock

+7
Original file line numberDiff line numberDiff line change
@@ -6206,6 +6206,13 @@ jsx-ast-utils@^2.4.1:
62066206
array-includes "^3.1.1"
62076207
object.assign "^4.1.0"
62086208

6209+
katex@^0.12.0:
6210+
version "0.12.0"
6211+
resolved "https://registry.yarnpkg.com/katex/-/katex-0.12.0.tgz#2fb1c665dbd2b043edcf8a1f5c555f46beaa0cb9"
6212+
integrity sha512-y+8btoc/CK70XqcHqjxiGWBOeIL8upbS0peTPXTvgrh21n1RiWWcIpSWM+4uXq+IAgNh9YYQWdc7LVDPDAEEAg==
6213+
dependencies:
6214+
commander "^2.19.0"
6215+
62096216
kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0:
62106217
version "3.2.2"
62116218
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"

0 commit comments

Comments
 (0)