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 }