571 lines
14 KiB
Go
571 lines
14 KiB
Go
// 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
|
|
}
|