Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions geometry.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,69 @@ func (b BBox) ContainsPoint(x, y float64) bool {
return x >= b.X0 && x <= b.X1 && y >= b.Y0 && y <= b.Y1
}

// ViewRect is a rectangle in viewer coordinates: origin top-left, Y growing
// DOWN, expressed as position plus size. This is the shape every web
// rendering target wants — CSS absolute positioning, canvas fillRect,
// SVG <rect>, and PDF.js viewport output all use it.
//
// It is deliberately a different type from BBox so the two coordinate
// systems cannot be confused at a call site. A BBox is PDF user space
// (origin bottom-left, Y up); a ViewRect is viewer space (origin top-left,
// Y down). Converting between them is the single most common source of
// upside-down highlight overlays.
type ViewRect struct {
Left, Top, Width, Height float64
}

// Viewport converts b from PDF user space into viewer coordinates,
// scaled by the ratio between the rendered page and its natural size.
//
// This is the transform for drawing a citation highlight over a rendered
// page. pageHeight is the value from Page.Height(); scale is rendered
// pixels per PDF point — for a page rasterised at 150 DPI that is
// 150/72, and for PDF.js it is the viewport scale you passed to
// getViewport({scale}).
//
// Every coordinate pdftable reports — Char, Word, Line, Rect, Table.BBox
// and Table.CellsBBox — is already normalised: the MediaBox origin has
// been translated to (0,0) and any /Rotate applied, so the space matches
// Page.Width() x Page.Height() exactly. That means no per-page fixups
// are needed here; the only conversion left is the Y flip.
//
// // Highlight a cited table cell over a page rendered at 150 DPI.
// r := table.CellsBBox[row][col].Viewport(page.Height(), 150.0/72.0)
// // -> CSS: left:r.Left, top:r.Top, width:r.Width, height:r.Height
func (b BBox) Viewport(pageHeight, scale float64) ViewRect {
return ViewRect{
Left: b.X0 * scale,
Top: (pageHeight - b.Y1) * scale,
Width: b.Width() * scale,
Height: b.Height() * scale,
}
}

// Normalized converts b into viewer coordinates expressed as fractions
// of the page, origin top-left, each value in 0..1.
//
// Prefer this over Viewport when the frontend can resize the page:
// percentages stay correct at any zoom level or container width, so the
// overlay does not have to be recomputed when the viewer re-renders.
// Multiply by the rendered element's pixel width/height at paint time.
//
// Returns the zero ViewRect if either page dimension is non-positive, rather
// than dividing by zero and emitting NaNs into a JSON payload.
func (b BBox) Normalized(pageWidth, pageHeight float64) ViewRect {
if pageWidth <= 0 || pageHeight <= 0 {
return ViewRect{}
}
return ViewRect{
Left: b.X0 / pageWidth,
Top: (pageHeight - b.Y1) / pageHeight,
Width: b.Width() / pageWidth,
Height: b.Height() / pageHeight,
}
}

// Snap rounds each of b's four coordinates to the nearest multiple of
// step. Used by layout-analysis code to coalesce near-equal positions
// (e.g. ruling lines drawn at 99.9, 100.0, 100.1) before clustering.
Expand Down
130 changes: 130 additions & 0 deletions viewport_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Copyright (c) 2026 Halleluyah Oludele
// Licensed under the MIT License.

package pdftable_test

import (
"math"
"testing"

"github.com/hallelx2/pdftable"
)

func closeTo(t *testing.T, label string, got, want float64) {
t.Helper()
if math.Abs(got-want) > 1e-9 {
t.Errorf("%s = %v, want %v", label, got, want)
}
}

// TestViewportFlipsYAxis pins the contract a citation-highlight frontend
// depends on. PDF user space puts the origin at the bottom-left with Y
// growing up; browsers, canvas and PDF.js put it top-left with Y growing
// down. Getting this backwards produces an overlay that is vertically
// mirrored — highlights that look plausible and land on the wrong row.
func TestViewportFlipsYAxis(t *testing.T) {
const pageH = 792.0 // US Letter

// A box sitting at the very TOP of the page (high Y in PDF space)
// must come back with a near-ZERO Top in viewer space.
top := pdftable.BBox{X0: 100, Y0: 782, X1: 200, Y1: 792}
r := top.Viewport(pageH, 1)
closeTo(t, "top box Top", r.Top, 0)
closeTo(t, "top box Left", r.Left, 100)
closeTo(t, "top box Width", r.Width, 100)
closeTo(t, "top box Height", r.Height, 10)

// A box at the BOTTOM (low Y) must land at the far side.
bottom := pdftable.BBox{X0: 0, Y0: 0, X1: 10, Y1: 10}
rb := bottom.Viewport(pageH, 1)
closeTo(t, "bottom box Top", rb.Top, 782)

if r.Top >= rb.Top {
t.Error("a box higher on the page must have a SMALLER Top in viewer space — Y axis is not flipped")
}
}

// TestViewportScale checks the rendered-pixels-per-point factor, the
// other half of what a frontend needs.
func TestViewportScale(t *testing.T) {
const pageH = 792.0
b := pdftable.BBox{X0: 56.7, Y0: 559.7, X1: 537.9, Y1: 568.4}

// 150 DPI raster: 150/72 pixels per point. These are the real
// coordinates of the "Less: Accumulated depreciation" row on 3M's
// 2018 10-K balance sheet, verified against the rendered page.
const scale = 150.0 / 72.0
r := b.Viewport(pageH, scale)
closeTo(t, "Left", r.Left, 56.7*scale)
closeTo(t, "Top", r.Top, (pageH-568.4)*scale)
closeTo(t, "Width", r.Width, (537.9-56.7)*scale)
closeTo(t, "Height", r.Height, (568.4-559.7)*scale)

// Scale 1 is the identity on size, flip only.
r1 := b.Viewport(pageH, 1)
closeTo(t, "unscaled Width", r1.Width, 537.9-56.7)
}

// TestNormalizedIsResolutionIndependent covers the percentage form,
// which is what a resizable viewer should store.
func TestNormalizedIsResolutionIndependent(t *testing.T) {
const pageW, pageH = 612.0, 792.0
b := pdftable.BBox{X0: 306, Y0: 396, X1: 612, Y1: 792} // exact top-right quadrant

n := b.Normalized(pageW, pageH)
closeTo(t, "Left", n.Left, 0.5)
closeTo(t, "Top", n.Top, 0.0)
closeTo(t, "Width", n.Width, 0.5)
closeTo(t, "Height", n.Height, 0.5)

// Multiplying by any rendered size must agree with Viewport at the
// matching scale — the two APIs cannot drift apart.
for _, px := range []float64{612, 1224, 2448} {
scale := px / pageW
v := b.Viewport(pageH, scale)
closeTo(t, "Left agrees", n.Left*px, v.Left)
closeTo(t, "Width agrees", n.Width*px, v.Width)
}

// Degenerate pages must not emit NaN into a JSON payload.
if got := b.Normalized(0, 0); got != (pdftable.ViewRect{}) {
t.Errorf("Normalized on a zero-sized page = %+v, want zero ViewRect", got)
}
}

// TestViewportOnRealCitation is the end-to-end check: take a real cell
// bbox from a real extraction and confirm the viewer rectangle lands
// inside the page, right way up.
func TestViewportOnRealCitation(t *testing.T) {
doc, err := pdftable.OpenFile("testdata/golden/simple1.pdf")
if err != nil {
t.Skipf("fixture unavailable: %v", err)
}
defer doc.Close()
p, err := doc.Page(1)
if err != nil {
t.Fatalf("Page: %v", err)
}
words, err := p.Words(pdftable.DefaultWordOpts())
if err != nil || len(words) == 0 {
t.Fatalf("Words: %v (n=%d)", err, len(words))
}
pw, ph := p.Width(), p.Height()
for _, w := range words {
b := pdftable.BBox{X0: w.X0, Y0: w.Y0, X1: w.X1, Y1: w.Y1}
r := b.Viewport(ph, 1)
if r.Top < 0 || r.Top > ph {
t.Errorf("word %q: Top=%v outside page height %v", w.Text, r.Top, ph)
}
if r.Left < 0 || r.Left > pw {
t.Errorf("word %q: Left=%v outside page width %v", w.Text, r.Left, pw)
}
if r.Width <= 0 || r.Height <= 0 {
t.Errorf("word %q: degenerate ViewRect %+v", w.Text, r)
}
n := b.Normalized(pw, ph)
if n.Left < 0 || n.Left > 1 || n.Top < 0 || n.Top > 1 {
t.Errorf("word %q: normalized ViewRect outside 0..1: %+v", w.Text, n)
}
}
}
Loading