hicli/html,web/timeline: add support for LaTeX rendering

This commit is contained in:
Tulir Asokan 2024-11-02 01:45:36 +02:00
parent 44dee015d4
commit 214d4fde53
10 changed files with 166 additions and 19 deletions

4
go.mod
View file

@ -16,7 +16,7 @@ require (
github.com/tidwall/gjson v1.18.0 github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5 github.com/tidwall/sjson v1.2.5
github.com/yuin/goldmark v1.7.7 github.com/yuin/goldmark v1.7.7
go.mau.fi/util v0.8.2-0.20241027163518-38d54fc87ee3 go.mau.fi/util v0.8.2-0.20241030110711-b3e597e16b74
go.mau.fi/zeroconfig v0.1.3 go.mau.fi/zeroconfig v0.1.3
golang.org/x/crypto v0.28.0 golang.org/x/crypto v0.28.0
golang.org/x/image v0.21.0 golang.org/x/image v0.21.0
@ -24,7 +24,7 @@ require (
golang.org/x/text v0.19.0 golang.org/x/text v0.19.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mauflag v1.0.0 maunium.net/go/mauflag v1.0.0
maunium.net/go/mautrix v0.21.2-0.20241026115159-a59d4d78677f maunium.net/go/mautrix v0.21.2-0.20241101162620-f606129e732f
mvdan.cc/xurls/v2 v2.5.0 mvdan.cc/xurls/v2 v2.5.0
) )

8
go.sum
View file

@ -61,8 +61,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/yuin/goldmark v1.7.7 h1:5m9rrB1sW3JUMToKFQfb+FGt1U7r57IHu5GrYrG2nqU= github.com/yuin/goldmark v1.7.7 h1:5m9rrB1sW3JUMToKFQfb+FGt1U7r57IHu5GrYrG2nqU=
github.com/yuin/goldmark v1.7.7/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.7/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
go.mau.fi/util v0.8.2-0.20241027163518-38d54fc87ee3 h1:9dDTNcVc3y9oU9bYvjpc3xsCupwGzfyYhrppaLy6l9k= go.mau.fi/util v0.8.2-0.20241030110711-b3e597e16b74 h1:hzVVXFEIQWefBlokVlQ2nr7EzRnMdMLF+K+kqWsm6OE=
go.mau.fi/util v0.8.2-0.20241027163518-38d54fc87ee3/go.mod h1:T1u/rD2rzidVrBLyaUdPpZiJdP/rsyi+aTzn0D+Q6wc= go.mau.fi/util v0.8.2-0.20241030110711-b3e597e16b74/go.mod h1:T1u/rD2rzidVrBLyaUdPpZiJdP/rsyi+aTzn0D+Q6wc=
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM= go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
@ -89,7 +89,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
maunium.net/go/mautrix v0.21.2-0.20241026115159-a59d4d78677f h1:fZL9ASp9m4KaC0QUEDkv5ptPwVvRjigy9uPI6NYZAD0= maunium.net/go/mautrix v0.21.2-0.20241101162620-f606129e732f h1:4iO+tXpXS8BNZuJP17BpaPZAURknrufVgkVrFkTHv7Y=
maunium.net/go/mautrix v0.21.2-0.20241026115159-a59d4d78677f/go.mod h1:sjCZR1R/3NET/WjkcXPL6WpAHlWKku9HjRsdOkbM8Qw= maunium.net/go/mautrix v0.21.2-0.20241101162620-f606129e732f/go.mod h1:sjCZR1R/3NET/WjkcXPL6WpAHlWKku9HjRsdOkbM8Qw=
mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8= mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=
mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE= mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE=

View file

@ -292,6 +292,7 @@ type LocalContent struct {
HTMLVersion int `json:"html_version,omitempty"` HTMLVersion int `json:"html_version,omitempty"`
WasPlaintext bool `json:"was_plaintext,omitempty"` WasPlaintext bool `json:"was_plaintext,omitempty"`
BigEmoji bool `json:"big_emoji,omitempty"` BigEmoji bool `json:"big_emoji,omitempty"`
HasMath bool `json:"has_math,omitempty"`
} }
type Event struct { type Event struct {

View file

@ -79,6 +79,15 @@ func calculateMediaSize(widthInt, heightInt int) (width, height float64, ok bool
return return
} }
func getAttribute(attrs []html.Attribute, key string) (string, bool) {
for _, attr := range attrs {
if attr.Key == key {
return attr.Val, true
}
}
return "", false
}
func parseImgAttributes(attrs []html.Attribute) (src, alt, title string, isCustomEmoji bool, width, height int) { func parseImgAttributes(attrs []html.Attribute) (src, alt, title string, isCustomEmoji bool, width, height int) {
for _, attr := range attrs { for _, attr := range attrs {
switch attr.Key { switch attr.Key {
@ -99,7 +108,7 @@ func parseImgAttributes(attrs []html.Attribute) (src, alt, title string, isCusto
return return
} }
func parseSpanAttributes(attrs []html.Attribute) (bgColor, textColor, spoiler, maths string, isSpoiler bool) { func parseSpanAttributes(attrs []html.Attribute) (bgColor, textColor, spoiler string, isSpoiler bool) {
for _, attr := range attrs { for _, attr := range attrs {
switch attr.Key { switch attr.Key {
case "data-mx-bg-color": case "data-mx-bg-color":
@ -113,8 +122,6 @@ func parseSpanAttributes(attrs []html.Attribute) (bgColor, textColor, spoiler, m
case "data-mx-spoiler": case "data-mx-spoiler":
spoiler = attr.Val spoiler = attr.Val
isSpoiler = true isSpoiler = true
case "data-mx-maths":
maths = attr.Val
} }
} }
return return
@ -430,7 +437,7 @@ func writeImg(w *strings.Builder, attr []html.Attribute) id.ContentURI {
} }
func writeSpan(w *strings.Builder, attr []html.Attribute) { func writeSpan(w *strings.Builder, attr []html.Attribute) {
bgColor, textColor, spoiler, _, isSpoiler := parseSpanAttributes(attr) bgColor, textColor, spoiler, isSpoiler := parseSpanAttributes(attr)
if isSpoiler && spoiler != "" { if isSpoiler && spoiler != "" {
w.WriteString(`<span class="spoiler-reason">`) w.WriteString(`<span class="spoiler-reason">`)
w.WriteString(spoiler) w.WriteString(spoiler)
@ -521,10 +528,6 @@ Loop:
if !tagIsAllowed(token.DataAtom) { if !tagIsAllowed(token.DataAtom) {
continue continue
} }
tagIsSelfClosing := isSelfClosing(token.DataAtom)
if token.Type == html.SelfClosingTagToken && !tagIsSelfClosing {
continue
}
switch token.DataAtom { switch token.DataAtom {
case atom.Pre: case atom.Pre:
codeBlock = &strings.Builder{} codeBlock = &strings.Builder{}
@ -539,8 +542,22 @@ Loop:
if !mxc.IsEmpty() { if !mxc.IsEmpty() {
inlineImages = append(inlineImages, mxc) inlineImages = append(inlineImages, mxc)
} }
case atom.Div:
math, ok := getAttribute(token.Attr, "data-mx-maths")
if ok {
built.WriteString(`<hicli-math displaymode="block"`)
writeAttribute(&built, "latex", math)
token.DataAtom = atom.Math
}
case atom.Span, atom.Font: case atom.Span, atom.Font:
writeSpan(&built, token.Attr) math, ok := getAttribute(token.Attr, "data-mx-maths")
if ok && token.DataAtom == atom.Span {
built.WriteString(`<hicli-math displaymode="inline"`)
writeAttribute(&built, "latex", math)
token.DataAtom = atom.Math
} else {
writeSpan(&built, token.Attr)
}
default: default:
built.WriteByte('<') built.WriteByte('<')
built.WriteString(token.Data) built.WriteString(token.Data)
@ -550,18 +567,24 @@ Loop:
} }
} }
} }
if token.Type == html.SelfClosingTagToken {
built.WriteByte('/')
}
built.WriteByte('>') built.WriteByte('>')
if !tagIsSelfClosing { if !isSelfClosing(token.DataAtom) && token.Type != html.SelfClosingTagToken {
ts.push(token.DataAtom) ts.push(token.DataAtom)
} }
case html.EndTagToken: case html.EndTagToken:
tagName, _ := tz.TagName() tagName, _ := tz.TagName()
tag := atom.Lookup(tagName) tag := atom.Lookup(tagName)
if !tagIsAllowed(tag) {
continue
}
if tag == atom.Pre && codeBlock != nil { if tag == atom.Pre && codeBlock != nil {
writeCodeBlock(&built, codeBlockLanguage, codeBlock) writeCodeBlock(&built, codeBlockLanguage, codeBlock)
codeBlockLanguage = "" codeBlockLanguage = ""
codeBlock = nil codeBlock = nil
} else if tagIsAllowed(tag) && ts.pop(tag) { } else if ts.pop(tag) {
// TODO instead of only popping when the last tag in the stack matches, this should go through the stack // TODO instead of only popping when the last tag in the stack matches, this should go through the stack
// and close all tags until it finds the matching tag // and close all tags until it finds the matching tag
if tag == atom.Font { if tag == atom.Font {
@ -571,6 +594,8 @@ Loop:
built.Write(tagName) built.Write(tagName)
built.WriteByte('>') built.WriteByte('>')
} }
} else if (tag == atom.Span || tag == atom.Div) && ts.pop(atom.Math) {
built.WriteString("</hicli-math>")
} }
case html.TextToken: case html.TextToken:
if codeBlock != nil { if codeBlock != nil {

View file

@ -367,7 +367,7 @@ func (h *HiClient) calculateLocalContent(ctx context.Context, dbEvt *database.Ev
} }
if content != nil { if content != nil {
var sanitizedHTML string var sanitizedHTML string
var wasPlaintext, bigEmoji bool var wasPlaintext, hasMath, bigEmoji bool
var inlineImages []id.ContentURI var inlineImages []id.ContentURI
if content.Format == event.FormatHTML && content.FormattedBody != "" { if content.Format == event.FormatHTML && content.FormattedBody != "" {
var err error var err error
@ -377,6 +377,7 @@ func (h *HiClient) calculateLocalContent(ctx context.Context, dbEvt *database.Ev
Stringer("event_id", dbEvt.ID). Stringer("event_id", dbEvt.ID).
Msg("Failed to sanitize HTML") Msg("Failed to sanitize HTML")
} }
hasMath = strings.Contains(sanitizedHTML, "<hicli-math")
if len(inlineImages) > 0 && dbEvt.RowID != 0 { if len(inlineImages) > 0 && dbEvt.RowID != 0 {
for _, uri := range inlineImages { for _, uri := range inlineImages {
h.addMediaCache(ctx, dbEvt.RowID, uri.CUString(), nil, nil, "") h.addMediaCache(ctx, dbEvt.RowID, uri.CUString(), nil, nil, "")
@ -406,12 +407,13 @@ func (h *HiClient) calculateLocalContent(ctx context.Context, dbEvt *database.Ev
HTMLVersion: CurrentHTMLSanitizerVersion, HTMLVersion: CurrentHTMLSanitizerVersion,
WasPlaintext: wasPlaintext, WasPlaintext: wasPlaintext,
BigEmoji: bigEmoji, BigEmoji: bigEmoji,
HasMath: hasMath,
}, inlineImages }, inlineImages
} }
return nil, nil return nil, nil
} }
const CurrentHTMLSanitizerVersion = 4 const CurrentHTMLSanitizerVersion = 6
func (h *HiClient) ReprocessExistingEvent(ctx context.Context, evt *database.Event) { func (h *HiClient) ReprocessExistingEvent(ctx context.Context, evt *database.Event) {
if (evt.Type != event.EventMessage.Type && evt.DecryptedType != event.EventMessage.Type) || if (evt.Type != event.EventMessage.Type && evt.DecryptedType != event.EventMessage.Type) ||

34
web/package-lock.json generated
View file

@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@types/react": "npm:types-react@rc", "@types/react": "npm:types-react@rc",
"@types/react-dom": "npm:types-react-dom@rc", "@types/react-dom": "npm:types-react-dom@rc",
"katex": "^0.16.11",
"react": "^19.0.0-rc-0751fac7-20241002", "react": "^19.0.0-rc-0751fac7-20241002",
"react-dom": "^19.0.0-rc-0751fac7-20241002", "react-dom": "^19.0.0-rc-0751fac7-20241002",
"react-spinners": "^0.14.1", "react-spinners": "^0.14.1",
@ -18,6 +19,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.11.1", "@eslint/js": "^9.11.1",
"@types/katex": "^0.16.7",
"@types/sanitize-html": "^2.13.0", "@types/sanitize-html": "^2.13.0",
"@vitejs/plugin-react-swc": "^3.5.0", "@vitejs/plugin-react-swc": "^3.5.0",
"eslint": "^9.11.1", "eslint": "^9.11.1",
@ -1678,6 +1680,13 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"dev": true "dev": true
}, },
"node_modules/@types/katex": {
"version": "0.16.7",
"resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz",
"integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": { "node_modules/@types/react": {
"name": "types-react", "name": "types-react",
"version": "19.0.0-rc.1", "version": "19.0.0-rc.1",
@ -2285,6 +2294,15 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true "dev": true
}, },
"node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -3782,6 +3800,22 @@
"json5": "lib/cli.js" "json5": "lib/cli.js"
} }
}, },
"node_modules/katex": {
"version": "0.16.11",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.11.tgz",
"integrity": "sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ==",
"funding": [
"https://opencollective.com/katex",
"https://github.com/sponsors/katex"
],
"license": "MIT",
"dependencies": {
"commander": "^8.3.0"
},
"bin": {
"katex": "cli.js"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",

View file

@ -13,6 +13,7 @@
"dependencies": { "dependencies": {
"@types/react": "npm:types-react@rc", "@types/react": "npm:types-react@rc",
"@types/react-dom": "npm:types-react-dom@rc", "@types/react-dom": "npm:types-react-dom@rc",
"katex": "^0.16.11",
"react": "^19.0.0-rc-0751fac7-20241002", "react": "^19.0.0-rc-0751fac7-20241002",
"react-dom": "^19.0.0-rc-0751fac7-20241002", "react-dom": "^19.0.0-rc-0751fac7-20241002",
"react-spinners": "^0.14.1", "react-spinners": "^0.14.1",
@ -20,6 +21,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.11.1", "@eslint/js": "^9.11.1",
"@types/katex": "^0.16.7",
"@types/sanitize-html": "^2.13.0", "@types/sanitize-html": "^2.13.0",
"@vitejs/plugin-react-swc": "^3.5.0", "@vitejs/plugin-react-swc": "^3.5.0",
"eslint": "^9.11.1", "eslint": "^9.11.1",

View file

@ -84,6 +84,7 @@ export interface LocalContent {
html_version?: number html_version?: number
was_plaintext?: boolean was_plaintext?: boolean
big_emoji?: boolean big_emoji?: boolean
has_math?: boolean
} }
export interface BaseDBEvent { export interface BaseDBEvent {

View file

@ -32,6 +32,19 @@ const onClickHTML = (evt: React.MouseEvent<HTMLDivElement>) => {
} }
} }
let mathImported = false
function importMath() {
if (mathImported) {
return
}
mathImported = true
import("./math.ts").then(
() => console.info("Imported math"),
err => console.error("Failed to import math", err),
)
}
const TextMessageBody = ({ event, sender }: EventContentProps) => { const TextMessageBody = ({ event, sender }: EventContentProps) => {
const content = event.content as MessageEventContent const content = event.content as MessageEventContent
const classNames = ["message-text"] const classNames = ["message-text"]
@ -48,6 +61,10 @@ const TextMessageBody = ({ event, sender }: EventContentProps) => {
if (event.local_content?.was_plaintext) { if (event.local_content?.was_plaintext) {
classNames.push("plaintext-body") classNames.push("plaintext-body")
} }
if (event.local_content?.has_math) {
classNames.push("math-body")
importMath()
}
if (event.local_content?.sanitized_html) { if (event.local_content?.sanitized_html) {
classNames.push("html-body") classNames.push("html-body")
return <div return <div

View file

@ -0,0 +1,65 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import katex from "katex"
import katexCSS from "katex/dist/katex.min.css?inline"
const sheet = new CSSStyleSheet()
sheet.replaceSync(katexCSS)
class HicliMath extends HTMLElement {
static observedAttributes = ["displaymode", "latex"]
#root?: HTMLElement
#latex?: string
#displayMode?: boolean
constructor() {
super()
}
connectedCallback() {
const root = this.attachShadow({ mode: "open" })
root.adoptedStyleSheets = [sheet]
// This seems to work fine
this.#root = root as unknown as HTMLElement
this.#render()
}
attributeChangedCallback(name: string, _oldValue: string, newValue: string) {
if (name === "latex") {
this.#latex = newValue
} else if (name === "displaymode") {
this.#displayMode = newValue === "block"
}
this.#render()
}
#render() {
if (!this.#root || !this.#latex) {
return
}
try {
katex.render(this.#latex, this.#root, {
output: "htmlAndMathml",
maxSize: 10,
displayMode: this.#displayMode,
})
} catch (err) {
console.error("Failed to render math", this.#latex, err)
}
}
}
customElements.define("hicli-math", HicliMath)