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)) }