internal/texmath/texmath_test.go
224 lines · 8255 bytes
8 symbols in this file
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><</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}