// Copyright (c) 2026 Petr Balvín (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 }