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 | |
| 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.
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | Makefile | 21 | ||||
| -rw-r--r-- | README | 156 | ||||
| -rw-r--r-- | apply.go | 158 | ||||
| -rw-r--r-- | apply_test.go | 358 | ||||
| -rw-r--r-- | example.pdf | 93 | ||||
| -rw-r--r-- | go.mod | 25 | ||||
| -rw-r--r-- | go.sum | 34 | ||||
| -rw-r--r-- | main.go | 163 | ||||
| -rw-r--r-- | main_test.go | 46 | ||||
| -rw-r--r-- | overlay.go | 355 | ||||
| -rw-r--r-- | pick.go | 806 | ||||
| -rw-r--r-- | pick_test.go | 71 | ||||
| -rw-r--r-- | plan.go | 293 | ||||
| -rw-r--r-- | plan_test.go | 104 | ||||
| -rw-r--r-- | render.go | 163 | ||||
| -rw-r--r-- | render_test.go | 60 |
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 @@ -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 @@ -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 +) @@ -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= @@ -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() +} @@ -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) + } + } +} @@ -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) + } +} |