package render import ( "bytes" "context" "image" _ "image/png" "os/exec" "strings" "testing" "time" ) func TestBuildDocument(t *testing.T) { t.Run("default preamble", func(t *testing.T) { doc := buildDocument("", `$x^2$`) if !strings.Contains(doc, "\\documentclass[preview,varwidth,border=4pt]{standalone}") { t.Errorf("missing document class:\n%s", doc) } if !strings.Contains(doc, defaultPackages) { t.Errorf("missing default packages:\n%s", doc) } if !strings.Contains(doc, "\\begin{document}\n$x^2$\n\\end{document}") { t.Errorf("expression not placed in the document body:\n%s", doc) } }) t.Run("custom preamble extends packages", func(t *testing.T) { doc := buildDocument(`\usepackage{chemist}`, `H_2O`) if !strings.Contains(doc, defaultPackages) { t.Errorf("default packages missing:\n%s", doc) } if !strings.Contains(doc, "\\usepackage{chemist}") { t.Errorf("custom preamble missing:\n%s", doc) } if !strings.Contains(doc, "\\begin{document}\nH_2O\n\\end{document}") { t.Errorf("expression not placed in the document body:\n%s", doc) } }) } func TestExtractError(t *testing.T) { log := strings.Join([]string{ "This is pdfTeX, Version 3.141592653-2.6-1.40.26", "! Undefined control sequence.", "l.4 \\begin{document}", " \\badcommand", "", "! LaTeX Error: File `missing.sty' not found.", "l.5 \\usepackage{missing}", }, "\n") excerpt := extractError(log) if !strings.Contains(excerpt, "! Undefined control sequence.") { t.Errorf("missing first error line in %q", excerpt) } if !strings.Contains(excerpt, "l.4 \\begin{document}") { t.Errorf("missing context line in %q", excerpt) } if strings.Contains(excerpt, "missing.sty") { t.Errorf("second error must not be reported with -halt-on-error behavior: %q", excerpt) } } func TestExtractErrorWithoutErrorLine(t *testing.T) { excerpt := extractError("some unrelated log content") if !strings.Contains(excerpt, "without an explicit error") { t.Errorf("unexpected excerpt: %q", excerpt) } } func TestRenderIntegration(t *testing.T) { if _, err := exec.LookPath("pdflatex"); err != nil { t.Skip("pdflatex not installed") } if _, err := exec.LookPath("pdftoppm"); err != nil { t.Skip("pdftoppm not installed") } r := New("pdflatex", "pdftoppm", 30*time.Second, 2, "") ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() result, err := r.Render(ctx, "", `$\int_0^\infty e^{-x^2}\,dx = \frac{\sqrt{\pi}}{2}$`, 150) if err != nil { t.Fatalf("render failed: %v", err) } if len(result.PNG) == 0 { t.Fatal("empty PNG") } if !strings.HasPrefix(string(result.PNG), "\x89PNG") { t.Fatal("output is not a PNG file") } width, height, err := pngSizeBytes(result.PNG) if err != nil { t.Fatalf("decode png size: %v", err) } if width < minRenderedSide && height < minRenderedSide { t.Errorf("png is %dx%d, want the smallest side at least %d", width, height, minRenderedSide) } if width > maxRenderedSide || height > maxRenderedSide { t.Errorf("png is %dx%d, want no side beyond %d", width, height, maxRenderedSide) } if len(result.PDF) == 0 || !strings.HasPrefix(string(result.PDF), "%PDF") { t.Fatal("output is not a PDF file") } if len(result.JPEG) == 0 || !strings.HasPrefix(string(result.JPEG), "\xff\xd8") { t.Fatal("output is not a JPEG file") } linked, err := r.Render(ctx, "", `\hypertarget{details}{Details}\par\hyperlink{details}{Go to details}`, 150) if err != nil { t.Fatalf("hyperlink render failed: %v", err) } if len(linked.PDF) == 0 || len(linked.PNG) == 0 || len(linked.JPEG) == 0 { t.Fatal("hyperlink render returned an empty format") } } func pngSizeBytes(data []byte) (int, int, error) { config, _, err := image.DecodeConfig(bytes.NewReader(data)) if err != nil { return 0, 0, err } return config.Width, config.Height, nil } func TestRenderErrorIntegration(t *testing.T) { if _, err := exec.LookPath("pdflatex"); err != nil { t.Skip("pdflatex not installed") } r := New("pdflatex", "pdftoppm", 30*time.Second, 2, "") ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() _, err := r.Render(ctx, "", `\undefinedcommandxyz`, 150) latexErr, ok := err.(*LatexError) if !ok { t.Fatalf("expected *LatexError, got %T: %v", err, err) } if !strings.Contains(latexErr.Excerpt, "! Undefined control sequence") { t.Errorf("unexpected excerpt: %q", latexErr.Excerpt) } } func TestRenderPreWrapsBareMath(t *testing.T) { if _, err := exec.LookPath("pdflatex"); err != nil { t.Skip("pdflatex not installed") } r := New("pdflatex", "pdftoppm", 30*time.Second, 2, "") ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() // Bare math is wrapped up front, so a single compile succeeds. result, err := r.Render(ctx, "", `\int_0^1 x\,dx`, 150) if err != nil { t.Fatalf("render failed: %v", err) } if len(result.PNG) == 0 || len(result.PDF) == 0 { t.Fatal("empty output") } } func TestRenderRetriesMissingDollarForMath(t *testing.T) { if _, err := exec.LookPath("pdflatex"); err != nil { t.Skip("pdflatex not installed") } r := New("pdflatex", "pdftoppm", 30*time.Second, 2, "") ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() // A math environment is not pre-wrapped; the compile fails once with // "Missing $ inserted" and the retry wraps the whole expression. result, err := r.Render(ctx, "", "\\begin{matrix}a&b\\\\c&d\\end{matrix} \\int_0^1 x\\,dx", 150) if err != nil { t.Fatalf("render failed: %v", err) } if len(result.PNG) == 0 || len(result.PDF) == 0 { t.Fatal("empty output") } } func TestRenderNoRetryForMixedInput(t *testing.T) { if _, err := exec.LookPath("pdflatex"); err != nil { t.Skip("pdflatex not installed") } r := New("pdflatex", "pdftoppm", 30*time.Second, 2, "") ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() // Mixed text with a math-only command inside is not retried: the error // is reported to the user as-is. _, err := r.Render(ctx, "", "\\begin{itemize}\\item \\int\\end{itemize}", 150) latexErr, ok := err.(*LatexError) if !ok { t.Fatalf("expected *LatexError, got %T: %v", err, err) } if !strings.Contains(latexErr.Excerpt, "Missing $ inserted") { t.Errorf("unexpected excerpt: %q", latexErr.Excerpt) } } func TestDefaultPreamble(t *testing.T) { preamble := DefaultPreamble() for _, fragment := range []string{ "\\documentclass[preview,varwidth,border=4pt]{standalone}", "\\usepackage{amsmath}", "\\usepackage{tikz}", "\\endofdump", } { if !strings.Contains(preamble, fragment) { t.Errorf("preamble missing %q", fragment) } } } func TestNormalizeExpression(t *testing.T) { cases := map[string]string{ `\mathbb{ R }`: `\mathbb{ R }`, `$\mathbb{R}$`: `$\mathbb{R}$`, `$$x^2 + y^2$$`: `$$x^2 + y^2$$`, `\[ \frac{1}{2} \]`: `\[ \frac{1}{2} \]`, `\(a+b\)`: `\(a+b\)`, " x ": `x`, `\begin{align} a &= b \\ c &= d \end{align}`: `\begin{align} a &= b \\ c &= d \end{align}`, } for input, want := range cases { got, err := normalizeExpression(input) if err != nil { t.Fatalf("normalizeExpression(%q): %v", input, err) } if got != want { t.Fatalf("normalizeExpression(%q) = %q, want %q", input, got, want) } } if _, err := normalizeExpression(`\input{/etc/passwd}`); err == nil { t.Fatal("dangerous command was accepted") } if _, err := normalizeExpression(" "); err == nil { t.Fatal("empty expression was accepted") } }