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 /pick.go | |
| download | pdfstamp-459f5935aab505cdb8174e5f9f04a9a3c426f37e.tar.gz | |
Terminal-only PDF stamp picker. Renders a coarse page preview in the
terminal, places text and image stamps with the keyboard, saves the
layout as a JSON plan, and burns it into an output PDF.
MuPDF (via go-fitz) renders the preview; pdfcpu overlays the generated
stamp PDF onto the source 1:1 and validates the result.
Diffstat (limited to 'pick.go')
| -rw-r--r-- | pick.go | 806 |
1 files changed, 806 insertions, 0 deletions
@@ -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)) +} |