feat: render Markdown, mathematics and Mermaid diagrams server-side

This commit is contained in:
2026-09-27 19:15:50 +02:00
commit 35bc42623b
72 changed files with 8914 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package mathml
import (
"bytes"
"encoding/xml"
"testing"
)
// corpus is the project's own hand-written corpus: every case states a TeX
// input and the exact MathML Core the engine produces for it.
var corpus = []struct {
name string
input string
want string
}{
{"single letter", "x", `<mi>x</mi>`},
{"digits", "42", `<mn>42</mn>`},
{"decimal", "1.5", `<mn>1.5</mn>`},
{"sum", "a+b", `<mi>a</mi><mo>+</mo><mi>b</mi>`},
{"greek", `\alpha + \beta`, `<mi>α</mi><mo>+</mo><mi>β</mi>`},
{"uppercase greek", `\Gamma`, `<mi>Γ</mi>`},
{"relation", `a \leq b`, `<mi>a</mi><mo>≤</mo><mi>b</mi>`},
{"operator with limits", `\sum_{i=1}^{n} i`,
`<msubsup><mo movablelimits="true">∑</mo><mrow><mi>i</mi><mo>=</mo><mn>1</mn></mrow><mi>n</mi></msubsup><mi>i</mi>`},
{"integral keeps side scripts", `\int_0^1 x`,
`<msubsup><mo>∫</mo><mn>0</mn><mn>1</mn></msubsup><mi>x</mi>`},
{"fraction", `\frac{a}{b}`, `<mfrac><mi>a</mi><mi>b</mi></mfrac>`},
{"fraction shorthand", `\frac12`, `<mfrac><mn>1</mn><mn>2</mn></mfrac>`},
{"binom", `\binom{n}{k}`,
`<mrow><mo fence="true" stretchy="true">(</mo><mfrac linethickness="0em"><mi>n</mi><mi>k</mi></mfrac><mo fence="true" stretchy="true">)</mo></mrow>`},
{"over infix", `a \over b`, `<mfrac><mi>a</mi><mi>b</mi></mfrac>`},
{"choose infix", `a \choose b`,
`<mrow><mo fence="true" stretchy="true">(</mo><mfrac linethickness="0em"><mi>a</mi><mi>b</mi></mfrac><mo fence="true" stretchy="true">)</mo></mrow>`},
{"sqrt", `\sqrt{x}`, `<msqrt><mi>x</mi></msqrt>`},
{"root with index", `\sqrt[3]{x}`, `<mroot><mi>x</mi><mn>3</mn></mroot>`},
{"sub and sup", `x_1^2`, `<msubsup><mi>x</mi><mn>1</mn><mn>2</mn></msubsup>`},
{"single digit script", `x^10`, `<msup><mi>x</mi><mn>1</mn></msup><mn>0</mn>`},
{"group", `{xy}`, `<mrow><mi>x</mi><mi>y</mi></mrow>`},
{"left right", `\left( x \right)`,
`<mrow><mo fence="true" stretchy="true">(</mo><mi>x</mi><mo fence="true" stretchy="true">)</mo></mrow>`},
{"left right invisible", `\left. x \right)`,
`<mrow><mo fence="true"></mo><mi>x</mi><mo fence="true" stretchy="true">)</mo></mrow>`},
{"function", `\sin x`, `<mi>sin</mi><mi>x</mi>`},
{"limit under", `\lim_{x \to 0}`,
`<msub><mi movablelimits="true">lim</mi><mrow><mi>x</mi><mo>→</mo><mn>0</mn></mrow></msub>`},
{"accent", `\hat{x}`, `<mover><mi>x</mi><mo stretchy="false">^</mo></mover>`},
{"vec accent", `\vec{v}`, `<mover><mi>v</mi><mo stretchy="false">→</mo></mover>`},
{"overline", `\overline{x}`, `<mover><mi>x</mi><mo stretchy="true">‾</mo></mover>`},
{"text", `\text{if}`, `<mtext>if</mtext>`},
{"text keeps spaces", `\text{hello world}`, `<mtext>hello world</mtext>`},
{"mathrm", `\mathrm{d}x`, `<mi mathvariant="normal">d</mi><mi>x</mi>`},
{"mathbb", `\mathbb{R}`, `<mi mathvariant="double-struck">R</mi>`},
{"operatorname", `\operatorname{sgn}`, `<mi>sgn</mi>`},
{"spacing", `a \, b`, `<mi>a</mi><mspace width="0.1667em"></mspace><mi>b</mi>`},
{"quad", `a \quad b`, `<mi>a</mi><mspace width="1em"></mspace><mi>b</mi>`},
{"prime", `x'`, `<mi>x</mi><mo>′</mo>`},
{"escaped brace", `\{x\}`, `<mo>{</mo><mi>x</mi><mo>}</mo>`},
{"overset", `\overset{a}{b}`, `<mover><mi>b</mi><mi>a</mi></mover>`},
{"matrix", `\begin{matrix} a & b \\ c & d \end{matrix}`,
`<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>`},
{"pmatrix", `\begin{pmatrix} a \\ b \end{pmatrix}`,
`<mrow><mo fence="true" stretchy="true">(</mo><mtable><mtr><mtd><mi>a</mi></mtd></mtr><mtr><mtd><mi>b</mi></mtd></mtr></mtable><mo fence="true" stretchy="true">)</mo></mrow>`},
{"cases", `\begin{cases} a & b \\ c & d \end{cases}`,
`<mrow><mo fence="true" stretchy="true">{</mo><mtable columnalign="left"><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 fence="true"></mo></mrow>`},
{"aligned", `\begin{aligned} a &= b \\ c &= d \end{aligned}`,
`<mtable columnalign="right left"><mtr><mtd><mi>a</mi></mtd><mtd><mo>=</mo><mi>b</mi></mtd></mtr><mtr><mtd><mi>c</mi></mtd><mtd><mo>=</mo><mi>d</mi></mtd></mtr></mtable>`},
{"array spec", `\begin{array}{c|l} a & b \end{array}`,
`<mtable><mtr><mtd><mi>a</mi></mtd><mtd><mi>b</mi></mtd></mtr></mtable>`},
{"macro", `\newcommand{\R}{\mathbb{R}} \R`,
`<mi mathvariant="double-struck">R</mi>`},
{"macro with argument", `\newcommand{\ip}[2]{\langle #1, #2 \rangle} \ip{a}{b}`,
`<mo>⟨</mo><mi>a</mi><mo>,</mo><mi>b</mi><mo>⟩</mo>`},
{"def", `\def\dx{\mathrm{d}x} \dx`,
`<mi mathvariant="normal">d</mi><mi>x</mi>`},
{"escaping in text", `\text{a < b & c}`,
`<mtext>a &lt; b &amp; c</mtext>`},
{"unicode letter", `λ`, `<mi>λ</mi>`},
{"pmod", `x \pmod n`,
`<mi>x</mi><mrow><mo fence="true" stretchy="true">(</mo><mrow><mi>mod</mi><mspace width="0.2778em"></mspace><mi>n</mi></mrow><mo fence="true" stretchy="true">)</mo></mrow>`},
{"colon relation precomposed", `f \coloneqq g`,
`<mi>f</mi><mo>≔</mo><mi>g</mi>`},
{"colon relation linear", `f \coloneq g`,
`<mi>f</mi><mo>:−</mo><mi>g</mi>`},
{"colon relation double", `f \Coloneqq g`,
`<mi>f</mi><mo>∷=</mo><mi>g</mi>`},
{"eqqcolon", `a \eqqcolon b`,
`<mi>a</mi><mo>≕</mo><mi>b</mi>`},
}
func TestCorpus(t *testing.T) {
for _, tc := range corpus {
t.Run(tc.name, func(t *testing.T) {
got := string(Render([]byte(tc.input), false))
want := `<math xmlns="http://www.w3.org/1998/Math/MathML">` + tc.want + `</math>`
if got != want {
t.Errorf("input %q\ngot: %s\nwant: %s", tc.input, got, want)
}
})
}
}
// degrade holds the inputs whose constructs lie outside the mappable
// surface: nothing may disappear, everything stays as verbatim source in
// a marked element.
var degrade = []struct {
name string
input string
}{
{"unknown command", `\tikz{x}`},
{"unsupported environment", `\begin{tikzpicture} \draw (0,0); \end{tikzpicture}`},
{"unclosed group", `{x`},
{"unclosed environment", `\begin{matrix} a \end{pmatrix}`},
{"reserved character", `a # b`},
{"stray ampersand", `a & b`},
{"stray row end", `a \\ b`},
{"recursive macro", `\newcommand{\x}{\x}\x`},
{"boxed", `\boxed{x}`},
{"sideset", `\sideset{_a^b}{_c^d}\sum`},
{"tag", `\tag{1} x`},
}
func TestDegradation(t *testing.T) {
for _, tc := range degrade {
t.Run(tc.name, func(t *testing.T) {
out := Render([]byte(tc.input), false)
if !bytes.Contains(out, []byte("<merror>")) {
t.Errorf("input %q produced no merror:\n%s", tc.input, out)
}
if !wellFormed(out) {
t.Errorf("input %q produced malformed XML:\n%s", tc.input, out)
}
})
}
}
func TestVerbatimSourceKept(t *testing.T) {
out := string(Render([]byte(`a + \unknowncmd b`), false))
want := `<merror><mtext>\unknowncmd</mtext></merror>`
if !bytes.Contains([]byte(out), []byte(want)) {
t.Errorf("degraded construct lost its source:\n%s", out)
}
}
func TestDeterministicOutput(t *testing.T) {
source := []byte(`\frac{1}{2}\sqrt[3]{x}\begin{pmatrix} a & b \\ c & d \end{pmatrix}\sum_{i=1}^{n} i`)
first := Render(source, true)
for range 5 {
if next := Render(source, true); !bytes.Equal(first, next) {
t.Fatal("Render of the same source differs between calls")
}
}
}
// wellFormed checks that the output parses as XML.
func wellFormed(out []byte) bool {
dec := xml.NewDecoder(bytes.NewReader(out))
for {
_, err := dec.Token()
if err != nil {
return err.Error() == "EOF"
}
}
}
func TestAllCorpusWellFormed(t *testing.T) {
for _, tc := range corpus {
if out := Render([]byte(tc.input), false); !wellFormed(out) {
t.Errorf("input %q produced malformed XML:\n%s", tc.input, out)
}
}
}
func TestDisplayBlock(t *testing.T) {
if got := string(Render([]byte("x"), true)); got != `<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><mi>x</mi></math>` {
t.Errorf("display form = %s", got)
}
}
func TestMacroDepthBounded(t *testing.T) {
// A macro that doubles itself grows past any depth limit; the parser
// must degrade rather than hang or exhaust memory.
out := Render([]byte(`\newcommand{\a}{\a\a}`+"\n"+`\a`), false)
if !bytes.Contains(out, []byte("<merror>")) {
t.Errorf("unbounded macro produced no merror:\n%s", out)
}
}