aboutsummaryrefslogtreecommitdiff
path: root/apply.go
diff options
context:
space:
mode:
Diffstat (limited to 'apply.go')
-rw-r--r--apply.go158
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
+}