1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
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
}
|