# Engine decision: typst (docker, digest-pinned) over pandoc+LaTeX Date: 2026-08-29 — Redmine 499 (mopac-pdf v0). Constraint set: our code is Go/PHP/C/Java only; prebuilt third-party binaries are allowed as digest-pinned docker tooling (supply chain), never vendored source. All dev in docker. ## Candidates 1. **typst** — `ghcr.io/typst/typst:0.15.1` (Rust binary, upstream image). 2. **pandoc + LaTeX** — `pandoc/latex:3.5` (Haskell pandoc + TeX Live, already cached on the host). Both are legally clean as container tooling. Both produce real PDFs from structured input. Measured on ultix-streaming, 2026-08-29, same content (TOC + table + embedded chart figure): | | typst 0.15.1 | pandoc 3.5 + xelatex | |---|---|---| | cold-ish compile (TOC + table + figure) | **0.94 s** | 7.66 s | | image size | ~200 MB | 758 MB | | output | 55 KB, modern layout | 33 KB, classic LaTeX look | | stdin -> stdout PDF | native (`compile - -`) | no (needs file io for pdf-engine) | | programmatic layout (templates) | typst scripting language: functions, show rules, `#show: tmpl.with(...)` | LaTeX templates: `\newcommand`, class files, preamble surgery | | charts/figures/tables | first-class (`#figure`, `#table`, image embedding) | fine via LaTeX packages, verbose | | error messages | precise, spans, exit codes | TeX log archaeology | | engine invocation | one process, one shot | pandoc -> (xela|pdf)latex, multi-pass for TOC | ## Decision **typst.** It wins on every axis that matters for this pipeline: - **Speed**: ~8x faster compiles matter when the COS briefing world fans out reports and the harness treats PDF generation as a tool call. - **Template ergonomics**: templates are small typed functions composed via `#show: report.with(title: ...)`, which is exactly the front-matter contract mopac-pdf needs. No LaTeX preamble/class gymnastics, no multi-pass TOC juggling. - **Modern output**: real typographic defaults (Libertinus), clean tables/figures/TOC out of the box — "beautifully formatted" is the spec. - **Streaming contract**: `typst compile --root /work doc.typ -` pipes PDF bytes to stdout — the engine layer stays bytes-in/bytes-out, no shared output files, no root-owned artifacts in repos. pandoc+LaTeX stays a fine document *conversion* tool (its markdown parser is broader) and remains available in the tool cache for one-off conversions; it is not the rendering engine for this pipeline. ## Pinning & supply chain The engine image is pinned by digest in `internal/engine`: ``` ghcr.io/typst/typst@sha256:032e292249bcd378480cc7c142cfa324b63ef8aadeb88d7e7230320c4c9c422f ``` (verified = `ghcr.io/typst/typst:0.15.1`; the version tag and the digest were cross-checked via `docker inspect`). The container runs `--network none`; the compile root is a private temp dir mounted at `/work`; typst never sees the network or the repo. Engine migrations are a digest bump + `MOPAC_PDF_ENGINE` override for canary testing. Our code treats the engine as a pure function: `compile(root, doc.typ) -> PDF bytes`, exercised through a stub in unit tests and the real container in smoke.