feat: render Markdown, mathematics and Mermaid diagrams server-side
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mathml
|
||||
|
||||
import "strings"
|
||||
|
||||
// envSpec describes a table environment: its delimiters, its column
|
||||
// alignment, whether it renders in script size, whether it takes a column
|
||||
// specification and whether it takes a number argument first.
|
||||
type envSpec struct {
|
||||
fences [2]string
|
||||
align string
|
||||
scripted bool
|
||||
colSpec bool
|
||||
number bool
|
||||
}
|
||||
|
||||
var environments = map[string]envSpec{
|
||||
"matrix": {}, "pmatrix": {fences: [2]string{"(", ")"}},
|
||||
"bmatrix": {fences: [2]string{"[", "]"}},
|
||||
"Bmatrix": {fences: [2]string{"{", "}"}},
|
||||
"vmatrix": {fences: [2]string{"|", "|"}},
|
||||
"Vmatrix": {fences: [2]string{"‖", "‖"}},
|
||||
"matrix*": {},
|
||||
"pmatrix*": {fences: [2]string{"(", ")"}},
|
||||
"bmatrix*": {fences: [2]string{"[", "]"}},
|
||||
"Bmatrix*": {fences: [2]string{"{", "}"}},
|
||||
"vmatrix*": {fences: [2]string{"|", "|"}},
|
||||
"Vmatrix*": {fences: [2]string{"‖", "‖"}},
|
||||
"smallmatrix": {scripted: true},
|
||||
"cases": {fences: [2]string{"{", ""}, align: "left"},
|
||||
"rcases": {fences: [2]string{"", "}"}, align: "left"},
|
||||
"dcases": {fences: [2]string{"{", ""}, align: "left"},
|
||||
"drcases": {fences: [2]string{"", "}"}, align: "left"},
|
||||
"aligned": {align: "right left"},
|
||||
"align": {align: "right left"},
|
||||
"align*": {align: "right left"},
|
||||
"alignedat": {align: "right left", number: true},
|
||||
"alignat": {align: "right left", number: true},
|
||||
"alignat*": {align: "right left", number: true},
|
||||
"split": {align: "right left"},
|
||||
"gather": {}, "gather*": {},
|
||||
"equation": {}, "equation*": {},
|
||||
"array": {colSpec: true},
|
||||
"darray": {colSpec: true},
|
||||
"subarray": {colSpec: true, scripted: true},
|
||||
}
|
||||
|
||||
// environment parses a whole \begin{name}...\end{name} construct. The
|
||||
// cursor sits just after the \begin token.
|
||||
func (p *parser) environment() *node {
|
||||
begin := p.toks[p.pos-1]
|
||||
name, ok := p.envName()
|
||||
if !ok {
|
||||
return errorNode(begin.text)
|
||||
}
|
||||
spec, supported := environments[name]
|
||||
if !supported {
|
||||
return p.degradeEnvironment(begin)
|
||||
}
|
||||
|
||||
if spec.colSpec {
|
||||
raw, ok := p.rawBraced()
|
||||
if !ok {
|
||||
return errorNode(begin.text)
|
||||
}
|
||||
_, supported = parseColSpec(raw)
|
||||
if !supported {
|
||||
return p.degradeEnvironment(begin)
|
||||
}
|
||||
} else if spec.number {
|
||||
if _, ok := p.rawBraced(); !ok {
|
||||
return errorNode(begin.text)
|
||||
}
|
||||
}
|
||||
rows := p.tableRows()
|
||||
if !p.atCommand("end") {
|
||||
p.pos = len(p.toks) - 1
|
||||
return errorNode(string(p.src[begin.start:]))
|
||||
}
|
||||
endStart := p.toks[p.pos].start
|
||||
p.pos++
|
||||
endName, ok := p.envName()
|
||||
if !ok || endName != name {
|
||||
p.pos = len(p.toks) - 1
|
||||
return errorNode(string(p.src[begin.start:]))
|
||||
}
|
||||
_ = endStart
|
||||
table := buildTable(rows, spec)
|
||||
if spec.scripted {
|
||||
inner := elA("mstyle", []attribute{{"scriptlevel", "1"}})
|
||||
inner.children = []*node{table}
|
||||
table = inner
|
||||
}
|
||||
if spec.fences == [2]string{"", ""} {
|
||||
return table
|
||||
}
|
||||
row := el("mrow")
|
||||
row.children = []*node{p.fenceOf(spec.fences[0]), table, p.fenceOf(spec.fences[1])}
|
||||
return row
|
||||
}
|
||||
|
||||
// envName reads the environment name in braces.
|
||||
func (p *parser) envName() (string, bool) {
|
||||
raw, ok := p.rawBraced()
|
||||
if !ok || raw == "" {
|
||||
return "", false
|
||||
}
|
||||
return raw, true
|
||||
}
|
||||
|
||||
// degradeEnvironment consumes a whole unsupported environment, up to and
|
||||
// including its matching \end, and degrades it as its verbatim source.
|
||||
func (p *parser) degradeEnvironment(begin token) *node {
|
||||
depth := 1
|
||||
end := len(p.src)
|
||||
i := p.pos
|
||||
for ; i < len(p.toks); i++ {
|
||||
t := p.toks[i]
|
||||
if t.kind != tokCommand {
|
||||
continue
|
||||
}
|
||||
switch t.text {
|
||||
case `\begin`:
|
||||
depth++
|
||||
case `\end`:
|
||||
depth--
|
||||
if depth == 0 {
|
||||
end = t.end
|
||||
// include the name argument of \end
|
||||
if i+2 < len(p.toks) && p.toks[i+1].kind == tokLBrace && p.toks[i+2].kind == tokRBrace {
|
||||
end = p.toks[i+2].end
|
||||
i += 2
|
||||
}
|
||||
i++
|
||||
p.pos = i
|
||||
return errorNode(string(p.src[begin.start:end]))
|
||||
}
|
||||
}
|
||||
}
|
||||
p.pos = len(p.toks) - 1
|
||||
return errorNode(string(p.src[begin.start:]))
|
||||
}
|
||||
|
||||
// tableRows parses the rows of a table, each row a slice of cells, until
|
||||
// the \end or the end of input.
|
||||
func (p *parser) tableRows() [][]*node {
|
||||
var rows [][]*node
|
||||
for {
|
||||
var cells []*node
|
||||
for {
|
||||
nodes := p.sequence(true)
|
||||
cell := el("mtd")
|
||||
cell.children = nodes
|
||||
cells = append(cells, cell)
|
||||
if p.at(tokAmpersand) {
|
||||
p.pos++
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
rows = append(rows, cells)
|
||||
if isRowEnd(p.cur()) {
|
||||
p.pos++
|
||||
p.rowSpacing()
|
||||
if p.at(tokEOF) || p.atCommand("end") {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// rowSpacing skips the optional bracket after a row separator.
|
||||
func (p *parser) rowSpacing() {
|
||||
if p.at(tokChar) && p.cur().text == "[" {
|
||||
for {
|
||||
t := p.cur()
|
||||
p.pos++
|
||||
if t.kind == tokEOF || t.kind == tokChar && t.text == "]" {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseColSpec reads an array column specification: alignment letters and
|
||||
// vertical rules, nothing else.
|
||||
func parseColSpec(raw string) (int, bool) {
|
||||
count := 0
|
||||
for _, c := range raw {
|
||||
switch c {
|
||||
case 'l', 'c', 'r':
|
||||
count++
|
||||
case '|', ' ', '\t':
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return count, true
|
||||
}
|
||||
|
||||
// buildTable assembles the mtable with its alignment attributes. The
|
||||
// "right left" alignment alternates over the widest row.
|
||||
func buildTable(rows [][]*node, spec envSpec) *node {
|
||||
table := el("mtable")
|
||||
cols := 0
|
||||
for _, row := range rows {
|
||||
if len(row) > cols {
|
||||
cols = len(row)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case spec.align == "left" && cols > 0:
|
||||
table.attrs = append(table.attrs, attribute{"columnalign", "left"})
|
||||
case spec.align == "right left" && cols > 0:
|
||||
var b strings.Builder
|
||||
for i := range cols {
|
||||
if i > 0 {
|
||||
b.WriteString(" ")
|
||||
}
|
||||
if i%2 == 0 {
|
||||
b.WriteString("right")
|
||||
} else {
|
||||
b.WriteString("left")
|
||||
}
|
||||
}
|
||||
table.attrs = append(table.attrs, attribute{"columnalign", b.String()})
|
||||
}
|
||||
for _, row := range rows {
|
||||
tr := el("mtr")
|
||||
tr.children = row
|
||||
table.children = append(table.children, tr)
|
||||
}
|
||||
return table
|
||||
}
|
||||
|
||||
// substack renders the rows of a \substack argument in script size.
|
||||
func (p *parser) substack(raw string) *node {
|
||||
q := newParser([]byte(raw), false)
|
||||
rows := q.tableRows()
|
||||
table := buildTable(rows, envSpec{})
|
||||
inner := elA("mstyle", []attribute{{"scriptlevel", "1"}})
|
||||
inner.children = []*node{table}
|
||||
return inner
|
||||
}
|
||||
Reference in New Issue
Block a user