A Go library that compiles LaTeX documents to PDF using the Tectonic engine compiled to WebAssembly. No native TeX installation required.
- 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
Compilecall gets its own isolated WASM instance - WASM compilation cache — optional on-disk cache cuts startup from ~1.4 s to ~50 ms
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
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.
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)"]
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.
| 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 |
\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.
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.
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.
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.gz — article.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.
The pre-built WASM artifact is included under wasm/. To rebuild it from the Tectonic source:
make wasmThis 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.
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.
MIT — see LICENSE.