Go CLI that turns the markdown currency of this stack (Redmine notes, Discourse posts, briefing output) into typeset PDFs: two embedded typst templates (report with title page/TOC/headers, dense brief), front-matter (title/subtitle/author/date/classification/template), GFM tables, and bar charts rendered in pure Go (go-chart) from fenced chart data blocks. Engine is a prebuilt typst 0.15.1 container pinned by digest and run --network none; PDF bytes to stdout or -o. Exit codes 0/1/2. All dev in docker (dev.sh/Makefile); unit tests + golden typst fixtures plus a host-side smoke against the real engine container. Generated with Crush Assisted-by: Crush:glm-5.2
3.1 KiB
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
- typst —
ghcr.io/typst/typst:0.15.1(Rust binary, upstream image). - 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 |
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.