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
226 lines
5.6 KiB
Go
226 lines
5.6 KiB
Go
// 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
|
|
}
|