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.

Regions on the statement page An A4 statement page with a customer address panel and account panel at the top, a summary box below them, the transaction table spanning from about 312 to 665 points from the top, and a terms footer. Cropping to the transaction region with a few points of margin removes the four other structures that automatic detection reports as tables. A4 statement, 595 x 842 pt, origin top-left 1 1 Address panel detected as a table 2 2 Account panel detected as a table 3 3 Summary box detected as a table 4 4 Crop: 36, 310, 559, 668 the transaction table 5 5 Terms footer detected as a table

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.

Fixed coordinates versus anchored box The left panel shows a fixed box from 310 to 668 points applied to a statement where the address had an extra line, so the table moved down by 14 points and the last two transactions fall outside the box. The right panel shows the box computed from the positions of the Description header and the Closing balance text on that page, so it moves with the table and captures every row. Fixed (36, 310, 559, 668) table moved down 14 pt header row: inside rows 1-23: inside rows 24-25: cut off result: 23 of 25 rows Anchored to text top = 'Description' - 3 pt bottom = 'Closing balance' - 3 box moves with the table rows 1-25: inside result: 25 of 25 rows

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.

Coordinate conventions per library pdfplumber crop takes x0, top, x1, bottom in points with the origin at the top left. camelot table_areas takes a string x1,y1,x2,y2 in points with the origin at the bottom left, where y1 is the top edge. tabula-py area takes top, left, bottom, right in points with the origin at the top left, and is ignored unless guess is False. All three use PDF points of 1/72 inch. Library Argument Origin Gotcha pdfplumber crop((x0, top, x1, bottom)) top-left strict=False for edges camelot table_areas=['x1,y1,x2,y2'] bottom-left y = height - top tabula-py area=[top, left, bottom, right] top-left needs guess=False

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.

Part of Extracting Tables from PDFs.