Extracting load capacity tables from scanned tower inspection PDFs with pdfplumber

A structural engineer signs off on a tower’s load-rating table once, on paper or as a scan, and that single table becomes the reference every subsequent lease amendment and co-location request is checked against. The problem is that the table rarely arrives as clean digital text — it comes back as a scanned inspection PDF where the original ruling lines survived as faint vector remnants on one page and vanished entirely on the next, where a design_capacity_kg column drifts half a millimeter out of alignment with its header, and where a misread cell can silently understate how much antenna load a structure can still carry. This page is the hands-on build guide for one extraction task inside the coordinate-aware PDFplumber Extraction Workflows: a small, runnable module that tries an explicit-lines table strategy first, falls back to a text-alignment strategy when the grid is gone, coerces every cell to a design_capacity_kg / current_load_kg float pair, rejects anything physically implausible, and seals the resulting rows with a tamper-evident audit hash. By the end you will have a script you can point at a real scanned report and hand to an auditor without flinching.

Prerequisites & Context

Before running the code below, have the following in place:

  • Python 3.10+ and pdfplumber. pip install pdfplumber. This task leans on table_settings — the dictionary that controls whether pdfplumber looks for actual ruled lines or infers columns from text position — which only pays off once you understand your report’s specific grid quality.
  • A scanned page that already has a text layer. This workflow assumes OCR has already run and the load-rating cells are selectable text, even if the visual grid is degraded. If your PDFs are still flat images with zero embedded text, route them through OCR for Legacy Inspection Forms first, and if the scans are skewed or noisy enough that OCR itself is unreliable, the deskewing pass described for handwritten logs belongs even earlier in that pipeline.
  • A canonical site identifier per file. Key every report to a TWR-#### site ID so extracted capacity rows cross-reference against the correct antenna structure and its FCC ASR number downstream.
  • A sense of which table-extraction strategy fits your vendor. If you inspect load-capacity tables from more than one vendor template, skim Table Extraction Strategies first — it covers how to decide between ruled-line detection, text-alignment inference, and other libraries entirely before you commit to tuning one extractor.
  • Engineering bounds for capacity and load. This example treats any design_capacity_kg at or below zero as corrupted data, and any current_load_kg above 150% of design_capacity_kg as a parsing error rather than a real structural reading — a genuine overload of that magnitude would already have triggered an emergency inspection, not a routine PDF.

If bolt-level readings from the same inspection also matter to your pipeline, Extracting Bolt Torque Data from PDF Inspection Reports with pdfplumber covers the sibling extraction task using the same crop-then-parse shape, and both extractors can run against the same document set.

Step-by-Step Implementation

Each step exists to defend against a specific way a scanned load-capacity table fails silently.

Step 1 — Crop to the load-rating zone. Call page.crop((x0, top, x1, bottom)) to isolate the region where the per-member capacity matrix sits before extracting anything. Scanned inspection reports routinely repeat a design capacity figure in a summary paragraph elsewhere on the page, and cropping first stops that narrative mention from being read as a certified table row.

Step 2 — Try the explicit-lines table strategy. Pass table_settings={"vertical_strategy": "lines", "horizontal_strategy": "lines"} to find_tables(). This is the highest-confidence path: when the scan preserved the original ruling as real vector lines, pdfplumber locks onto the actual grid instead of guessing, so try it before anything heuristic.

Step 3 — Fall back to the text strategy when lines are missing. Scanned pages frequently lose their ruling lines entirely or render them too faint for pdfplumber’s line detector to register. When find_tables() under the lines strategy returns nothing, retry with table_settings={"vertical_strategy": "text", "horizontal_strategy": "text"}, which infers column and row boundaries from the alignment of the OCR’d text itself.

Step 4 — Skip header and continuation rows. Guard every row on its first cell: a blank or non-member first cell means it is a header, a merged-cell continuation, or a scanning artifact, not a load-rating record, and should be discarded before coercion.

Step 5 — Coerce cells to design_capacity_kg and current_load_kg floats. Strip thousands separators and unit suffixes, then cast to float. Wrap the cast in the extractor’s own error handling so a cell that OCR mangled into unparseable text raises a clear, named failure instead of an opaque ValueError three calls up the stack.

Step 6 — Validate against engineering bounds and raise LoadTableExtractionError. Reject a non-positive design_capacity_kg, a negative current_load_kg, or a current_load_kg that exceeds 150% of its paired capacity. Any of those is far more likely to be a misread column than a genuine structural reading, and a loud exception during ingestion is cheaper than a wrong capacity figure reaching a lease decision.

Step 7 — Seal the canonical record set with hashlib.sha256. Serialize the validated LoadRatingRecord rows with sorted keys and hash the result. Regenerating that digest later proves the capacity table an auditor is looking at today is byte-for-byte what the extractor produced on ingestion day.

Complete Runnable Example

The module below implements every step with realistic telecom identifiers, structured logging, a custom exception, and a SHA-256 audit hash over the extracted set. The diagram traces one page through the lines-strategy path and its text-strategy fallback before the code.

Lines-first load capacity extraction with a text-strategy fallback and audit hash A top-to-bottom flowchart. Open PDF and stream pages leads to Crop to load zone, then to the decision Lines found. The yes branch goes to Parse ruled table, tagged method equals lines; the no branch goes to Text-strategy scan, tagged method equals text. Both branches join and flow into the decision Values valid. The no branch raises a LoadTableExtractionError and stops. The yes branch flows to Build LoadRatingRecord, and finally to SHA-256 audit hash. yes no yes no Open PDF · stream pages Crop to load zone Lines found? Parse ruled table method = lines Text-strategy scan method = text Values valid? Raise LoadTableExtractionError Build LoadRatingRecord SHA-256 audit hash

Figure: load capacity extraction with an explicit-lines table strategy and a text-alignment fallback.

python
# pip install pdfplumber
import hashlib
import json
import logging
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import List

import pdfplumber

logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
logger = logging.getLogger("load_table_extractor")

LOAD_ZONE = (40, 120, 560, 640)   # load-rating matrix region; tune per vendor template
LINES_SETTINGS = {"vertical_strategy": "lines", "horizontal_strategy": "lines", "snap_tolerance": 4}
TEXT_SETTINGS = {"vertical_strategy": "text", "horizontal_strategy": "text", "text_tolerance": 3}


class LoadTableExtractionError(Exception):
    """Raised when a load-capacity row fails coercion or safety validation."""


@dataclass
class LoadRatingRecord:
    site_id: str
    member_id: str
    design_capacity_kg: float
    current_load_kg: float
    page: int


def _coerce(cell: str) -> float:
    return float(cell.replace(",", "").replace("kg", "").strip())


def _validate(site_id: str, member_id: str, design_capacity_kg: float, current_load_kg: float) -> None:
    if design_capacity_kg <= 0:
        raise LoadTableExtractionError(f"{site_id} member {member_id}: bad design_capacity_kg {design_capacity_kg}")
    if current_load_kg < 0 or current_load_kg > design_capacity_kg * 1.5:
        raise LoadTableExtractionError(f"{site_id} member {member_id}: current_load_kg {current_load_kg} out of range")


def _rows(crop, settings: dict) -> list:
    return [row for table in crop.find_tables(table_settings=settings) for row in table.extract()]


def extract_load_ratings(site_id: str, pdf_path: Path) -> List[LoadRatingRecord]:
    records: List[LoadRatingRecord] = []
    with pdfplumber.open(pdf_path) as pdf:
        for i, page in enumerate(pdf.pages, start=1):
            crop = page.crop(LOAD_ZONE)
            rows, method = _rows(crop, LINES_SETTINGS), "lines"
            if not rows:
                logger.warning("no ruled table on page %d for %s; trying text strategy", i, site_id)
                rows, method = _rows(crop, TEXT_SETTINGS), "text"
            for row in rows:
                if not row or not row[0] or not row[0].strip() or row[0].strip().lower().startswith("member"):
                    continue
                try:
                    design_capacity_kg, current_load_kg = _coerce(row[1]), _coerce(row[2])
                except (ValueError, IndexError, TypeError) as exc:
                    raise LoadTableExtractionError(f"{site_id} row {row}: unparseable load cell") from exc
                _validate(site_id, row[0], design_capacity_kg, current_load_kg)
                records.append(LoadRatingRecord(site_id, row[0].strip(), design_capacity_kg, current_load_kg, i))
                logger.debug("row parsed via %s strategy on page %d", method, i)
    return records


def audit_hash(records: List[LoadRatingRecord]) -> str:
    canonical = json.dumps([asdict(r) for r in records], sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(canonical.encode("utf-8")).hexdigest()


if __name__ == "__main__":
    recs = extract_load_ratings("TWR-8842", Path("twr8842_load_capacity.pdf"))
    digest = audit_hash(recs)
    for r in recs:
        logger.info("LOAD | %s | member=%s | design=%.1fkg | current=%.1fkg | p%d",
                    r.site_id, r.member_id, r.design_capacity_kg, r.current_load_kg, r.page)
    logger.info("AUDIT | TWR-8842 | %d records | %s", len(recs), digest[:12])
    print(f"TWR-8842: {len(recs)} load records | audit={digest[:12]}")

Verification & Expected Output

Against a scanned report whose load-rating grid still carries usable ruling lines on page 1, the extractor emits one structured line per member plus a sealing audit line:

text
2026-04-14 09:12:03 | INFO | LOAD | TWR-8842 | member=LEG-A | design=4500.0kg | current=3120.0kg | p1
2026-04-14 09:12:03 | INFO | LOAD | TWR-8842 | member=LEG-B | design=4500.0kg | current=3340.0kg | p1
2026-04-14 09:12:03 | WARNING | no ruled table on page 2 for TWR-8842; trying text strategy
2026-04-14 09:12:03 | INFO | LOAD | TWR-8842 | member=BRACE-3 | design=1800.0kg | current=910.0kg | p2
2026-04-14 09:12:03 | INFO | AUDIT | TWR-8842 | 3 records | 5a1e9c04d7b2
TWR-8842: 3 load records | audit=5a1e9c04d7b2

To assert behavior in a test, check invariants rather than exact figures: assert all(r.current_load_kg <= r.design_capacity_kg * 1.5 for r in recs) confirms validation ran, and assert audit_hash(recs) == audit_hash(recs) confirms the digest is deterministic for identical input. Two failure signatures matter in practice. A no ruled table on page N warning followed by zero records even after the text-strategy retry means LOAD_ZONE missed the matrix entirely — widen the crop and re-run. A LoadTableExtractionError naming a current_load_kg of something like 312000.0 almost always means a decimal point or thousands separator was lost during OCR, merging two columns into one cell; inspect that page’s raw crop.extract_words() output rather than loosening the 150% bound to make the error disappear.

Gotchas & Edge Cases

  • Ruling lines that are half-present. A scan can retain the horizontal rules of a load table while losing the vertical ones (or vice versa), which makes find_tables() under the lines strategy return a garbled single-column table instead of failing cleanly. Treat a returned table whose row width is 1 as equivalent to no table found, and fall through to the text strategy rather than trusting it.
  • Text-strategy column bleed on narrow member IDs. When falling back to vertical_strategy: "text", a short member_id like LEG-A sitting close to its design_capacity_kg value can get merged into one inferred column if text_tolerance is too loose. Tighten text_tolerance incrementally and re-check with crop.find_tables(table_settings=TEXT_SETTINGS)[0].extract() until each field lands in its own column before trusting the output.
  • Units silently mixed across a portfolio. Most tower load tables report in kilograms, but an older report scanned from an imperial-unit template may list capacity in pounds without a clear per-cell unit marker. Never coerce blindly — check the report’s stated unit convention once per vendor template, and if a page mixes units, normalize explicitly (lb * 0.453592) rather than letting a pounds figure pass the 150% bound check as if it were kilograms.

FAQ

Why try the lines strategy before the text strategy?

Ruled-line detection is the highest-confidence path because it locks onto the actual grid geometry rather than inferring one, so when a scan preserves its original table lines as vectors, that structure gives you clean, unambiguous columns. Not every scan keeps those lines legible, though — degraded scans or aggressive compression can wash them out, and find_tables() under the lines strategy returns nothing. The text strategy recovers those pages by inferring columns from where the OCR’d characters actually align, so a page with a damaged grid still yields usable rows instead of an empty result.

How do I tune LOAD_ZONE and the strategy settings for a new vendor template?

Open one representative report and render page.to_image().debug_tablefinder(table_settings=LINES_SETTINGS) to see exactly what pdfplumber detects as lines within the region you cropped. If the debug image shows a clean grid, keep the lines strategy and adjust LOAD_ZONE until the box tightly encloses the matrix; if the grid looks broken or absent, switch straight to the text settings and tune text_tolerance until each column separates correctly. Because every record can be inspected via its source page number, cross-check a handful of extracted rows against the original PDF before trusting a new template in production.

What should happen when both the lines and text strategies return nothing?

Zero rows from both strategies on a page that visibly contains a load table almost always means the crop coordinates in LOAD_ZONE do not overlap the table at all, or the page has no text layer yet. Log the page dimensions and the cropped bounding box together so a reviewer can see the mismatch immediately, and if page.chars is empty, route the document to the OCR pipeline instead of treating a silent zero-record result as a tower with no rated members.

Related pages