diff options
| author | Lena <lena@omega> | 2026-04-01 00:00:00 +0000 |
|---|---|---|
| committer | Lena <lena@omega> | 2026-04-01 00:00:00 +0000 |
| commit | 459f5935aab505cdb8174e5f9f04a9a3c426f37e (patch) | |
| tree | 525a5b39b05ef2c799b953ad3969e869ab484745 /main.go | |
| download | pdfstamp-master.tar.gz | |
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.go | 163 |
1 files changed, 163 insertions, 0 deletions
@@ -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) +} |