latexgrambot: inline LaTeX bot for Telegram in Go

Renders LaTeX expressions into PNG pictures, PDF documents and native
Telegram rich text messages, entirely locally with TeX Live and poppler.

- Chat mode with /image, /pdf, /rich plus per-user preamble and DPI
- Inline mode delivering picture, PDF and rich text results
- Broad default package set preloaded into a TeX format at image build
- MongoDB persistence: settings, users, groups, render statistics
- Private S3-compatible bucket with short-lived presigned URLs
- OpenTelemetry traces over OTLP
- Kubernetes manifests and Argo CD application, Docker Compose, GitHub
  Actions CI, BSD 3-Clause license
This commit is contained in:
corrado.mulas
2026-10-04 03:38:08 +02:00
commit 98a3614d58
29 changed files with 5140 additions and 0 deletions

446
internal/render/render.go Normal file
View File

@@ -0,0 +1,446 @@
// Package render turns LaTeX expressions into PNG and PDF files using a
// locally installed TeX Live (pdflatex) and poppler (pdftoppm). No network
// service is involved.
package render
import (
"bytes"
"context"
"errors"
"fmt"
"image"
_ "image/jpeg"
_ "image/png"
"math"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"latexgrambot/internal/rich"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
var renderTracer = otel.Tracer("latexgrambot/render")
const (
// minRenderedSide is the minimum size of the smallest PNG side. Small
// formulas rasterize to a few hundred pixels at any DPI; Telegram then
// upscales and JPEG-recompresses them, blurring thin glyphs. Upscaling
// the raster beforehand keeps the final picture crisp.
minRenderedSide = 2560
// maxRenderedSide guards against pathologically tall formulas.
maxRenderedSide = 4096
)
// documentClass is the fixed preview layout: the standalone class crops
// tightly around the content.
const documentClass = `\documentclass[preview,varwidth,border=4pt]{standalone}`
// defaultPackages is inserted into the document when the user has no custom
// preamble: the broad standard package set so documents compile without a
// custom preamble.
const defaultPackages = `\usepackage[utf8]{inputenc}
\usepackage[T1]{fontenc}
\usepackage{lmodern}
\usepackage{textcomp}
\usepackage{amsmath}
\usepackage{amssymb}
\usepackage{amsfonts}
\usepackage{amsthm}
\usepackage{amscd}
\usepackage{mathtools}
\usepackage{mathrsfs}
\usepackage{bm}
\usepackage{cancel}
\usepackage{braket}
\usepackage{siunitx}
\usepackage{mhchem}
\usepackage{chemfig}
\usepackage{systeme}
\usepackage{polynom}
\usepackage{wasysym}
\usepackage{stmaryrd}
\usepackage{pifont}
\usepackage{eurosym}
\usepackage{graphicx}
\usepackage{xcolor}
\usepackage{colortbl}
\usepackage{tikz}
\usepackage{pgfplots}
\pgfplotsset{compat=1.18}
\usepackage{tikz-cd}
\usepackage{tcolorbox}
\usepackage{qrcode}
\usepackage{float}
\usepackage{array}
\usepackage{multirow}
\usepackage{makecell}
\usepackage{booktabs}
\usepackage{tabularx}
\usepackage{longtable}
\usepackage{multicol}
\usepackage{enumitem}
\usepackage{caption}
\usepackage{subcaption}
\usepackage{wrapfig}
\usepackage{rotating}
\usepackage[normalem]{ulem}
\usepackage{soul}
\usepackage{listings}
\usepackage{algorithm}
\usepackage{algpseudocode}
\usepackage{microtype}
\usepackage{xspace}
\usepackage{etoolbox}
\usepackage{hyperref}`
// DefaultPreamble returns the preamble file used at image build time to
// preload the default class and packages into the "latexgrambot" TeX
// format, so renders no longer pay the package-load cost.
func DefaultPreamble() string {
return documentClass + "\n" + defaultPackages + "\n\\endofdump\n"
}
// Result carries the compiled output. JPEG is used for URL-based Telegram
// inline photos, while PNG is used for direct chat replies.
type Result struct {
PNG []byte
JPEG []byte
PDF []byte
}
// LatexError wraps a LaTeX compile failure and carries the log excerpt shown
// to the user.
type LatexError struct {
Excerpt string
}
func (e *LatexError) Error() string { return "latex compilation failed" }
// Renderer compiles LaTeX documents locally.
type Renderer struct {
Pdflatex string
Pdftoppm string
Timeout time.Duration
WorkDir string // base directory for temp dirs; os.TempDir() when empty
// Format is the preloaded TeX format (built from DefaultPreamble) used
// when the default preamble is in effect. Empty disables it.
Format string
sem chan struct{}
}
// New creates a Renderer that allows at most maxConcurrent simultaneous
// pdflatex runs.
func New(pdflatex, pdftoppm string, timeout time.Duration, maxConcurrent int, workDir string) *Renderer {
if maxConcurrent < 1 {
maxConcurrent = 1
}
return &Renderer{
Pdflatex: pdflatex,
Pdftoppm: pdftoppm,
Timeout: timeout,
WorkDir: workDir,
sem: make(chan struct{}, maxConcurrent),
}
}
// dangerousCommands are TeX primitives that read or write files; user input
// must never reach them.
var dangerousCommands = regexp.MustCompile(`\\(input|include|openin|openout|write|read|catcode|newwrite|immediate)\b`)
// normalizeExpression validates and trims user LaTeX without changing its
// mode. The bot has already applied the RICH_DEFAULT_MATH policy before it
// calls the renderer; wrapping everything here would incorrectly place text
// commands such as \hyperlink and \section inside display math.
func normalizeExpression(expression string) (string, error) {
expr := strings.TrimSpace(expression)
if expr == "" {
return "", &LatexError{Excerpt: "the expression is empty"}
}
if dangerousCommands.MatchString(expr) {
return "", &LatexError{Excerpt: "unsupported TeX command in the expression"}
}
return expr, nil
}
// stripOuterMathDelimiters removes matching outer math delimiters, so a
// forced math retry never nests delimiters.
func stripOuterMathDelimiters(expr string) string {
for _, pair := range [][2]string{{`$$`, `$$`}, {`$`, `$`}, {`\[`, `\]`}, {`\(`, `\)`}} {
if len(expr) >= len(pair[0])+len(pair[1]) &&
strings.HasPrefix(expr, pair[0]) && strings.HasSuffix(expr, pair[1]) {
return strings.TrimSpace(expr[len(pair[0]) : len(expr)-len(pair[1])])
}
}
return expr
}
// Formats selects which outputs a render produces.
type Formats uint8
const (
FormatPNG Formats = 1 << iota
FormatJPEG
FormatPDF
)
// Render compiles the expression and produces every format.
func (r *Renderer) Render(ctx context.Context, preamble, expression string, dpi int) (*Result, error) {
return r.RenderFormats(ctx, preamble, expression, dpi, FormatPNG|FormatJPEG|FormatPDF)
}
// RenderFormats compiles the expression inside the given preamble and
// produces only the requested formats, so callers never pay for
// rasterization they do not use. The expression is normalised (math
// delimiters stripped, plain expressions wrapped in math mode) and inserted
// between \begin{document} and \end{document}.
func (r *Renderer) RenderFormats(ctx context.Context, preamble, expression string, dpi int, formats Formats) (result *Result, err error) {
normalized, err := normalizeExpression(expression)
if err != nil {
return nil, err
}
ctx, span := renderTracer.Start(ctx, "render",
trace.WithAttributes(
attribute.Int("dpi", dpi),
attribute.Int("expression.length", len(expression)),
attribute.Int("expression.normalized_length", len(normalized)),
attribute.Int("preamble.length", len(preamble)),
),
)
defer span.End()
defer func() {
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "render failed")
}
}()
select {
case r.sem <- struct{}{}:
defer func() { <-r.sem }()
case <-ctx.Done():
return nil, ctx.Err()
}
workdir, err := os.MkdirTemp(r.WorkDir, "latexgrambot-")
if err != nil {
return nil, fmt.Errorf("create workdir: %w", err)
}
defer os.RemoveAll(workdir)
texPath := filepath.Join(workdir, "document.tex")
// Preloaded format: with the default preamble the class and packages
// are already in the format, so only the body is compiled.
useFormat := r.Format != "" && strings.TrimSpace(preamble) == ""
compile := func(body string) error {
document := buildDocument(preamble, body)
if useFormat {
document = "\\begin{document}\n" + body + "\n\\end{document}\n"
}
if err := os.WriteFile(texPath, []byte(document), 0o600); err != nil {
return fmt.Errorf("write document: %w", err)
}
// -no-shell-escape is the default, spelled out here on purpose: the
// \write18 escapes must never be enabled for user-supplied LaTeX.
args := []string{
"-interaction=nonstopmode", "-halt-on-error", "-no-shell-escape",
"-output-directory=" + workdir,
}
if useFormat {
args = append(args, "-fmt="+r.Format)
}
return r.run(ctx, workdir, r.Pdflatex, append(args, "document.tex")...)
}
// Entirely-math expressions are put into math mode up front: waiting
// for the "Missing $ inserted" error would compile every formula twice.
firstAttempt := normalized
if rich.IsMathExpression(expression) && !rich.ContainsMathEnvironment(expression) {
firstAttempt = `\[` + stripOuterMathDelimiters(strings.TrimSpace(normalized)) + `\]`
}
if err := compile(firstAttempt); err != nil {
logContent, readErr := os.ReadFile(filepath.Join(workdir, "document.log"))
if readErr != nil {
return nil, err
}
// One retry for cases the up-front check missed: when the compiler
// only complained about missing math delimiters and the whole
// expression is math. If the retry fails too, the original error is
// reported so the user decides what to do.
if strings.Contains(string(logContent), "Missing $ inserted") &&
rich.IsMathExpression(expression) && firstAttempt == normalized {
forced := `\[` + stripOuterMathDelimiters(strings.TrimSpace(expression)) + `\]`
for _, name := range []string{"document.aux", "document.log", "document.pdf"} {
_ = os.Remove(filepath.Join(workdir, name))
}
if retryErr := compile(forced); retryErr != nil {
return nil, &LatexError{Excerpt: extractError(string(logContent))}
}
} else {
return nil, &LatexError{Excerpt: extractError(string(logContent))}
}
}
pdfPath := filepath.Join(workdir, "document.pdf")
result = &Result{}
if formats&FormatPDF != 0 {
result.PDF, err = os.ReadFile(pdfPath)
if err != nil {
return nil, fmt.Errorf("read compiled pdf: %w", err)
}
}
if formats&FormatPNG != 0 {
pngPath := filepath.Join(workdir, "document.png")
if err := r.renderRaster(ctx, workdir, dpi, pdfPath, pngPath, "png"); err != nil {
return nil, err
}
result.PNG, err = os.ReadFile(pngPath)
if err != nil {
return nil, fmt.Errorf("read rendered png: %w", err)
}
}
if formats&FormatJPEG != 0 {
jpegPath := filepath.Join(workdir, "document.jpg")
if err := r.renderRaster(ctx, workdir, dpi, pdfPath, jpegPath, "jpeg"); err != nil {
return nil, err
}
result.JPEG, err = os.ReadFile(jpegPath)
if err != nil {
return nil, fmt.Errorf("read rendered jpeg: %w", err)
}
}
return result, nil
}
// renderRaster rasterizes the first PDF page to a PNG or JPEG, re-rendering
// it upscaled when the natural size is too small for a crisp Telegram
// display. Rasterizing the JPEG directly avoids decoding and re-encoding a
// full-size PNG in Go.
func (r *Renderer) renderRaster(ctx context.Context, workdir string, dpi int, pdfPath, outPath, format string) error {
base := filepath.Join(workdir, "document")
args := []string{"-" + format, "-r", strconv.Itoa(dpi), "-singlefile", "-f", "1", "-l", "1"}
if err := r.run(ctx, workdir, r.Pdftoppm, append(args, pdfPath, base)...); err != nil {
return err
}
width, height, err := imageSize(outPath)
if err != nil {
return fmt.Errorf("inspect rendered %s: %w", format, err)
}
small := min(width, height)
large := max(width, height)
scale := 1.0
if small > 0 && small < minRenderedSide {
scale = float64(minRenderedSide) / float64(small)
if scale*float64(large) > maxRenderedSide {
scale = float64(maxRenderedSide) / float64(large)
}
}
if scale <= 1 {
return nil
}
scaledWidth := int(math.Round(float64(width) * scale))
args = append(args,
"-scale-to-x", strconv.Itoa(scaledWidth), "-scale-to-y", "-1")
return r.run(ctx, workdir, r.Pdftoppm, append(args, pdfPath, base)...)
}
func imageSize(path string) (int, int, error) {
file, err := os.Open(path)
if err != nil {
return 0, 0, err
}
defer file.Close()
config, _, err := image.DecodeConfig(file)
if err != nil {
return 0, 0, err
}
return config.Width, config.Height, nil
}
// buildDocument assembles the full .tex source. User preamble additions are
// loaded after the broad default package set, so setting custom definitions
// cannot accidentally remove core packages such as hyperref.
func buildDocument(preamble, expression string) string {
pkgs := defaultPackages
if strings.TrimSpace(preamble) != "" {
pkgs += "\n" + strings.TrimSpace(preamble)
}
return documentClass + "\n" +
pkgs + "\n" +
"\\begin{document}\n" +
expression + "\n" +
"\\end{document}\n"
}
// extractError pulls the first "! ..." error line and its "l.NN ..." context
// line from the pdflatex log.
func extractError(log string) string {
var first, context string
for _, line := range strings.Split(log, "\n") {
trimmed := strings.TrimSpace(line)
if first == "" && strings.HasPrefix(trimmed, "!") {
first = trimmed
continue
}
if first != "" && strings.HasPrefix(trimmed, "l.") {
context = trimmed
break
}
}
if first == "" {
return "compilation failed without an explicit error message"
}
if context == "" {
return first
}
return first + "\n" + context
}
// run executes a command in dir, inheriting the timeout from ctx. Home,
// TMPDIR and TEXMFOUTPUT point into the workdir so TeX never needs to write
// outside it.
func (r *Renderer) run(ctx context.Context, dir, bin string, args ...string) error {
cmd := exec.CommandContext(ctx, bin, args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"HOME="+dir,
"TMPDIR="+dir,
"TEXMFOUTPUT="+dir,
)
var output bytes.Buffer
cmd.Stdout = &output
cmd.Stderr = &output
if err := cmd.Run(); err != nil {
if ctx.Err() != nil {
return fmt.Errorf("%s: %w", filepath.Base(bin), ctx.Err())
}
var execErr *exec.Error
if errors.As(err, &execErr) {
return fmt.Errorf("%s is not available: %w", bin, execErr)
}
return fmt.Errorf("%s failed: %w (%s)", filepath.Base(bin), err, firstLine(output.String()))
}
return nil
}
// firstLine returns the first non-empty line of s, or an empty string.
func firstLine(s string) string {
for _, line := range strings.Split(s, "\n") {
if strings.TrimSpace(line) != "" {
return strings.TrimSpace(line)
}
}
return ""
}

View File

@@ -0,0 +1,243 @@
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")
}
}