aboutsummaryrefslogtreecommitdiff
path: root/main.go
diff options
context:
space:
mode:
authorLena <lena@omega>2026-04-01 00:00:00 +0000
committerLena <lena@omega>2026-04-01 00:00:00 +0000
commit459f5935aab505cdb8174e5f9f04a9a3c426f37e (patch)
tree525a5b39b05ef2c799b953ad3969e869ab484745 /main.go
downloadpdfstamp-459f5935aab505cdb8174e5f9f04a9a3c426f37e.tar.gz
Enter pdfstampHEADmaster
Terminal-only PDF stamp picker. Renders a coarse page preview in the terminal, places text and image stamps with the keyboard, saves the layout as a JSON plan, and burns it into an output PDF. MuPDF (via go-fitz) renders the preview; pdfcpu overlays the generated stamp PDF onto the source 1:1 and validates the result.
Diffstat (limited to 'main.go')
-rw-r--r--main.go163
1 files changed, 163 insertions, 0 deletions
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..d9cce3a
--- /dev/null
+++ b/main.go
@@ -0,0 +1,163 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+const usage = `pdfstamp - terminal PDF stamp picker
+
+usage:
+ pdfstamp pick INPUT.pdf [-o PLAN.json] place stamps; write plan (stdout if no -o)
+ pdfstamp apply PLAN.json OUTPUT.pdf burn a plan into an output PDF
+ pdfstamp INPUT.pdf OUTPUT.pdf pick then apply in one step
+
+PLAN.json may be - to read the plan from stdin, so commands compose:
+ pdfstamp pick INPUT.pdf | pdfstamp apply - OUTPUT.pdf
+`
+
+func main() {
+ args := os.Args[1:]
+ if len(args) == 0 {
+ fmt.Fprint(os.Stderr, usage)
+ os.Exit(2)
+ }
+ switch args[0] {
+ case "pick":
+ cmdPick(args[1:])
+ case "apply":
+ cmdApply(args[1:])
+ case "-h", "--help", "help":
+ fmt.Print(usage)
+ default:
+ if len(args) == 2 {
+ cmdOneShot(args[0], args[1])
+ return
+ }
+ usageErr("unknown command %q", args[0])
+ }
+}
+
+func cmdPick(args []string) {
+ input, planPath, err := parsePickArgs(args)
+ if err != nil {
+ usageErr("%v", err)
+ }
+ if planPath != "" {
+ if err := refuseInPlace(input, planPath); err != nil {
+ die("%v", err)
+ }
+ }
+
+ d, err := openDoc(input)
+ if err != nil {
+ die("open %s: %v", input, err)
+ }
+ defer d.close()
+
+ p := newPicker(d, input)
+ p.savePath = planPath
+ if err := p.run(); err != nil {
+ die("%v", err)
+ }
+}
+
+// parsePickArgs parses "pick INPUT.pdf [-o PLAN.json]" strictly: the plan path
+// is set only by -o, and unknown options or extra positionals are rejected.
+func parsePickArgs(args []string) (input, planPath string, err error) {
+ for i := 0; i < len(args); i++ {
+ arg := args[i]
+ switch {
+ case arg == "-o":
+ i++
+ if i >= len(args) {
+ return "", "", fmt.Errorf("pick: -o requires a file argument")
+ }
+ planPath = args[i]
+ case strings.HasPrefix(arg, "-") && arg != "-":
+ return "", "", fmt.Errorf("pick: unknown option %q", arg)
+ case input == "":
+ input = arg
+ default:
+ return "", "", fmt.Errorf("pick: unexpected argument %q (plan path goes after -o)", arg)
+ }
+ }
+ if input == "" {
+ return "", "", fmt.Errorf("pick: missing INPUT.pdf")
+ }
+ return input, planPath, nil
+}
+
+func cmdApply(args []string) {
+ if len(args) != 2 {
+ usageErr("apply: usage is apply PLAN.json OUTPUT.pdf")
+ }
+
+ var p *plan
+ var err error
+ if args[0] == "-" {
+ // stdin plans carry absolute paths (pick emits them) or paths relative
+ // to the current directory; use them as given.
+ p, err = readPlan(os.Stdin, "<stdin>")
+ } else {
+ p, err = loadPlan(args[0])
+ if err == nil {
+ p.resolveRelativeTo(filepath.Dir(args[0]))
+ }
+ }
+ if err != nil {
+ die("%v", err)
+ }
+ if err := apply(p, args[1]); err != nil {
+ die("%v", err)
+ }
+ fmt.Printf("wrote %s\n", terminalText(args[1]))
+}
+
+func cmdOneShot(input, output string) {
+ // Fail before the picker starts, not at commit time after placement work.
+ if err := refuseInPlace(input, output); err != nil {
+ die("%v", err)
+ }
+ d, err := openDoc(input)
+ if err != nil {
+ die("open %s: %v", input, err)
+ }
+ defer d.close()
+
+ p := newPicker(d, input)
+ p.applyPath = output
+ if err := p.run(); err != nil {
+ die("%v", err)
+ }
+ // Printed after run() so the terminal is back on the main screen, and only
+ // on a real apply: quitting with q writes nothing.
+ if p.wrote {
+ fmt.Printf("wrote %s\n", terminalText(output))
+ }
+}
+
+func die(format string, a ...any) {
+ fmt.Fprintf(os.Stderr, "pdfstamp: %s\n", terminalText(fmt.Sprintf(format, a...)))
+ os.Exit(1)
+}
+
+func usageErr(format string, a ...any) {
+ fmt.Fprintf(os.Stderr, "pdfstamp: %s\n", terminalText(fmt.Sprintf(format, a...)))
+ fmt.Fprint(os.Stderr, usage)
+ os.Exit(2)
+}
+
+// terminalText neutralizes control and escape characters in text that comes
+// from file names, plan contents, or library errors, none of which are
+// trusted to be safe to write to a terminal.
+func terminalText(s string) string {
+ return strings.Map(func(r rune) rune {
+ if r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f) {
+ return '?'
+ }
+ return r
+ }, s)
+}