feat: render Markdown, mathematics and Mermaid diagrams server-side
This commit is contained in:
@@ -0,0 +1,570 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mathml
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func isDigitByte(c byte) bool { return c >= '0' && c <= '9' }
|
||||
|
||||
// isIdentifierRune reports whether the rune names a variable: a letter in
|
||||
// any script.
|
||||
func isIdentifierRune(r rune) bool {
|
||||
return unicode.IsLetter(r)
|
||||
}
|
||||
|
||||
func spaceNode(width string) *node {
|
||||
return &node{kind: "mspace", attrs: []attribute{{"width", width}}}
|
||||
}
|
||||
|
||||
// fenced wraps content in stretchy fence delimiters.
|
||||
func fenced(open string, content *node, close string) *node {
|
||||
row := el("mrow")
|
||||
row.children = []*node{fenceNode(open), content, fenceNode(close)}
|
||||
return row
|
||||
}
|
||||
|
||||
func fenceNode(char string) *node {
|
||||
return text("mo", char, attribute{"fence", "true"}, attribute{"stretchy", "true"})
|
||||
}
|
||||
|
||||
// wrapRow packs nodes into one row; an empty list becomes an empty row.
|
||||
func wrapRow(nodes []*node) *node {
|
||||
if len(nodes) == 1 {
|
||||
return nodes[0]
|
||||
}
|
||||
row := el("mrow")
|
||||
row.children = nodes
|
||||
return row
|
||||
}
|
||||
|
||||
// command parses one backslash command and whatever it takes with it. The
|
||||
// cursor sits on the command token.
|
||||
func (p *parser) command() *node {
|
||||
t := p.cur()
|
||||
name := t.text[1:]
|
||||
p.pos++
|
||||
// The single-character escapes: braces, the reserved characters and
|
||||
// the double bar.
|
||||
switch name {
|
||||
case "{", "}", "%", "$", "#", "&", "_", "|", "<", ">", ",", ":", ";", "!", " ":
|
||||
if w, ok := spaces[name]; ok {
|
||||
return spaceNode(w)
|
||||
}
|
||||
if name == "|" {
|
||||
return text("mo", "‖")
|
||||
}
|
||||
return p.withVariant(el("mo"), name)
|
||||
}
|
||||
switch name {
|
||||
case "frac", "dfrac", "tfrac", "cfrac":
|
||||
num := p.argument()
|
||||
den := p.argument()
|
||||
return el("mfrac", num, den)
|
||||
case "binom", "dbinom", "tbinom":
|
||||
num := p.argument()
|
||||
den := p.argument()
|
||||
return fenced("(", elA("mfrac", []attribute{{"linethickness", "0em"}}, num, den), ")")
|
||||
case "genfrac":
|
||||
return p.genfrac()
|
||||
case "sqrt":
|
||||
return p.sqrt()
|
||||
case "substack":
|
||||
raw, ok := p.rawBraced()
|
||||
if !ok {
|
||||
return errorNode(t.text)
|
||||
}
|
||||
return p.substack(raw)
|
||||
case "text", "textrm", "textnormal", "mbox", "textmd", "textsc", "textsl":
|
||||
return p.textArgument()
|
||||
case "textbf", "textup":
|
||||
return p.textArgument(attribute{"fontweight", "bold"})
|
||||
case "textit", "emph":
|
||||
return p.textArgument(attribute{"fontstyle", "italic"})
|
||||
case "textsf":
|
||||
return p.textArgument(attribute{"mathvariant", "sans-serif"})
|
||||
case "texttt":
|
||||
return p.textArgument(attribute{"mathvariant", "monospace"})
|
||||
case "operatorname", "operatornamewithlimits":
|
||||
raw, ok := p.rawBraced()
|
||||
if !ok {
|
||||
return errorNode(t.text)
|
||||
}
|
||||
return identifierMulti(raw, name == "operatornamewithlimits")
|
||||
case "operatorname*":
|
||||
raw, ok := p.rawBraced()
|
||||
if !ok {
|
||||
return errorNode(t.text)
|
||||
}
|
||||
return identifierMulti(raw, true)
|
||||
case "mathchoice":
|
||||
// four arguments, of which the parser renders the second, the
|
||||
// text style one; the display, script and scriptscript variants
|
||||
// of MathML Core are the renderer's business
|
||||
_, _ = p.argument(), p.argument()
|
||||
if n := p.argument(); n != nil {
|
||||
_, _ = p.argument(), p.argument()
|
||||
return n
|
||||
}
|
||||
return errorNode(t.text)
|
||||
case "phantom", "hphantom", "vphantom":
|
||||
arg := p.argument()
|
||||
inner := el("mphantom")
|
||||
inner.children = arg.children
|
||||
inner.text = arg.text
|
||||
return inner
|
||||
case "overset", "stackrel":
|
||||
over := p.argument()
|
||||
base := p.argument()
|
||||
return el("mover", base, over)
|
||||
case "underset":
|
||||
under := p.argument()
|
||||
base := p.argument()
|
||||
return el("munder", base, under)
|
||||
case "pmod":
|
||||
arg := p.argument()
|
||||
row := el("mrow")
|
||||
row.children = []*node{
|
||||
identifierMulti("mod", false), spaceNode("0.2778em"), arg,
|
||||
}
|
||||
return fenced("(", row, ")")
|
||||
case "left":
|
||||
return p.leftRight()
|
||||
case "big", "Big", "bigg", "Bigg",
|
||||
"bigl", "Bigl", "biggl", "Biggl",
|
||||
"bigr", "Bigr", "biggr", "Biggr",
|
||||
"bigm", "Bigm", "biggm", "Biggm":
|
||||
return p.bigDelimiter()
|
||||
case "middle":
|
||||
return text("mo", "", attribute{"fence", "true"}, attribute{"stretchy", "true"})
|
||||
case "pod":
|
||||
return fenced("(", p.argument(), ")")
|
||||
case "xrightarrow", "xleftarrow", "xleftrightarrow", "xhookrightarrow",
|
||||
"xhookleftarrow", "xRightarrow", "xLeftarrow", "xLeftrightarrow",
|
||||
"xrightleftharpoons", "xmapsto", "xtwoheadleftarrow", "xtwoheadrightarrow",
|
||||
"xleftharpoonup", "xrightharpoonup", "xleftharpoondown", "xrightharpoondown",
|
||||
"xleftrightharpoons", "xtofrom", "xlongequal":
|
||||
return p.xArrow(name)
|
||||
case "displaystyle", "textstyle", "scriptstyle", "scriptscriptstyle":
|
||||
return p.styleDeclaration(name)
|
||||
case "newcommand", "renewcommand", "providecommand", "def", "gdef", "DeclareMathOperator", "DeclareMathOperator*":
|
||||
return p.macroDefinition(name)
|
||||
case "begin":
|
||||
return p.environment()
|
||||
case "end":
|
||||
return errorNode(t.text)
|
||||
case "limits", "nolimits":
|
||||
return errorNode(t.text)
|
||||
case "not":
|
||||
return p.not()
|
||||
case "hspace", "mspace":
|
||||
return p.spaceArgument()
|
||||
case "color", "textcolor":
|
||||
return p.color(name)
|
||||
}
|
||||
if s, ok := symbols[name]; ok {
|
||||
return p.symbolNode(t.text, s)
|
||||
}
|
||||
if functions[name] {
|
||||
return identifierMulti(name, movableFunctions[name])
|
||||
}
|
||||
if v, ok := styles[name]; ok {
|
||||
return p.styleArgument(v)
|
||||
}
|
||||
if accent, ok := accents[name]; ok {
|
||||
return p.accent(accent)
|
||||
}
|
||||
if w, ok := spaces[name]; ok {
|
||||
return spaceNode(w)
|
||||
}
|
||||
return errorNode(t.text)
|
||||
}
|
||||
|
||||
// symbolNode renders a table symbol, upright under a variant switch.
|
||||
func (p *parser) symbolNode(source string, s symbol) *node {
|
||||
if s.mi {
|
||||
return p.identifier(s.char)
|
||||
}
|
||||
n := p.withVariant(el("mo"), s.char)
|
||||
if s.movable {
|
||||
n.attrs = append(n.attrs, attribute{"movablelimits", "true"})
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// identifierMulti renders a name as an upright identifier, which a
|
||||
// multi-character mi is by default. The movable flag marks the limit
|
||||
// operators.
|
||||
func identifierMulti(name string, movable bool) *node {
|
||||
if movable {
|
||||
return text("mi", name, attribute{"movablelimits", "true"})
|
||||
}
|
||||
return text("mi", name)
|
||||
}
|
||||
|
||||
// textArgument reads a text command's argument verbatim.
|
||||
func (p *parser) textArgument(attrs ...attribute) *node {
|
||||
raw, ok := p.rawBraced()
|
||||
if !ok {
|
||||
return errorNode(`\` + "text")
|
||||
}
|
||||
return &node{kind: "mtext", text: raw, attrs: attrs}
|
||||
}
|
||||
|
||||
// styleArgument parses the argument under a forced variant.
|
||||
func (p *parser) styleArgument(variant string) *node {
|
||||
save := p.variant
|
||||
if variant == "italic" {
|
||||
p.variant = ""
|
||||
} else {
|
||||
p.variant = variant
|
||||
}
|
||||
arg := p.argument()
|
||||
p.variant = save
|
||||
return arg
|
||||
}
|
||||
|
||||
// accent puts its mark over or under the argument.
|
||||
func (p *parser) accent(a struct {
|
||||
char string
|
||||
under bool
|
||||
}) *node {
|
||||
base := p.argument()
|
||||
mark := text("mo", a.char, attribute{"stretchy", "false"})
|
||||
if a.under {
|
||||
return el("munder", base, mark)
|
||||
}
|
||||
if a.char == "\u203e" || a.char == "\u23de" || a.char == "_" || a.char == "\u23e1" || a.char == "\u23df" {
|
||||
mark = text("mo", a.char, attribute{"stretchy", "true"})
|
||||
}
|
||||
return el("mover", base, mark)
|
||||
}
|
||||
|
||||
// sqrt parses a radical with its optional index.
|
||||
func (p *parser) sqrt() *node {
|
||||
if p.at(tokChar) && p.cur().text == "[" {
|
||||
p.pos++
|
||||
var index []*node
|
||||
for !p.at(tokEOF) && !(p.at(tokChar) && p.cur().text == "]") {
|
||||
if n := p.atom(); n != nil {
|
||||
index = append(index, n)
|
||||
}
|
||||
}
|
||||
if p.at(tokChar) && p.cur().text == "]" {
|
||||
p.pos++
|
||||
return el("mroot", p.argument(), wrapRow(index))
|
||||
}
|
||||
return errorNode(`\sqrt`)
|
||||
}
|
||||
return el("msqrt", p.argument())
|
||||
}
|
||||
|
||||
// genfrac parses the six arguments of \genfrac: the delimiters, the rule
|
||||
// thickness, the style and the numerator and denominator.
|
||||
func (p *parser) genfrac() *node {
|
||||
open := p.delimiterArg()
|
||||
close := p.delimiterArg()
|
||||
thick, hasThick := p.bracketArg()
|
||||
style, _ := p.bracketArg()
|
||||
num := p.argument()
|
||||
den := p.argument()
|
||||
frac := el("mfrac")
|
||||
if hasThick {
|
||||
frac.attrs = append(frac.attrs, attribute{"linethickness", thick})
|
||||
}
|
||||
frac.children = []*node{num, den}
|
||||
if style >= "2" {
|
||||
wrapped := elA("mstyle", []attribute{{"scriptlevel", "1"}})
|
||||
wrapped.children = []*node{frac}
|
||||
frac = wrapped
|
||||
}
|
||||
if open == "" && close == "" {
|
||||
return frac
|
||||
}
|
||||
if open == "" {
|
||||
open = "."
|
||||
}
|
||||
if close == "" {
|
||||
close = "."
|
||||
}
|
||||
row := el("mrow")
|
||||
row.children = []*node{p.fenceOf(open), frac, p.fenceOf(close)}
|
||||
return row
|
||||
}
|
||||
|
||||
// bracketArg reads an optional bracketed argument, reporting whether one
|
||||
// was there.
|
||||
func (p *parser) bracketArg() (string, bool) {
|
||||
if !p.at(tokChar) || p.cur().text != "[" {
|
||||
return "", false
|
||||
}
|
||||
p.pos++
|
||||
var b strings.Builder
|
||||
for {
|
||||
t := p.cur()
|
||||
if t.kind == tokEOF {
|
||||
return "", true
|
||||
}
|
||||
if t.kind == tokChar && t.text == "]" {
|
||||
p.pos++
|
||||
return b.String(), true
|
||||
}
|
||||
b.WriteString(t.text)
|
||||
p.pos++
|
||||
}
|
||||
}
|
||||
|
||||
// delimiterArg reads a \genfrac delimiter argument: a character, a
|
||||
// command or an empty group.
|
||||
func (p *parser) delimiterArg() string {
|
||||
t := p.cur()
|
||||
switch {
|
||||
case t.kind == tokLBrace:
|
||||
p.pos++
|
||||
if p.at(tokRBrace) {
|
||||
p.pos++
|
||||
return ""
|
||||
}
|
||||
d, _ := p.delimiter()
|
||||
return d
|
||||
case t.kind == tokChar || t.kind == tokCommand:
|
||||
d, _ := p.delimiter()
|
||||
return d
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// leftRight parses a stretchy delimited row.
|
||||
func (p *parser) leftRight() *node {
|
||||
start := p.pos
|
||||
open, ok := p.delimiter()
|
||||
if !ok {
|
||||
return errorNode(`\left`)
|
||||
}
|
||||
var content []*node
|
||||
for {
|
||||
t := p.cur()
|
||||
if t.kind == tokEOF {
|
||||
p.pos = len(p.toks) - 1
|
||||
return errorNode(string(p.src[start-1:]))
|
||||
}
|
||||
if t.kind == tokCommand && t.text == `\right` {
|
||||
p.pos++
|
||||
break
|
||||
}
|
||||
if n := p.atom(); n != nil {
|
||||
content = append(content, n)
|
||||
}
|
||||
}
|
||||
close, _ := p.delimiter()
|
||||
row := el("mrow")
|
||||
row.children = append([]*node{p.fenceOf(open)}, content...)
|
||||
row.children = append(row.children, p.fenceOf(close))
|
||||
return row
|
||||
}
|
||||
|
||||
// delimiter reads one delimiter: a character or a table command, with the
|
||||
// dot meaning invisible. The second result reports whether a delimiter
|
||||
// was there at all.
|
||||
func (p *parser) delimiter() (string, bool) {
|
||||
t := p.cur()
|
||||
switch t.kind {
|
||||
case tokChar:
|
||||
p.pos++
|
||||
if t.text == "." {
|
||||
return "", true
|
||||
}
|
||||
return t.text, true
|
||||
case tokCommand:
|
||||
name := t.text[1:]
|
||||
if name == "|" {
|
||||
p.pos++
|
||||
return "‖", true
|
||||
}
|
||||
if s, ok := symbols[name]; ok {
|
||||
p.pos++
|
||||
return s.char, true
|
||||
}
|
||||
if _, ok := spaces[name]; ok {
|
||||
p.pos++
|
||||
return "", true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// fenceOf makes the fence for a delimiter name; an empty name is the
|
||||
// invisible fence.
|
||||
func (p *parser) fenceOf(d string) *node {
|
||||
if d == "" {
|
||||
return text("mo", "", attribute{"fence", "true"})
|
||||
}
|
||||
if d == "." {
|
||||
return text("mo", "", attribute{"fence", "true"})
|
||||
}
|
||||
return fenceNode(d)
|
||||
}
|
||||
|
||||
// bigDelimiter renders \big and its relatives around one delimiter.
|
||||
func (p *parser) bigDelimiter() *node {
|
||||
t := p.cur()
|
||||
d, ok := p.delimiter()
|
||||
if !ok {
|
||||
return errorNode(t.text)
|
||||
}
|
||||
return text("mo", d, attribute{"stretchy", "true"})
|
||||
}
|
||||
|
||||
// xArrowNames gives the shaft of every extensible arrow command.
|
||||
var xArrowNames = map[string]string{
|
||||
"xrightarrow": "→",
|
||||
"xleftarrow": "←",
|
||||
"xleftrightarrow": "↔",
|
||||
"xhookrightarrow": "↪",
|
||||
"xhookleftarrow": "↩",
|
||||
"xRightarrow": "⇒",
|
||||
"xLeftarrow": "⇐",
|
||||
"xLeftrightarrow": "⇔",
|
||||
"xrightleftharpoons": "⇌",
|
||||
"xmapsto": "↦",
|
||||
"xtwoheadleftarrow": "↞",
|
||||
"xtwoheadrightarrow": "↠",
|
||||
"xleftharpoonup": "↼",
|
||||
"xrightharpoonup": "⇀",
|
||||
"xleftharpoondown": "↽",
|
||||
"xrightharpoondown": "⇁",
|
||||
"xleftrightharpoons": "⇋",
|
||||
"xtofrom": "⇄",
|
||||
"xlongequal": "=",
|
||||
}
|
||||
|
||||
// xArrow parses an extensible arrow: an optional underscript in brackets,
|
||||
// then the overscript in braces.
|
||||
func (p *parser) xArrow(name string) *node {
|
||||
char := xArrowNames[name]
|
||||
arrow := text("mo", char, attribute{"stretchy", "true"})
|
||||
var under *node
|
||||
if c, ok := p.bracketArg(); ok && c != "" {
|
||||
under = &node{kind: "mrow", text: c}
|
||||
}
|
||||
over := p.argument()
|
||||
if under == nil {
|
||||
return el("mover", arrow, over)
|
||||
}
|
||||
return el("munderover", arrow, under, over)
|
||||
}
|
||||
|
||||
// styleDeclaration wraps the rest of the current group in an mstyle.
|
||||
func (p *parser) styleDeclaration(name string) *node {
|
||||
rest := p.sequence(false)
|
||||
switch name {
|
||||
case "displaystyle":
|
||||
n := elA("mstyle", []attribute{{"displaystyle", "true"}})
|
||||
n.children = rest
|
||||
return n
|
||||
case "textstyle":
|
||||
n := elA("mstyle", []attribute{{"displaystyle", "false"}})
|
||||
n.children = rest
|
||||
return n
|
||||
case "scriptstyle":
|
||||
n := elA("mstyle", []attribute{{"scriptlevel", "1"}})
|
||||
n.children = rest
|
||||
return n
|
||||
default:
|
||||
n := elA("mstyle", []attribute{{"scriptlevel", "2"}})
|
||||
n.children = rest
|
||||
return n
|
||||
}
|
||||
}
|
||||
|
||||
// not combines the negation slash with the relation that follows.
|
||||
func (p *parser) not() *node {
|
||||
t := p.cur()
|
||||
if t.kind == tokCommand {
|
||||
name := t.text[1:]
|
||||
if s, ok := symbols[name]; ok {
|
||||
p.pos++
|
||||
return text("mo", s.char+"̸")
|
||||
}
|
||||
}
|
||||
return text("mo", "¬")
|
||||
}
|
||||
|
||||
// spaceArgument reads the argument of \hspace and \mspace.
|
||||
func (p *parser) spaceArgument() *node {
|
||||
t := p.cur()
|
||||
if t.kind == tokLBrace {
|
||||
p.pos++
|
||||
var b strings.Builder
|
||||
for {
|
||||
c := p.cur()
|
||||
if c.kind == tokEOF || c.kind == tokRBrace {
|
||||
break
|
||||
}
|
||||
b.WriteString(c.text)
|
||||
p.pos++
|
||||
}
|
||||
if p.at(tokRBrace) {
|
||||
p.pos++
|
||||
}
|
||||
if validWidth(b.String()) {
|
||||
return spaceNode(b.String())
|
||||
}
|
||||
return errorNode(t.text)
|
||||
}
|
||||
return errorNode(t.text)
|
||||
}
|
||||
|
||||
// validWidth accepts a number with a CSS length unit.
|
||||
func validWidth(s string) bool {
|
||||
i := 0
|
||||
for i < len(s) && (isDigitByte(s[i]) || s[i] == '.' || s[i] == '-') {
|
||||
i++
|
||||
}
|
||||
if i == 0 {
|
||||
return false
|
||||
}
|
||||
switch s[i:] {
|
||||
case "em", "ex", "px", "pt", "cm", "mm", "in", "mu", "%":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// color wraps its argument or the rest of the group in a coloured style.
|
||||
func (p *parser) color(name string) *node {
|
||||
c, ok := p.rawBraced()
|
||||
if !ok || !validColour(c) {
|
||||
return errorNode(`\` + name)
|
||||
}
|
||||
n := elA("mstyle", []attribute{{"mathcolor", c}})
|
||||
if name == "textcolor" {
|
||||
n.children = []*node{p.argument()}
|
||||
return n
|
||||
}
|
||||
n.children = p.sequence(false)
|
||||
return n
|
||||
}
|
||||
|
||||
// validColour accepts the colour names and the hex forms.
|
||||
func validColour(s string) bool {
|
||||
switch s {
|
||||
case "red", "green", "blue", "cyan", "magenta", "yellow", "black",
|
||||
"white", "gray", "grey", "orange", "purple", "brown", "pink",
|
||||
"olive", "violet", "teal", "navy", "darkgray", "lightgray":
|
||||
return true
|
||||
}
|
||||
if len(s) == 7 && s[0] == '#' || len(s) == 4 && s[0] == '#' {
|
||||
for i := 1; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if !isDigitByte(c) && !(c >= 'a' && c <= 'f') && !(c >= 'A' && c <= 'F') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user