feat: render Markdown, mathematics and Mermaid diagrams server-side
This commit is contained in:
@@ -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 < b & 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user