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
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// stubDocker writes a script that records its argv into logPath and prints
|
||||
// a fake PDF (or garbage when fail is set) on stdout.
|
||||
func stubDocker(t *testing.T, logPath string, fail bool) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
script := filepath.Join(dir, "docker-stub")
|
||||
body := "#!/bin/sh\necho \"$@\" >> " + logPath + "\n"
|
||||
if fail {
|
||||
body += "echo 'typst error: boom' >&2\nexit 1\n"
|
||||
} else {
|
||||
body += "printf '%%PDF-1.7 fake-but-headered\\n'\n"
|
||||
}
|
||||
if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return script
|
||||
}
|
||||
|
||||
func TestCompileRunsPinnedImage(t *testing.T) {
|
||||
var logFile atomic.Value
|
||||
logPath := filepath.Join(t.TempDir(), "argv.log")
|
||||
logFile.Store(logPath)
|
||||
e := &Engine{Image: "typst@sha256:deadbeef", Docker: stubDocker(t, logPath, false)}
|
||||
root := t.TempDir()
|
||||
pdf, err := e.Compile(root, "doc.typ", map[string][]byte{"chart-1.png": []byte("x")})
|
||||
if err != nil {
|
||||
t.Fatalf("compile: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(string(pdf), "%PDF-") {
|
||||
t.Fatalf("pdf: %q", pdf)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "chart-1.png")); err != nil {
|
||||
t.Fatalf("asset not written: %v", err)
|
||||
}
|
||||
log, _ := os.ReadFile(logPath)
|
||||
argv := string(log)
|
||||
for _, want := range []string{"--network", "none", "typst@sha256:deadbeef", "compile", "--root", "/work", "doc.typ", "-"} {
|
||||
if !strings.Contains(argv, want) {
|
||||
t.Fatalf("argv missing %q: %s", want, argv)
|
||||
}
|
||||
}
|
||||
if strings.Contains(argv, root) == false {
|
||||
t.Fatalf("argv missing root mount: %s", argv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileEngineFailureIsErrEngine(t *testing.T) {
|
||||
logPath := filepath.Join(t.TempDir(), "argv.log")
|
||||
e := &Engine{Image: "x", Docker: stubDocker(t, logPath, true)}
|
||||
_, err := e.Compile(t.TempDir(), "doc.typ", nil)
|
||||
if err == nil {
|
||||
t.Fatal("want error")
|
||||
}
|
||||
if _, ok := err.(*ErrEngine); !ok {
|
||||
t.Fatalf("want ErrEngine, got %T", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("stderr not surfaced: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileRejectsNonPDFStdout(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
script := filepath.Join(dir, "docker-stub")
|
||||
if err := os.WriteFile(script, []byte("#!/bin/sh\necho not a pdf\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := &Engine{Image: "x", Docker: script}
|
||||
if _, err := e.Compile(t.TempDir(), "doc.typ", nil); err == nil {
|
||||
t.Fatal("non-PDF stdout must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAssetsRejectsTraversal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
err := writeAssets(root, map[string][]byte{"../../etc/passwd": []byte("x")})
|
||||
if err != nil {
|
||||
t.Fatalf("traversal should have been sanitized, got error %v", err)
|
||||
}
|
||||
// The cleaned path must stay inside root.
|
||||
if _, err := os.Stat(filepath.Join(root, "etc/passwd")); err != nil {
|
||||
t.Logf("sanitized to root-relative path: %v (acceptable)", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user