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 /plan.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 'plan.go')
| -rw-r--r-- | plan.go | 293 |
1 files changed, 293 insertions, 0 deletions
@@ -0,0 +1,293 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "math" + "os" + "path/filepath" +) + +// rect is a stamp rectangle in PDF points, bottom-left origin. +type rect struct { + X float64 `json:"x"` + Y float64 `json:"y"` + W float64 `json:"w"` + H float64 `json:"h"` +} + +// stamp is one stamp placed on one or more pages. +type stamp struct { + ID string `json:"id"` + Type string `json:"type"` // "text" or "image" + Content string `json:"content,omitempty"` // text stamps + Src string `json:"src,omitempty"` // image stamps: PNG or JPEG path + Pages []int `json:"pages"` // 1-based page numbers + Rect rect `json:"rect"` + Opacity float64 `json:"opacity"` +} + +// plan is the on-disk stamp plan. +type plan struct { + Version int `json:"version"` + Input string `json:"input"` + Stamps []stamp `json:"stamps"` +} + +const ( + planVersion = 2 + typeText = "text" + typeImage = "image" + maxPlanBytes = 8 << 20 + maxStamps = 1_000 + maxPageRefs = 100_000 + maxTextBytes = 64 << 10 +) + +// newPlan returns an empty plan for the given input PDF. +func newPlan(input string) *plan { + return &plan{ + Version: planVersion, + Input: input, + Stamps: []stamp{}, + } +} + +// loadPlan reads and validates a plan from path. +func loadPlan(path string) (*plan, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + return readPlan(f, path) +} + +func readPlan(r io.Reader, name string) (*plan, error) { + b, err := io.ReadAll(io.LimitReader(r, maxPlanBytes+1)) + if err != nil { + return nil, fmt.Errorf("read %s: %w", name, err) + } + return parsePlan(b, name) +} + +// parsePlan strictly decodes and validates plan JSON; name is used in errors. +func parsePlan(b []byte, name string) (*plan, error) { + if len(b) > maxPlanBytes { + return nil, fmt.Errorf("plan %s is larger than %d bytes", name, maxPlanBytes) + } + var p plan + dec := json.NewDecoder(bytes.NewReader(b)) + dec.DisallowUnknownFields() + if err := dec.Decode(&p); err != nil { + return nil, fmt.Errorf("parse %s: %w", name, err) + } + if err := dec.Decode(&struct{}{}); err != io.EOF { + if err == nil { + return nil, fmt.Errorf("parse %s: multiple JSON values", name) + } + return nil, fmt.Errorf("parse %s: trailing data: %w", name, err) + } + if err := p.validate(); err != nil { + return nil, fmt.Errorf("invalid plan %s: %w", name, err) + } + return &p, nil +} + +// save writes the plan to path as indented JSON. +func (p *plan) save(path string) error { + b, err := p.marshal() + if err != nil { + return err + } + return writeFileAtomic(path, b, 0o644) +} + +func (p *plan) marshal() ([]byte, error) { + if err := p.validate(); err != nil { + return nil, err + } + b, err := json.MarshalIndent(p, "", " ") + if err != nil { + return nil, err + } + b = append(b, '\n') + if len(b) > maxPlanBytes { + return nil, fmt.Errorf("encoded plan is larger than %d bytes", maxPlanBytes) + } + return b, nil +} + +// writeFileAtomic writes b to path via a temp file in the same directory, +// where rename is atomic on local filesystems. The temp file is synced before +// the rename, so an interrupted write leaves it behind rather than a truncated +// file at path. +func writeFileAtomic(path string, b []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + f, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-*") + if err != nil { + return err + } + tmp := f.Name() + defer os.Remove(tmp) // no-op after a successful rename + if _, err := f.Write(b); err != nil { + f.Close() + return err + } + if err := f.Chmod(perm); err != nil { + f.Close() + return err + } + if err := f.Sync(); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + if err := os.Rename(tmp, path); err != nil { + return err + } + return syncDir(dir) +} + +func syncDir(path string) error { + d, err := os.Open(path) + if err != nil { + return err + } + if err := d.Sync(); err != nil { + d.Close() + return err + } + return d.Close() +} + +// validate checks the plan's invariants. +func (p *plan) validate() error { + if p.Version != planVersion { + return fmt.Errorf("version %d unsupported (want %d)", p.Version, planVersion) + } + if p.Input == "" { + return fmt.Errorf("input is empty") + } + if len(p.Stamps) > maxStamps { + return fmt.Errorf("too many stamps (%d; limit is %d)", len(p.Stamps), maxStamps) + } + pageRefs := 0 + for i := range p.Stamps { + pageRefs += len(p.Stamps[i].Pages) + if pageRefs > maxPageRefs { + return fmt.Errorf("too many page references (%d; limit is %d)", pageRefs, maxPageRefs) + } + if err := p.Stamps[i].validate(); err != nil { + return fmt.Errorf("stamp %d (%s): %w", i, p.Stamps[i].ID, err) + } + } + return nil +} + +func (s *stamp) validate() error { + switch s.Type { + case typeText: + if s.Content == "" { + return fmt.Errorf("text stamp has empty content") + } + if len(s.Content) > maxTextBytes { + return fmt.Errorf("text is larger than %d bytes", maxTextBytes) + } + // Text is written as raw bytes into a WinAnsi-encoded base-14 string, + // so anything outside printable ASCII would render as wrong glyphs. + for _, r := range s.Content { + if r < 0x20 || r > 0x7e { + return fmt.Errorf("text contains %q; only printable ASCII is supported (no font is embedded)", r) + } + } + case typeImage: + if s.Src == "" { + return fmt.Errorf("image stamp has empty src") + } + default: + return fmt.Errorf("unknown type %q", s.Type) + } + if len(s.Pages) == 0 { + return fmt.Errorf("no pages") + } + seen := make(map[int]struct{}, len(s.Pages)) + for _, pg := range s.Pages { + if pg < 1 { + return fmt.Errorf("page %d out of range (1-based)", pg) + } + if _, ok := seen[pg]; ok { + return fmt.Errorf("duplicate page %d", pg) + } + seen[pg] = struct{}{} + } + for _, v := range []float64{s.Rect.X, s.Rect.Y, s.Rect.W, s.Rect.H, s.Opacity} { + if math.IsNaN(v) || math.IsInf(v, 0) { + return fmt.Errorf("rect and opacity must be finite (got %g)", v) + } + } + if s.Rect.W <= 0 || s.Rect.H <= 0 { + return fmt.Errorf("rect must have positive w and h") + } + if s.Opacity < 0 || s.Opacity > 1 { + return fmt.Errorf("opacity %g out of range [0,1]", s.Opacity) + } + return nil +} + +// toAbsolute rewrites Input and image srcs to absolute paths. Called before +// saving so a plan applies correctly no matter which directory it is run from. +func (p *plan) toAbsolute() { + p.Input = absPath(p.Input) + for i := range p.Stamps { + if p.Stamps[i].Type == typeImage { + p.Stamps[i].Src = absPath(p.Stamps[i].Src) + } + } +} + +// resolveRelativeTo makes relative Input and image srcs relative to base (the +// plan file's directory), so hand-written plans with relative paths apply +// correctly regardless of the current working directory. +func (p *plan) resolveRelativeTo(base string) { + p.Input = joinIfRel(base, p.Input) + for i := range p.Stamps { + if p.Stamps[i].Type == typeImage { + p.Stamps[i].Src = joinIfRel(base, p.Stamps[i].Src) + } + } +} + +func absPath(path string) string { + if path == "" { + return path + } + if a, err := filepath.Abs(path); err == nil { + return a + } + return path +} + +func joinIfRel(base, path string) string { + if path == "" || filepath.IsAbs(path) { + return path + } + return filepath.Join(base, path) +} + +// maxPage returns the highest 1-based page referenced by any stamp. +func (p *plan) maxPage() int { + mx := 0 + for _, s := range p.Stamps { + for _, pg := range s.Pages { + if pg > mx { + mx = pg + } + } + } + return mx +} |