internal/texmath/texmath_test.go

v1.43.0
gitbay/internal/texmath/texmath_test.go history · blame · raw

224 lines · 8255 bytes

  1package texmath
  2
  3import (
  4	"encoding/xml"
  5	"errors"
  6	"io"
  7	"regexp"
  8	"strings"
  9	"testing"
 10	"time"
 11)
 12
 13func TestConvert(t *testing.T) {
 14	cases := []struct {
 15		tex, want string
 16	}{
 17		{`x`, `<math><mi>x</mi></math>`},
 18		{`12.5+x`, `<math><mn>12.5</mn><mo>+</mo><mi>x</mi></math>`},
 19		{`a-b`, `<math><mi>a</mi><mo>−</mo><mi>b</mi></math>`},
 20		{`x^2`, `<math><msup><mi>x</mi><mn>2</mn></msup></math>`},
 21		{`x_i^2`, `<math><msubsup><mi>x</mi><mi>i</mi><mn>2</mn></msubsup></math>`},
 22		{`x^{2n}`, `<math><msup><mi>x</mi><mrow><mn>2</mn><mi>n</mi></mrow></msup></math>`},
 23		{`\frac{a}{b}`, `<math><mfrac><mrow><mi>a</mi></mrow><mrow><mi>b</mi></mrow></mfrac></math>`},
 24		{`\frac12`, `<math><mfrac><mn>1</mn><mn>2</mn></mfrac></math>`},
 25		{`\sqrt{x}`, `<math><msqrt><mrow><mi>x</mi></mrow></msqrt></math>`},
 26		{`\sqrt[3]{x}`, `<math><mroot><mrow><mi>x</mi></mrow><mrow><mn>3</mn></mrow></mroot></math>`},
 27		{`\alpha\Gamma`, `<math><mi>α</mi><mi mathvariant="normal">Γ</mi></math>`},
 28		{`a\le b`, `<math><mi>a</mi><mo>≤</mo><mi>b</mi></math>`},
 29		{`(a)`, `<math><mo stretchy="false">(</mo><mi>a</mi><mo stretchy="false">)</mo></math>`},
 30		{`\left( x \right.`, `<math><mrow><mo>(</mo><mi>x</mi></mrow></math>`},
 31		{`\left\{ x \middle| y \right\}`, `<math><mrow><mo>{</mo><mi>x</mi><mo>|</mo><mi>y</mi><mo>}</mo></mrow></math>`},
 32		{`\sin x`, `<math><mi>sin</mi><mspace width="0.1667em"></mspace><mi>x</mi></math>`},
 33		{`\sin(x)`, `<math><mi>sin</mi><mo stretchy="false">(</mo><mi>x</mi><mo stretchy="false">)</mo></math>`},
 34		{`\text{if } x`, `<math><mtext>if </mtext><mi>x</mi></math>`},
 35		{`\mathbb{R}\mathbf{v}`, `<math><mrow><mi>ℝ</mi></mrow><mrow><mi>𝐯</mi></mrow></math>`},
 36		{`\mathrm{d}x`, `<math><mrow><mi mathvariant="normal">d</mi></mrow><mi>x</mi></math>`},
 37		{`\hat{x}`, `<math><mover accent="true"><mrow><mi>x</mi></mrow><mo>^</mo></mover></math>`},
 38		{`a\,b`, `<math><mi>a</mi><mspace width="0.1667em"></mspace><mi>b</mi></math>`},
 39		{`\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>`},
 40		{`\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>`},
 41		{`\begin{matrix}a\\\end{matrix}`, `<math><mtable><mtr><mtd><mi>a</mi></mtd></mtr></mtable></math>`},
 42		{`x % comment` + "\n" + `+1`, `<math><mi>x</mi><mo>+</mo><mn>1</mn></math>`},
 43		{`a<b`, `<math><mi>a</mi><mo>&lt;</mo><mi>b</mi></math>`},
 44	}
 45	for _, c := range cases {
 46		got, err := Convert(c.tex, false)
 47		if err != nil {
 48			t.Errorf("Convert(%q): %v", c.tex, err)
 49			continue
 50		}
 51		if got != c.want {
 52			t.Errorf("Convert(%q)\n got %s\nwant %s", c.tex, got, c.want)
 53		}
 54	}
 55}
 56
 57func TestConvertDisplayLimits(t *testing.T) {
 58	got, err := Convert(`\sum_{i=1}^n i`, true)
 59	if err != nil {
 60		t.Fatal(err)
 61	}
 62	if !strings.HasPrefix(got, `<math display="block"><munderover><mo>∑</mo>`) {
 63		t.Errorf("display sum: %s", got)
 64	}
 65	got, _ = Convert(`\sum_{i=1}^n i`, false)
 66	if !strings.HasPrefix(got, `<math><msubsup><mo>∑</mo>`) {
 67		t.Errorf("inline sum: %s", got)
 68	}
 69	got, _ = Convert(`\lim_{x\to 0} f`, true)
 70	if !strings.HasPrefix(got, `<math display="block"><munder><mi>lim</mi>`) {
 71		t.Errorf("display lim: %s", got)
 72	}
 73}
 74
 75func TestConvertRefuses(t *testing.T) {
 76	for _, tex := range []string{
 77		`\frac{a}`, `x^`, `{x`, `x}`, `x^1^2`, `\left( x`, `\right)`,
 78		`\begin{pmatrix}a\end{bmatrix}`, `\begin{tabular}x\end{tabular}`,
 79		`a & b`, `a \\ b`, `\unknown`,
 80		`\href{javascript:alert(1)}{x}`, `\url{javascript:alert(1)}`,
 81		`\style{color:red}{x}`, `\color{red}{x}`, `\class{a}{x}`, `\htmlId{a}{x}`,
 82		`\def\a{x}\a`, `\newcommand{\a}{x}`, `\require{html}`, `\unicode{x}`,
 83		`\includegraphics{x}`, `\input{/etc/passwd}`,
 84		`\sqrt\displaystyle`, `\frac\displaystyle y`, `\hat\displaystyle`,
 85		`\overbrace\displaystyle`, `\binom\displaystyle1`, `\mathbf\limits`, `x^\nolimits`,
 86	} {
 87		if out, err := Convert(tex, false); err == nil {
 88			t.Errorf("Convert(%q) = %s, want an error", tex, out)
 89		}
 90	}
 91}
 92
 93func TestConvertBounds(t *testing.T) {
 94	deep := strings.Repeat("{", 100000) + "x" + strings.Repeat("}", 100000)
 95	fracs := strings.Repeat(`\frac{`, MaxDepth+1) + "x"
 96	for _, tex := range []string{deep, fracs, strings.Repeat("x", MaxInput+1)} {
 97		start := time.Now()
 98		if _, err := Convert(tex, false); err == nil {
 99			t.Errorf("Convert(%.20q...) succeeded", tex)
100		}
101		if d := time.Since(start); d > time.Second {
102			t.Errorf("Convert(%.20q...) took %v", tex, d)
103		}
104	}
105	// The largest accepted input: output stays linear in it.
106	tex := strings.Repeat(`{}`, MaxInput/2)
107	start := time.Now()
108	out, err := Convert(tex, false)
109	if err != nil {
110		t.Fatal(err)
111	}
112	if len(out) > 16*MaxInput {
113		t.Errorf("output %d bytes for %d bytes of input", len(out), len(tex))
114	}
115	if d := time.Since(start); d > time.Second {
116		t.Errorf("took %v", d)
117	}
118}
119
120var tagAttr = regexp.MustCompile(`<([a-z]+)((?: [a-z]+="[^"]*")*)>`)
121var attrPair = regexp.MustCompile(` ([a-z]+)="([^"]*)"`)
122
123// Every element and attribute in the output is one Elements and Attrs name,
124// so the sanitizer's allowlist built from them is complete.
125func TestConvertEmitsOnlyListed(t *testing.T) {
126	allowed := map[string]bool{}
127	for _, e := range Elements {
128		allowed[e] = true
129	}
130	var inputs []string
131	for name := range symbols {
132		inputs = append(inputs, `\`+name)
133	}
134	for name := range functions {
135		inputs = append(inputs, `\`+name+`_a^b x`)
136	}
137	for name := range accents {
138		inputs = append(inputs, `\`+name+`{x}`)
139	}
140	for name := range spaces {
141		inputs = append(inputs, `a\`+name+` b`)
142	}
143	for name := range fonts {
144		inputs = append(inputs, `\`+name+`{Ab1}`)
145	}
146	for name := range environments {
147		inputs = append(inputs, `\begin{`+name+`}a&b\\c&d\end{`+name+`}`)
148	}
149	inputs = append(inputs, `\binom12`, `\sqrt[3]{x}`, `\left(\middle|\right)`, `\text{a}`,
150		`\operatorname{rank}A`, `\operatorname*{arg\,max}_x`, `x_1^2`, `\sum_1^2`, `\sum_1`, `\sum^2`, `(a)~'`)
151	for _, in := range inputs {
152		for _, display := range []bool{false, true} {
153			out, err := Convert(in, display)
154			if err != nil {
155				if strings.HasPrefix(in, `\begin{`) {
156					continue // equation and friends take no & or \\
157				}
158				t.Errorf("Convert(%q): %v", in, err)
159				continue
160			}
161			for _, m := range tagAttr.FindAllStringSubmatch(out, -1) {
162				if !allowed[m[1]] {
163					t.Errorf("%q emits <%s>", in, m[1])
164				}
165				for _, a := range attrPair.FindAllStringSubmatch(m[2], -1) {
166					want, ok := Attrs[m[1]][a[1]]
167					if !ok || (want != "<length>" && want != a[2]) {
168						t.Errorf("%q emits %s %s=%q", in, m[1], a[1], a[2])
169					}
170				}
171			}
172		}
173	}
174}
175
176// FuzzConvert: no panic, output bounded by the input, and output that is
177// well-formed XML using only the listed elements and attribute values.
178func FuzzConvert(f *testing.F) {
179	for _, seed := range []string{
180		`x^2`, `\frac{a}{b}`, `\sqrt[3]{x}`, `\left(\frac12\right)`, `\sum_{i=1}^n i`,
181		`\begin{pmatrix}a&b\\c&d\end{pmatrix}`, `\text{a<b}`, `\mathbb{R}`, `\hat{x}~'`,
182		`\sqrt\displaystyle`, `\operatorname*{argmax}_x`, `{{{x}}}`, `a\,b\quad c`,
183	} {
184		f.Add(seed, false)
185	}
186	allowed := map[string]bool{}
187	for _, e := range Elements {
188		allowed[e] = true
189	}
190	length := regexp.MustCompile(`^-?[0-9]+(\.[0-9]+)?em$`)
191	f.Fuzz(func(t *testing.T, tex string, display bool) {
192		out, err := Convert(tex, display)
193		if err != nil {
194			return
195		}
196		if len(out) > 64*len(tex)+256 {
197			t.Fatalf("%d bytes out for %d in", len(out), len(tex))
198		}
199		d := xml.NewDecoder(strings.NewReader(out))
200		for {
201			tok, err := d.Token()
202			if errors.Is(err, io.EOF) {
203				break
204			}
205			if err != nil {
206				t.Fatalf("not XML: %v\n%s", err, out)
207			}
208			start, ok := tok.(xml.StartElement)
209			if !ok {
210				continue
211			}
212			if !allowed[start.Name.Local] || start.Name.Space != "" {
213				t.Fatalf("element %v in %s", start.Name, out)
214			}
215			for _, a := range start.Attr {
216				want, ok := Attrs[start.Name.Local][a.Name.Local]
217				if !ok || a.Name.Space != "" || (want == "<length>" && !length.MatchString(a.Value)) ||
218					(want != "<length>" && want != a.Value) {
219					t.Fatalf("attribute %v=%q on %s in %s", a.Name, a.Value, start.Name.Local, out)
220				}
221			}
222		}
223	})
224}