Skip to content

Repository files navigation

tecgonic

A Go library that compiles LaTeX documents to PDF using the Tectonic engine compiled to WebAssembly. No native TeX installation required.

Features

  • Pure Go — the Tectonic engine runs as WASM via andsifr, our optimized fork of wazero (no CGo)
  • Self-contained bundle — one call (PrepareBundle) downloads and caches the TeX Live bundle
  • Concurrent compilation — each Compile call gets its own isolated WASM instance
  • WASM compilation cache — optional on-disk cache cuts startup from ~1.4 s to ~50 ms

Quick start

package main

import (
	"context"
	"log"
	"os"
	"path/filepath"

	"github.com/mgilbir/tecgonic"
)

func main() {
	if err := run(); err != nil {
		log.Fatal(err)
	}
}

func run() error {
	ctx := context.Background()

	cache, err := os.UserCacheDir()
	if err != nil {
		return err
	}
	bundleDir := filepath.Join(cache, "tecgonic", "bundle")
	wasmCacheDir := filepath.Join(cache, "tecgonic", "wasm-cache")

	// Download the TeX bundle (one-time).
	if err := tecgonic.PrepareBundle(ctx, bundleDir, tecgonic.WithProgress(os.Stderr)); err != nil {
		return err
	}

	// Create the compiler and generate the format file (one-time).
	compiler, err := tecgonic.New(ctx,
		tecgonic.WithDefaultBundleDir(bundleDir),
		tecgonic.WithCompilationCache(wasmCacheDir),
	)
	if err != nil {
		return err
	}
	defer compiler.Close(ctx)
	if err := compiler.GenerateFormat(ctx, ""); err != nil { // "" -> default bundle dir
		return err
	}

	// Compile a single self-contained source to PDF.
	pdf, err := compiler.CompileSource(ctx, []byte(`\documentclass{article}
\begin{document}
Hello, World!
\end{document}
`))
	if err != nil {
		return err
	}
	return os.WriteFile("output.pdf", pdf, 0o644)
}

See examples/simple for a complete runnable example.

The quick start has two phases — a one-time setup and a per-document compile that is safe to run concurrently:

flowchart LR
    subgraph once["One-time setup"]
        direction TB
        PB["PrepareBundle<br/>download + extract the bundle<br/>(skipped if already present)"]
        NEW["New<br/>compile the WASM module<br/>(~1.4 s, or ~50 ms cached)"]
        GF["GenerateFormat<br/>write latex.fmt<br/>(skipped if present)"]
        PB --> NEW --> GF
    end
    subgraph percall["Per document — concurrent-safe"]
        C["Compile / CompileSource<br/>isolated WASM instance"] --> PDF["PDF bytes"]
    end
    GF --> C
Loading

Multi-file documents

CompileSource handles a single self-contained source. For documents that pull in other files — \input, \includegraphics, .bib, or custom .cls/.sty — use Compile with any fs.FS and the name of the main source within it:

fsys := os.DirFS("/path/to/project") // or embed.FS, fstest.MapFS, fs.Sub(...)
pdf, err := compiler.Compile(ctx, fsys, "paper.tex")

The filesystem is served to the engine read-only and is never written to the host; it defines the document's entire input visibility, so it doubles as a trust boundary you control. References resolve relative to the main source's own directory (a main source at src/paper.tex reads \input{intro} as src/intro.tex), and the output PDF is named after the basename (paper.pdf).

Because os.DirFS follows symlinks out of its root, prefer an in-memory fs.FS (or fs.Sub of a vetted tree) for untrusted input. For the full hardening story — the sandbox guarantees plus the CPU, memory, and disk knobs — see docs/untrusted-input.md.

Error handling

Compile, CompileSource, and GenerateFormat return an *EngineError when the engine run itself fails. Its Kind (or the Is* helpers) tells you how to react:

Kind Meaning Typical response
KindTexError tectonic aborted on a controlled TeX error — usually an invalid document Return the logs to the document author
KindEngine an operational fault: a WASM trap, an unexpected exit, or an environment fault (unloadable format file, missing bundle mount) Alert on-call
KindCancelled the run was cancelled or timed out (only with WithContextCancellation) Retry or report the timeout

How a failure is classified — the engine signals a controlled abort with a reserved exit code, so the decision no longer depends on scraping stderr:

flowchart TD
    F["Compile / GenerateFormat fails"] --> Q1{"context canceled<br/>or deadline exceeded?"}
    Q1 -- yes --> KC["KindCancelled"]
    Q1 -- no --> Q2{"engine reported a<br/>controlled abort?"}
    Q2 -- no --> KE["KindEngine<br/>(trap, unexpected exit,<br/>or operational fault)"]
    Q2 -- yes --> Q3{"log shows an environment<br/>fault? (missing format file,<br/>unreadable bundle)"}
    Q3 -- yes --> KE
    Q3 -- no --> KT["KindTexError<br/>(usually a bad document)"]
Loading
pdf, err := compiler.Compile(ctx, fsys, "paper.tex")
if err != nil {
	var engErr *tecgonic.EngineError
	if errors.As(err, &engErr) {
		switch {
		case engErr.IsTexError():
			// Show engErr.Logs to the author.
		case engErr.IsCancelled(): // also: errors.Is(err, context.Canceled)
			// Deadline/cancellation.
		default: // IsEngineFailure()
			// Operational fault — alert.
		}
	}
	return err
}

KindTexError usually means the document is at fault, but tectonic aborts through the same channel for one environment fault it only detects mid-run: a package missing from the bundle. Before routing every KindTexError back to the author, confirm the bundle is the one the document expects. Misconfigurations that tecgonic can detect up front — a nonexistent bundle directory, a bundle with no latex.fmt, a missing fonts directory — fail with a plain error (not an *EngineError) before the engine runs.

Troubleshooting

Symptom Likely cause
bundle directory … has no latex.fmt GenerateFormat was never run against that bundle dir
bundle directory …: no such file or directory Wrong WithBundleDir / WithDefaultBundleDir path
File 'xxx.sty' not found (a KindTexError) The bundle lacks the package (using the minibundle? a partial bundle?)
main source … must use the .tex extension or none Rename the main source to .tex (or drop the extension)
bundle stream truncated from PrepareBundle The download was cut short (a partial/cached object); it retries on the next call
Compile ignores a context deadline WithContextCancellation is off by default — enable it (see below)
\today renders 1970-01-01 A pre-ABI-2 WASM module — New rejects it; rebuild with make wasm (the current module reports ABI 2)
PDFs differ byte-for-byte between runs Expected: the date defaults to "now". Pass WithBuildDate(fixed) for reproducible output
Benchmarks skip with "set TECGONIC_BUNDLE_DIR" The heavy benchmarks need a full bundle; the minibundle lacks their packages

Document date

\today, \year/\month/\day, and the PDF timestamp default to the host date at the moment of the call. Pass WithBuildDate(t) to set a specific date — pin a fixed value for reproducible, byte-identical output:

pdf, err := compiler.CompileSource(ctx, tex,
	tecgonic.WithBuildDate(time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC)))

The date is passed to the sandboxed engine as a single fixed value, so it exposes no wall-clock to the document (no timing side-channel) — see docs/untrusted-input.md.

WASM compilation cache

Creating a Compiler with New() involves compiling the Tectonic WASM module, which takes ~1.4 s. Pass WithCompilationCache(dir) to cache the compiled module on disk. Subsequent calls load the cached result in ~50 ms — a ~26x speedup.

compiler, err := tecgonic.New(ctx,
	tecgonic.WithDefaultBundleDir(bundleDir),
	tecgonic.WithCompilationCache("/path/to/cache"),
)

Benchmark results (AMD Ryzen 9 6900HX):

BenchmarkNew/NoCache       1   1360 ms/op   79 MB/op   117k allocs/op
BenchmarkNew/WithCache    22     51 ms/op  6.8 MB/op    31k allocs/op

The cache directory can be shared across processes. The first invocation populates the cache; all later invocations (including from different processes) read from it.

Performance

Compilation is CPU-bound. Three levers trade correctness headroom for speed — the context-cancellation cost, pass capping (WithMaxPasses), and state seeding (WithStateDir) — and the engine runs on our optimized wazero fork. All are covered in docs/performance.md.

Development

Requires the Go toolchain declared in go.mod (currently Go 1.25) or newer.

make test   # go test ./...
make lint   # golangci-lint run ./... (see .golangci.yml)

Tests run with no setup: TestMain extracts a small committed bundle (testdata/minibundle.tar.gzarticle.cls, Latin Modern, and a prebuilt latex.fmt, enough for the compile tests) into a temp directory. To run against a full bundle instead (more fonts, document classes, and benchmarks), point TECGONIC_BUNDLE_DIR at an extracted bundle that includes latex.fmt:

TECGONIC_BUNDLE_DIR=/path/to/bundle go test ./...

The longtblr benchmarks (BenchmarkCompileLongtblr, …SinglePass, …WarmAux) need a full bundle for their packages; without TECGONIC_BUNDLE_DIR they skip. BenchmarkCompileSimple runs against the minibundle.

Building the WASM module

The pre-built WASM artifact is included under wasm/. To rebuild it from the Tectonic source:

make wasm

This uses Docker to cross-compile Tectonic to wasm32-wasip1. Pin a reproducible source revision with make wasm TECTONIC_REF=<commit-sha>; changing the ref also busts the Docker git-clone cache, so a rebuild after an upstream push picks up the new source without needing --no-cache. See the Dockerfile for details.

The module is built from a fork of Tectonic (mgilbir/tectonic@wasm) that adds the WASI reactor build. Where that fork comes from, how it tracks upstream, the ABI handshake that keeps the module and this library in sync, and the step-by-step update procedure are documented in docs/wasm-provenance.md.

Thanks

This project would not be possible without:

  • Tectonic — a modernized, complete, self-contained TeX/LaTeX engine. Tectonic does all the heavy lifting of turning LaTeX into PDF; tecgonic simply makes it callable from Go.
  • wazero — a zero-dependency WebAssembly runtime for Go. wazero makes it practical to embed the Tectonic WASM binary in a pure-Go library with no CGo and no external dependencies.

License

MIT — see LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages