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,225 @@
|
||||
// Package cli wires the mopac-pdf pipeline: front-matter + markdown in,
|
||||
// typst template composition, pure-Go chart assets, digest-pinned engine
|
||||
// container, PDF bytes out.
|
||||
//
|
||||
// Exit codes: 0 ok, 1 usage/input error, 2 engine error.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"ukrrs.com/mopac/pdf/internal/chart"
|
||||
"ukrrs.com/mopac/pdf/internal/engine"
|
||||
"ukrrs.com/mopac/pdf/internal/frontmatter"
|
||||
"ukrrs.com/mopac/pdf/internal/markdown"
|
||||
"ukrrs.com/mopac/pdf/internal/pdfinfo"
|
||||
"ukrrs.com/mopac/pdf/internal/typdoc"
|
||||
"ukrrs.com/mopac/pdf/templates"
|
||||
)
|
||||
|
||||
// Version is the CLI version.
|
||||
const Version = "0.1.0"
|
||||
|
||||
const usage = `mopac-pdf (v` + Version + `) — markdown + front-matter in, beautiful PDF out
|
||||
|
||||
Usage:
|
||||
mopac-pdf [flags] [INPUT.md] (default input: stdin)
|
||||
|
||||
Flags:
|
||||
-o FILE write PDF to FILE (default: stdout)
|
||||
-t NAME template: report | brief (default: front-matter
|
||||
"template" key, else "report")
|
||||
-T DIR template directory override (DIR/NAME.typ wins over the
|
||||
embedded shipped templates)
|
||||
-V print version and exit
|
||||
-pages FILE print the page count of an existing PDF and exit
|
||||
(verification helper; tiny /Pages /Count parser)
|
||||
|
||||
Input is markdown with optional front-matter (title, subtitle, author, date,
|
||||
classification, template). Fenced blocks tagged "chart" render as figures:
|
||||
|
||||
` + "```" + `chart
|
||||
type: bar
|
||||
title: Revenue by quarter
|
||||
unit: $K
|
||||
Q1: 120
|
||||
Q2: 180
|
||||
` + "```" + `
|
||||
|
||||
Engine: digest-pinned typst container (override: MOPAC_PDF_ENGINE; docker
|
||||
binary override: MOPAC_PDF_DOCKER; keep compile root: MOPAC_PDF_KEEP=1).
|
||||
|
||||
Exit codes: 0 ok, 1 usage/input error, 2 engine error.
|
||||
`
|
||||
|
||||
// Run is the CLI entry point; returns the process exit code.
|
||||
func Run(argv []string, stdout, stderr io.Writer) int {
|
||||
args := argv
|
||||
var outPath, tmplName, tmplDir, pagesPath string
|
||||
var showVersion bool
|
||||
rest := []string{}
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "-o":
|
||||
if i+1 >= len(args) {
|
||||
fmt.Fprintln(stderr, "mopac-pdf: -o needs a value")
|
||||
return 1
|
||||
}
|
||||
i++
|
||||
outPath = args[i]
|
||||
case "-t":
|
||||
if i+1 >= len(args) {
|
||||
fmt.Fprintln(stderr, "mopac-pdf: -t needs a value")
|
||||
return 1
|
||||
}
|
||||
i++
|
||||
tmplName = args[i]
|
||||
case "-T":
|
||||
if i+1 >= len(args) {
|
||||
fmt.Fprintln(stderr, "mopac-pdf: -T needs a value")
|
||||
return 1
|
||||
}
|
||||
i++
|
||||
tmplDir = args[i]
|
||||
case "-pages":
|
||||
if i+1 >= len(args) {
|
||||
fmt.Fprintln(stderr, "mopac-pdf: -pages needs a value")
|
||||
return 1
|
||||
}
|
||||
i++
|
||||
pagesPath = args[i]
|
||||
case "-V", "--version":
|
||||
showVersion = true
|
||||
case "-h", "--help":
|
||||
fmt.Fprint(stdout, usage)
|
||||
return 0
|
||||
case "help":
|
||||
fmt.Fprint(stdout, usage)
|
||||
return 0
|
||||
default:
|
||||
if strings.HasPrefix(args[i], "-") && args[i] != "-" {
|
||||
fmt.Fprintf(stderr, "mopac-pdf: unknown flag %q\n\n%s", args[i], usage)
|
||||
return 1
|
||||
}
|
||||
rest = append(rest, args[i])
|
||||
}
|
||||
}
|
||||
if showVersion {
|
||||
fmt.Fprintf(stdout, "mopac-pdf %s\n", Version)
|
||||
return 0
|
||||
}
|
||||
if len(rest) > 1 {
|
||||
fmt.Fprintf(stderr, "mopac-pdf: unexpected argument %q (one input file)\n\n%s", rest[1], usage)
|
||||
return 1
|
||||
}
|
||||
inPath := "-"
|
||||
if len(rest) == 1 {
|
||||
inPath = rest[0]
|
||||
}
|
||||
|
||||
if pagesPath != "" {
|
||||
data, err := os.ReadFile(pagesPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "mopac-pdf: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
n, err := pdfinfo.Pages(data)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "mopac-pdf: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(stdout, "%d\n", n)
|
||||
return 0
|
||||
}
|
||||
|
||||
// Input.
|
||||
var input []byte
|
||||
var err error
|
||||
baseDir := "."
|
||||
if inPath == "-" && pagesPath == "" {
|
||||
input, err = io.ReadAll(os.Stdin)
|
||||
} else if inPath != "-" {
|
||||
input, err = os.ReadFile(inPath)
|
||||
baseDir = filepath.Dir(inPath)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "mopac-pdf: read input: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
meta, body, err := frontmatter.Split(string(input))
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "mopac-pdf: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if tmplName == "" {
|
||||
tmplName = meta.Template
|
||||
}
|
||||
if tmplName == "" {
|
||||
tmplName = "report"
|
||||
}
|
||||
tmplSrc, err := templates.Source(tmplDir, tmplName)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "mopac-pdf: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
// Markdown -> typst (+ assets).
|
||||
blocks := markdown.Parse(body)
|
||||
assets := typdoc.Assets{}
|
||||
rendered, err := typdoc.RenderBody(blocks, assets, typdoc.Funcs{
|
||||
RenderChart: func(c *markdown.Chart, _ string) ([]byte, error) { return chart.Bar(c) },
|
||||
StageImage: func(path, _ string) ([]byte, error) {
|
||||
return os.ReadFile(filepath.Join(baseDir, path))
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "mopac-pdf: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
doc := typdoc.Compose(tmplName, meta, rendered)
|
||||
|
||||
// Compile root + engine.
|
||||
root, err := os.MkdirTemp("", "mopac-pdf-")
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "mopac-pdf: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if os.Getenv("MOPAC_PDF_KEEP") == "1" {
|
||||
fmt.Fprintf(stderr, "mopac-pdf: compile root kept at %s\n", root)
|
||||
} else {
|
||||
defer os.RemoveAll(root)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "template.typ"), []byte(tmplSrc), 0o644); err != nil {
|
||||
fmt.Fprintf(stderr, "mopac-pdf: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "doc.typ"), []byte(doc), 0o644); err != nil {
|
||||
fmt.Fprintf(stderr, "mopac-pdf: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
pdf, err := engine.New().Compile(root, "doc.typ", assets)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "mopac-pdf: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
|
||||
// Output.
|
||||
if outPath != "" {
|
||||
if err := os.WriteFile(outPath, pdf, 0o644); err != nil {
|
||||
fmt.Fprintf(stderr, "mopac-pdf: write output: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
} else {
|
||||
if _, err := stdout.Write(pdf); err != nil {
|
||||
fmt.Fprintf(stderr, "mopac-pdf: write output: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
Reference in New Issue
Block a user