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_test.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_test.go')
| -rw-r--r-- | apply_test.go | 358 |
1 files changed, 358 insertions, 0 deletions
diff --git a/apply_test.go b/apply_test.go new file mode 100644 index 0000000..860d97c --- /dev/null +++ b/apply_test.go @@ -0,0 +1,358 @@ +package main + +import ( + "bytes" + "encoding/base64" + "fmt" + "image" + "image/color" + "image/png" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gen2brain/go-fitz" + "github.com/pdfcpu/pdfcpu/pkg/api" +) + +// noTempLeftovers fails if any atomic-write temp file remains in dir. +func noTempLeftovers(t *testing.T, dir string) { + t.Helper() + leftovers, _ := filepath.Glob(filepath.Join(dir, ".*.tmp-*")) + if len(leftovers) != 0 { + t.Errorf("temp files left behind: %v", leftovers) + } +} + +// TestApply verifies end to end that a plan is burned into the output at the +// correct coordinates: a text stamp darkens its rect, an image stamp lands +// exactly in its rect (and not outside), and unstamped areas are untouched. +func TestApply(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src.pdf") + imgPath := filepath.Join(dir, "stamp.png") + out := filepath.Join(dir, "out.pdf") + + const ph = 792.0 + writeFile(t, src, blankPDF(2, "0 0 612 792", "")) + writeFile(t, imgPath, redPNG(16, 16)) + + p := &plan{ + Version: planVersion, Input: src, + Stamps: []stamp{ + {ID: "t1", Type: typeText, Content: "TEST", Pages: []int{1}, + Rect: rect{X: 100, Y: 700, W: 120, H: 40}, Opacity: 1}, + {ID: "t2", Type: typeText, Content: strings.Repeat("W", 20), Pages: []int{1}, + Rect: rect{X: 100, Y: 300, W: 100, H: 30}, Opacity: 1}, + {ID: "i1", Type: typeImage, Src: imgPath, Pages: []int{1}, + Rect: rect{X: 400, Y: 100, W: 64, H: 64}, Opacity: 1}, + }, + } + + if err := apply(p, out); err != nil { + t.Fatalf("apply: %v", err) + } + + // The output must not keep the temp file's restrictive 0600 mode. + if fi, err := os.Stat(out); err != nil { + t.Fatalf("stat output: %v", err) + } else if fi.Mode().Perm() != 0o644 { + t.Errorf("output mode = %o, want 644", fi.Mode().Perm()) + } + + // Page count preserved. + n, err := api.PageCountFile(out) + if err != nil { + t.Fatalf("PageCount: %v", err) + } + if n != 2 { + t.Fatalf("page count = %d, want 2", n) + } + + // render page 0 of both source and output at 72 dpi (1 px = 1 pt). + srcImg := renderPage(t, src, 0) + outImg := renderPage(t, out, 0) + + // pt (x,y) bottom-left -> px (x, ph-y) top-left at 72 dpi. + px := func(x, y float64) (int, int) { return int(x), int(ph - y) } + + // Text rect darkened somewhere. + if darkCount(outImg, 100, 60, 200, 90) == 0 { + t.Errorf("text stamp produced no dark pixels in its rect") + } + + // Wide text must fit its rect (100..200 x 300..330 pt): dark inside, + // nothing spilling left or right. + if darkCount(outImg, 100, 462, 200, 492) == 0 { + t.Errorf("wide text stamp produced no dark pixels in its rect") + } + if n := darkCount(outImg, 40, 462, 96, 492); n != 0 { + t.Errorf("wide text spilled left of its rect (%d dark px)", n) + } + if n := darkCount(outImg, 204, 462, 260, 492); n != 0 { + t.Errorf("wide text spilled right of its rect (%d dark px)", n) + } + + // Image stamp: center is red, registration is exact. + cx, cy := px(432, 132) // center of 400..464 x 100..164 + if !isRed(outImg.At(cx, cy)) { + t.Errorf("image stamp center (%d,%d) not red: %v", cx, cy, outImg.At(cx, cy)) + } + // Just outside the image rect must remain white (no overflow / misplacement). + ox, oy := px(480, 132) + if !isWhite(outImg.At(ox, oy)) { + t.Errorf("pixel right of image rect (%d,%d) not white: %v", ox, oy, outImg.At(ox, oy)) + } + + // Control: an unstamped area is unchanged white in both. + mx, my := px(300, 400) + if !isWhite(srcImg.At(mx, my)) || !isWhite(outImg.At(mx, my)) { + t.Errorf("control pixel changed: src=%v out=%v", srcImg.At(mx, my), outImg.At(mx, my)) + } + + // Page 2 had no stamps: it must remain blank white. + out2 := renderPage(t, out, 1) + if darkCount(out2, 0, 0, 612, 792) != 0 { + t.Errorf("page 2 should be blank but has dark pixels") + } + + // The atomic write renamed its temp into place, leaving none behind. + noTempLeftovers(t, dir) +} + +// TestApplyRefusesInPlace verifies apply will not write over its own input, +// including when the output is a differently-spelled alias of the input. +func TestApplyRefusesInPlace(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "doc.pdf") + writeFile(t, src, blankPDF(1, "0 0 612 792", "")) + orig, _ := os.ReadFile(src) + + p := &plan{ + Version: planVersion, Input: src, + Stamps: []stamp{{ID: "t", Type: typeText, Content: "X", Pages: []int{1}, + Rect: rect{X: 10, Y: 10, W: 50, H: 20}, Opacity: 1}}, + } + + if err := apply(p, src); err == nil || !strings.Contains(err.Error(), "same file") { + t.Errorf("apply over input path: expected refusal, got %v", err) + } + + link := filepath.Join(dir, "alias.pdf") + if err := os.Symlink(src, link); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + if err := apply(p, link); err == nil || !strings.Contains(err.Error(), "same file") { + t.Errorf("apply over symlink to input: expected refusal, got %v", err) + } + + if got, _ := os.ReadFile(src); !bytes.Equal(got, orig) { + t.Errorf("input was modified by a refused in-place apply") + } +} + +// TestApplyPreservesOutputOnFailure verifies a failed apply leaves an existing +// good output intact and removes its temp file. +func TestApplyPreservesOutputOnFailure(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src.pdf") + out := filepath.Join(dir, "out.pdf") + writeFile(t, src, blankPDF(1, "0 0 612 792", "")) + + sentinel := []byte("existing good output\n") + writeFile(t, out, sentinel) + + // References a page beyond the document, so apply must fail. + p := &plan{ + Version: planVersion, Input: src, + Stamps: []stamp{{ID: "t", Type: typeText, Content: "X", Pages: []int{99}, + Rect: rect{X: 10, Y: 10, W: 50, H: 20}, Opacity: 1}}, + } + if err := apply(p, out); err == nil { + t.Fatal("expected apply to fail on out-of-range page") + } + + if got, _ := os.ReadFile(out); !bytes.Equal(got, sentinel) { + t.Errorf("existing output was modified on failure") + } + noTempLeftovers(t, dir) +} + +func TestRejectsWebPContent(t *testing.T) { + b, err := base64.StdEncoding.DecodeString("UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEAAUAmJQBOgCHwAP7+AA==") + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "stamp.png") + writeFile(t, path, b) + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if _, _, err := decodeImageConfig(f); err == nil || !strings.Contains(err.Error(), "not supported") { + t.Fatalf("expected WebP rejection, got %v", err) + } +} + +// TestApplyRotatedPages verifies stamps land at their picked (viewed-space) +// coordinates on pages with /Rotate: pdfcpu counter-rotates the overlay, so +// the red image must appear exactly at its rect in the rendered (viewed) +// output for every rotation. +func TestApplyRotatedPages(t *testing.T) { + dir := t.TempDir() + imgPath := filepath.Join(dir, "red.png") + writeFile(t, imgPath, redPNG(16, 16)) + + for _, rot := range []int{90, 180, 270} { + src := filepath.Join(dir, fmt.Sprintf("src%d.pdf", rot)) + writeFile(t, src, blankPDF(1, "0 0 612 792", fmt.Sprintf("/Rotate %d", rot))) + + // Viewed height: a 612x792 page displays as 792x612 at 90/270. + vh := 792 + if rot == 90 || rot == 270 { + vh = 612 + } + + p := &plan{ + Version: planVersion, Input: src, + Stamps: []stamp{{ID: "i", Type: typeImage, Src: imgPath, Pages: []int{1}, + Rect: rect{X: 100, Y: 100, W: 64, H: 64}, Opacity: 1}}, + } + out := filepath.Join(dir, fmt.Sprintf("out%d.pdf", rot)) + if err := apply(p, out); err != nil { + t.Fatalf("rot %d: apply: %v", rot, err) + } + + img := renderPage(t, out, 0) // MuPDF renders the viewed orientation + cx, cy := 132, vh-132 // center of 100..164 x 100..164, viewed + if !isRed(img.At(cx, cy)) { + t.Errorf("rot %d: stamp center (%d,%d) not red: %v", rot, cx, cy, img.At(cx, cy)) + } + if ox, oy := 180, vh-132; !isWhite(img.At(ox, oy)) { + t.Errorf("rot %d: pixel right of rect (%d,%d) not white: %v", rot, ox, oy, img.At(ox, oy)) + } + } +} + +// TestApplyRejectsInvalidRotate verifies a /Rotate that is not a multiple of +// 90 fails loudly. +func TestApplyRejectsInvalidRotate(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "r.pdf") + writeFile(t, src, blankPDF(1, "0 0 612 792", "/Rotate 45")) + + p := &plan{ + Version: planVersion, Input: src, + Stamps: []stamp{{ID: "t", Type: typeText, Content: "X", Pages: []int{1}, + Rect: rect{X: 10, Y: 10, W: 50, H: 20}, Opacity: 1}}, + } + err := apply(p, filepath.Join(dir, "o.pdf")) + if err == nil || !strings.Contains(err.Error(), "Rotate") { + t.Fatalf("expected /Rotate rejection, got: %v", err) + } +} + +// TestApplyRejectsNonZeroOrigin verifies the page guard fails loudly on a +// page whose MediaBox origin is not (0,0) instead of misplacing stamps. +func TestApplyRejectsNonZeroOrigin(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "o.pdf") + writeFile(t, src, blankPDF(1, "30 40 642 832", "")) + + p := &plan{ + Version: planVersion, Input: src, + Stamps: []stamp{{ID: "t", Type: typeText, Content: "X", Pages: []int{1}, + Rect: rect{X: 10, Y: 10, W: 50, H: 20}, Opacity: 1}}, + } + err := apply(p, filepath.Join(dir, "out.pdf")) + if err == nil || !strings.Contains(err.Error(), "origin") { + t.Fatalf("expected non-zero origin rejection, got: %v", err) + } +} + +// --- fixtures --- + +// blankPDF builds a minimal valid PDF with n blank pages of the given +// MediaBox and extra page dict entries (e.g. "/Rotate 90"). +func blankPDF(n int, mediaBox, extra string) []byte { + pw := &pdfWriter{} + cat := pw.reserve() + pages := pw.reserve() + kids := "" + for i := 0; i < n; i++ { + c := pw.add(makeStream("", []byte{})) + page := pw.add([]byte(fmt.Sprintf( + "<< /Type /Page /Parent %d 0 R /MediaBox [%s] %s /Resources << >> /Contents %d 0 R >>", + pages, mediaBox, extra, c))) + kids += fmt.Sprintf("%d 0 R ", page) + } + pw.set(pages, []byte(fmt.Sprintf("<< /Type /Pages /Kids [%s] /Count %d >>", kids, n))) + pw.set(cat, []byte(fmt.Sprintf("<< /Type /Catalog /Pages %d 0 R >>", pages))) + return pw.bytes() +} + +func redPNG(w, h int) []byte { + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.Set(x, y, color.NRGBA{R: 230, G: 20, B: 20, A: 255}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + panic(err) + } + return buf.Bytes() +} + +// --- helpers --- + +func writeFile(t *testing.T, path string, b []byte) { + t.Helper() + if err := os.WriteFile(path, b, 0o644); err != nil { + t.Fatal(err) + } +} + +func renderPage(t *testing.T, path string, pg int) *image.RGBA { + t.Helper() + d, err := fitz.New(path) + if err != nil { + t.Fatalf("fitz open %s: %v", path, err) + } + defer d.Close() + img, err := d.ImageDPI(pg, 72) + if err != nil { + t.Fatalf("render %s p%d: %v", path, pg, err) + } + return img +} + +func darkCount(img *image.RGBA, x0, y0, x1, y1 int) int { + n := 0 + b := img.Bounds() + for y := y0; y < y1; y++ { + for x := x0; x < x1; x++ { + if x < b.Min.X || y < b.Min.Y || x >= b.Max.X || y >= b.Max.Y { + continue + } + c := img.RGBAAt(x, y) + if c.R < 100 && c.G < 100 && c.B < 100 { + n++ + } + } + } + return n +} + +func isRed(c color.Color) bool { + r, g, b, _ := c.RGBA() + return r>>8 > 150 && g>>8 < 100 && b>>8 < 100 +} + +func isWhite(c color.Color) bool { + r, g, b, _ := c.RGBA() + return r>>8 > 240 && g>>8 > 240 && b>>8 > 240 +} |