Extract a PDF Table by Bounding Box Coordinates
The statement page has a header block, an address panel, a summary box, the transaction table you need, and a footer with terms — and automatic table detection returns all of them. page.extract_tables() gives five tables of which one is useful; camelot's lattice mode merges the summary box into the transactions; tabula returns the address as a two-column table. The one reliable fact about the document is that the transaction table always occupies the same region of the page.
Root Cause
Automatic table finders look for ruling lines or aligned text across the whole page, and business documents are full of structures that look tabular: label/value panels, multi-column addresses, boxed summaries. The finder cannot know which one you want, and small layout changes — an extra address line pushing the summary down — change which "tables" it returns and in what order. Restricting extraction to a bounding box removes everything else from consideration. The complication is coordinates: pdfplumber measures from the top-left corner in points, camelot's table_areas measures from the bottom-left corner, tabula's area uses top-left but in the order top, left, bottom, right, and a page with a /Rotate value or a non-zero crop box origin shifts all of them.
Minimal Diagnostic
Find the table's region from the words that bound it rather than guessing numbers. Print the page size, rotation and crop box, then the positions of the header row and the last line of the table.
# pip install pdfplumber
from pathlib import Path
import pdfplumber
SOURCE = Path("in/statement-2026-09.pdf")
HEADER_WORD = "Description" # a word in the table's header row
END_WORD = "Closing balance" # text just below the table
def locate_table(pdf_path: Path, page_index: int = 0) -> None:
try:
pdf = pdfplumber.open(pdf_path)
except Exception as exc:
raise SystemExit(f"cannot open {pdf_path}: {exc}")
with pdf:
page = pdf.pages[page_index]
print(f"page {page.width:.0f} x {page.height:.0f} pt, rotation={page.rotation}, "
f"cropbox={page.cropbox}, mediabox={page.mediabox}")
words = page.extract_words(keep_blank_chars=True)
for w in words:
if HEADER_WORD in w["text"] or END_WORD in w["text"]:
print(f"{w['text']!r:<24} x0={w['x0']:.1f} top={w['top']:.1f} x1={w['x1']:.1f} bottom={w['bottom']:.1f}")
tables = page.find_tables()
print(f"auto-detected tables: {len(tables)}")
for t in tables:
print(" bbox", tuple(round(v, 1) for v in t.bbox))
if __name__ == "__main__":
locate_table(SOURCE)
page 595 x 842 pt, rotation=0, cropbox=(0, 0, 595.28, 841.89), mediabox=(0, 0, 595.28, 841.89)
'Description' x0=112.4 top=318.2 x1=168.9 bottom=328.6
'Closing balance' x0=40.2 top=671.0 x1=121.7 bottom=681.4
auto-detected tables: 5
bbox (40.0, 96.5, 300.2, 170.3)
bbox (330.1, 96.5, 555.0, 170.3)
bbox (40.0, 196.0, 555.0, 280.4)
bbox (40.0, 312.0, 555.3, 664.7)
bbox (40.0, 700.2, 555.0, 790.0)
The transaction table sits between the header row at top≈318 and the closing balance at top≈671, spanning the page's content width. Auto-detection found it as the fourth of five tables — an index that will change the day the address panel gains a line.
Fix: Crop to the Region, Then Extract
In pdfplumber, page.crop(bbox) returns a view restricted to the rectangle (x0, top, x1, bottom), measured in points from the top-left of the page. Table finding and text extraction on the cropped view ignore everything outside. Changed lines carry comments.
# pip install pdfplumber "pandas>=2.2"
from pathlib import Path
import pandas as pd
import pdfplumber
SOURCE = Path("in/statement-2026-09.pdf")
TABLE_BOX = (36, 310, 559, 668) # changed: x0, top, x1, bottom in points
def table_from_box(pdf_path: Path, page_index: int, box: tuple[float, float, float, float]) -> pd.DataFrame:
with pdfplumber.open(pdf_path) as pdf:
page = pdf.pages[page_index]
region = page.crop(box, strict=False) # changed: extract only inside the box
rows = region.extract_table({
"vertical_strategy": "text", # statement has no vertical rules
"horizontal_strategy": "text",
"snap_tolerance": 3,
"intersection_tolerance": 5,
})
if not rows:
raise ValueError(f"no table found inside {box} on page {page_index + 1}")
header, *body = rows
frame = pd.DataFrame(body, columns=[(h or f"col_{i}").strip() for i, h in enumerate(header)])
return frame.replace({"": pd.NA}).dropna(how="all")
if __name__ == "__main__":
try:
print(table_from_box(SOURCE, 0, TABLE_BOX).head())
except (OSError, ValueError) as exc:
raise SystemExit(f"extraction failed: {exc}")
strict=False allows a box that extends a point past the page edge without raising ValueError: Bounding box ... is not fully within parent page bounding box — useful when the box is computed rather than typed. Leave two or three points of margin around the measured table: characters whose bounding boxes cross the crop edge are kept or dropped by their centre, and a tight box can shave off the first or last row.
Variant Fix 1: Anchor the Box to Text Instead of Fixed Numbers
Fixed coordinates break when the table moves down because an address had an extra line. Compute the box per page from the anchor words found in the diagnostic, so the region follows the table:
# pip install pdfplumber
import pdfplumber
def anchored_box(page, header_text: str, end_text: str, margin: float = 3.0) -> tuple[float, float, float, float]:
words = page.extract_words(keep_blank_chars=True)
header = next((w for w in words if header_text in w["text"]), None)
end = next((w for w in words if end_text in w["text"] and (header is None or w["top"] > header["top"])), None)
if header is None:
raise ValueError(f"header {header_text!r} not found on page {page.page_number}")
top = header["top"] - margin
bottom = (end["top"] - margin) if end else page.height - 40 # no end marker: run to footer area
content = [w for w in words if top <= w["top"] <= bottom]
x0 = min(w["x0"] for w in content) - margin
x1 = max(w["x1"] for w in content) + margin
return (max(0, x0), max(0, top), min(page.width, x1), min(page.height, bottom))
Continuation pages of a long statement often have the header but not the closing balance; the fallback runs the box down to just above the footer. Combining anchored boxes with the multi-page stitching in handle multi-page PDF tables in pandas handles tables that flow across pages.
Variant Fix 2: The Same Box in camelot and tabula
Each library expresses the region differently. Convert once from pdfplumber's top-left convention:
# pip install "camelot-py[base]" tabula-py pdfplumber
import camelot
import pdfplumber
import tabula
def to_camelot_area(box, page_height: float) -> str:
x0, top, x1, bottom = box
return f"{x0},{page_height - top},{x1},{page_height - bottom}" # x1,y1 top-left ; x2,y2 bottom-right, origin bottom-left
def to_tabula_area(box) -> list[float]:
x0, top, x1, bottom = box
return [top, x0, bottom, x1] # top, left, bottom, right
path, box = "in/statement-2026-09.pdf", (36, 310, 559, 668)
with pdfplumber.open(path) as pdf:
height = float(pdf.pages[0].height)
camelot_tables = camelot.read_pdf(path, pages="1", flavor="stream",
table_areas=[to_camelot_area(box, height)])
tabula_frames = tabula.read_pdf(path, pages=1, area=to_tabula_area(box),
guess=False, stream=True) # guess=False: honour the area
camelot's table_areas string is "x1,y1,x2,y2" with (x1, y1) the top-left and (x2, y2) the bottom-right corner, but in PDF coordinates where y grows upward — hence subtracting from the page height. tabula ignores area unless guess=False, a detail that makes many area-based scripts silently extract the whole page. If tabula is unavailable on a server, fix tabula java not found error covers the runtime requirement.
Measuring a Box Visually
When anchor text is not reliable — scanned headers, tables with no header row — measure the region once from a rendered page. pdfplumber can draw candidate boxes on a page image, which turns guessing into a quick visual check:
# pip install pdfplumber
from pathlib import Path
import pdfplumber
def preview_box(pdf_path: Path, page_index: int, box, out: Path) -> Path:
with pdfplumber.open(pdf_path) as pdf:
page = pdf.pages[page_index]
image = page.to_image(resolution=100)
image.draw_rect(box, stroke="red", stroke_width=2)
for table in page.crop(box, strict=False).find_tables():
image.draw_rect(table.bbox, stroke="blue", stroke_width=1)
out.parent.mkdir(parents=True, exist_ok=True)
image.save(out)
return out
if __name__ == "__main__":
print(preview_box(Path("in/statement-2026-09.pdf"), 0, (36, 310, 559, 668), Path("out/box-preview.png")))
The red rectangle is your box; blue rectangles are tables found inside it. Adjust the numbers until exactly one blue rectangle fits inside the red one with a little margin, then commit the box — or the anchor rule derived from it — to the job's configuration with a note about which document version it was measured on.
Verification
Assert the extracted table has the expected header, the expected row count relative to a control total on the page, and that no row contains text belonging to the neighbouring regions.
# pip install pdfplumber "pandas>=2.2"
import re
from pathlib import Path
import pandas as pd
import pdfplumber
EXPECTED_HEADER = ["Date", "Description", "Money out", "Money in", "Balance"]
def verify_table(pdf_path: Path, frame: pd.DataFrame) -> None:
assert list(frame.columns) == EXPECTED_HEADER, f"header is {list(frame.columns)}"
leaked = frame.apply(lambda col: col.astype("string").str.contains("Account number|Terms|Page \\d", na=False)).any().any()
assert not leaked, "text from outside the table leaked into the frame"
with pdfplumber.open(pdf_path) as pdf:
text = pdf.pages[0].extract_text() or ""
count = re.search(r"(\d+)\s+transactions", text)
if count:
assert len(frame) == int(count.group(1)), f"{len(frame)} rows, statement says {count.group(1)}"
money = lambda s: pd.to_numeric(s.astype("string").str.replace(",", ""), errors="coerce").fillna(0)
print(f"{len(frame)} rows; out {money(frame['Money out']).sum():,.2f}, in {money(frame['Money in']).sum():,.2f}")
Many statements print a transaction count or period totals; comparing against them is the strongest check available, because it catches rows cut off at the box edge — the most common failure of coordinate-based extraction.
FAQ
What units are PDF coordinates in? Points, 1/72 of an inch. An A4 page is about 595 × 842 points; US Letter is 612 × 792.
Why is my box offset on some files?
Their crop box does not start at (0, 0), or the page has /Rotate. pdfplumber reports page.bbox and page.rotation; subtract the crop box origin and handle rotated pages before applying a fixed box.
Can I use a box on scanned PDFs? Only after OCR adds text, and OCR output coordinates may differ slightly from the image. Measure boxes on the OCR'd file, as in how to extract tables from scanned PDFs.
Does cropping speed up extraction? Yes, noticeably on dense pages, because the table finder and word grouping only process characters inside the box.
Related
- Extracting Tables from PDFs — strategies, lattice versus stream and the full workflow
- Fix Camelot Wrong Column Split in Stream Mode — column boundaries once the region is right
- Redacting Sensitive Data in PDFs — the same coordinates used to remove regions
- Comparing PDF Table Extraction Libraries — choosing between pdfplumber, camelot and tabula
Part of Extracting Tables from PDFs.