// 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 }