package texmath import ( "encoding/xml" "errors" "io" "regexp" "strings" "testing" "time" ) func TestConvert(t *testing.T) { cases := []struct { tex, want string }{ {`x`, `x`}, {`12.5+x`, `12.5+x`}, {`a-b`, `a−b`}, {`x^2`, `x2`}, {`x_i^2`, `xi2`}, {`x^{2n}`, `x2n`}, {`\frac{a}{b}`, `ab`}, {`\frac12`, `12`}, {`\sqrt{x}`, `x`}, {`\sqrt[3]{x}`, `x3`}, {`\alpha\Gamma`, `αΓ`}, {`a\le b`, `a≤b`}, {`(a)`, `(a)`}, {`\left( x \right.`, `(x`}, {`\left\{ x \middle| y \right\}`, `{x|y}`}, {`\sin x`, `sinx`}, {`\sin(x)`, `sin(x)`}, {`\text{if } x`, `if x`}, {`\mathbb{R}\mathbf{v}`, `ℝ𝐯`}, {`\mathrm{d}x`, `dx`}, {`\hat{x}`, `x^`}, {`a\,b`, `ab`}, {`\binom{n}{k}`, `(nk)`}, {`\begin{pmatrix}a&b\\c&d\end{pmatrix}`, `(abcd)`}, {`\begin{matrix}a\\\end{matrix}`, `a`}, {`x % comment` + "\n" + `+1`, `x+1`}, {`aa<b`}, } for _, c := range cases { got, err := Convert(c.tex, false) if err != nil { t.Errorf("Convert(%q): %v", c.tex, err) continue } if got != c.want { t.Errorf("Convert(%q)\n got %s\nwant %s", c.tex, got, c.want) } } } func TestConvertDisplayLimits(t *testing.T) { got, err := Convert(`\sum_{i=1}^n i`, true) if err != nil { t.Fatal(err) } if !strings.HasPrefix(got, `∑`) { t.Errorf("display sum: %s", got) } got, _ = Convert(`\sum_{i=1}^n i`, false) if !strings.HasPrefix(got, `∑`) { t.Errorf("inline sum: %s", got) } got, _ = Convert(`\lim_{x\to 0} f`, true) if !strings.HasPrefix(got, `lim`) { t.Errorf("display lim: %s", got) } } func TestConvertRefuses(t *testing.T) { for _, tex := range []string{ `\frac{a}`, `x^`, `{x`, `x}`, `x^1^2`, `\left( x`, `\right)`, `\begin{pmatrix}a\end{bmatrix}`, `\begin{tabular}x\end{tabular}`, `a & b`, `a \\ b`, `\unknown`, `\href{javascript:alert(1)}{x}`, `\url{javascript:alert(1)}`, `\style{color:red}{x}`, `\color{red}{x}`, `\class{a}{x}`, `\htmlId{a}{x}`, `\def\a{x}\a`, `\newcommand{\a}{x}`, `\require{html}`, `\unicode{x}`, `\includegraphics{x}`, `\input{/etc/passwd}`, `\sqrt\displaystyle`, `\frac\displaystyle y`, `\hat\displaystyle`, `\overbrace\displaystyle`, `\binom\displaystyle1`, `\mathbf\limits`, `x^\nolimits`, } { if out, err := Convert(tex, false); err == nil { t.Errorf("Convert(%q) = %s, want an error", tex, out) } } } func TestConvertBounds(t *testing.T) { deep := strings.Repeat("{", 100000) + "x" + strings.Repeat("}", 100000) fracs := strings.Repeat(`\frac{`, MaxDepth+1) + "x" for _, tex := range []string{deep, fracs, strings.Repeat("x", MaxInput+1)} { start := time.Now() if _, err := Convert(tex, false); err == nil { t.Errorf("Convert(%.20q...) succeeded", tex) } if d := time.Since(start); d > time.Second { t.Errorf("Convert(%.20q...) took %v", tex, d) } } // The largest accepted input: output stays linear in it. tex := strings.Repeat(`{}`, MaxInput/2) start := time.Now() out, err := Convert(tex, false) if err != nil { t.Fatal(err) } if len(out) > 16*MaxInput { t.Errorf("output %d bytes for %d bytes of input", len(out), len(tex)) } if d := time.Since(start); d > time.Second { t.Errorf("took %v", d) } } var tagAttr = regexp.MustCompile(`<([a-z]+)((?: [a-z]+="[^"]*")*)>`) var attrPair = regexp.MustCompile(` ([a-z]+)="([^"]*)"`) // Every element and attribute in the output is one Elements and Attrs name, // so the sanitizer's allowlist built from them is complete. func TestConvertEmitsOnlyListed(t *testing.T) { allowed := map[string]bool{} for _, e := range Elements { allowed[e] = true } var inputs []string for name := range symbols { inputs = append(inputs, `\`+name) } for name := range functions { inputs = append(inputs, `\`+name+`_a^b x`) } for name := range accents { inputs = append(inputs, `\`+name+`{x}`) } for name := range spaces { inputs = append(inputs, `a\`+name+` b`) } for name := range fonts { inputs = append(inputs, `\`+name+`{Ab1}`) } for name := range environments { inputs = append(inputs, `\begin{`+name+`}a&b\\c&d\end{`+name+`}`) } inputs = append(inputs, `\binom12`, `\sqrt[3]{x}`, `\left(\middle|\right)`, `\text{a}`, `\operatorname{rank}A`, `\operatorname*{arg\,max}_x`, `x_1^2`, `\sum_1^2`, `\sum_1`, `\sum^2`, `(a)~'`) for _, in := range inputs { for _, display := range []bool{false, true} { out, err := Convert(in, display) if err != nil { if strings.HasPrefix(in, `\begin{`) { continue // equation and friends take no & or \\ } t.Errorf("Convert(%q): %v", in, err) continue } for _, m := range tagAttr.FindAllStringSubmatch(out, -1) { if !allowed[m[1]] { t.Errorf("%q emits <%s>", in, m[1]) } for _, a := range attrPair.FindAllStringSubmatch(m[2], -1) { want, ok := Attrs[m[1]][a[1]] if !ok || (want != "" && want != a[2]) { t.Errorf("%q emits %s %s=%q", in, m[1], a[1], a[2]) } } } } } } // FuzzConvert: no panic, output bounded by the input, and output that is // well-formed XML using only the listed elements and attribute values. func FuzzConvert(f *testing.F) { for _, seed := range []string{ `x^2`, `\frac{a}{b}`, `\sqrt[3]{x}`, `\left(\frac12\right)`, `\sum_{i=1}^n i`, `\begin{pmatrix}a&b\\c&d\end{pmatrix}`, `\text{a 64*len(tex)+256 { t.Fatalf("%d bytes out for %d in", len(out), len(tex)) } d := xml.NewDecoder(strings.NewReader(out)) for { tok, err := d.Token() if errors.Is(err, io.EOF) { break } if err != nil { t.Fatalf("not XML: %v\n%s", err, out) } start, ok := tok.(xml.StartElement) if !ok { continue } if !allowed[start.Name.Local] || start.Name.Space != "" { t.Fatalf("element %v in %s", start.Name, out) } for _, a := range start.Attr { want, ok := Attrs[start.Name.Local][a.Name.Local] if !ok || a.Name.Space != "" || (want == "" && !length.MatchString(a.Value)) || (want != "" && want != a.Value) { t.Fatalf("attribute %v=%q on %s in %s", a.Name, a.Value, start.Name.Local, out) } } } }) }