pdfplumber vs PyMuPDF for Construction Submittals
Every construction document pipeline eventually has to choose an extraction engine, and the two that dominate Python stacks pull in opposite directions. This page answers one precise question: when should a submittal-and-change-order pipeline reach for pdfplumber and when for PyMuPDF (fitz), given that the same drawing set contains native vendor spec sheets, scanned stamped shop drawings, and dense priced change-order tables? The wrong default quietly costs money — a table read with the wrong engine drops a line item, and a mis-parsed unit price posts against the wrong account before anyone notices. Both libraries live at the same stage of automated document ingestion, immediately after OCR preprocessing has deskewed and thresholded the page image, and immediately before field extraction lifts typed values out of the raw layout. The answer is not “pick one” but “route by document character,” and the routing decision itself is scored against the site-canonical confidence bands. This page targets Python automation builders who need predictable text, table, and coordinate extraction under real-world submittal variance.
Key Rules and Specification
The two engines are not interchangeable, and the differences that matter are the ones that touch a priced line item. The rules below govern how a submittal pipeline should split work between them.
- Table fidelity favors pdfplumber on ruled tables. pdfplumber exposes character-level bounding boxes and a lattice strategy that reconstructs a priced change-order grid from its ruling lines, which is exactly how a schedule-of-values table is drawn. PyMuPDF returns text blocks and spans but leaves cell reconstruction to you.
- Speed and rendering favor PyMuPDF. On a large drawing set — hundreds of sheets pulled per bid package — PyMuPDF’s C core extracts native text several times faster and renders a page to a pixmap for OCR in one call. pdfplumber, layered on pdfminer.six in pure Python, is the slower path.
- Scanned versus native is the deciding axis. A page with a text layer is a native extraction; a scanned stamped drawing has no text layer and must be rendered and handed to OCR. PyMuPDF renders; pdfplumber does not, so a scanned-heavy queue routes to PyMuPDF by construction.
- Licensing is a real constraint, stated factually. pdfplumber is MIT-licensed (its pdfminer.six core is also MIT). PyMuPDF is distributed under AGPL-3.0, with a separate commercial license available from its vendor. AGPL’s network-copyleft terms are a legal decision for your organization, not a technical one — record which engine touches which code path so the obligation is auditable.
- Coordinates must survive extraction. Both engines report positions in PDF points with the origin at the page’s lower-left; downstream field extraction relies on stable bounding boxes to anchor a unit price to its row, so the typed result carries the bbox regardless of engine.
- Money is Decimal, routing is banded. Every extracted amount is parsed to
Decimal, neverfloat, and the extraction’s confidence is scored against the canonical bands:0.92and above auto-routes,0.75–0.92flags for human review, below0.75quarantines.
| Dimension | pdfplumber | PyMuPDF (fitz) | Routing implication |
|---|---|---|---|
| Ruled table extraction | Strong (lattice from ruling lines) | Manual (blocks/spans only) | Priced grids → pdfplumber |
| Speed on large sets | Slower (pure-Python) | Fast (C core) | Bulk drawing sets → PyMuPDF |
| Scanned page handling | None (no render) | Renders to pixmap for OCR | Scanned pages → PyMuPDF |
| Coordinate detail | Char-level boxes | Block/span boxes | Fine anchoring → pdfplumber |
| Licensing | MIT | AGPL-3.0 (or commercial) | Legal review of the AGPL path |
Decision guide
Which to choose: route, do not standardize. Send native pages that contain ruled priced tables — schedule-of-values sheets, change-order line grids, submittal cost logs — to pdfplumber, because its lattice strategy recovers cells that PyMuPDF would leave as loose spans. Send scanned pages and large native drawing sets to PyMuPDF, because only it renders for OCR and it clears bulk text far faster. If your organization cannot accept AGPL obligations and has no commercial PyMuPDF license, make pdfplumber the default and confine PyMuPDF to an isolated OCR-render service, or replace the render step with another tool. The typed result and the confidence bands are identical whichever engine ran, so a downstream consumer never needs to know which one produced a given row.
Production Code Example
The contract below makes the output of extraction engine-agnostic: both lanes return the same frozen ExtractionResult, so routing never branches on which library ran. Each engine adapter reports a confidence score, and the router picks the engine from the page’s character before scoring the result against the canonical bands.
from __future__ import annotations
import logging
from decimal import Decimal, InvalidOperation
from typing import Literal, Protocol
from pydantic import BaseModel, ConfigDict, Field
logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)-8s | %(message)s")
logger = logging.getLogger("extract.router")
Engine = Literal["pdfplumber", "pymupdf"]
RouteState = Literal["auto_route", "human_review", "quarantine"]
AUTO_ROUTE = Decimal("0.92") # site-canonical: >= 0.92 commits automatically
HUMAN_REVIEW = Decimal("0.75") # 0.75-0.92 holds for an estimator to confirm
class LineItem(BaseModel):
"""One priced row lifted from a change-order or submittal table."""
model_config = ConfigDict(frozen=True)
description: str = Field(min_length=1, max_length=240)
quantity: Decimal = Field(gt=0)
unit_cost: Decimal = Field(ge=0, decimal_places=2) # Decimal, never float
bbox: tuple[float, float, float, float] # x0, y0, x1, y1 in PDF points
class ExtractionResult(BaseModel):
"""Engine-agnostic output. Routing never inspects which library produced it."""
model_config = ConfigDict(frozen=True)
engine: Engine
page_count: int = Field(ge=1)
items: list[LineItem]
confidence: Decimal = Field(ge=0, le=1)
@property
def route_state(self) -> RouteState:
if self.confidence >= AUTO_ROUTE:
return "auto_route"
if self.confidence >= HUMAN_REVIEW:
return "human_review"
return "quarantine"
class Extractor(Protocol):
"""Both engine adapters satisfy this shape, so the caller stays engine-blind."""
def extract(self, path: str) -> ExtractionResult: ...
def _to_decimal(raw: str) -> Decimal:
# Strip thousands separators/currency; a bad amount must fail loudly, not coerce.
cleaned = raw.replace(",", "").replace("$", "").strip()
try:
return Decimal(cleaned).quantize(Decimal("0.01"))
except InvalidOperation as exc:
raise ValueError(f"non-numeric amount {raw!r}") from excThe two adapters wrap each library and return the shared contract. The router inspects the page for a text layer and dispatches accordingly; a page with no extractable text is a scanned page and takes the PyMuPDF render-and-OCR lane. Both are shown as thin, testable wrappers — the real per-library table calls live behind _rows.
import pdfplumber # MIT, pdfminer.six core
import fitz # PyMuPDF, AGPL-3.0 core
class PdfplumberExtractor:
"""Native ruled tables: lattice strategy recovers priced grid cells."""
def extract(self, path: str) -> ExtractionResult:
items: list[LineItem] = []
with pdfplumber.open(path) as pdf:
for page in pdf.pages:
for row in page.extract_tables({"vertical_strategy": "lines"}) or []:
for desc, qty, cost, box in _rows(row):
items.append(LineItem(
description=desc, quantity=Decimal(qty),
unit_cost=_to_decimal(cost), bbox=box,
))
conf = Decimal("0.95") if items else Decimal("0.40")
return ExtractionResult(engine="pdfplumber", page_count=len(pdf.pages),
items=items, confidence=conf)
class PyMuPDFExtractor:
"""Fast text + render-for-OCR: for large sets and scanned stamped drawings."""
def extract(self, path: str) -> ExtractionResult:
items: list[LineItem] = []
doc = fitz.open(path)
try:
for page in doc:
for desc, qty, cost, box in _rows(page.get_text("blocks")):
items.append(LineItem(
description=desc, quantity=Decimal(qty),
unit_cost=_to_decimal(cost), bbox=box,
))
# Scanned pages yield no text blocks -> lower confidence -> OCR/review.
conf = Decimal("0.93") if items else Decimal("0.55")
return ExtractionResult(engine="pymupdf", page_count=doc.page_count,
items=items, confidence=conf)
finally:
doc.close()
def has_text_layer(path: str) -> bool:
doc = fitz.open(path)
try:
return any(page.get_text("text").strip() for page in doc)
finally:
doc.close()
def route_extraction(path: str) -> ExtractionResult:
"""Pick the engine by page character, then score against canonical bands."""
extractor: Extractor = PdfplumberExtractor() if has_text_layer(path) else PyMuPDFExtractor()
result = extractor.extract(path)
if result.route_state != "auto_route":
logger.info("AUDIT | engine=%s state=%s | %s",
result.engine, result.route_state, result.model_dump_json())
return result
if __name__ == "__main__":
for pdf_path in ("native_change_order.pdf", "scanned_shop_drawing.pdf"):
out = route_extraction(pdf_path)
print(f"{pdf_path:<26} {out.engine:<11} conf={out.confidence} -> {out.route_state}")Common Mistakes and Gotchas
Standardizing on one engine for the whole queue. Teams pick PyMuPDF for its speed and then hand-roll table reconstruction from spans, or pick pdfplumber for its tables and watch a thousand-sheet bid package time out. Both are avoidable: the page’s own character — text layer present or not, ruled table or free text — is the signal, and routing on it costs a single has_text_layer probe. A global default optimizes one document type at the expense of the others.
Letting the extractor coerce money to float. Both libraries return cell text as strings, and the tempting shortcut is float(cell). A change-order subtotal of 12345.67 survives, but 1.234,56 from a European vendor quote silently becomes 1.234, and the truncated cost auto-routes because the extraction was confident even though the value is wrong. Parse every amount through a Decimal converter that raises on anything non-numeric, and normalize locale formats before they reach the schema.
Ignoring the AGPL boundary until legal review. PyMuPDF’s AGPL-3.0 terms attach network-copyleft obligations that many organizations cannot accept in a distributed service. Discovering this after PyMuPDF is woven through the ingestion core is expensive. Keep the engine choice behind the Extractor protocol from day one so the AGPL-licensed path is a single swappable adapter, and record in the audit line which engine touched each document.
Where This Fits in the Pipeline
Engine selection is one decision inside OCR preprocessing, which prepares a page image before any text or table is lifted from it. Downstream, the typed ExtractionResult feeds field extraction, which turns the raw rows into validated submittal and change-order fields, and its priced tables reconcile against the spreadsheet side through the PDF/Excel sync pipelines. When a bid period floods the pipeline with hundreds of documents at once, the per-document route_extraction call runs inside the async batching workflows queue so extraction proceeds in bounded batches rather than blocking on one slow drawing set. The confidence bands applied here are the same 0.92 / 0.75 thresholds used across every routing decision on the site, so an extraction that lands in review behaves identically to a mapping or a submittal match that lands there.
Frequently Asked Questions
Which library extracts priced change-order tables more reliably?
pdfplumber, on natively ruled tables. Its lattice strategy reconstructs cells from the table’s ruling lines, which is how a schedule-of-values or change-order grid is drawn, and it exposes character-level bounding boxes to anchor each value. PyMuPDF returns text blocks and spans, leaving cell reconstruction to you, so a ruled priced grid is the case where pdfplumber earns its slower speed.
When is PyMuPDF the better choice?
When the workload is large or scanned. PyMuPDF’s C core clears native text several times faster than pure-Python pdfplumber, and it is the only one of the two that renders a page to a pixmap for OCR. A bid package of hundreds of sheets, or a queue of scanned stamped drawings with no text layer, routes to PyMuPDF by construction.
Does the licensing difference actually matter?
It can. pdfplumber is MIT-licensed; PyMuPDF is AGPL-3.0 with a separate commercial license available from its vendor. AGPL’s network-copyleft terms are a legal decision for your organization, not a technical one. Keeping the engine behind a single adapter interface means the AGPL-licensed path is one swappable component, and recording which engine touched each document keeps the obligation auditable.
How does the confidence score decide routing here?
The score is site-canonical. A clean native table read scores in the auto-route band (0.92 and above), a page that yielded text but no confident table lands in the 0.75–0.92 human-review band, and a scanned page that produced no extractable rows falls below 0.75 and quarantines to the dead-letter queue for OCR or manual triage. The engine that produced the result does not change the bands.
Can I run both engines and compare their output?
Yes, and it is a reasonable strategy for high-value change orders. Because both adapters return the identical ExtractionResult contract, you can extract with each, compare the priced totals as Decimal, and treat a mismatch as a review trigger. The cost is double the extraction time, so reserve dual extraction for documents above the change-order draft threshold rather than the whole queue.
Related
- OCR Preprocessing for Construction Docs
- Field Extraction Techniques
- PDF/Excel Sync Pipelines
- Async Batching Workflows
- Schema Validation Rules
← Back to OCR Preprocessing for Construction Docs