Commit a097b4f379

a097b4f3797a8fdad8ba856b5ef1d30bc62805f8

parent: 418efcebf3

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 05:18 UTC

texmath: convert a TeX subset to MathML

Ref #294

Layout: unified · split

internal/texmath/symbols.go added +184
@@ -0,0 +1,184 @@
1package texmath
2
3type sym struct {
4 tag string
5 text string
6 upright bool // mathvariant="normal": TeX sets capital Greek upright
7 fixed bool // stretchy="false": a bracket typed without \left
8 limits bool
9}
10
11func mi(s string) sym { return sym{tag: "mi", text: s} }
12func mo(s string) sym { return sym{tag: "mo", text: s} }
13func upright(s string) sym { return sym{tag: "mi", text: s, upright: true} }
14func bracket(s string) sym { return sym{tag: "mo", text: s, fixed: true} }
15func large(s string) sym { return sym{tag: "mo", text: s, limits: true} }
16
17var symbols = map[string]sym{
18 // Greek
19 "alpha": mi("α"), "beta": mi("β"), "gamma": mi("γ"), "delta": mi("δ"),
20 "epsilon": mi("ϵ"), "varepsilon": mi("ε"), "zeta": mi("ζ"), "eta": mi("η"),
21 "theta": mi("θ"), "vartheta": mi("ϑ"), "iota": mi("ι"), "kappa": mi("κ"),
22 "lambda": mi("λ"), "mu": mi("μ"), "nu": mi("ν"), "xi": mi("ξ"),
23 "omicron": mi("ο"), "pi": mi("π"), "varpi": mi("ϖ"), "rho": mi("ρ"),
24 "varrho": mi("ϱ"), "sigma": mi("σ"), "varsigma": mi("ς"), "tau": mi("τ"),
25 "upsilon": mi("υ"), "phi": mi("ϕ"), "varphi": mi("φ"), "chi": mi("χ"),
26 "psi": mi("ψ"), "omega": mi("ω"),
27 "Gamma": upright("Γ"), "Delta": upright("Δ"), "Theta": upright("Θ"),
28 "Lambda": upright("Λ"), "Xi": upright("Ξ"), "Pi": upright("Π"),
29 "Sigma": upright("Σ"), "Upsilon": upright("Υ"), "Phi": upright("Φ"),
30 "Psi": upright("Ψ"), "Omega": upright("Ω"),
31
32 // Letter-like
33 "infty": mi("∞"), "partial": mi("∂"), "nabla": mi("∇"), "emptyset": mi("∅"),
34 "varnothing": mi("∅"), "hbar": mi("ℏ"), "ell": mi("ℓ"), "Re": mi("ℜ"),
35 "Im": mi("ℑ"), "aleph": mi("ℵ"), "wp": mi("℘"), "imath": mi("ı"), "jmath": mi("ȷ"),
36
37 // Binary operators
38 "pm": mo("±"), "mp": mo("∓"), "times": mo("×"), "div": mo("÷"),
39 "cdot": mo("⋅"), "ast": mo("∗"), "star": mo("⋆"), "circ": mo("∘"),
40 "bullet": mo("∙"), "cap": mo("∩"), "cup": mo("∪"), "setminus": mo("∖"),
41 "wedge": mo("∧"), "land": mo("∧"), "vee": mo("∨"), "lor": mo("∨"),
42 "oplus": mo("⊕"), "ominus": mo("⊖"), "otimes": mo("⊗"), "odot": mo("⊙"),
43 "sqcup": mo("⊔"), "sqcap": mo("⊓"), "dagger": mo("†"), "ddagger": mo("‡"),
44 "lnot": mo("¬"), "neg": mo("¬"),
45
46 // Relations
47 "le": mo("≤"), "leq": mo("≤"), "ge": mo("≥"), "geq": mo("≥"),
48 "ne": mo("≠"), "neq": mo("≠"), "ll": mo("≪"), "gg": mo("≫"),
49 "approx": mo("≈"), "equiv": mo("≡"), "sim": mo("∼"), "simeq": mo("≃"),
50 "cong": mo("≅"), "propto": mo("∝"), "in": mo("∈"), "notin": mo("∉"),
51 "ni": mo("∋"), "subset": mo("⊂"), "supset": mo("⊃"), "subseteq": mo("⊆"),
52 "supseteq": mo("⊇"), "perp": mo("⊥"), "parallel": mo("∥"), "mid": mo("∣"),
53 "vdash": mo("⊢"), "models": mo("⊨"), "coloneqq": mo("≔"), "prec": mo("≺"),
54 "succ": mo("≻"), "preceq": mo("⪯"), "succeq": mo("⪰"), "doteq": mo("≐"),
55
56 // Arrows
57 "to": mo("→"), "rightarrow": mo("→"), "leftarrow": mo("←"), "gets": mo("←"),
58 "leftrightarrow": mo("↔"), "Rightarrow": mo("⇒"), "Leftarrow": mo("⇐"),
59 "Leftrightarrow": mo("⇔"), "implies": mo("⟹"), "impliedby": mo("⟸"),
60 "iff": mo("⟺"), "mapsto": mo("↦"), "longrightarrow": mo("⟶"),
61 "longleftarrow": mo("⟵"), "Longrightarrow": mo("⟹"), "uparrow": mo("↑"),
62 "downarrow": mo("↓"), "hookrightarrow": mo("↪"), "rightharpoonup": mo("⇀"),
63
64 // Logic and sets
65 "forall": mo("∀"), "exists": mo("∃"), "nexists": mo("∄"), "therefore": mo("∴"),
66 "because": mo("∵"), "top": mo("⊤"), "bot": mo("⊥"), "angle": mo("∠"),
67 "prime": mo("′"), "degree": mo("°"),
68
69 // Dots
70 "ldots": mo("…"), "dots": mo("…"), "cdots": mo("⋯"), "vdots": mo("⋮"),
71 "ddots": mo("⋱"),
72
73 // Brackets typed without \left
74 "{": bracket("{"), "}": bracket("}"), "|": bracket("‖"),
75 "langle": bracket("⟨"), "rangle": bracket("⟩"), "lvert": bracket("|"),
76 "rvert": bracket("|"), "lVert": bracket("‖"), "rVert": bracket("‖"),
77 "vert": bracket("|"), "Vert": bracket("‖"), "lfloor": bracket("⌊"),
78 "rfloor": bracket("⌋"), "lceil": bracket("⌈"), "rceil": bracket("⌉"),
79 "lbrace": bracket("{"), "rbrace": bracket("}"), "backslash": mo("\\"),
80
81 // Escaped characters
82 "%": mo("%"), "$": mo("$"), "#": mo("#"), "&": mo("&"), "_": mo("_"),
83
84 // Large operators
85 "sum": large("∑"), "prod": large("∏"), "coprod": large("∐"),
86 "bigcup": large("⋃"), "bigcap": large("⋂"), "bigvee": large("⋁"),
87 "bigwedge": large("⋀"), "bigoplus": large("⨁"), "bigotimes": large("⨂"),
88 "bigsqcup": large("⨆"),
89 "int": mo("∫"), "iint": mo("∬"), "iiint": mo("∭"), "oint": mo("∮"),
90}
91
92// spaces are the spacing commands and their widths.
93var spaces = map[string]string{
94 ",": "0.1667em", "thinspace": "0.1667em",
95 ":": "0.2222em", ">": "0.2222em", "medspace": "0.2222em",
96 ";": "0.2778em", "thickspace": "0.2778em",
97 "!": "-0.1667em", "negthinspace": "-0.1667em",
98 " ": "0.3333em", "enspace": "0.5em",
99 "quad": "1em", "qquad": "2em",
100}
101
102// functions are the named functions, set upright; true takes limits under
103// and over in display style.
104var functions = map[string]bool{
105 "sin": false, "cos": false, "tan": false, "cot": false, "sec": false, "csc": false,
106 "arcsin": false, "arccos": false, "arctan": false, "sinh": false, "cosh": false,
107 "tanh": false, "coth": false, "log": false, "ln": false, "lg": false, "exp": false,
108 "dim": false, "deg": false, "hom": false, "ker": false, "arg": false,
109 "lim": true, "liminf": true, "limsup": true, "max": true, "min": true,
110 "sup": true, "inf": true, "det": true, "gcd": true, "Pr": true,
111}
112
113type accent struct {
114 mark string
115 accent bool // mover accent="true": the mark sits close, like an accent
116 under bool
117}
118
119var accents = map[string]accent{
120 "hat": {mark: "^", accent: true}, "widehat": {mark: "^", accent: true},
121 "tilde": {mark: "~", accent: true}, "widetilde": {mark: "~", accent: true},
122 "bar": {mark: "¯", accent: true}, "overline": {mark: "‾", accent: true},
123 "vec": {mark: "→", accent: true}, "overrightarrow": {mark: "→", accent: true},
124 "dot": {mark: "˙", accent: true}, "ddot": {mark: "¨", accent: true},
125 "acute": {mark: "´", accent: true}, "grave": {mark: "`", accent: true},
126 "breve": {mark: "˘", accent: true}, "check": {mark: "ˇ", accent: true},
127 "overbrace": {mark: "⏞"},
128 "underline": {mark: "_", under: true},
129 "underbrace": {mark: "⏟", under: true},
130}
131
132var fonts = map[string]string{
133 "mathrm": "rm", "mathbf": "bf", "boldsymbol": "bf", "bm": "bf",
134 "mathbb": "bb", "mathcal": "cal", "mathscr": "cal", "mathfrak": "frak",
135 "mathsf": "sf", "mathtt": "tt", "mathit": "it",
136}
137
138var delimiters = map[string]string{
139 "{": "{", "}": "}", "lbrace": "{", "rbrace": "}", "|": "‖",
140 "langle": "⟨", "rangle": "⟩", "lvert": "|", "rvert": "|", "vert": "|",
141 "lVert": "‖", "rVert": "‖", "Vert": "‖", "lfloor": "⌊", "rfloor": "⌋",
142 "lceil": "⌈", "rceil": "⌉", "backslash": "\\",
143}
144
145// alphabet is where a font's capitals, small letters and digits start in
146// Mathematical Alphanumeric Symbols (0 where the font has none), plus the
147// letters Unicode had already encoded elsewhere and left as holes.
148type alphabet struct {
149 upper, lower, digit rune
150 holes map[rune]rune
151}
152
153var alphabets = map[string]alphabet{
154 "bf": {0x1D400, 0x1D41A, 0x1D7CE, nil},
155 "it": {0x1D434, 0x1D44E, 0, map[rune]rune{'h': 'ℎ'}},
156 "bb": {0x1D538, 0x1D552, 0x1D7D8, map[rune]rune{
157 'C': 'ℂ', 'H': 'ℍ', 'N': 'ℕ', 'P': 'ℙ', 'Q': 'ℚ', 'R': 'ℝ', 'Z': 'ℤ'}},
158 "cal": {0x1D49C, 0x1D4B6, 0, map[rune]rune{
159 'B': 'ℬ', 'E': 'ℰ', 'F': 'ℱ', 'H': 'ℋ', 'I': 'ℐ', 'L': 'ℒ', 'M': 'ℳ',
160 'R': 'ℛ', 'e': 'ℯ', 'g': 'ℊ', 'o': 'ℴ'}},
161 "frak": {0x1D504, 0x1D51E, 0, map[rune]rune{
162 'C': 'ℭ', 'H': 'ℌ', 'I': 'ℑ', 'R': 'ℜ', 'Z': 'ℨ'}},
163 "sf": {0x1D5A0, 0x1D5BA, 0x1D7E2, nil},
164 "tt": {0x1D670, 0x1D68A, 0x1D7F6, nil},
165}
166
167func alphanumeric(font string, r rune) rune {
168 a, ok := alphabets[font]
169 if !ok {
170 return r
171 }
172 if h, ok := a.holes[r]; ok {
173 return h
174 }
175 switch {
176 case r >= 'A' && r <= 'Z':
177 return a.upper + r - 'A'
178 case r >= 'a' && r <= 'z':
179 return a.lower + r - 'a'
180 case r >= '0' && r <= '9' && a.digit != 0:
181 return a.digit + r - '0'
182 }
183 return r
184}
internal/texmath/texmath.go added +749
@@ -0,0 +1,749 @@
1// Package texmath converts a subset of TeX math to MathML.
2//
3// The subset: letters, numbers and operator characters; ^ and _; braces;
4// \frac and friends, \binom, \sqrt with an optional index; Greek letters and
5// the common symbols, relations and arrows in symbols.go; function names
6// (\sin, \lim, \operatorname{...}); large operators, which take their limits
7// above and below in display style; \left, \middle and \right; accents,
8// \overline, \underline and the braces; spacing commands; \text; the font
9// commands \mathrm, \mathbf, \mathbb, \mathcal, \mathscr, \mathfrak, \mathsf,
10// \mathtt, \mathit, \boldsymbol; and the environments matrix, pmatrix,
11// bmatrix, Bmatrix, vmatrix, Vmatrix, smallmatrix, cases, aligned, align,
12// gathered, gather, split, equation and displaymath.
13//
14// Anything else, including every command that defines macros, sets colors or
15// styles, or links, is an error; the caller shows the source instead. The
16// output uses only the elements and attributes listed in Elements and Attrs,
17// and its size is linear in the input, which is capped at MaxInput bytes and
18// MaxDepth levels of nesting.
19package texmath
20
21import (
22 "errors"
23 "fmt"
24 "html"
25 "strings"
26 "unicode"
27 "unicode/utf8"
28)
29
30// Limits on one expression.
31const (
32 MaxInput = 8 << 10
33 MaxDepth = 64
34)
35
36// Elements is every MathML element Convert emits.
37var Elements = []string{
38 "math", "mrow", "mi", "mn", "mo", "mtext", "mspace",
39 "mfrac", "msqrt", "mroot", "msub", "msup", "msubsup",
40 "munder", "mover", "munderover", "mtable", "mtr", "mtd",
41}
42
43// Attrs is every attribute Convert emits, by element, with the values it
44// can take: a fixed value, or "<length>" for an em length.
45var Attrs = map[string]map[string]string{
46 "math": {"display": "block"},
47 "mi": {"mathvariant": "normal"},
48 "mo": {"stretchy": "false"},
49 "mfrac": {"linethickness": "0"},
50 "mover": {"accent": "true"},
51 "mspace": {"width": "<length>"},
52}
53
54var (
55 ErrTooLong = errors.New("texmath: expression too long")
56 ErrTooDeep = errors.New("texmath: expression nested too deeply")
57)
58
59// Convert renders tex as one <math> element; display selects block layout
60// and display-style limits.
61func Convert(tex string, display bool) (string, error) {
62 if len(tex) > MaxInput {
63 return "", ErrTooLong
64 }
65 if !utf8.ValidString(tex) {
66 return "", errors.New("texmath: invalid UTF-8")
67 }
68 p := &parser{src: tex, display: display}
69 kids, err := p.list(func(t token) bool { return false })
70 if err != nil {
71 return "", err
72 }
73 if t := p.peek(); t.kind != eof {
74 return "", p.unexpected(t)
75 }
76 root := &node{tag: "math", kids: kids}
77 if display {
78 root.attr("display", "block")
79 }
80 var b strings.Builder
81 root.write(&b)
82 return b.String(), nil
83}
84
85type node struct {
86 tag string
87 attrs [][2]string
88 text string
89 kids []*node
90 limits bool // a large operator: limits go under and over in display style
91 fn bool // a function name: a thin space follows unless a delimiter does
92}
93
94func (n *node) attr(k, v string) *node {
95 n.attrs = append(n.attrs, [2]string{k, v})
96 return n
97}
98
99func (n *node) write(b *strings.Builder) {
100 b.WriteByte('<')
101 b.WriteString(n.tag)
102 for _, a := range n.attrs {
103 b.WriteString(" " + a[0] + `="` + html.EscapeString(a[1]) + `"`)
104 }
105 b.WriteByte('>')
106 b.WriteString(html.EscapeString(n.text))
107 for _, k := range n.kids {
108 k.write(b)
109 }
110 b.WriteString("</" + n.tag + ">")
111}
112
113func el(tag string, kids ...*node) *node { return &node{tag: tag, kids: kids} }
114func leaf(tag, text string) *node { return &node{tag: tag, text: text} }
115func row(kids []*node) *node { return &node{tag: "mrow", kids: kids} }
116func space(w string) *node { return (&node{tag: "mspace"}).attr("width", w) }
117
118// fixed is an operator that must not stretch to the height of its row, the
119// way a bracket typed without \left does not in TeX.
120func fixed(s string) *node { return leaf("mo", s).attr("stretchy", "false") }
121
122type kind int
123
124const (
125 eof kind = iota
126 char // one character
127 cmd // a control sequence; val is its name without the backslash
128)
129
130type token struct {
131 kind kind
132 val string
133 pos int
134}
135
136type parser struct {
137 src string
138 pos int
139 depth int
140 display bool
141 font string
142}
143
144func (p *parser) skip() {
145 for p.pos < len(p.src) {
146 c := p.src[p.pos]
147 switch {
148 case c == ' ' || c == '\t' || c == '\n' || c == '\r':
149 p.pos++
150 case c == '%':
151 for p.pos < len(p.src) && p.src[p.pos] != '\n' {
152 p.pos++
153 }
154 default:
155 return
156 }
157 }
158}
159
160func (p *parser) peek() token {
161 save := p.pos
162 t := p.next()
163 p.pos = save
164 return t
165}
166
167func (p *parser) next() token {
168 p.skip()
169 start := p.pos
170 if p.pos >= len(p.src) {
171 return token{kind: eof, pos: start}
172 }
173 if p.src[p.pos] == '\\' {
174 p.pos++
175 if p.pos >= len(p.src) {
176 return token{kind: cmd, val: "", pos: start}
177 }
178 if isASCIILetter(p.src[p.pos]) {
179 end := p.pos
180 for end < len(p.src) && isASCIILetter(p.src[end]) {
181 end++
182 }
183 name := p.src[p.pos:end]
184 p.pos = end
185 // \operatorname* and friends: the star is part of the name.
186 if p.pos < len(p.src) && p.src[p.pos] == '*' && starred[name] {
187 p.pos++
188 name += "*"
189 }
190 return token{kind: cmd, val: name, pos: start}
191 }
192 r, n := utf8.DecodeRuneInString(p.src[p.pos:])
193 p.pos += n
194 return token{kind: cmd, val: string(r), pos: start}
195 }
196 r, n := utf8.DecodeRuneInString(p.src[p.pos:])
197 p.pos += n
198 return token{kind: char, val: string(r), pos: start}
199}
200
201var starred = map[string]bool{"operatorname": true}
202
203func isASCIILetter(c byte) bool { return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' }
204
205func (p *parser) unexpected(t token) error {
206 switch t.kind {
207 case eof:
208 return errors.New("texmath: unexpected end of input")
209 case cmd:
210 return fmt.Errorf(`texmath: unexpected \%s at %d`, t.val, t.pos)
211 }
212 return fmt.Errorf("texmath: unexpected %q at %d", t.val, t.pos)
213}
214
215func (p *parser) expect(kind kind, val string) error {
216 if t := p.next(); t.kind != kind || t.val != val {
217 return p.unexpected(t)
218 }
219 return nil
220}
221
222func isChar(t token, s string) bool { return t.kind == char && t.val == s }
223func isCmd(t token, s string) bool { return t.kind == cmd && t.val == s }
224
225// list parses atoms until the input ends or stop matches the next token,
226// which is left unread.
227func (p *parser) list(stop func(token) bool) ([]*node, error) {
228 var out []*node
229 for {
230 t := p.peek()
231 if t.kind == eof || stop(t) {
232 return out, nil
233 }
234 n, err := p.scripted()
235 if err != nil {
236 return nil, err
237 }
238 if n == nil {
239 continue
240 }
241 out = append(out, n)
242 if n.fn && !p.delimiterNext() {
243 out = append(out, space("0.1667em"))
244 }
245 }
246}
247
248// delimiterNext reports whether the next token closes a group or opens a
249// bracket, where TeX puts no space after a function name.
250func (p *parser) delimiterNext() bool {
251 t := p.peek()
252 if t.kind == eof {
253 return true
254 }
255 if t.kind == char {
256 return strings.Contains("()[]{}|&.,;", t.val)
257 }
258 switch t.val {
259 case "left", "right", "\\", "end", ",", ";", "!", "quad", "qquad", "{", "}":
260 return true
261 }
262 return false
263}
264
265// scripted parses one atom and any sub- and superscripts attached to it.
266func (p *parser) scripted() (*node, error) {
267 var base *node
268 if t := p.peek(); !isChar(t, "^") && !isChar(t, "_") {
269 var err error
270 if base, err = p.atom(); err != nil {
271 return nil, err
272 }
273 }
274 var sub, sup *node
275 limits := base != nil && base.limits && p.display
276 for {
277 t := p.peek()
278 switch {
279 case isCmd(t, "limits"):
280 p.next()
281 limits = base != nil && base.limits
282 continue
283 case isCmd(t, "nolimits"):
284 p.next()
285 limits = false
286 continue
287 case isChar(t, "^"), isChar(t, "_"):
288 default:
289 if sub == nil && sup == nil {
290 return base, nil
291 }
292 if base == nil {
293 base = row(nil)
294 }
295 return script(base, sub, sup, limits), nil
296 }
297 p.next()
298 arg, err := p.arg()
299 if err != nil {
300 return nil, err
301 }
302 if t.val == "^" {
303 if sup != nil {
304 return nil, fmt.Errorf("texmath: double superscript at %d", t.pos)
305 }
306 sup = arg
307 } else {
308 if sub != nil {
309 return nil, fmt.Errorf("texmath: double subscript at %d", t.pos)
310 }
311 sub = arg
312 }
313 }
314}
315
316func script(base, sub, sup *node, limits bool) *node {
317 fn := base.fn
318 var n *node
319 switch {
320 case limits && sub != nil && sup != nil:
321 n = el("munderover", base, sub, sup)
322 case limits && sub != nil:
323 n = el("munder", base, sub)
324 case limits:
325 n = el("mover", base, sup)
326 case sub != nil && sup != nil:
327 n = el("msubsup", base, sub, sup)
328 case sub != nil:
329 n = el("msub", base, sub)
330 default:
331 n = el("msup", base, sup)
332 }
333 n.fn = fn
334 return n
335}
336
337// arg parses a command's argument: a braced group, or else one token.
338func (p *parser) arg() (*node, error) {
339 t := p.peek()
340 switch {
341 case t.kind == eof:
342 return nil, p.unexpected(t)
343 case t.kind == char && strings.Contains("}&^_", t.val):
344 return nil, p.unexpected(t)
345 case t.kind == char && t.val >= "0" && t.val <= "9":
346 p.next()
347 return leaf("mn", p.styled(t.val)), nil
348 }
349 return p.atom()
350}
351
352func (p *parser) enter() error {
353 p.depth++
354 if p.depth > MaxDepth {
355 return ErrTooDeep
356 }
357 return nil
358}
359
360// atom parses one atom. A nil node with a nil error is a command that
361// renders nothing, such as \displaystyle.
362func (p *parser) atom() (*node, error) {
363 if err := p.enter(); err != nil {
364 return nil, err
365 }
366 defer func() { p.depth-- }()
367 t := p.next()
368 switch t.kind {
369 case eof:
370 return nil, p.unexpected(t)
371 case char:
372 return p.charAtom(t)
373 }
374 return p.command(t)
375}
376
377func (p *parser) charAtom(t token) (*node, error) {
378 r, _ := utf8.DecodeRuneInString(t.val)
379 switch {
380 case t.val == "{":
381 font := p.font
382 kids, err := p.list(func(t token) bool { return isChar(t, "}") })
383 p.font = font
384 if err != nil {
385 return nil, err
386 }
387 if err := p.expect(char, "}"); err != nil {
388 return nil, err
389 }
390 return row(kids), nil
391 case r >= '0' && r <= '9':
392 num := t.val
393 for p.pos < len(p.src) {
394 c := p.src[p.pos]
395 if c >= '0' && c <= '9' || c == '.' && p.pos+1 < len(p.src) && p.src[p.pos+1] >= '0' && p.src[p.pos+1] <= '9' {
396 num += string(c)
397 p.pos++
398 continue
399 }
400 break
401 }
402 return leaf("mn", p.styled(num)), nil
403 case unicode.IsLetter(r):
404 n := leaf("mi", p.styled(t.val))
405 if p.font == "rm" {
406 n.attr("mathvariant", "normal")
407 }
408 return n, nil
409 }
410 switch t.val {
411 case "}", "&", "$", "#", "\\":
412 return nil, p.unexpected(t)
413 case "(", ")", "[", "]", "|", "/":
414 return fixed(t.val), nil
415 case "-":
416 return leaf("mo", "−"), nil
417 case "*":
418 return leaf("mo", "∗"), nil
419 case "'":
420 return leaf("mo", "′"), nil
421 case "~":
422 return space("0.3333em"), nil
423 }
424 if r < 0x20 || r == utf8.RuneError {
425 return nil, p.unexpected(t)
426 }
427 return leaf("mo", t.val), nil
428}
429
430func (p *parser) command(t token) (*node, error) {
431 name := t.val
432 if s, ok := symbols[name]; ok {
433 n := leaf(s.tag, s.text)
434 if s.upright {
435 n.attr("mathvariant", "normal")
436 }
437 if s.fixed {
438 n.attr("stretchy", "false")
439 }
440 n.limits = s.limits
441 return n, nil
442 }
443 if w, ok := spaces[name]; ok {
444 return space(w), nil
445 }
446 if f, ok := functions[name]; ok {
447 n := leaf("mi", name)
448 n.limits, n.fn = f, true
449 return n, nil
450 }
451 if a, ok := accents[name]; ok {
452 arg, err := p.arg()
453 if err != nil {
454 return nil, err
455 }
456 if a.under {
457 return el("munder", arg, leaf("mo", a.mark)), nil
458 }
459 n := el("mover", arg, leaf("mo", a.mark))
460 if a.accent {
461 n.attr("accent", "true")
462 }
463 return n, nil
464 }
465 if f, ok := fonts[name]; ok {
466 font := p.font
467 p.font = f
468 arg, err := p.arg()
469 p.font = font
470 return arg, err
471 }
472 switch name {
473 case "frac", "dfrac", "tfrac", "cfrac":
474 num, err := p.arg()
475 if err != nil {
476 return nil, err
477 }
478 den, err := p.arg()
479 if err != nil {
480 return nil, err
481 }
482 return el("mfrac", num, den), nil
483 case "binom", "dbinom", "tbinom":
484 top, err := p.arg()
485 if err != nil {
486 return nil, err
487 }
488 bottom, err := p.arg()
489 if err != nil {
490 return nil, err
491 }
492 frac := el("mfrac", top, bottom).attr("linethickness", "0")
493 return row([]*node{leaf("mo", "("), frac, leaf("mo", ")")}), nil
494 case "sqrt":
495 var index *node
496 if isChar(p.peek(), "[") {
497 p.next()
498 kids, err := p.list(func(t token) bool { return isChar(t, "]") })
499 if err != nil {
500 return nil, err
501 }
502 if err := p.expect(char, "]"); err != nil {
503 return nil, err
504 }
505 index = row(kids)
506 }
507 arg, err := p.arg()
508 if err != nil {
509 return nil, err
510 }
511 if index != nil {
512 return el("mroot", arg, index), nil
513 }
514 return el("msqrt", arg), nil
515 case "left":
516 open, err := p.delimiter()
517 if err != nil {
518 return nil, err
519 }
520 kids, err := p.list(func(t token) bool { return isCmd(t, "right") })
521 if err != nil {
522 return nil, err
523 }
524 if err := p.expect(cmd, "right"); err != nil {
525 return nil, err
526 }
527 cls, err := p.delimiter()
528 if err != nil {
529 return nil, err
530 }
531 var out []*node
532 if open != "" {
533 out = append(out, leaf("mo", open))
534 }
535 out = append(out, kids...)
536 if cls != "" {
537 out = append(out, leaf("mo", cls))
538 }
539 return row(out), nil
540 case "middle":
541 d, err := p.delimiter()
542 if err != nil {
543 return nil, err
544 }
545 return leaf("mo", d), nil
546 case "text", "textrm", "textnormal", "mbox":
547 s, err := p.rawGroup()
548 if err != nil {
549 return nil, err
550 }
551 return leaf("mtext", s), nil
552 case "operatorname", "operatorname*":
553 s, err := p.rawGroup()
554 if err != nil {
555 return nil, err
556 }
557 n := leaf("mi", s)
558 if utf8.RuneCountInString(s) == 1 {
559 n.attr("mathvariant", "normal")
560 }
561 n.limits, n.fn = name == "operatorname*", true
562 return n, nil
563 case "begin":
564 return p.environment()
565 case "displaystyle", "textstyle", "scriptstyle", "scriptscriptstyle", "limits", "nolimits":
566 return nil, nil
567 }
568 return nil, p.unexpected(t)
569}
570
571// rawGroup reads a braced argument as text, for \text and \operatorname.
572// Nested braces must balance; \{, \}, \$, \%, \&, \#, \_ and \\ stand for
573// the character.
574func (p *parser) rawGroup() (string, error) {
575 if err := p.expect(char, "{"); err != nil {
576 return "", err
577 }
578 var b strings.Builder
579 depth := 0
580 for p.pos < len(p.src) {
581 c := p.src[p.pos]
582 switch {
583 case c == '\\' && p.pos+1 < len(p.src) && strings.IndexByte(`{}$%&#_\`, p.src[p.pos+1]) >= 0:
584 b.WriteByte(p.src[p.pos+1])
585 p.pos += 2
586 continue
587 case c == '{':
588 depth++
589 case c == '}':
590 if depth == 0 {
591 p.pos++
592 return b.String(), nil
593 }
594 depth--
595 }
596 b.WriteByte(c)
597 p.pos++
598 }
599 return "", errors.New("texmath: unterminated group")
600}
601
602// delimiter reads what follows \left, \middle or \right; "." is none.
603func (p *parser) delimiter() (string, error) {
604 t := p.next()
605 if t.kind == char {
606 switch t.val {
607 case ".":
608 return "", nil
609 case "<":
610 return "⟨", nil
611 case ">":
612 return "⟩", nil
613 case "(", ")", "[", "]", "|", "/":
614 return t.val, nil
615 }
616 }
617 if t.kind == cmd {
618 if d, ok := delimiters[t.val]; ok {
619 return d, nil
620 }
621 }
622 return "", p.unexpected(t)
623}
624
625type env struct {
626 open, close string
627 table bool
628}
629
630var environments = map[string]env{
631 "matrix": {table: true},
632 "smallmatrix": {table: true},
633 "pmatrix": {"(", ")", true},
634 "bmatrix": {"[", "]", true},
635 "Bmatrix": {"{", "}", true},
636 "vmatrix": {"|", "|", true},
637 "Vmatrix": {"‖", "‖", true},
638 "cases": {"{", "", true},
639 "aligned": {table: true},
640 "align": {table: true},
641 "align*": {table: true},
642 "gathered": {table: true},
643 "gather": {table: true},
644 "gather*": {table: true},
645 "split": {table: true},
646 "equation": {},
647 "equation*": {},
648 "displaymath": {},
649}
650
651func (p *parser) envName() (string, error) {
652 if err := p.expect(char, "{"); err != nil {
653 return "", err
654 }
655 end := strings.IndexByte(p.src[p.pos:], '}')
656 if end < 0 {
657 return "", errors.New("texmath: unterminated environment name")
658 }
659 name := p.src[p.pos : p.pos+end]
660 p.pos += end + 1
661 return name, nil
662}
663
664func (p *parser) environment() (*node, error) {
665 name, err := p.envName()
666 if err != nil {
667 return nil, err
668 }
669 e, ok := environments[name]
670 if !ok {
671 return nil, fmt.Errorf("texmath: unsupported environment %q", name)
672 }
673 var body *node
674 if e.table {
675 body, err = p.table()
676 } else {
677 var kids []*node
678 kids, err = p.list(func(t token) bool { return isCmd(t, "end") })
679 body = row(kids)
680 }
681 if err != nil {
682 return nil, err
683 }
684 if err := p.expect(cmd, "end"); err != nil {
685 return nil, err
686 }
687 if end, err := p.envName(); err != nil {
688 return nil, err
689 } else if end != name {
690 return nil, fmt.Errorf(`texmath: \begin{%s} ended by \end{%s}`, name, end)
691 }
692 if e.open == "" && e.close == "" {
693 return body, nil
694 }
695 out := []*node{}
696 if e.open != "" {
697 out = append(out, leaf("mo", e.open))
698 }
699 out = append(out, body)
700 if e.close != "" {
701 out = append(out, leaf("mo", e.close))
702 }
703 return row(out), nil
704}
705
706// table parses rows separated by \\ and cells separated by &, up to \end.
707func (p *parser) table() (*node, error) {
708 stop := func(t token) bool { return isChar(t, "&") || isCmd(t, "\\") || isCmd(t, "end") }
709 tbl := el("mtable")
710 cur := el("mtr")
711 for {
712 kids, err := p.list(stop)
713 if err != nil {
714 return nil, err
715 }
716 cur.kids = append(cur.kids, el("mtd", kids...))
717 t := p.peek()
718 switch {
719 case isChar(t, "&"):
720 p.next()
721 case isCmd(t, "\\"):
722 p.next()
723 tbl.kids = append(tbl.kids, cur)
724 cur = el("mtr")
725 case isCmd(t, "end"):
726 // A trailing \\ leaves one empty cell; it is not a row.
727 if len(cur.kids) > 1 || len(cur.kids[0].kids) > 0 {
728 tbl.kids = append(tbl.kids, cur)
729 }
730 return tbl, nil
731 default:
732 return nil, p.unexpected(t)
733 }
734 }
735}
736
737// styled maps letters and digits into the current font's Mathematical
738// Alphanumeric Symbols, which is how MathML Core spells \mathbb and the rest:
739// mathvariant is honoured only as "normal".
740func (p *parser) styled(s string) string {
741 if p.font == "" || p.font == "rm" {
742 return s
743 }
744 var b strings.Builder
745 for _, r := range s {
746 b.WriteRune(alphanumeric(p.font, r))
747 }
748 return b.String()
749}
internal/texmath/texmath_test.go added +169
@@ -0,0 +1,169 @@
1package texmath
2
3import (
4 "regexp"
5 "strings"
6 "testing"
7 "time"
8)
9
10func TestConvert(t *testing.T) {
11 cases := []struct {
12 tex, want string
13 }{
14 {`x`, `<math><mi>x</mi></math>`},
15 {`12.5+x`, `<math><mn>12.5</mn><mo>+</mo><mi>x</mi></math>`},
16 {`a-b`, `<math><mi>a</mi><mo>−</mo><mi>b</mi></math>`},
17 {`x^2`, `<math><msup><mi>x</mi><mn>2</mn></msup></math>`},
18 {`x_i^2`, `<math><msubsup><mi>x</mi><mi>i</mi><mn>2</mn></msubsup></math>`},
19 {`x^{2n}`, `<math><msup><mi>x</mi><mrow><mn>2</mn><mi>n</mi></mrow></msup></math>`},
20 {`\frac{a}{b}`, `<math><mfrac><mrow><mi>a</mi></mrow><mrow><mi>b</mi></mrow></mfrac></math>`},
21 {`\frac12`, `<math><mfrac><mn>1</mn><mn>2</mn></mfrac></math>`},
22 {`\sqrt{x}`, `<math><msqrt><mrow><mi>x</mi></mrow></msqrt></math>`},
23 {`\sqrt[3]{x}`, `<math><mroot><mrow><mi>x</mi></mrow><mrow><mn>3</mn></mrow></mroot></math>`},
24 {`\alpha\Gamma`, `<math><mi>α</mi><mi mathvariant="normal">Γ</mi></math>`},
25 {`a\le b`, `<math><mi>a</mi><mo>≤</mo><mi>b</mi></math>`},
26 {`(a)`, `<math><mo stretchy="false">(</mo><mi>a</mi><mo stretchy="false">)</mo></math>`},
27 {`\left( x \right.`, `<math><mrow><mo>(</mo><mi>x</mi></mrow></math>`},
28 {`\left\{ x \middle| y \right\}`, `<math><mrow><mo>{</mo><mi>x</mi><mo>|</mo><mi>y</mi><mo>}</mo></mrow></math>`},
29 {`\sin x`, `<math><mi>sin</mi><mspace width="0.1667em"></mspace><mi>x</mi></math>`},
30 {`\sin(x)`, `<math><mi>sin</mi><mo stretchy="false">(</mo><mi>x</mi><mo stretchy="false">)</mo></math>`},
31 {`\text{if } x`, `<math><mtext>if </mtext><mi>x</mi></math>`},
32 {`\mathbb{R}\mathbf{v}`, `<math><mrow><mi>ℝ</mi></mrow><mrow><mi>𝐯</mi></mrow></math>`},
33 {`\mathrm{d}x`, `<math><mrow><mi mathvariant="normal">d</mi></mrow><mi>x</mi></math>`},
34 {`\hat{x}`, `<math><mover accent="true"><mrow><mi>x</mi></mrow><mo>^</mo></mover></math>`},
35 {`a\,b`, `<math><mi>a</mi><mspace width="0.1667em"></mspace><mi>b</mi></math>`},
36 {`\binom{n}{k}`, `<math><mrow><mo>(</mo><mfrac linethickness="0"><mrow><mi>n</mi></mrow><mrow><mi>k</mi></mrow></mfrac><mo>)</mo></mrow></math>`},
37 {`\begin{pmatrix}a&b\\c&d\end{pmatrix}`, `<math><mrow><mo>(</mo><mtable><mtr><mtd><mi>a</mi></mtd><mtd><mi>b</mi></mtd></mtr><mtr><mtd><mi>c</mi></mtd><mtd><mi>d</mi></mtd></mtr></mtable><mo>)</mo></mrow></math>`},
38 {`\begin{matrix}a\\\end{matrix}`, `<math><mtable><mtr><mtd><mi>a</mi></mtd></mtr></mtable></math>`},
39 {`x % comment` + "\n" + `+1`, `<math><mi>x</mi><mo>+</mo><mn>1</mn></math>`},
40 {`a<b`, `<math><mi>a</mi><mo>&lt;</mo><mi>b</mi></math>`},
41 }
42 for _, c := range cases {
43 got, err := Convert(c.tex, false)
44 if err != nil {
45 t.Errorf("Convert(%q): %v", c.tex, err)
46 continue
47 }
48 if got != c.want {
49 t.Errorf("Convert(%q)\n got %s\nwant %s", c.tex, got, c.want)
50 }
51 }
52}
53
54func TestConvertDisplayLimits(t *testing.T) {
55 got, err := Convert(`\sum_{i=1}^n i`, true)
56 if err != nil {
57 t.Fatal(err)
58 }
59 if !strings.HasPrefix(got, `<math display="block"><munderover><mo>∑</mo>`) {
60 t.Errorf("display sum: %s", got)
61 }
62 got, _ = Convert(`\sum_{i=1}^n i`, false)
63 if !strings.HasPrefix(got, `<math><msubsup><mo>∑</mo>`) {
64 t.Errorf("inline sum: %s", got)
65 }
66 got, _ = Convert(`\lim_{x\to 0} f`, true)
67 if !strings.HasPrefix(got, `<math display="block"><munder><mi>lim</mi>`) {
68 t.Errorf("display lim: %s", got)
69 }
70}
71
72func TestConvertRefuses(t *testing.T) {
73 for _, tex := range []string{
74 `\frac{a}`, `x^`, `{x`, `x}`, `x^1^2`, `\left( x`, `\right)`,
75 `\begin{pmatrix}a\end{bmatrix}`, `\begin{tabular}x\end{tabular}`,
76 `a & b`, `a \\ b`, `\unknown`,
77 `\href{javascript:alert(1)}{x}`, `\url{javascript:alert(1)}`,
78 `\style{color:red}{x}`, `\color{red}{x}`, `\class{a}{x}`, `\htmlId{a}{x}`,
79 `\def\a{x}\a`, `\newcommand{\a}{x}`, `\require{html}`, `\unicode{x}`,
80 `\includegraphics{x}`, `\input{/etc/passwd}`,
81 } {
82 if out, err := Convert(tex, false); err == nil {
83 t.Errorf("Convert(%q) = %s, want an error", tex, out)
84 }
85 }
86}
87
88func TestConvertBounds(t *testing.T) {
89 deep := strings.Repeat("{", 100000) + "x" + strings.Repeat("}", 100000)
90 fracs := strings.Repeat(`\frac{`, MaxDepth+1) + "x"
91 for _, tex := range []string{deep, fracs, strings.Repeat("x", MaxInput+1)} {
92 start := time.Now()
93 if _, err := Convert(tex, false); err == nil {
94 t.Errorf("Convert(%.20q...) succeeded", tex)
95 }
96 if d := time.Since(start); d > time.Second {
97 t.Errorf("Convert(%.20q...) took %v", tex, d)
98 }
99 }
100 // The largest accepted input: output stays linear in it.
101 tex := strings.Repeat(`{}`, MaxInput/2)
102 start := time.Now()
103 out, err := Convert(tex, false)
104 if err != nil {
105 t.Fatal(err)
106 }
107 if len(out) > 16*MaxInput {
108 t.Errorf("output %d bytes for %d bytes of input", len(out), len(tex))
109 }
110 if d := time.Since(start); d > time.Second {
111 t.Errorf("took %v", d)
112 }
113}
114
115var tagAttr = regexp.MustCompile(`<([a-z]+)((?: [a-z]+="[^"]*")*)>`)
116var attrPair = regexp.MustCompile(` ([a-z]+)="([^"]*)"`)
117
118// Every element and attribute in the output is one Elements and Attrs name,
119// so the sanitizer's allowlist built from them is complete.
120func TestConvertEmitsOnlyListed(t *testing.T) {
121 allowed := map[string]bool{}
122 for _, e := range Elements {
123 allowed[e] = true
124 }
125 var inputs []string
126 for name := range symbols {
127 inputs = append(inputs, `\`+name)
128 }
129 for name := range functions {
130 inputs = append(inputs, `\`+name+`_a^b x`)
131 }
132 for name := range accents {
133 inputs = append(inputs, `\`+name+`{x}`)
134 }
135 for name := range spaces {
136 inputs = append(inputs, `a\`+name+` b`)
137 }
138 for name := range fonts {
139 inputs = append(inputs, `\`+name+`{Ab1}`)
140 }
141 for name := range environments {
142 inputs = append(inputs, `\begin{`+name+`}a&b\\c&d\end{`+name+`}`)
143 }
144 inputs = append(inputs, `\binom12`, `\sqrt[3]{x}`, `\left(\middle|\right)`, `\text{a}`,
145 `\operatorname{rank}A`, `\operatorname*{arg\,max}_x`, `x_1^2`, `\sum_1^2`, `\sum_1`, `\sum^2`, `(a)~'`)
146 for _, in := range inputs {
147 for _, display := range []bool{false, true} {
148 out, err := Convert(in, display)
149 if err != nil {
150 if strings.HasPrefix(in, `\begin{`) {
151 continue // equation and friends take no & or \\
152 }
153 t.Errorf("Convert(%q): %v", in, err)
154 continue
155 }
156 for _, m := range tagAttr.FindAllStringSubmatch(out, -1) {
157 if !allowed[m[1]] {
158 t.Errorf("%q emits <%s>", in, m[1])
159 }
160 for _, a := range attrPair.FindAllStringSubmatch(m[2], -1) {
161 want, ok := Attrs[m[1]][a[1]]
162 if !ok || (want != "<length>" && want != a[2]) {
163 t.Errorf("%q emits %s %s=%q", in, m[1], a[1], a[2])
164 }
165 }
166 }
167 }
168 }
169}