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 /apply.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 'apply.go')
| -rw-r--r-- | apply.go | 158 |
1 files changed, 158 insertions, 0 deletions
diff --git a/apply.go b/apply.go new file mode 100644 index 0000000..471883c --- /dev/null +++ b/apply.go @@ -0,0 +1,158 @@ +package main + +import ( + "bytes" + "fmt" + "math" + "os" + "path/filepath" + + "github.com/pdfcpu/pdfcpu/pkg/api" + "github.com/pdfcpu/pdfcpu/pkg/pdfcpu" + "github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model" + "github.com/pdfcpu/pdfcpu/pkg/pdfcpu/types" +) + +// apply burns the plan's stamps into outPath, reading the source PDF from +// plan.Input. It builds an in-memory overlay PDF (one page per source page) +// and lets pdfcpu overlay it 1:1 (multi-stamp, pos:c, sc:1 abs), which is an +// identity transform because the overlay pages match the source MediaBoxes. +func apply(p *plan, outPath string) error { + if err := p.validate(); err != nil { + return err + } + if err := refuseInPlace(p.Input, outPath); err != nil { + return err + } + + conf := model.NewDefaultConfiguration() + + dims, err := pageDims(p.Input) + if err != nil { + return fmt.Errorf("%s: %w", p.Input, err) + } + if mp := p.maxPage(); mp > len(dims) { + return fmt.Errorf("plan references page %d but %s has %d pages", mp, p.Input, len(dims)) + } + + overlay, err := buildOverlay(p, dims) + if err != nil { + return err + } + + // Set the placement explicitly rather than via a descriptor string: the + // overlay pages match the source MediaBoxes, so an unscaled, centered, + // unrotated stamp is an exact 1:1 overlay. NoDiagonal is required because + // pdfcpu's default is a diagonal watermark. + wm, err := pdfcpu.ParsePDFWatermarkDetails("", "", true, types.POINTS) + if err != nil { + return err + } + wm.PDF = bytes.NewReader(overlay) + wm.Scale, wm.ScaleAbs = 1.0, true + wm.Pos = types.Center + wm.Rotation = 0 + wm.Diagonal = model.NoDiagonal + + // Write to a temp file in the destination directory, validate it, then + // rename over outPath. A failed apply or an invalid result never replaces + // an existing good output, and the rename is atomic on local filesystems. + tmp, err := os.CreateTemp(filepath.Dir(outPath), "."+filepath.Base(outPath)+".tmp-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + tmp.Close() // pdfcpu writes the file itself + defer os.Remove(tmpPath) + + if err := api.AddWatermarksFile(p.Input, tmpPath, nil, wm, conf); err != nil { + return fmt.Errorf("apply stamps: %w", err) + } + if err := api.ValidateFile(tmpPath, conf); err != nil { + return fmt.Errorf("validate output %s: %w", outPath, err) + } + // CreateTemp made the file 0600 and pdfcpu rewrites it in place, so set + // normal permissions before publishing it (the same mode plan.save uses). + if err := os.Chmod(tmpPath, 0o644); err != nil { + return err + } + // pdfcpu wrote the temp file itself; sync it so a crash right after the + // rename cannot leave a truncated output at outPath. + tf, err := os.OpenFile(tmpPath, os.O_RDWR, 0) + if err != nil { + return err + } + err = tf.Sync() + tf.Close() + if err != nil { + return err + } + if err := os.Rename(tmpPath, outPath); err != nil { + return fmt.Errorf("write %s: %w", outPath, err) + } + if err := syncDir(filepath.Dir(outPath)); err != nil { + return fmt.Errorf("sync directory for %s: %w", outPath, err) + } + return nil +} + +// refuseInPlace rejects writing an output (stamped PDF or plan) over the +// input file, which would destroy the source. os.SameFile catches aliases +// that string comparison misses (relative paths, symlinks, hard links); if +// the output does not exist yet it cannot be the input. +func refuseInPlace(in, out string) error { + inInfo, err := os.Stat(in) + if err != nil { + return err + } + outInfo, err := os.Stat(out) + if err != nil { + return nil + } + if os.SameFile(inInfo, outInfo) { + return fmt.Errorf("output %s is the same file as input %s; refusing to overwrite the input", out, in) + } + return nil +} + +// pageDims returns each page's viewed size in points, swapping width and +// height for /Rotate 90 or 270: pdfcpu counter-rotates watermarks on rotated +// pages, so a viewed-size overlay lands 1:1 in viewed coordinates, which is +// the space the picker's MuPDF preview and the plan already use. Pages whose +// box origin is not at (0,0) are rejected; failing loudly there beats +// silently misplacing every stamp. +func pageDims(inFile string) ([]types.Dim, error) { + ctx, err := api.ReadContextFile(inFile) + if err != nil { + return nil, err + } + if err := ctx.EnsurePageCount(); err != nil { + return nil, err + } + dims := make([]types.Dim, ctx.PageCount) + for i := 1; i <= ctx.PageCount; i++ { + _, _, attrs, err := ctx.PageDict(i, false) + if err != nil { + return nil, err + } + if attrs.Rotate%90 != 0 { + return nil, fmt.Errorf("page %d has invalid /Rotate %d (must be a multiple of 90)", i, attrs.Rotate) + } + box, name := attrs.MediaBox, "MediaBox" + if attrs.CropBox != nil { + box, name = attrs.CropBox, "CropBox" + } + if box == nil { + return nil, fmt.Errorf("page %d has no MediaBox", i) + } + if math.Abs(box.LL.X) > 0.5 || math.Abs(box.LL.Y) > 0.5 { + return nil, fmt.Errorf("page %d has a non-zero %s origin (%.1f,%.1f); not supported", i, name, box.LL.X, box.LL.Y) + } + w, h := box.Width(), box.Height() + if r := ((attrs.Rotate % 360) + 360) % 360; r == 90 || r == 270 { + w, h = h, w + } + dims[i-1] = types.Dim{Width: w, Height: h} + } + return dims, nil +} |