aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--Makefile21
-rw-r--r--README156
-rw-r--r--apply.go158
-rw-r--r--apply_test.go358
-rw-r--r--example.pdf93
-rw-r--r--go.mod25
-rw-r--r--go.sum34
-rw-r--r--main.go163
-rw-r--r--main_test.go46
-rw-r--r--overlay.go355
-rw-r--r--pick.go806
-rw-r--r--pick_test.go71
-rw-r--r--plan.go293
-rw-r--r--plan_test.go104
-rw-r--r--render.go163
-rw-r--r--render_test.go60
17 files changed, 2908 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..62cc411
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/pdfstamp
+/plan.json
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..6653599
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,21 @@
+BIN := pdfstamp
+CC ?= cc
+
+build:
+ CC=$(CC) CGO_ENABLED=1 go build -tags musl \
+ -ldflags '-linkmode external -extldflags -static' -o $(BIN) .
+
+test:
+ CC=$(CC) CGO_ENABLED=1 go vet -tags musl ./...
+ CC=$(CC) CGO_ENABLED=1 go test -tags musl ./...
+
+fmt:
+ gofmt -w *.go
+
+run: build
+ ./$(BIN) pick example.pdf -o plan.json
+
+clean:
+ rm -f $(BIN)
+
+.PHONY: build test fmt run clean
diff --git a/README b/README
new file mode 100644
index 0000000..c2d54d5
--- /dev/null
+++ b/README
@@ -0,0 +1,156 @@
+pdfstamp
+========
+pdfstamp is a terminal-only PDF stamp-region picker. It renders a coarse,
+content-aware page preview directly in the terminal and lets you place text and
+image stamps with the keyboard, save the layout as a JSON plan, and burn the
+stamps into an output PDF.
+
+It does coarse placement, not pixel-perfect editing. Zoom in for finer control
+over stamps, initials, signatures, dates, and labels.
+
+
+Build
+-----
+pdfstamp uses MuPDF (via go-fitz) for rendering, which requires cgo. go-fitz
+bundles musl-built MuPDF static libraries (no system PDF library is needed),
+and the build links fully static: the one binary has no libc dependency and
+runs on both glibc and musl machines.
+
+On Alpine:
+
+ apk add build-base go
+ make build
+
+On Debian, install the musl toolchain and select it explicitly:
+
+ apt install build-essential musl-tools
+ make build CC=musl-gcc
+
+There is no native Windows build; on Windows, run the same static binary
+under WSL.
+
+
+Usage
+-----
+ pdfstamp pick INPUT.pdf [-o PLAN.json] place stamps; write the plan
+ pdfstamp apply PLAN.json OUTPUT.pdf burn a plan into an output PDF
+ pdfstamp INPUT.pdf OUTPUT.pdf pick then apply in one step
+
+With no -o, pick writes the plan to stdout. apply reads the plan from stdin when
+the path is -, so the two steps compose as a pipe:
+
+ pdfstamp pick INPUT.pdf | pdfstamp apply - OUTPUT.pdf
+
+The picker draws to /dev/tty, so stdout carries only the plan. apply reads the
+source PDF path from the plan's "input" field. A sample document ships with the
+repo; try:
+
+ pdfstamp pick example.pdf -o plan.json
+ pdfstamp apply plan.json stamped.pdf
+
+
+Controls
+--------
+The picker is modal on selection: with no stamp selected, h/j/k/l move the
+cursor; with a stamp selected (tab), they move the stamp.
+
+ n / p next / previous page
+ g go to page
+ h j k l move cursor (or selected stamp)
+ arrows move (same as h j k l)
+ H J K L resize selected stamp
+ space start / finish a rectangle, then add a stamp in it
+ a add a stamp at the cursor (or edit the selected one)
+ d delete the selected stamp
+ P set the selected stamp's pages (e.g. 1,3-5 or all)
+ o set the selected stamp's opacity (0-1)
+ tab cycle stamp selection (and back to cursor mode)
+ + / - zoom in / out
+ enter save the plan (pick), or apply and quit (one-step)
+ ? toggle help
+ q quit
+
+A truecolor terminal (COLORTERM=truecolor) gets a colored half-block preview;
+otherwise a grayscale ASCII preview is used. tmux and screen usually strip
+COLORTERM; set it inside the multiplexer to keep the colored preview.
+
+
+Plan format
+-----------
+The plan is plain JSON. Coordinates are PDF points with a bottom-left origin.
+Pages are 1-based. Text uses Helvetica, sized to fit the rectangle (no per-stamp
+font/size/color). Only printable ASCII is supported, as no font is embedded;
+plans with other characters are rejected.
+Images (PNG or JPEG) are scaled to fit the rectangle while preserving aspect
+ratio, anchored at the bottom-left corner.
+
+pick writes absolute paths so a saved plan applies from any directory. In a
+hand-written plan, relative "input" and "src" paths are resolved relative to the
+plan file's directory (or the working directory when the plan is read from
+stdin).
+
+ {
+ "version": 2,
+ "input": "input.pdf",
+ "stamps": [
+ { "id": "stamp-1", "type": "text", "content": "APPROVED",
+ "pages": [1], "rect": { "x": 420, "y": 610, "w": 120, "h": 42 },
+ "opacity": 0.8 },
+ { "id": "stamp-2", "type": "image", "src": "sig.png",
+ "pages": [1, 3], "rect": { "x": 100, "y": 90, "w": 80, "h": 40 },
+ "opacity": 1.0 }
+ ]
+ }
+
+
+How it works
+------------
+pick rasterizes each page with MuPDF and draws it as Unicode half-blocks, one
+character per two vertical pixels, mapping terminal cells to PDF points.
+
+apply builds an in-memory overlay PDF with one page per source page, drawing
+each stamp at its exact point coordinates (text via a base-14 Helvetica content
+stream, images as RGB XObjects with a soft-mask for transparency). pdfcpu then
+overlays that PDF onto the source 1:1 (multi-stamp, centered, scale 1, no
+rotation) and validates the result. Because the overlay pages share the source
+MediaBoxes, the overlay is an identity transform. On pages with /Rotate, the
+overlay uses the viewed page size and pdfcpu counter-rotates it, so stamps land
+where the picker showed them.
+
+
+Debugging
+---------
+The overlay content streams are uncompressed and human-readable; dump the
+overlay or output with a viewer or `strings` to inspect the drawing operators
+(`cm`, `Tj`, `Do`). Run the integration test, which applies a known plan and
+asserts that stamps land in the expected pixel regions:
+
+ make test
+
+On Debian, use the same musl compiler override as the build:
+
+ make test CC=musl-gcc
+
+If stamps appear rotated or offset, check the watermark fields set in apply.go:
+pdfcpu defaults to a diagonal placement, so Rotation 0 and NoDiagonal must both
+be set to get a straight 1:1 overlay.
+
+
+Exit status
+-----------
+0 on success, 1 on runtime errors, 2 on usage errors. The picker exits 130 on
+SIGINT and 143 on SIGTERM, after restoring the terminal.
+
+
+Limitations
+-----------
+apply refuses, rather than silently misplacing stamps, on pages it cannot map
+1:1: pages whose MediaBox/CropBox origin is not at (0,0). Text stamps are
+printable ASCII only (no embedded font). Each stamp image is capped at 50
+megapixels, with a 100-megapixel total across distinct images.
+
+
+License
+-------
+pdfstamp links MuPDF (AGPL-3.0). Distributing the binary therefore puts it under
+the AGPL-3.0 unless you hold a commercial MuPDF license. pdfcpu is Apache-2.0.
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
+}
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
+}
diff --git a/example.pdf b/example.pdf
new file mode 100644
index 0000000..2a1e189
--- /dev/null
+++ b/example.pdf
@@ -0,0 +1,93 @@
+%PDF-1.7
+%
+1 0 obj
+<< /Type /Catalog /Pages 2 0 R >>
+endobj
+2 0 obj
+<< /Type /Pages /Kids [5 0 R 7 0 R 9 0 R] /Count 3 >>
+endobj
+3 0 obj
+<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
+endobj
+4 0 obj
+<< /Length 673 >>
+stream
+BT /F1 22 Tf 72 720 Td (SERVICE AGREEMENT) Tj ET
+BT /F1 11 Tf 72 688 Td 15 TL
+(This Service Agreement \("Agreement"\) is entered into as of the date) Tj T*
+(written below, by and between the Provider and the Client.) Tj T*
+T*
+(1. The Provider shall perform the services described in Schedule A.) Tj T*
+(2. The Client shall pay the fees set out in Schedule B.) Tj T*
+(3. Either party may terminate this Agreement with 30 days notice.) Tj T*
+(4. This Agreement is governed by the laws of the stated jurisdiction.) Tj T*
+ET
+0.7 w 432 690 m 540 690 l 540 752 l 432 752 l 432 690 l S
+BT /F1 8 Tf 452 716 Td (STAMP HERE) Tj ET
+BT /F1 9 Tf 72 96 Td (Page 1 of 3 - Agreement) Tj ET
+endstream
+endobj
+5 0 obj
+<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> >> /Contents 4 0 R >>
+endobj
+6 0 obj
+<< /Length 549 >>
+stream
+BT /F1 18 Tf 72 720 Td (SCHEDULE A - SERVICES) Tj ET
+BT /F1 11 Tf 72 688 Td 15 TL
+(The Provider shall deliver the following services:) Tj T*
+T*
+(- Initial consultation and requirements review.) Tj T*
+(- Design, implementation, and delivery of the agreed work.) Tj T*
+(- One revision cycle within fourteen days of delivery.) Tj T*
+(- Reasonable support for thirty days after acceptance.) Tj T*
+ET
+0.7 w 432 690 m 540 690 l 540 752 l 432 752 l 432 690 l S
+BT /F1 8 Tf 452 716 Td (STAMP HERE) Tj ET
+BT /F1 9 Tf 72 96 Td (Page 2 of 3 - Schedule A) Tj ET
+endstream
+endobj
+7 0 obj
+<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> >> /Contents 6 0 R >>
+endobj
+8 0 obj
+<< /Length 595 >>
+stream
+BT /F1 18 Tf 72 720 Td (SCHEDULE B - FEES) Tj ET
+BT /F1 11 Tf 72 688 Td 15 TL
+(The Client shall pay the following fees:) Tj T*
+T*
+(- A fixed project fee, invoiced on acceptance.) Tj T*
+(- Expenses pre-approved in writing by the Client.) Tj T*
+(- Payment due within thirty days of each invoice.) Tj T*
+ET
+0.7 w
+72 170 m 300 170 l S
+360 170 m 520 170 l S
+BT /F1 10 Tf 72 156 Td (Authorized Signature) Tj ET
+BT /F1 10 Tf 360 156 Td (Date) Tj ET
+0.7 w 432 690 m 540 690 l 540 752 l 432 752 l 432 690 l S
+BT /F1 8 Tf 452 716 Td (STAMP HERE) Tj ET
+BT /F1 9 Tf 72 96 Td (Page 3 of 3 - Schedule B) Tj ET
+endstream
+endobj
+9 0 obj
+<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> >> /Contents 8 0 R >>
+endobj
+xref
+0 10
+0000000000 65535 f
+0000000015 00000 n
+0000000064 00000 n
+0000000133 00000 n
+0000000203 00000 n
+0000000927 00000 n
+0000001053 00000 n
+0000001653 00000 n
+0000001779 00000 n
+0000002425 00000 n
+trailer
+<< /Size 10 /Root 1 0 R >>
+startxref
+2551
+%%EOF
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..b0c6876
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,25 @@
+module pdfstamp
+
+go 1.25.0
+
+require (
+ github.com/gen2brain/go-fitz v1.24.15
+ github.com/pdfcpu/pdfcpu v0.12.1
+ golang.org/x/term v0.43.0
+)
+
+require (
+ github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
+ github.com/ebitengine/purego v0.8.4 // indirect
+ github.com/hhrutter/lzw v1.0.0 // indirect
+ github.com/hhrutter/pkcs7 v0.2.2 // indirect
+ github.com/hhrutter/tiff v1.0.3 // indirect
+ github.com/jupiterrider/ffi v0.5.0 // indirect
+ github.com/mattn/go-runewidth v0.0.23 // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ golang.org/x/crypto v0.50.0 // indirect
+ golang.org/x/image v0.43.0 // indirect
+ golang.org/x/sys v0.44.0 // indirect
+ golang.org/x/text v0.38.0 // indirect
+ gopkg.in/yaml.v2 v2.4.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..64620c7
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,34 @@
+github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
+github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
+github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
+github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+github.com/gen2brain/go-fitz v1.24.15 h1:sJNB1MOWkqnzzENPHggFpgxTwW0+S5WF/rM5wUBpJWo=
+github.com/gen2brain/go-fitz v1.24.15/go.mod h1:SftkiVbTHqF141DuiLwBBM65zP7ig6AVDQpf2WlHamo=
+github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
+github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
+github.com/hhrutter/pkcs7 v0.2.2 h1:xMoifoVWah1LNym3C0pomEiLmyJyVIBXt/8oTPyPz+8=
+github.com/hhrutter/pkcs7 v0.2.2/go.mod h1:aEzKz0+ZAlz7YaEMY47jDHL14hVWD6iXt0AgqgAvWgE=
+github.com/hhrutter/tiff v1.0.3 h1:POV5xITOE1Lt5FvP24ylft0LyCmHmc8GkJ1SVlvUyk0=
+github.com/hhrutter/tiff v1.0.3/go.mod h1:zZDLVY4cp9za2FLrryAaGszwWYAUM6DrRiBR0l//mxA=
+github.com/jupiterrider/ffi v0.5.0 h1:j2nSgpabbV1JOwgP4Kn449sJUHq3cVLAZVBoOYn44V8=
+github.com/jupiterrider/ffi v0.5.0/go.mod h1:x7xdNKo8h0AmLuXfswDUBxUsd2OqUP4ekC8sCnsmbvo=
+github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
+github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/pdfcpu/pdfcpu v0.12.1 h1:HwoN72zJCj+pPbfMDChYBTZrT7SY0VwgUzqeaId3I20=
+github.com/pdfcpu/pdfcpu v0.12.1/go.mod h1:7KPpVLMavcpliPrtN6o7Kuk3cFtYq8nii3SJnnsK7ps=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
+golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
+golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
+golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
+golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
+golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
+golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
+golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
+golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..d9cce3a
--- /dev/null
+++ b/main.go
@@ -0,0 +1,163 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+const usage = `pdfstamp - terminal PDF stamp picker
+
+usage:
+ pdfstamp pick INPUT.pdf [-o PLAN.json] place stamps; write plan (stdout if no -o)
+ pdfstamp apply PLAN.json OUTPUT.pdf burn a plan into an output PDF
+ pdfstamp INPUT.pdf OUTPUT.pdf pick then apply in one step
+
+PLAN.json may be - to read the plan from stdin, so commands compose:
+ pdfstamp pick INPUT.pdf | pdfstamp apply - OUTPUT.pdf
+`
+
+func main() {
+ args := os.Args[1:]
+ if len(args) == 0 {
+ fmt.Fprint(os.Stderr, usage)
+ os.Exit(2)
+ }
+ switch args[0] {
+ case "pick":
+ cmdPick(args[1:])
+ case "apply":
+ cmdApply(args[1:])
+ case "-h", "--help", "help":
+ fmt.Print(usage)
+ default:
+ if len(args) == 2 {
+ cmdOneShot(args[0], args[1])
+ return
+ }
+ usageErr("unknown command %q", args[0])
+ }
+}
+
+func cmdPick(args []string) {
+ input, planPath, err := parsePickArgs(args)
+ if err != nil {
+ usageErr("%v", err)
+ }
+ if planPath != "" {
+ if err := refuseInPlace(input, planPath); err != nil {
+ die("%v", err)
+ }
+ }
+
+ d, err := openDoc(input)
+ if err != nil {
+ die("open %s: %v", input, err)
+ }
+ defer d.close()
+
+ p := newPicker(d, input)
+ p.savePath = planPath
+ if err := p.run(); err != nil {
+ die("%v", err)
+ }
+}
+
+// parsePickArgs parses "pick INPUT.pdf [-o PLAN.json]" strictly: the plan path
+// is set only by -o, and unknown options or extra positionals are rejected.
+func parsePickArgs(args []string) (input, planPath string, err error) {
+ for i := 0; i < len(args); i++ {
+ arg := args[i]
+ switch {
+ case arg == "-o":
+ i++
+ if i >= len(args) {
+ return "", "", fmt.Errorf("pick: -o requires a file argument")
+ }
+ planPath = args[i]
+ case strings.HasPrefix(arg, "-") && arg != "-":
+ return "", "", fmt.Errorf("pick: unknown option %q", arg)
+ case input == "":
+ input = arg
+ default:
+ return "", "", fmt.Errorf("pick: unexpected argument %q (plan path goes after -o)", arg)
+ }
+ }
+ if input == "" {
+ return "", "", fmt.Errorf("pick: missing INPUT.pdf")
+ }
+ return input, planPath, nil
+}
+
+func cmdApply(args []string) {
+ if len(args) != 2 {
+ usageErr("apply: usage is apply PLAN.json OUTPUT.pdf")
+ }
+
+ var p *plan
+ var err error
+ if args[0] == "-" {
+ // stdin plans carry absolute paths (pick emits them) or paths relative
+ // to the current directory; use them as given.
+ p, err = readPlan(os.Stdin, "<stdin>")
+ } else {
+ p, err = loadPlan(args[0])
+ if err == nil {
+ p.resolveRelativeTo(filepath.Dir(args[0]))
+ }
+ }
+ if err != nil {
+ die("%v", err)
+ }
+ if err := apply(p, args[1]); err != nil {
+ die("%v", err)
+ }
+ fmt.Printf("wrote %s\n", terminalText(args[1]))
+}
+
+func cmdOneShot(input, output string) {
+ // Fail before the picker starts, not at commit time after placement work.
+ if err := refuseInPlace(input, output); err != nil {
+ die("%v", err)
+ }
+ d, err := openDoc(input)
+ if err != nil {
+ die("open %s: %v", input, err)
+ }
+ defer d.close()
+
+ p := newPicker(d, input)
+ p.applyPath = output
+ if err := p.run(); err != nil {
+ die("%v", err)
+ }
+ // Printed after run() so the terminal is back on the main screen, and only
+ // on a real apply: quitting with q writes nothing.
+ if p.wrote {
+ fmt.Printf("wrote %s\n", terminalText(output))
+ }
+}
+
+func die(format string, a ...any) {
+ fmt.Fprintf(os.Stderr, "pdfstamp: %s\n", terminalText(fmt.Sprintf(format, a...)))
+ os.Exit(1)
+}
+
+func usageErr(format string, a ...any) {
+ fmt.Fprintf(os.Stderr, "pdfstamp: %s\n", terminalText(fmt.Sprintf(format, a...)))
+ fmt.Fprint(os.Stderr, usage)
+ os.Exit(2)
+}
+
+// terminalText neutralizes control and escape characters in text that comes
+// from file names, plan contents, or library errors, none of which are
+// trusted to be safe to write to a terminal.
+func terminalText(s string) string {
+ return strings.Map(func(r rune) rune {
+ if r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f) {
+ return '?'
+ }
+ return r
+ }, s)
+}
diff --git a/main_test.go b/main_test.go
new file mode 100644
index 0000000..680e821
--- /dev/null
+++ b/main_test.go
@@ -0,0 +1,46 @@
+package main
+
+import "testing"
+
+func TestParsePickArgs(t *testing.T) {
+ ok := map[string]struct {
+ args []string
+ input string
+ plan string
+ }{
+ "input only": {[]string{"in.pdf"}, "in.pdf", ""},
+ "input with -o": {[]string{"in.pdf", "-o", "p.json"}, "in.pdf", "p.json"},
+ "-o before input": {[]string{"-o", "p.json", "in.pdf"}, "in.pdf", "p.json"},
+ }
+ for name, c := range ok {
+ input, plan, err := parsePickArgs(c.args)
+ if err != nil {
+ t.Errorf("%s: unexpected error: %v", name, err)
+ continue
+ }
+ if input != c.input || plan != c.plan {
+ t.Errorf("%s: got input=%q plan=%q, want %q %q", name, input, plan, c.input, c.plan)
+ }
+ }
+
+ bad := map[string][]string{
+ "no args": {},
+ "missing -o value": {"in.pdf", "-o"},
+ "unknown option": {"in.pdf", "-x"},
+ "extra positional": {"in.pdf", "extra.json"},
+ }
+ for name, args := range bad {
+ if _, _, err := parsePickArgs(args); err == nil {
+ t.Errorf("%s: expected error, got nil", name)
+ }
+ }
+}
+
+func TestTerminalText(t *testing.T) {
+ if got, want := terminalText("safe café"), "safe café"; got != want {
+ t.Errorf("terminalText safe text = %q, want %q", got, want)
+ }
+ if got, want := terminalText("a\nb\x1b\u0085"), "a?b??"; got != want {
+ t.Errorf("terminalText controls = %q, want %q", got, want)
+ }
+}
diff --git a/overlay.go b/overlay.go
new file mode 100644
index 0000000..f7e6221
--- /dev/null
+++ b/overlay.go
@@ -0,0 +1,355 @@
+package main
+
+import (
+ "bytes"
+ "compress/zlib"
+ "fmt"
+ "image"
+ _ "image/jpeg"
+ _ "image/png"
+ "io"
+ "math"
+ "os"
+ "strconv"
+ "strings"
+
+ "github.com/pdfcpu/pdfcpu/pkg/pdfcpu/types"
+)
+
+// Image limits bound the decoded working set, not the compressed file size.
+const (
+ maxImagePixels = 50_000_000
+ maxTotalImagePixels = 100_000_000
+)
+
+// buildOverlay constructs an in-memory PDF with one page per source page,
+// each sized to the source page and drawing that page's stamps at absolute
+// coordinates (bottom-left origin, points). It is meant to be overlaid 1:1
+// onto the source by pdfcpu (multi-stamp, pos:c, sc:1 abs).
+func buildOverlay(p *plan, dims []types.Dim) ([]byte, error) {
+ n := len(dims)
+ byPage := map[int][]*stamp{}
+ haveText := false
+ for i := range p.Stamps {
+ s := &p.Stamps[i]
+ if s.Type == typeText {
+ haveText = true
+ }
+ for _, pg := range s.Pages {
+ if pg >= 1 && pg <= n {
+ byPage[pg] = append(byPage[pg], s)
+ }
+ }
+ }
+
+ w := &pdfWriter{}
+ catalog := w.reserve()
+ pages := w.reserve()
+
+ // Shared font (Helvetica base-14, no embedding).
+ fontNum := 0
+ if haveText {
+ fontNum = w.add([]byte("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"))
+ }
+
+ // One ExtGState per distinct opacity, created before the shared Resources
+ // dict so that dict lists every ExtGState the pages reference.
+ gs := map[string]string{} // opacity key -> ExtGState name
+ var gsEntries []string
+ for i := range p.Stamps {
+ k := opacityKey(p.Stamps[i].Opacity)
+ if _, ok := gs[k]; ok {
+ continue
+ }
+ name := fmt.Sprintf("GS%d", len(gs))
+ num := w.add([]byte(fmt.Sprintf("<< /Type /ExtGState /ca %s /CA %s >>", k, k)))
+ gs[k] = name
+ gsEntries = append(gsEntries, fmt.Sprintf("/%s %d 0 R", name, num))
+ }
+
+ // One image XObject per distinct src.
+ imgs := map[string]imageRes{}
+ var xobjEntries []string
+ remainingPixels := int64(maxTotalImagePixels)
+ for i := range p.Stamps {
+ s := &p.Stamps[i]
+ if s.Type != typeImage {
+ continue
+ }
+ if _, ok := imgs[s.Src]; ok {
+ continue
+ }
+ res, pixels, err := embedImage(w, s.Src, remainingPixels)
+ if err != nil {
+ return nil, fmt.Errorf("image stamp %q: %w", s.Src, err)
+ }
+ remainingPixels -= pixels
+ res.name = fmt.Sprintf("Im%d", len(imgs))
+ imgs[s.Src] = res
+ xobjEntries = append(xobjEntries, fmt.Sprintf("/%s %d 0 R", res.name, res.num))
+ }
+
+ var rb strings.Builder
+ rb.WriteString("<< /ProcSet [/PDF /Text /ImageB /ImageC /ImageI]")
+ if fontNum != 0 {
+ fmt.Fprintf(&rb, " /Font << /F1 %d 0 R >>", fontNum)
+ }
+ if len(gsEntries) > 0 {
+ fmt.Fprintf(&rb, " /ExtGState << %s >>", strings.Join(gsEntries, " "))
+ }
+ if len(xobjEntries) > 0 {
+ fmt.Fprintf(&rb, " /XObject << %s >>", strings.Join(xobjEntries, " "))
+ }
+ rb.WriteString(" >>")
+ resNum := w.add([]byte(rb.String()))
+
+ pageNums := make([]int, n)
+ for i := 0; i < n; i++ {
+ content := pageContent(byPage[i+1], gs, imgs)
+ cNum := w.add(makeStream("", content))
+ dict := fmt.Sprintf(
+ "<< /Type /Page /Parent %d 0 R /MediaBox [0 0 %s %s] /Resources %d 0 R /Contents %d 0 R >>",
+ pages, ff(dims[i].Width), ff(dims[i].Height), resNum, cNum)
+ pageNums[i] = w.add([]byte(dict))
+ }
+
+ kids := make([]string, n)
+ for i, num := range pageNums {
+ kids[i] = fmt.Sprintf("%d 0 R", num)
+ }
+ w.set(pages, []byte(fmt.Sprintf("<< /Type /Pages /Kids [%s] /Count %d >>", strings.Join(kids, " "), n)))
+ w.set(catalog, []byte(fmt.Sprintf("<< /Type /Catalog /Pages %d 0 R >>", pages)))
+
+ return w.bytes(), nil
+}
+
+// pageContent renders all stamps for one page into a content stream.
+func pageContent(stamps []*stamp, gs map[string]string, imgs map[string]imageRes) []byte {
+ var b bytes.Buffer
+ for _, s := range stamps {
+ state := gs[opacityKey(s.Opacity)]
+ switch s.Type {
+ case typeText:
+ size, dx, dy := fitText(s.Content, s.Rect.W, s.Rect.H)
+ // cm: translate to the rect origin; Td centers the line in the box.
+ fmt.Fprintf(&b, "q /%s gs 1 0 0 1 %s %s cm BT /F1 %s Tf 0 g %s %s Td (%s) Tj ET Q\n",
+ state, ff(s.Rect.X), ff(s.Rect.Y),
+ ff(size), ff(dx), ff(dy), textEscaper.Replace(s.Content))
+ case typeImage:
+ res := imgs[s.Src]
+ sc := math.Min(s.Rect.W/float64(res.w), s.Rect.H/float64(res.h))
+ fmt.Fprintf(&b, "q /%s gs %s 0 0 %s %s %s cm /%s Do Q\n",
+ state, ff(float64(res.w)*sc), ff(float64(res.h)*sc),
+ ff(s.Rect.X), ff(s.Rect.Y), res.name)
+ }
+ }
+ return b.Bytes()
+}
+
+// opacityKey formats an opacity for the ExtGState dict; stamps share a state
+// when they format identically.
+func opacityKey(v float64) string { return strconv.FormatFloat(v, 'f', 3, 64) }
+
+// helvWidths holds Helvetica glyph widths for printable ASCII (0x20..0x7e)
+// in 1/1000 em, from the Adobe core-14 AFM metrics. Validation restricts
+// text content to this range, so exact fitting needs no embedded font.
+var helvWidths = [95]int{
+ 278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278,
+ 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556,
+ 1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778,
+ 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556,
+ 333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556,
+ 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584,
+}
+
+// helvCapHeight is Helvetica's cap height in em units (AFM CapHeight 718).
+const helvCapHeight = 0.718
+
+// textWidth returns the width of s in em units (multiply by the font size).
+func textWidth(s string) float64 {
+ w := 0
+ for i := 0; i < len(s); i++ {
+ w += helvWidths[s[i]-0x20]
+ }
+ return float64(w) / 1000
+}
+
+// fitText sizes text to its rectangle using the real metrics: height drives
+// the size, shrunk if the line would overflow the width. dx/dy center the
+// line inside the box (offsets from the rect origin).
+func fitText(text string, w, h float64) (size, dx, dy float64) {
+ uw := textWidth(text)
+ size = 0.72 * h
+ if uw*size > w {
+ size = w / uw
+ }
+ return size, (w - uw*size) / 2, (h - helvCapHeight*size) / 2
+}
+
+// textEscaper escapes PDF string delimiters; validation already restricts
+// content to printable ASCII.
+var textEscaper = strings.NewReplacer("\\", "\\\\", "(", "\\(", ")", "\\)")
+
+// ff formats a float for PDF content with trailing zeros trimmed.
+func ff(v float64) string {
+ s := strconv.FormatFloat(v, 'f', 4, 64)
+ if strings.Contains(s, ".") {
+ s = strings.TrimRight(strings.TrimRight(s, "0"), ".")
+ }
+ if s == "-0" {
+ s = "0"
+ }
+ return s
+}
+
+type imageRes struct {
+ name string // XObject name in the resources dict
+ num int // image XObject number
+ w, h int // pixel dimensions
+}
+
+// embedImage decodes a PNG/JPEG and writes an RGB image XObject (plus an
+// 8-bit DeviceGray SMask when the image has transparency). Header dimensions
+// are checked before decoding so a huge file fails fast, not out of memory.
+func embedImage(w *pdfWriter, path string, remainingPixels int64) (imageRes, int64, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return imageRes{}, 0, err
+ }
+ defer f.Close()
+ cfg, _, err := decodeImageConfig(f)
+ if err != nil {
+ return imageRes{}, 0, err
+ }
+ pixels := int64(cfg.Width) * int64(cfg.Height)
+ if pixels > remainingPixels {
+ return imageRes{}, 0, fmt.Errorf("images exceed the total limit of %d pixels", maxTotalImagePixels)
+ }
+ if _, err := f.Seek(0, io.SeekStart); err != nil {
+ return imageRes{}, 0, err
+ }
+ img, format, err := image.Decode(f)
+ if err != nil {
+ return imageRes{}, 0, err
+ }
+ if !supportedImageFormat(format) {
+ return imageRes{}, 0, fmt.Errorf("image format %q is not supported (want PNG or JPEG)", format)
+ }
+ bnds := img.Bounds()
+ pw, ph := bnds.Dx(), bnds.Dy()
+ if pw != cfg.Width || ph != cfg.Height {
+ return imageRes{}, 0, fmt.Errorf("decoded dimensions %dx%d do not match header %dx%d", pw, ph, cfg.Width, cfg.Height)
+ }
+ rgb := make([]byte, 0, 3*pw*ph)
+ hasAlpha := true
+ if opaque, ok := img.(interface{ Opaque() bool }); ok {
+ hasAlpha = !opaque.Opaque()
+ }
+ var alpha []byte
+ if hasAlpha {
+ alpha = make([]byte, 0, pw*ph)
+ }
+ for y := bnds.Min.Y; y < bnds.Max.Y; y++ {
+ for x := bnds.Min.X; x < bnds.Max.X; x++ {
+ r, g, b, a := img.At(x, y).RGBA() // 16-bit, alpha-premultiplied
+ a8 := byte(a >> 8)
+ // Un-premultiply so the SMask carries the transparency.
+ if a == 0 {
+ rgb = append(rgb, 0, 0, 0)
+ } else {
+ rgb = append(rgb, byte((r*0xffff/a)>>8), byte((g*0xffff/a)>>8), byte((b*0xffff/a)>>8))
+ }
+ if hasAlpha {
+ alpha = append(alpha, a8)
+ }
+ }
+ }
+
+ smask := 0
+ if hasAlpha {
+ smask = w.add(makeStream(
+ fmt.Sprintf("/Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode", pw, ph),
+ flate(alpha)))
+ }
+ dict := fmt.Sprintf("/Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode", pw, ph)
+ if smask != 0 {
+ dict += fmt.Sprintf(" /SMask %d 0 R", smask)
+ }
+ num := w.add(makeStream(dict, flate(rgb)))
+ return imageRes{num: num, w: pw, h: ph}, pixels, nil
+}
+
+// decodeImageConfig reads an image header and enforces maxImagePixels. It is
+// shared by embedImage and the picker's pick-time check so both agree.
+func decodeImageConfig(f *os.File) (image.Config, string, error) {
+ cfg, format, err := image.DecodeConfig(f)
+ if err != nil {
+ return cfg, "", err
+ }
+ if !supportedImageFormat(format) {
+ return cfg, format, fmt.Errorf("image format %q is not supported (want PNG or JPEG)", format)
+ }
+ if cfg.Width < 1 || cfg.Height < 1 || int64(cfg.Width) > int64(maxImagePixels)/int64(cfg.Height) {
+ return cfg, format, fmt.Errorf("image is %dx%d; the limit is %d pixels", cfg.Width, cfg.Height, maxImagePixels)
+ }
+ return cfg, format, nil
+}
+
+func supportedImageFormat(format string) bool {
+ return format == "png" || format == "jpeg"
+}
+
+func flate(b []byte) []byte {
+ var buf bytes.Buffer
+ zw := zlib.NewWriter(&buf)
+ zw.Write(b)
+ zw.Close()
+ return buf.Bytes()
+}
+
+// --- minimal PDF object writer ---
+
+type pdfWriter struct {
+ objs [][]byte // objs[i] is the body of object number i+1
+}
+
+func (w *pdfWriter) reserve() int { w.objs = append(w.objs, nil); return len(w.objs) }
+func (w *pdfWriter) add(body []byte) int {
+ w.objs = append(w.objs, body)
+ return len(w.objs)
+}
+func (w *pdfWriter) set(num int, body []byte) { w.objs[num-1] = body }
+
+// makeStream builds a stream object body: dict (without /Length) + stream data.
+func makeStream(dictInner string, data []byte) []byte {
+ var b bytes.Buffer
+ if dictInner == "" {
+ fmt.Fprintf(&b, "<< /Length %d >>\nstream\n", len(data))
+ } else {
+ fmt.Fprintf(&b, "<< %s /Length %d >>\nstream\n", dictInner, len(data))
+ }
+ b.Write(data)
+ b.WriteString("\nendstream")
+ return b.Bytes()
+}
+
+// bytes serializes all objects with a classic cross-reference table.
+func (w *pdfWriter) bytes() []byte {
+ var b bytes.Buffer
+ b.WriteString("%PDF-1.7\n%\xe2\xe3\xcf\xd3\n")
+ offsets := make([]int, len(w.objs))
+ for i, body := range w.objs {
+ offsets[i] = b.Len()
+ fmt.Fprintf(&b, "%d 0 obj\n", i+1)
+ b.Write(body)
+ b.WriteString("\nendobj\n")
+ }
+ xref := b.Len()
+ fmt.Fprintf(&b, "xref\n0 %d\n", len(w.objs)+1)
+ b.WriteString("0000000000 65535 f \n")
+ for _, off := range offsets {
+ fmt.Fprintf(&b, "%010d 00000 n \n", off)
+ }
+ fmt.Fprintf(&b, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(w.objs)+1, xref)
+ return b.Bytes()
+}
diff --git a/pick.go b/pick.go
new file mode 100644
index 0000000..e33b088
--- /dev/null
+++ b/pick.go
@@ -0,0 +1,806 @@
+package main
+
+import (
+ "bufio"
+ "fmt"
+ "math"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "slices"
+ "strconv"
+ "strings"
+ "sync"
+ "syscall"
+
+ "golang.org/x/term"
+)
+
+// ramp maps brightness (dark..light) to characters for the no-truecolor
+// fallback: dense ink for dark pixels, blank for the white page.
+const ramp = "@%#*+=-:. "
+
+type overlayCell struct {
+ set bool
+ r rune
+ color [3]uint8
+}
+
+// picker holds the interactive editing state.
+type picker struct {
+ doc *doc
+ plan *plan
+ name string // input file name shown in header
+ out *bufio.Writer
+ in *bufio.Reader
+ fd int
+ rows int
+ tc bool // truecolor terminal
+
+ page int // 0-based
+ npages int
+ cx, cy float64 // cursor in points (bottom-left origin)
+ zoom float64
+ sel int // index into plan.Stamps, -1 for cursor mode
+
+ anchor bool
+ ax, ay float64
+ idSeq int
+ msg string
+ showHelp bool
+ view *view
+
+ savePath string // pick mode: where enter saves
+ applyPath string // one-shot: where enter applies+quits
+ wrote bool // one-shot: applyPath was written (vs quitting via q)
+}
+
+func newPicker(d *doc, input string) *picker {
+ return &picker{
+ doc: d,
+ plan: newPlan(input),
+ name: input,
+ npages: d.numPage(),
+ zoom: 1,
+ sel: -1,
+ tc: truecolor(),
+ }
+}
+
+func truecolor() bool {
+ ct := strings.ToLower(os.Getenv("COLORTERM"))
+ return strings.Contains(ct, "truecolor") || strings.Contains(ct, "24bit")
+}
+
+// run drives the interactive loop until the user quits. The UI is drawn to and
+// read from /dev/tty, leaving stdin/stdout free so the plan can be piped.
+func (p *picker) run() error {
+ tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
+ if err != nil {
+ return fmt.Errorf("open controlling terminal: %w", err)
+ }
+ defer tty.Close()
+ p.fd = int(tty.Fd())
+
+ old, err := term.MakeRaw(p.fd)
+ if err != nil {
+ return fmt.Errorf("raw mode (need a real terminal): %w", err)
+ }
+ p.out = bufio.NewWriter(tty)
+ p.in = bufio.NewReader(tty)
+
+ // Restore the terminal exactly once, on both the normal and the signal
+ // path. The signal path writes straight to the tty to avoid racing the
+ // main goroutine on the buffered writer.
+ var once sync.Once
+ restore := func() {
+ once.Do(func() {
+ tty.WriteString("\x1b[?1049l\x1b[?25h") // leave alt screen, show cursor
+ term.Restore(p.fd, old)
+ })
+ }
+ defer restore()
+
+ sig := make(chan os.Signal, 1)
+ signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
+ defer signal.Stop(sig)
+ go func() {
+ s := <-sig
+ restore()
+ if s == syscall.SIGTERM {
+ os.Exit(143) // 128 + SIGTERM
+ }
+ os.Exit(130) // 128 + SIGINT
+ }()
+
+ p.out.WriteString("\x1b[?1049h\x1b[?25l") // alt screen, hide cursor
+ p.centerCursor()
+ for {
+ if err := p.draw(); err != nil {
+ return err
+ }
+ quit, err := p.handle()
+ if err != nil {
+ return err
+ }
+ if quit {
+ return nil
+ }
+ }
+}
+
+func (p *picker) centerCursor() {
+ w, h, err := p.doc.pageSize(p.page)
+ if err == nil {
+ p.cx, p.cy = w/2, h/2
+ }
+}
+
+// --- drawing ---
+
+func (p *picker) draw() error {
+ w, h, err := term.GetSize(p.fd)
+ if err != nil || w < 20 || h < 8 {
+ w, h = 80, 24
+ }
+ p.rows = h
+ paneRows := h - 5 // header, 2 separators, status, hints
+ if paneRows < 1 {
+ paneRows = 1
+ }
+
+ v, err := p.doc.render(p.page, w, paneRows, p.zoom, p.cx, p.cy)
+ if err != nil {
+ return err
+ }
+ p.view = v
+
+ ov := make([][]overlayCell, paneRows)
+ for i := range ov {
+ ov[i] = make([]overlayCell, w)
+ }
+ p.drawStamps(ov)
+ if p.anchor {
+ p.drawRectPt(ov, p.ax, p.ay, p.cx, p.cy, [3]uint8{80, 220, 80})
+ }
+ p.drawCursor(ov)
+
+ p.out.WriteString("\x1b[H")
+ p.writeHeader(w)
+ p.writeSep(w)
+ p.writePane(v, ov)
+ p.writeSep(w)
+ p.writeStatus(w)
+ p.writeHints(w)
+ if p.showHelp {
+ p.writeHelp(h)
+ }
+ return p.out.Flush()
+}
+
+func (p *picker) writeHeader(w int) {
+ left := terminalText(p.name)
+ right := fmt.Sprintf("page %d / %d zoom %.1fx", p.page+1, p.npages, p.zoom)
+ p.out.WriteString("\x1b[7m" + pad(left, right, w) + "\x1b[0m\r\n")
+}
+
+func (p *picker) writeSep(w int) {
+ p.out.WriteString(strings.Repeat("─", w) + "\r\n")
+}
+
+func (p *picker) writePane(v *view, ov [][]overlayCell) {
+ for r := 0; r < v.rows; r++ {
+ var b strings.Builder
+ for c := 0; c < v.cols; c++ {
+ if o := ov[r][c]; o.set {
+ if p.tc {
+ fmt.Fprintf(&b, "\x1b[48;2;0;0;0m\x1b[38;2;%d;%d;%dm%c", o.color[0], o.color[1], o.color[2], o.r)
+ } else {
+ b.WriteRune(o.r)
+ }
+ continue
+ }
+ cell := v.cell(r, c)
+ if p.tc {
+ fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%dm\x1b[48;2;%d;%d;%dm▀",
+ cell.top[0], cell.top[1], cell.top[2], cell.bot[0], cell.bot[1], cell.bot[2])
+ } else {
+ b.WriteByte(rampChar(cell.top, cell.bot))
+ }
+ }
+ if p.tc {
+ b.WriteString("\x1b[0m")
+ }
+ p.out.WriteString(b.String() + "\r\n")
+ }
+}
+
+func (p *picker) writeStatus(w int) {
+ s := "stamp: (none) cursor x:" + fmt.Sprintf("%.0f y:%.0f", p.cx, p.cy)
+ if p.sel >= 0 && p.sel < len(p.plan.Stamps) {
+ st := p.plan.Stamps[p.sel]
+ label := st.Content
+ if st.Type == typeImage {
+ label = st.Src
+ }
+ s = fmt.Sprintf("stamp: %s x:%.0f y:%.0f w:%.0f h:%.0f op:%.2f pgs:%s",
+ fit(terminalText(label), 10), st.Rect.X, st.Rect.Y, st.Rect.W, st.Rect.H, st.Opacity,
+ formatPages(st.Pages))
+ }
+ if p.msg != "" {
+ s += " " + terminalText(p.msg)
+ }
+ p.out.WriteString("\x1b[7m" + fit(s, w) + "\x1b[0m\r\n")
+}
+
+func (p *picker) writeHints(w int) {
+ h := "h/j/k/l move H/J/K/L resize space rect a add/edit d del tab sel P pages o opacity +/- zoom n/p page g goto enter save ? help q quit"
+ p.out.WriteString(fit(h, w))
+}
+
+func (p *picker) writeHelp(h int) {
+ lines := []string{
+ " pdfstamp controls ",
+ "",
+ " n / p next / previous page",
+ " g go to page",
+ " h j k l move cursor (or selected stamp)",
+ " arrows move (same as h j k l)",
+ " H J K L resize selected stamp",
+ " space start / finish a rectangle",
+ " a add stamp (or edit selected)",
+ " d delete selected stamp",
+ " P set stamp pages (1,3-5 or all)",
+ " o set stamp opacity (0-1)",
+ " tab cycle stamp selection",
+ " + / - zoom in / out",
+ " enter save plan (or apply, one-shot)",
+ " ? toggle this help",
+ " q quit",
+ "",
+ " press any key ",
+ }
+ top := 3
+ for i, ln := range lines {
+ if top+i > h {
+ break
+ }
+ fmt.Fprintf(p.out, "\x1b[%d;4H\x1b[7m %s \x1b[0m", top+i, fit(ln, 40))
+ }
+}
+
+func (p *picker) drawStamps(ov [][]overlayCell) {
+ for i, s := range p.plan.Stamps {
+ if !slices.Contains(s.Pages, p.page+1) {
+ continue
+ }
+ color := [3]uint8{90, 200, 230}
+ if i == p.sel {
+ color = [3]uint8{240, 220, 60}
+ }
+ p.drawRectPt(ov, s.Rect.X, s.Rect.Y, s.Rect.X+s.Rect.W, s.Rect.Y+s.Rect.H, color)
+ }
+}
+
+func (p *picker) drawCursor(ov [][]overlayCell) {
+ col, row := p.view.ptToCell(p.cx, p.cy)
+ setOverlay(ov, row, col, '+', [3]uint8{240, 80, 80})
+}
+
+// drawRectPt draws a box (in point coordinates) onto the overlay grid.
+func (p *picker) drawRectPt(ov [][]overlayCell, x0, y0, x1, y1 float64, color [3]uint8) {
+ c0, r0 := p.view.ptToCell(math.Min(x0, x1), math.Max(y0, y1)) // top-left
+ c1, r1 := p.view.ptToCell(math.Max(x0, x1), math.Min(y0, y1)) // bottom-right
+ if c1 < c0 {
+ c0, c1 = c1, c0
+ }
+ if r1 < r0 {
+ r0, r1 = r1, r0
+ }
+ for c := c0; c <= c1; c++ {
+ setOverlay(ov, r0, c, '─', color)
+ setOverlay(ov, r1, c, '─', color)
+ }
+ for r := r0; r <= r1; r++ {
+ setOverlay(ov, r, c0, '│', color)
+ setOverlay(ov, r, c1, '│', color)
+ }
+ setOverlay(ov, r0, c0, '┌', color)
+ setOverlay(ov, r0, c1, '┐', color)
+ setOverlay(ov, r1, c0, '└', color)
+ setOverlay(ov, r1, c1, '┘', color)
+}
+
+func setOverlay(ov [][]overlayCell, row, col int, r rune, color [3]uint8) {
+ if row < 0 || row >= len(ov) || col < 0 || col >= len(ov[0]) {
+ return
+ }
+ ov[row][col] = overlayCell{set: true, r: r, color: color}
+}
+
+// --- input handling ---
+
+func (p *picker) handle() (quit bool, err error) {
+ b, err := p.in.ReadByte()
+ if err != nil {
+ return true, err
+ }
+ if p.showHelp {
+ p.showHelp = false
+ return false, nil
+ }
+ p.msg = ""
+
+ sx, sy := p.view.stepPt()
+ switch b {
+ case 'q':
+ return true, nil
+ case '?':
+ p.showHelp = true
+ case 'n':
+ p.gotoPage(p.page + 1)
+ case 'p':
+ p.gotoPage(p.page - 1)
+ case 'g':
+ if s, ok := p.prompt("go to page: "); ok {
+ if v, e := strconv.Atoi(strings.TrimSpace(s)); e == nil {
+ p.gotoPage(v - 1)
+ } else {
+ p.msg = "bad page number"
+ }
+ }
+ case '+', '=':
+ p.zoom = clampf(p.zoom*1.5, 0.2, 20)
+ case '-', '_':
+ p.zoom = clampf(p.zoom/1.5, 0.2, 20)
+ case '\t':
+ p.cycleSel()
+ case 'h':
+ p.move(-sx, 0)
+ case 'l':
+ p.move(sx, 0)
+ case 'j':
+ p.move(0, -sy)
+ case 'k':
+ p.move(0, sy)
+ case 'H':
+ p.resize(-sx, 0)
+ case 'L':
+ p.resize(sx, 0)
+ case 'J':
+ p.resize(0, -sy)
+ case 'K':
+ p.resize(0, sy)
+ case ' ':
+ p.toggleRect()
+ case 'a':
+ p.addOrEdit()
+ case 'd':
+ p.deleteSel()
+ case 'P':
+ p.setPages()
+ case 'o':
+ p.setOpacity()
+ case '\r', '\n':
+ return p.commit()
+ case 0x1b: // ESC: arrow-key sequence, or a lone ESC that cancels.
+ if p.in.Buffered() == 0 {
+ p.sel, p.anchor = -1, false
+ break
+ }
+ b2, _ := p.in.ReadByte()
+ if b2 != '[' && b2 != 'O' {
+ drain(p.in)
+ break
+ }
+ b3, _ := p.in.ReadByte()
+ switch b3 {
+ case 'A': // up
+ p.move(0, sy)
+ case 'B': // down
+ p.move(0, -sy)
+ case 'C': // right
+ p.move(sx, 0)
+ case 'D': // left
+ p.move(-sx, 0)
+ default:
+ drain(p.in)
+ }
+ }
+ return false, nil
+}
+
+func (p *picker) move(dx, dy float64) {
+ if p.sel >= 0 {
+ s := &p.plan.Stamps[p.sel]
+ s.Rect.X += dx
+ s.Rect.Y += dy
+ return
+ }
+ w, h, _ := p.doc.pageSize(p.page)
+ p.cx = clampf(p.cx+dx, 0, w)
+ p.cy = clampf(p.cy+dy, 0, h)
+}
+
+func (p *picker) resize(dw, dh float64) {
+ if p.sel < 0 {
+ return
+ }
+ s := &p.plan.Stamps[p.sel]
+ s.Rect.W = math.Max(4, s.Rect.W+dw)
+ s.Rect.H = math.Max(4, s.Rect.H+dh)
+}
+
+func (p *picker) gotoPage(pg int) {
+ if pg < 0 {
+ pg = 0
+ }
+ if pg > p.npages-1 {
+ pg = p.npages - 1
+ }
+ if pg != p.page {
+ p.page = pg
+ p.sel, p.anchor = -1, false
+ p.centerCursor()
+ }
+}
+
+func (p *picker) cycleSel() {
+ idx := p.stampsOnPage()
+ if len(idx) == 0 {
+ p.sel = -1
+ return
+ }
+ // none -> first -> ... -> none
+ cur := -1
+ for i, g := range idx {
+ if g == p.sel {
+ cur = i
+ }
+ }
+ if cur == len(idx)-1 {
+ p.sel = -1
+ } else {
+ p.sel = idx[cur+1]
+ }
+}
+
+func (p *picker) stampsOnPage() []int {
+ var idx []int
+ for i, s := range p.plan.Stamps {
+ if slices.Contains(s.Pages, p.page+1) {
+ idx = append(idx, i)
+ }
+ }
+ return idx
+}
+
+func (p *picker) toggleRect() {
+ if !p.anchor {
+ p.anchor = true
+ p.sel = -1 // move the cursor (not a selected stamp) while drawing
+ p.ax, p.ay = p.cx, p.cy
+ p.msg = "rect: move and press space to finish"
+ return
+ }
+ p.anchor = false
+ r := rect{
+ X: math.Min(p.ax, p.cx),
+ Y: math.Min(p.ay, p.cy),
+ W: math.Abs(p.cx - p.ax),
+ H: math.Abs(p.cy - p.ay),
+ }
+ if r.W < 4 || r.H < 4 {
+ p.msg = "rect too small"
+ return
+ }
+ p.createStamp(r)
+}
+
+func (p *picker) addOrEdit() {
+ if p.sel >= 0 {
+ p.editStamp(&p.plan.Stamps[p.sel])
+ return
+ }
+ // default rect anchored at cursor
+ r := rect{X: p.cx, Y: p.cy, W: 120, H: 42}
+ p.createStamp(r)
+}
+
+func (p *picker) createStamp(r rect) {
+ p.idSeq++
+ s := stamp{
+ ID: fmt.Sprintf("stamp-%d", p.idSeq),
+ Type: typeText,
+ Pages: []int{p.page + 1},
+ Rect: r,
+ Opacity: 0.8,
+ }
+ if !p.editStamp(&s) {
+ p.idSeq--
+ return
+ }
+ p.plan.Stamps = append(p.plan.Stamps, s)
+ p.sel = len(p.plan.Stamps) - 1
+}
+
+// editStamp prompts for type and content; returns false if cancelled. The
+// unused field is cleared so retyping a stamp cannot leave a stale src or
+// content behind in the plan.
+func (p *picker) editStamp(s *stamp) bool {
+ t, ok := p.promptKey("type [t]ext / [i]mage: ")
+ if !ok {
+ return false
+ }
+ switch t {
+ case 't':
+ v, ok := p.prompt("text: ")
+ if !ok || strings.TrimSpace(v) == "" {
+ return false
+ }
+ s.Type = typeText
+ s.Content, s.Src = v, ""
+ if s.Opacity == 0 {
+ s.Opacity = 0.8
+ }
+ case 'i':
+ v, ok := p.prompt("image path: ")
+ if !ok || strings.TrimSpace(v) == "" {
+ return false
+ }
+ src := strings.TrimSpace(v)
+ if err := checkImage(src); err != nil {
+ p.msg = err.Error()
+ return false
+ }
+ s.Type = typeImage
+ s.Src, s.Content = src, ""
+ s.Opacity = 1.0
+ default:
+ return false
+ }
+ return true
+}
+
+// checkImage gives immediate feedback at pick time rather than waiting for
+// apply to fail: the path must exist and its header must decode as a PNG or
+// JPEG within the pixel cap.
+func checkImage(path string) error {
+ switch strings.ToLower(filepath.Ext(path)) {
+ case ".png", ".jpg", ".jpeg":
+ default:
+ return fmt.Errorf("image must be .png, .jpg, or .jpeg")
+ }
+ f, err := os.Open(path)
+ if err != nil {
+ return fmt.Errorf("image not found: %s", path)
+ }
+ defer f.Close()
+ if _, _, err := decodeImageConfig(f); err != nil {
+ return fmt.Errorf("bad image %s: %v", path, err)
+ }
+ return nil
+}
+
+// setPages reassigns the selected stamp to a page list, e.g. for initials on
+// every page. Deselects when the stamp leaves the current page.
+func (p *picker) setPages() {
+ if p.sel < 0 {
+ p.msg = "no stamp selected"
+ return
+ }
+ v, ok := p.prompt(fmt.Sprintf("pages 1-%d (e.g. 1,3-5 or all): ", p.npages))
+ if !ok {
+ return
+ }
+ pages, err := parsePages(v, p.npages)
+ if err != nil {
+ p.msg = err.Error()
+ return
+ }
+ p.plan.Stamps[p.sel].Pages = pages
+ if !slices.Contains(pages, p.page+1) {
+ p.sel = -1
+ }
+}
+
+// setOpacity prompts for the selected stamp's opacity.
+func (p *picker) setOpacity() {
+ if p.sel < 0 {
+ p.msg = "no stamp selected"
+ return
+ }
+ v, ok := p.prompt("opacity 0-1: ")
+ if !ok {
+ return
+ }
+ f, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
+ if err != nil || math.IsNaN(f) || f < 0 || f > 1 {
+ p.msg = "opacity must be a number in [0,1]"
+ return
+ }
+ p.plan.Stamps[p.sel].Opacity = f
+}
+
+// parsePages parses a 1-based page list like "2", "1,3-5", or "all" (meaning
+// 1..n), returning a sorted, deduplicated list.
+func parsePages(s string, n int) ([]int, error) {
+ s = strings.TrimSpace(s)
+ if s == "all" {
+ pages := make([]int, n)
+ for i := range pages {
+ pages[i] = i + 1
+ }
+ return pages, nil
+ }
+ seen := map[int]bool{}
+ for _, part := range strings.Split(s, ",") {
+ part = strings.TrimSpace(part)
+ lo, hi, isRange := strings.Cut(part, "-")
+ if !isRange {
+ hi = lo
+ }
+ a, err1 := strconv.Atoi(strings.TrimSpace(lo))
+ b, err2 := strconv.Atoi(strings.TrimSpace(hi))
+ if err1 != nil || err2 != nil || a < 1 || b > n || a > b {
+ return nil, fmt.Errorf("bad page spec %q (have %d pages)", part, n)
+ }
+ for pg := a; pg <= b; pg++ {
+ seen[pg] = true
+ }
+ }
+ pages := make([]int, 0, len(seen))
+ for pg := range seen {
+ pages = append(pages, pg)
+ }
+ slices.Sort(pages)
+ return pages, nil
+}
+
+// formatPages renders a page list compactly, e.g. "1,3-5".
+func formatPages(pgs []int) string {
+ s := slices.Clone(pgs)
+ slices.Sort(s)
+ var parts []string
+ for i := 0; i < len(s); {
+ j := i
+ for j+1 < len(s) && s[j+1] == s[j]+1 {
+ j++
+ }
+ if i == j {
+ parts = append(parts, strconv.Itoa(s[i]))
+ } else {
+ parts = append(parts, fmt.Sprintf("%d-%d", s[i], s[j]))
+ }
+ i = j + 1
+ }
+ return strings.Join(parts, ",")
+}
+
+func (p *picker) deleteSel() {
+ if p.sel < 0 || p.sel >= len(p.plan.Stamps) {
+ return
+ }
+ p.plan.Stamps = append(p.plan.Stamps[:p.sel], p.plan.Stamps[p.sel+1:]...)
+ p.sel = -1
+ p.msg = "deleted"
+}
+
+func (p *picker) commit() (quit bool, err error) {
+ switch {
+ case p.applyPath != "": // one-step: burn into the output PDF and quit
+ if err := apply(p.plan, p.applyPath); err != nil {
+ p.msg = "apply failed: " + err.Error()
+ return false, nil
+ }
+ p.wrote = true
+ return true, nil
+ case p.savePath != "": // pick -o FILE: write the plan, keep editing
+ p.plan.toAbsolute()
+ if err := p.plan.save(p.savePath); err != nil {
+ p.msg = "save failed: " + err.Error()
+ return false, nil
+ }
+ p.msg = "saved " + p.savePath
+ return false, nil
+ default: // pick with no -o: emit the plan to stdout and quit (pipe-friendly)
+ p.plan.toAbsolute()
+ b, err := p.plan.marshal()
+ if err != nil {
+ p.msg = "encode failed: " + err.Error()
+ return false, nil
+ }
+ if _, err := os.Stdout.Write(b); err != nil {
+ return true, fmt.Errorf("write plan: %w", err)
+ }
+ return true, nil
+ }
+}
+
+// --- minibuffer prompts ---
+
+func (p *picker) prompt(label string) (string, bool) {
+ var s []rune
+ for {
+ fmt.Fprintf(p.out, "\x1b[%d;1H\x1b[K\x1b[7m%s%s\x1b[0m", p.rows, label, string(s))
+ p.out.Flush()
+ b, err := p.in.ReadByte()
+ if err != nil {
+ return "", false
+ }
+ switch b {
+ case '\r', '\n':
+ return string(s), true
+ case 0x1b:
+ drain(p.in)
+ return "", false
+ case 127, 8:
+ if len(s) > 0 {
+ s = s[:len(s)-1]
+ }
+ default:
+ if b >= 0x20 && b < 0x7f {
+ s = append(s, rune(b))
+ }
+ }
+ }
+}
+
+func (p *picker) promptKey(label string) (byte, bool) {
+ fmt.Fprintf(p.out, "\x1b[%d;1H\x1b[K\x1b[7m%s\x1b[0m", p.rows, label)
+ p.out.Flush()
+ b, err := p.in.ReadByte()
+ if err != nil || b == 0x1b {
+ return 0, false
+ }
+ return b, true
+}
+
+// stepPt is the point-distance of one cell, used for cursor/stamp nudges.
+func (v *view) stepPt() (sx, sy float64) {
+ return 1 / v.scaleX, 2 / v.scaleY
+}
+
+// --- helpers ---
+
+func drain(in *bufio.Reader) {
+ for in.Buffered() > 0 {
+ in.ReadByte()
+ }
+}
+
+func clampf(v, lo, hi float64) float64 {
+ if v < lo {
+ return lo
+ }
+ if v > hi {
+ return hi
+ }
+ return v
+}
+
+func rampChar(top, bot [3]uint8) byte {
+ l := (lum(top) + lum(bot)) / 2
+ idx := int(l * float64(len(ramp)-1))
+ return ramp[idx]
+}
+
+func lum(c [3]uint8) float64 {
+ return (0.299*float64(c[0]) + 0.587*float64(c[1]) + 0.114*float64(c[2])) / 255
+}
+
+func pad(left, right string, w int) string {
+ gap := w - len([]rune(left)) - len([]rune(right))
+ if gap < 1 {
+ return fit(left+" "+right, w)
+ }
+ return left + strings.Repeat(" ", gap) + right
+}
+
+func fit(s string, w int) string {
+ r := []rune(s)
+ if len(r) > w {
+ return string(r[:w])
+ }
+ return s + strings.Repeat(" ", w-len(r))
+}
diff --git a/pick_test.go b/pick_test.go
new file mode 100644
index 0000000..c7ab985
--- /dev/null
+++ b/pick_test.go
@@ -0,0 +1,71 @@
+package main
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestParsePages(t *testing.T) {
+ ok := map[string]struct {
+ in string
+ n int
+ want []int
+ }{
+ "single": {"2", 5, []int{2}},
+ "list": {"1,3", 5, []int{1, 3}},
+ "range": {"2-4", 5, []int{2, 3, 4}},
+ "mixed": {"1,3-5", 5, []int{1, 3, 4, 5}},
+ "overlap": {"1-3,2-4", 5, []int{1, 2, 3, 4}},
+ "all": {"all", 3, []int{1, 2, 3}},
+ "spaces": {" 1 , 3 - 4 ", 5, []int{1, 3, 4}},
+ "unordered": {"4,1", 5, []int{1, 4}},
+ "full bounds": {"1-5", 5, []int{1, 2, 3, 4, 5}},
+ }
+ for name, c := range ok {
+ got, err := parsePages(c.in, c.n)
+ if err != nil {
+ t.Errorf("%s: unexpected error: %v", name, err)
+ continue
+ }
+ if !reflect.DeepEqual(got, c.want) {
+ t.Errorf("%s: parsePages(%q,%d) = %v, want %v", name, c.in, c.n, got, c.want)
+ }
+ }
+
+ bad := map[string]struct {
+ in string
+ n int
+ }{
+ "empty": {"", 5},
+ "zero": {"0", 5},
+ "beyond doc": {"6", 5},
+ "range beyond": {"4-6", 5},
+ "reversed": {"4-2", 5},
+ "junk": {"x", 5},
+ "trailing junk": {"1,", 5},
+ "open range": {"3-", 5},
+ }
+ for name, c := range bad {
+ if got, err := parsePages(c.in, c.n); err == nil {
+ t.Errorf("%s: parsePages(%q,%d) = %v, expected error", name, c.in, c.n, got)
+ }
+ }
+}
+
+func TestFormatPages(t *testing.T) {
+ cases := map[string]struct {
+ in []int
+ want string
+ }{
+ "single": {[]int{2}, "2"},
+ "run": {[]int{1, 2, 3}, "1-3"},
+ "mixed": {[]int{1, 3, 4, 5, 9}, "1,3-5,9"},
+ "unsorted": {[]int{4, 1, 2}, "1-2,4"},
+ "two apart": {[]int{1, 3}, "1,3"},
+ }
+ for name, c := range cases {
+ if got := formatPages(c.in); got != c.want {
+ t.Errorf("%s: formatPages(%v) = %q, want %q", name, c.in, got, c.want)
+ }
+ }
+}
diff --git a/plan.go b/plan.go
new file mode 100644
index 0000000..14f4988
--- /dev/null
+++ b/plan.go
@@ -0,0 +1,293 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "math"
+ "os"
+ "path/filepath"
+)
+
+// rect is a stamp rectangle in PDF points, bottom-left origin.
+type rect struct {
+ X float64 `json:"x"`
+ Y float64 `json:"y"`
+ W float64 `json:"w"`
+ H float64 `json:"h"`
+}
+
+// stamp is one stamp placed on one or more pages.
+type stamp struct {
+ ID string `json:"id"`
+ Type string `json:"type"` // "text" or "image"
+ Content string `json:"content,omitempty"` // text stamps
+ Src string `json:"src,omitempty"` // image stamps: PNG or JPEG path
+ Pages []int `json:"pages"` // 1-based page numbers
+ Rect rect `json:"rect"`
+ Opacity float64 `json:"opacity"`
+}
+
+// plan is the on-disk stamp plan.
+type plan struct {
+ Version int `json:"version"`
+ Input string `json:"input"`
+ Stamps []stamp `json:"stamps"`
+}
+
+const (
+ planVersion = 2
+ typeText = "text"
+ typeImage = "image"
+ maxPlanBytes = 8 << 20
+ maxStamps = 1_000
+ maxPageRefs = 100_000
+ maxTextBytes = 64 << 10
+)
+
+// newPlan returns an empty plan for the given input PDF.
+func newPlan(input string) *plan {
+ return &plan{
+ Version: planVersion,
+ Input: input,
+ Stamps: []stamp{},
+ }
+}
+
+// loadPlan reads and validates a plan from path.
+func loadPlan(path string) (*plan, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+ return readPlan(f, path)
+}
+
+func readPlan(r io.Reader, name string) (*plan, error) {
+ b, err := io.ReadAll(io.LimitReader(r, maxPlanBytes+1))
+ if err != nil {
+ return nil, fmt.Errorf("read %s: %w", name, err)
+ }
+ return parsePlan(b, name)
+}
+
+// parsePlan strictly decodes and validates plan JSON; name is used in errors.
+func parsePlan(b []byte, name string) (*plan, error) {
+ if len(b) > maxPlanBytes {
+ return nil, fmt.Errorf("plan %s is larger than %d bytes", name, maxPlanBytes)
+ }
+ var p plan
+ dec := json.NewDecoder(bytes.NewReader(b))
+ dec.DisallowUnknownFields()
+ if err := dec.Decode(&p); err != nil {
+ return nil, fmt.Errorf("parse %s: %w", name, err)
+ }
+ if err := dec.Decode(&struct{}{}); err != io.EOF {
+ if err == nil {
+ return nil, fmt.Errorf("parse %s: multiple JSON values", name)
+ }
+ return nil, fmt.Errorf("parse %s: trailing data: %w", name, err)
+ }
+ if err := p.validate(); err != nil {
+ return nil, fmt.Errorf("invalid plan %s: %w", name, err)
+ }
+ return &p, nil
+}
+
+// save writes the plan to path as indented JSON.
+func (p *plan) save(path string) error {
+ b, err := p.marshal()
+ if err != nil {
+ return err
+ }
+ return writeFileAtomic(path, b, 0o644)
+}
+
+func (p *plan) marshal() ([]byte, error) {
+ if err := p.validate(); err != nil {
+ return nil, err
+ }
+ b, err := json.MarshalIndent(p, "", " ")
+ if err != nil {
+ return nil, err
+ }
+ b = append(b, '\n')
+ if len(b) > maxPlanBytes {
+ return nil, fmt.Errorf("encoded plan is larger than %d bytes", maxPlanBytes)
+ }
+ return b, nil
+}
+
+// writeFileAtomic writes b to path via a temp file in the same directory,
+// where rename is atomic on local filesystems. The temp file is synced before
+// the rename, so an interrupted write leaves it behind rather than a truncated
+// file at path.
+func writeFileAtomic(path string, b []byte, perm os.FileMode) error {
+ dir := filepath.Dir(path)
+ f, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-*")
+ if err != nil {
+ return err
+ }
+ tmp := f.Name()
+ defer os.Remove(tmp) // no-op after a successful rename
+ if _, err := f.Write(b); err != nil {
+ f.Close()
+ return err
+ }
+ if err := f.Chmod(perm); err != nil {
+ f.Close()
+ return err
+ }
+ if err := f.Sync(); err != nil {
+ f.Close()
+ return err
+ }
+ if err := f.Close(); err != nil {
+ return err
+ }
+ if err := os.Rename(tmp, path); err != nil {
+ return err
+ }
+ return syncDir(dir)
+}
+
+func syncDir(path string) error {
+ d, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ if err := d.Sync(); err != nil {
+ d.Close()
+ return err
+ }
+ return d.Close()
+}
+
+// validate checks the plan's invariants.
+func (p *plan) validate() error {
+ if p.Version != planVersion {
+ return fmt.Errorf("version %d unsupported (want %d)", p.Version, planVersion)
+ }
+ if p.Input == "" {
+ return fmt.Errorf("input is empty")
+ }
+ if len(p.Stamps) > maxStamps {
+ return fmt.Errorf("too many stamps (%d; limit is %d)", len(p.Stamps), maxStamps)
+ }
+ pageRefs := 0
+ for i := range p.Stamps {
+ pageRefs += len(p.Stamps[i].Pages)
+ if pageRefs > maxPageRefs {
+ return fmt.Errorf("too many page references (%d; limit is %d)", pageRefs, maxPageRefs)
+ }
+ if err := p.Stamps[i].validate(); err != nil {
+ return fmt.Errorf("stamp %d (%s): %w", i, p.Stamps[i].ID, err)
+ }
+ }
+ return nil
+}
+
+func (s *stamp) validate() error {
+ switch s.Type {
+ case typeText:
+ if s.Content == "" {
+ return fmt.Errorf("text stamp has empty content")
+ }
+ if len(s.Content) > maxTextBytes {
+ return fmt.Errorf("text is larger than %d bytes", maxTextBytes)
+ }
+ // Text is written as raw bytes into a WinAnsi-encoded base-14 string,
+ // so anything outside printable ASCII would render as wrong glyphs.
+ for _, r := range s.Content {
+ if r < 0x20 || r > 0x7e {
+ return fmt.Errorf("text contains %q; only printable ASCII is supported (no font is embedded)", r)
+ }
+ }
+ case typeImage:
+ if s.Src == "" {
+ return fmt.Errorf("image stamp has empty src")
+ }
+ default:
+ return fmt.Errorf("unknown type %q", s.Type)
+ }
+ if len(s.Pages) == 0 {
+ return fmt.Errorf("no pages")
+ }
+ seen := make(map[int]struct{}, len(s.Pages))
+ for _, pg := range s.Pages {
+ if pg < 1 {
+ return fmt.Errorf("page %d out of range (1-based)", pg)
+ }
+ if _, ok := seen[pg]; ok {
+ return fmt.Errorf("duplicate page %d", pg)
+ }
+ seen[pg] = struct{}{}
+ }
+ for _, v := range []float64{s.Rect.X, s.Rect.Y, s.Rect.W, s.Rect.H, s.Opacity} {
+ if math.IsNaN(v) || math.IsInf(v, 0) {
+ return fmt.Errorf("rect and opacity must be finite (got %g)", v)
+ }
+ }
+ if s.Rect.W <= 0 || s.Rect.H <= 0 {
+ return fmt.Errorf("rect must have positive w and h")
+ }
+ if s.Opacity < 0 || s.Opacity > 1 {
+ return fmt.Errorf("opacity %g out of range [0,1]", s.Opacity)
+ }
+ return nil
+}
+
+// toAbsolute rewrites Input and image srcs to absolute paths. Called before
+// saving so a plan applies correctly no matter which directory it is run from.
+func (p *plan) toAbsolute() {
+ p.Input = absPath(p.Input)
+ for i := range p.Stamps {
+ if p.Stamps[i].Type == typeImage {
+ p.Stamps[i].Src = absPath(p.Stamps[i].Src)
+ }
+ }
+}
+
+// resolveRelativeTo makes relative Input and image srcs relative to base (the
+// plan file's directory), so hand-written plans with relative paths apply
+// correctly regardless of the current working directory.
+func (p *plan) resolveRelativeTo(base string) {
+ p.Input = joinIfRel(base, p.Input)
+ for i := range p.Stamps {
+ if p.Stamps[i].Type == typeImage {
+ p.Stamps[i].Src = joinIfRel(base, p.Stamps[i].Src)
+ }
+ }
+}
+
+func absPath(path string) string {
+ if path == "" {
+ return path
+ }
+ if a, err := filepath.Abs(path); err == nil {
+ return a
+ }
+ return path
+}
+
+func joinIfRel(base, path string) string {
+ if path == "" || filepath.IsAbs(path) {
+ return path
+ }
+ return filepath.Join(base, path)
+}
+
+// maxPage returns the highest 1-based page referenced by any stamp.
+func (p *plan) maxPage() int {
+ mx := 0
+ for _, s := range p.Stamps {
+ for _, pg := range s.Pages {
+ if pg > mx {
+ mx = pg
+ }
+ }
+ }
+ return mx
+}
diff --git a/plan_test.go b/plan_test.go
new file mode 100644
index 0000000..efabe1c
--- /dev/null
+++ b/plan_test.go
@@ -0,0 +1,104 @@
+package main
+
+import (
+ "math"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestPlanValidate(t *testing.T) {
+ base := func(s stamp) *plan {
+ return &plan{Version: planVersion, Input: "x.pdf", Stamps: []stamp{s}}
+ }
+
+ good := stamp{ID: "s", Type: typeText, Content: "hi", Pages: []int{1}, Rect: rect{W: 10, H: 10}, Opacity: 0.5}
+ if err := base(good).validate(); err != nil {
+ t.Fatalf("good plan rejected: %v", err)
+ }
+
+ bad := map[string]stamp{
+ "empty text": {Type: typeText, Content: "", Pages: []int{1}, Rect: rect{W: 1, H: 1}},
+ "non-ascii text": {Type: typeText, Content: "héllo", Pages: []int{1}, Rect: rect{W: 1, H: 1}, Opacity: 1},
+ "control char": {Type: typeText, Content: "a\nb", Pages: []int{1}, Rect: rect{W: 1, H: 1}, Opacity: 1},
+ "empty src": {Type: typeImage, Src: "", Pages: []int{1}, Rect: rect{W: 1, H: 1}, Opacity: 1},
+ "unknown type": {Type: "frob", Pages: []int{1}, Rect: rect{W: 1, H: 1}},
+ "no pages": {Type: typeText, Content: "x", Rect: rect{W: 1, H: 1}},
+ "page below one": {Type: typeText, Content: "x", Pages: []int{0}, Rect: rect{W: 1, H: 1}},
+ "duplicate page": {Type: typeText, Content: "x", Pages: []int{1, 1}, Rect: rect{W: 1, H: 1}},
+ "zero width": {Type: typeText, Content: "x", Pages: []int{1}, Rect: rect{W: 0, H: 1}},
+ "opacity high": {Type: typeText, Content: "x", Pages: []int{1}, Rect: rect{W: 1, H: 1}, Opacity: 1.5},
+ "opacity neg": {Type: typeText, Content: "x", Pages: []int{1}, Rect: rect{W: 1, H: 1}, Opacity: -0.1},
+ "nan x": {Type: typeText, Content: "x", Pages: []int{1}, Rect: rect{X: math.NaN(), W: 1, H: 1}},
+ "inf w": {Type: typeText, Content: "x", Pages: []int{1}, Rect: rect{W: math.Inf(1), H: 1}},
+ }
+ for name, s := range bad {
+ if err := base(s).validate(); err == nil {
+ t.Errorf("%s: expected validation error, got nil", name)
+ }
+ }
+
+ // plan-level invariants.
+ plans := map[string]*plan{
+ "older version": {Version: planVersion - 1, Input: "x"},
+ "newer version": {Version: planVersion + 1, Input: "x"},
+ "empty input": {Version: planVersion, Input: ""},
+ }
+ for name, p := range plans {
+ if err := p.validate(); err == nil {
+ t.Errorf("%s: expected validation error, got nil", name)
+ }
+ }
+}
+
+func TestParsePlanStrict(t *testing.T) {
+ valid := `{"version":2,"input":"x.pdf","stamps":[]}`
+ for name, data := range map[string]string{
+ "unknown field": strings.Replace(valid, `"stamps"`, `"opactiy":1,"stamps"`, 1),
+ "dropped field": strings.Replace(valid, `"stamps"`, `"unit":"pt","stamps"`, 1),
+ "second value": valid + `{}`,
+ "trailing junk": valid + `x`,
+ } {
+ if _, err := parsePlan([]byte(data), name); err == nil {
+ t.Errorf("%s: expected error, got nil", name)
+ }
+ }
+}
+
+func TestPlanLimits(t *testing.T) {
+ if _, err := parsePlan([]byte(strings.Repeat(" ", maxPlanBytes+1)), "large"); err == nil {
+ t.Error("oversized plan: expected error, got nil")
+ }
+
+ p := newPlan("x.pdf")
+ p.Stamps = make([]stamp, maxStamps+1)
+ if err := p.validate(); err == nil {
+ t.Error("too many stamps: expected error, got nil")
+ }
+
+ p.Stamps = []stamp{{Type: typeText, Content: strings.Repeat("x", maxTextBytes+1), Pages: []int{1}, Rect: rect{W: 1, H: 1}}}
+ if err := p.validate(); err == nil {
+ t.Error("oversized text: expected error, got nil")
+ }
+
+ pages := make([]int, maxPageRefs+1)
+ for i := range pages {
+ pages[i] = i + 1
+ }
+ p.Stamps = []stamp{{Type: typeText, Content: "x", Pages: pages, Rect: rect{W: 1, H: 1}}}
+ if err := p.validate(); err == nil {
+ t.Error("too many page references: expected error, got nil")
+ }
+}
+
+func TestPlanSaveRejectsOversizedOutput(t *testing.T) {
+ p := newPlan(strings.Repeat("x", maxPlanBytes))
+ path := filepath.Join(t.TempDir(), "plan.json")
+ if err := p.save(path); err == nil {
+ t.Fatal("expected oversized encoded plan to fail")
+ }
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ t.Fatalf("oversized plan created an output: %v", err)
+ }
+}
diff --git a/render.go b/render.go
new file mode 100644
index 0000000..59330e5
--- /dev/null
+++ b/render.go
@@ -0,0 +1,163 @@
+package main
+
+import (
+ "fmt"
+ "image"
+ "math"
+
+ "github.com/gen2brain/go-fitz"
+)
+
+// outside is the cell color shown for the area around the page (margins).
+var outside = [3]uint8{30, 30, 30}
+
+// doc is an open PDF document for previewing.
+type doc struct {
+ f *fitz.Document
+
+ // Single-entry raster cache: cursor moves redraw every frame but only
+ // page/zoom/resize change the rendered bitmap, so avoid re-running MuPDF.
+ cachePage int
+ cacheDPI float64
+ cacheImg *image.RGBA
+}
+
+// raster returns the page rendered at dpi, reusing the cached bitmap when the
+// page and dpi are unchanged.
+func (d *doc) raster(pg int, dpi float64) (*image.RGBA, error) {
+ if d.cacheImg != nil && d.cachePage == pg && math.Abs(d.cacheDPI-dpi) < 1e-6 {
+ return d.cacheImg, nil
+ }
+ img, err := d.f.ImageDPI(pg, dpi)
+ if err != nil {
+ return nil, err
+ }
+ d.cachePage, d.cacheDPI, d.cacheImg = pg, dpi, img
+ return img, nil
+}
+
+// openDoc opens a PDF for rendering.
+func openDoc(path string) (*doc, error) {
+ f, err := fitz.New(path)
+ if err != nil {
+ return nil, err
+ }
+ return &doc{f: f}, nil
+}
+
+func (d *doc) close() error { return d.f.Close() }
+func (d *doc) numPage() int { return d.f.NumPage() }
+
+// pageSize returns the page dimensions in points (0-based page index).
+func (d *doc) pageSize(pg int) (w, h float64, err error) {
+ b, err := d.f.Bound(pg)
+ if err != nil {
+ return 0, 0, err
+ }
+ return float64(b.Dx()), float64(b.Dy()), nil
+}
+
+// cell is one terminal character drawn with an upper half-block: top is the
+// foreground (upper pixel), bot is the background (lower pixel).
+type cell struct {
+ top, bot [3]uint8
+}
+
+// view is a rendered page region mapped onto a terminal cell grid, plus the
+// affine mapping between cells and PDF points (bottom-left origin).
+type view struct {
+ cols, rows int
+ cells []cell // row-major, len rows*cols
+
+ scaleX, scaleY float64 // image px per pt
+ blankX, blankY int // pane-px offset where the image starts
+ scrollX, scrollY int // first visible image px
+ pageH float64
+}
+
+func (v *view) cell(r, c int) *cell { return &v.cells[r*v.cols+c] }
+
+// ptToCell maps a PDF point to the nearest cell (col,row).
+func (v *view) ptToCell(x, y float64) (col, row int) {
+ imgX := x * v.scaleX
+ imgY := (v.pageH - y) * v.scaleY
+ col = int(math.Round(imgX + float64(v.blankX-v.scrollX)))
+ row = int(math.Round((imgY + float64(v.blankY-v.scrollY)) / 2))
+ return col, row
+}
+
+// render rasterizes page pg (0-based) into a cols x rows cell grid. zoom is a
+// multiplier over fit-to-pane scale; the viewport is centered on (focusX,
+// focusY) in points when the rendered image is larger than the pane.
+func (d *doc) render(pg, cols, rows int, zoom, focusX, focusY float64) (*view, error) {
+ if cols < 1 || rows < 1 {
+ return nil, fmt.Errorf("pane too small")
+ }
+ pw, ph, err := d.pageSize(pg)
+ if err != nil {
+ return nil, err
+ }
+
+ paneX, paneY := cols, 2*rows
+ fit := math.Min(float64(paneX)/pw, float64(paneY)/ph)
+ scale := fit * zoom
+ dpi := scale * 72
+ if dpi < 2 {
+ dpi = 2
+ }
+
+ img, err := d.raster(pg, dpi)
+ if err != nil {
+ return nil, err
+ }
+ b := img.Bounds()
+ iw, ih := b.Dx(), b.Dy()
+ scaleX := float64(iw) / pw
+ scaleY := float64(ih) / ph
+
+ focusPxX := int(math.Round(focusX * scaleX))
+ focusPxY := int(math.Round((ph - focusY) * scaleY))
+ blankX, scrollX := axisMap(paneX, iw, focusPxX)
+ blankY, scrollY := axisMap(paneY, ih, focusPxY)
+
+ v := &view{
+ cols: cols, rows: rows, cells: make([]cell, cols*rows),
+ scaleX: scaleX, scaleY: scaleY,
+ blankX: blankX, blankY: blankY, scrollX: scrollX, scrollY: scrollY,
+ pageH: ph,
+ }
+
+ sample := func(paneXc, paneYc int) [3]uint8 {
+ ix := paneXc - blankX + scrollX
+ iy := paneYc - blankY + scrollY
+ if ix < 0 || ix >= iw || iy < 0 || iy >= ih {
+ return outside
+ }
+ c := img.RGBAAt(b.Min.X+ix, b.Min.Y+iy)
+ return [3]uint8{c.R, c.G, c.B}
+ }
+ for r := 0; r < rows; r++ {
+ for c := 0; c < cols; c++ {
+ cell := v.cell(r, c)
+ cell.top = sample(c, 2*r)
+ cell.bot = sample(c, 2*r+1)
+ }
+ }
+ return v, nil
+}
+
+// axisMap centers content within pane when it fits, otherwise scrolls so the
+// focus pixel is centered, clamped to content bounds.
+func axisMap(pane, content, focusPx int) (blank, scroll int) {
+ if content <= pane {
+ return (pane - content) / 2, 0
+ }
+ s := focusPx - pane/2
+ if s < 0 {
+ s = 0
+ }
+ if s > content-pane {
+ s = content - pane
+ }
+ return 0, s
+}
diff --git a/render_test.go b/render_test.go
new file mode 100644
index 0000000..263c2da
--- /dev/null
+++ b/render_test.go
@@ -0,0 +1,60 @@
+package main
+
+import "testing"
+
+// TestAxisMap checks centering when content fits and clamped scrolling when it
+// does not.
+func TestAxisMap(t *testing.T) {
+ cases := []struct {
+ name string
+ pane, content, focus int
+ wantBlank, wantScroll int
+ }{
+ {"fits centered", 100, 40, 0, 30, 0},
+ {"exact fit", 40, 40, 0, 0, 0},
+ {"scroll centered on focus", 40, 100, 50, 0, 30},
+ {"scroll clamped low", 40, 100, 0, 0, 0},
+ {"scroll clamped high", 40, 100, 999, 0, 60},
+ }
+ for _, c := range cases {
+ blank, scroll := axisMap(c.pane, c.content, c.focus)
+ if blank != c.wantBlank || scroll != c.wantScroll {
+ t.Errorf("%s: axisMap(%d,%d,%d) = (%d,%d), want (%d,%d)",
+ c.name, c.pane, c.content, c.focus, blank, scroll, c.wantBlank, c.wantScroll)
+ }
+ }
+}
+
+// TestPtToCell checks the point->cell mapping and that moving a point by one
+// stepPt lands exactly one cell away (the property the cursor/stamp nudges
+// rely on).
+func TestPtToCell(t *testing.T) {
+ // Page 600x800 pt rendered to a 120x80 px image (scaleX=0.2, scaleY=0.1),
+ // fitting within the pane (no scroll), centered with a 4-cell/2-px margin.
+ v := &view{
+ cols: 128, rows: 41,
+ scaleX: 0.2, scaleY: 0.1,
+ blankX: 4, blankY: 2,
+ scrollX: 0, scrollY: 0,
+ pageH: 800,
+ }
+
+ // Bottom-left of the page maps to the bottom-left of the image area.
+ col, row := v.ptToCell(0, 0)
+ if col != 4 || row != 41 {
+ t.Errorf("ptToCell(0,0) = (%d,%d), want (4,41)", col, row)
+ }
+
+ // One horizontal step moves exactly one column; one vertical step one row.
+ sx, sy := v.stepPt()
+ x, y := 300.0, 400.0
+ c0, r0 := v.ptToCell(x, y)
+ c1, _ := v.ptToCell(x+sx, y)
+ if c1-c0 != 1 {
+ t.Errorf("one stepPt in x moved %d cols, want 1", c1-c0)
+ }
+ _, r1 := v.ptToCell(x, y+sy)
+ if r0-r1 != 1 { // +y is up, which is a smaller row number
+ t.Errorf("one stepPt in y moved %d rows, want 1", r0-r1)
+ }
+}