forked from Mirrors/gomuks
hicli/html,web/timeline: add support for LaTeX rendering
This commit is contained in:
parent
44dee015d4
commit
214d4fde53
10 changed files with 166 additions and 19 deletions
4
go.mod
4
go.mod
|
@ -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
8
go.sum
|
@ -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=
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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:
|
||||
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:
|
||||
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 {
|
||||
|
|
|
@ -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
34
web/package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -84,6 +84,7 @@ export interface LocalContent {
|
|||
html_version?: number
|
||||
was_plaintext?: boolean
|
||||
big_emoji?: boolean
|
||||
has_math?: boolean
|
||||
}
|
||||
|
||||
export interface BaseDBEvent {
|
||||
|
|
|
@ -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
|
||||
|
|
65
web/src/ui/timeline/content/math.ts
Normal file
65
web/src/ui/timeline/content/math.ts
Normal 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)
|
Loading…
Add table
Reference in a new issue