Files
mrcharles 10e930e46d mopac-pdf v0: markdown to beautiful PDFs via digest-pinned typst
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
2026-08-29 05:44:24 -05:00

99 lines
3.0 KiB
Go

// Package engine runs the digest-pinned typst container that turns a
// prepared compile root into PDF bytes. The host never runs a typesetting
// toolchain; the contract is plain docker exec with bytes in/out.
package engine
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// Default image: ghcr.io/typst/typst:0.15.1, digest-pinned. Overridable via
// MOPAC_PDF_ENGINE (tests, engine migrations).
const (
DefaultImage = "ghcr.io/typst/typst@sha256:032e292249bcd378480cc7c142cfa324b63ef8aadeb88d7e7230320c4c9c422f"
EnvImage = "MOPAC_PDF_ENGINE"
// EnvDocker overrides the docker binary path (tests use a stub).
EnvDocker = "MOPAC_PDF_DOCKER"
)
// Engine compiles a work directory (containing doc.typ plus any assets)
// into a PDF.
type Engine struct {
Image string
Docker string
}
// New returns an Engine from defaults + environment overrides.
func New() *Engine {
image := DefaultImage
if v := os.Getenv(EnvImage); v != "" {
image = v
}
docker := "docker"
if v := os.Getenv(EnvDocker); v != "" {
docker = v
}
return &Engine{Image: image, Docker: docker}
}
// ErrEngine marks failures that belong to the engine layer (exit code 2 in
// the CLI): docker unavailable, typst compile errors.
type ErrEngine struct{ Err error }
func (e *ErrEngine) Error() string { return "engine: " + e.Err.Error() }
func (e *ErrEngine) Unwrap() error { return e.Err }
// Compile writes the assets into root, then runs:
//
// docker run --rm -v ROOT:/work -w /work IMAGE compile --root /work doc.typ -
//
// and returns the PDF bytes from stdout. docName is the typst entry file
// already present in root.
func (e *Engine) Compile(root, docName string, assets map[string][]byte) ([]byte, error) {
if err := writeAssets(root, assets); err != nil {
return nil, &ErrEngine{err}
}
args := []string{
"run", "--rm", "--network", "none",
"-v", root + ":/work", "-w", "/work",
"--user", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()),
e.Image, "compile", "--root", "/work", docName, "-",
}
cmd := exec.Command(e.Docker, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
detail := strings.TrimSpace(stderr.String())
if detail == "" {
detail = err.Error()
}
return nil, &ErrEngine{fmt.Errorf("typst compile: %s", detail)}
}
pdf := stdout.Bytes()
if len(pdf) == 0 || !bytes.HasPrefix(pdf, []byte("%PDF-")) {
return nil, &ErrEngine{fmt.Errorf("typst compile: engine produced no PDF (stdout %d bytes)", len(pdf))}
}
return pdf, nil
}
// writeAssets writes asset bytes into root (0600, own dirs).
func writeAssets(root string, assets map[string][]byte) error {
for name, data := range assets {
clean := filepath.Clean("/" + name)[1:] // reject traversal into a root-relative name
dst := filepath.Join(root, clean)
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
if err := os.WriteFile(dst, data, 0o644); err != nil {
return err
}
}
return nil
}