1
0
Fork 0
forked from Mirrors/gomuks

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/sjson v1.2.5
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
golang.org/x/crypto v0.28.0
golang.org/x/image v0.21.0
@ -24,7 +24,7 @@ require (
golang.org/x/text v0.19.0
gopkg.in/yaml.v3 v3.0.1
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
)

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/yuin/goldmark v1.7.7 h1:5m9rrB1sW3JUMToKFQfb+FGt1U7r57IHu5GrYrG2nqU=
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.20241027163518-38d54fc87ee3/go.mod h1:T1u/rD2rzidVrBLyaUdPpZiJdP/rsyi+aTzn0D+Q6wc=
go.mau.fi/util v0.8.2-0.20241030110711-b3e597e16b74 h1:hzVVXFEIQWefBlokVlQ2nr7EzRnMdMLF+K+kqWsm6OE=
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/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
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=
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/mautrix v0.21.2-0.20241026115159-a59d4d78677f h1:fZL9ASp9m4KaC0QUEDkv5ptPwVvRjigy9uPI6NYZAD0=
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 h1:4iO+tXpXS8BNZuJP17BpaPZAURknrufVgkVrFkTHv7Y=
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/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE=

View file

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

View file

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

View file

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

34
web/package-lock.json generated
View file

@ -11,6 +11,7 @@
"dependencies": {
"@types/react": "npm:types-react@rc",
"@types/react-dom": "npm:types-react-dom@rc",
"katex": "^0.16.11",
"react": "^19.0.0-rc-0751fac7-20241002",
"react-dom": "^19.0.0-rc-0751fac7-20241002",
"react-spinners": "^0.14.1",
@ -18,6 +19,7 @@
},
"devDependencies": {
"@eslint/js": "^9.11.1",
"@types/katex": "^0.16.7",
"@types/sanitize-html": "^2.13.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"eslint": "^9.11.1",
@ -1678,6 +1680,13 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"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": {
"name": "types-react",
"version": "19.0.0-rc.1",
@ -2285,6 +2294,15 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"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": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -3782,6 +3800,22 @@
"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": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",

View file

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

View file

@ -84,6 +84,7 @@ export interface LocalContent {
html_version?: number
was_plaintext?: boolean
big_emoji?: boolean
has_math?: boolean
}
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 content = event.content as MessageEventContent
const classNames = ["message-text"]
@ -48,6 +61,10 @@ const TextMessageBody = ({ event, sender }: EventContentProps) => {
if (event.local_content?.was_plaintext) {
classNames.push("plaintext-body")
}
if (event.local_content?.has_math) {
classNames.push("math-body")
importMath()
}
if (event.local_content?.sanitized_html) {
classNames.push("html-body")
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)