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 }