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