Intermediate12 min read

Extract PDF tables without losing the rows

Read a ruled equipment table and preserve its column labels, cell values, and page, table, and row numbers.

Compare the two extractions

A PDF can draw a heading, cells, and grid lines as separate elements. If you split the extracted text into arbitrary chunks, a loan limit can end up without its equipment ID or column label. For this ruled table, use the visible cell boundaries and keep each row together.

Use the Python environment from the PDF extraction lab. Save the sample policy and script beside pdf_pages.py.

Download policy_table.py
View policy_table.py
import argparse
from dataclasses import dataclass
from datetime import date
from hashlib import sha256
from io import BytesIO
from pathlib import Path
import re

import pdfplumber

HEADERS = ("Equipment ID", "Equipment", "Loan limit", "Conditions")


@dataclass(frozen=True)
class EquipmentRow:
    page: int
    table: int
    row: int
    cells: tuple[str, ...]


@dataclass(frozen=True)
class Edition:
    filename: str
    effective: date
    digest: str
    rows: dict[str, EquipmentRow]


def read_edition(path):
    data = path.read_bytes()
    rows = {}
    with pdfplumber.open(BytesIO(data)) as pdf:
        first_page = pdf.pages[0].extract_text() if pdf.pages else ""
        match = re.search(r"^Effective ([0-9]{4}-[0-9]{2}-[0-9]{2})$", first_page or "", re.M)
        if not match:
            raise ValueError(f"{path.name}: expected an effective date on page 1.")
        effective = date.fromisoformat(match[1])
        for page in pdf.pages:
            for table_number, table in enumerate(page.extract_tables(), start=1):
                headers = tuple(" ".join((cell or "").split()) for cell in table[0])
                if headers != HEADERS:
                    raise ValueError(f"{path.name}, page {page.page_number}: unexpected table headers.")
                for row_number, row in enumerate(table[1:], start=2):
                    cells = tuple(" ".join((cell or "").split()) for cell in row)
                    if len(cells) != len(HEADERS) or not all(cells):
                        raise ValueError(f"{path.name}, page {page.page_number}: incomplete row {row_number}.")
                    equipment_id = cells[0]
                    if equipment_id in rows:
                        raise ValueError(f"{path.name}: repeated ID {equipment_id}.")
                    rows[equipment_id] = EquipmentRow(page.page_number, table_number, row_number, cells)
    if not rows:
        raise ValueError(f"{path.name}: no equipment rows were extracted.")
    return Edition(path.name, effective, sha256(data).hexdigest(), rows)


def print_row(edition, row):
    print(f"{edition.filename} | effective {edition.effective} | page {row.page}, table {row.table}, row {row.row}")
    for column, value in zip(HEADERS, row.cells):
        print(f"  {column}: {value}")


def lookup(folder, equipment_id, as_of):
    paths = sorted(folder.glob("equipment-policy-*.pdf"))
    if not paths:
        raise ValueError(f"No equipment-policy PDFs found in {folder}.")
    editions = [read_edition(path) for path in paths]
    dates = {edition.effective for edition in editions}
    if len(dates) != len(editions):
        raise ValueError("More than one source has the same effective date. Review the editions.")
    active = max((edition for edition in editions if edition.effective <= as_of),
                 key=lambda edition: edition.effective, default=None)
    if active is None:
        print(f"No edition is effective on {as_of}.")
        return
    row = active.rows.get(equipment_id)
    if row is None:
        print(f"{equipment_id} is absent from {active.filename} (effective {active.effective}).")
        return
    print_row(active, row)
    print(f"  File SHA-256: {active.digest}")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Inspect and search a fictional equipment policy.")
    commands = parser.add_subparsers(dest="command", required=True)
    inspect = commands.add_parser("inspect")
    inspect.add_argument("pdf", type=Path)
    find = commands.add_parser("find")
    find.add_argument("folder", type=Path)
    find.add_argument("equipment_id", type=str.upper)
    find.add_argument("--as-of", type=date.fromisoformat, required=True)
    args = parser.parse_args()

    try:
        if args.command == "inspect":
            edition = read_edition(args.pdf)
            for row in edition.rows.values():
                print_row(edition, row)
        else:
            lookup(args.folder, args.equipment_id, args.as_of)
    except (OSError, ValueError, pdfplumber.utils.exceptions.PdfminerException) as error:
        raise SystemExit(str(error)) from None
python -m pip install "pdfplumber==0.11.9"
python pdf_pages.py equipment-policy-2026-06-01.pdf --page 1
python policy_table.py inspect equipment-policy-2026-06-01.pdf

Check a row in the PDF

You’re ready when

The inspector prints three rows. Find EQ-314 on page 1, table 1, row 2. Its equipment is the flatbed scanner, its loan limit is 30 minutes, and its condition is to reserve at the media desk. The heading row counts as row 1.

Open the PDF and confirm that the values are in the correct columns. The extractor checks the expected headers and rejects incomplete rows or duplicate IDs. It prints each column name alongside the cell value so the meaning stays attached to the value.

Choose what to index

Use the ID for exact lookups. For a natural-language search, a row can become a passage such as “Equipment ID: EQ-314. Equipment: Flatbed scanner. Loan limit: 30 minutes.” Keep the file, page, table, and row with that passage so the reader can verify it.

Try another PDF with inspect. This script expects the sample policy format. Borderless tables, merged headings, and scans need different extraction and checks. Review the extracted rows against the page before putting them in an index.

Check your understanding

What evidence would help a reader verify a loan limit?

Give the edition and page, the equipment row, and the column that contains the limit.

Sources

Search the guide