Files
scriptorium/internal/mathml/command.go
T

571 lines
14 KiB
Go
Raw Normal View History

// 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
}