Scraping zoning PDFs with Python and PyPDF2

You run a county’s latest batch of zoning amendments through PdfReader(path).pages[0].extract_text() and get back (cid:12)(cid:9)(cid:45) instead of a district code, or a setback bearing that reads 00.052 .N E with the digits transposed. The PDFs open fine in a viewer, so the data is there — but PyPDF2 returns garbled bytes, empty strings, or coordinates in the wrong order. This page answers one narrow question: how do you reliably pull zoning district codes and parcel coordinate references out of non-standardized municipal PDFs when the character mappings, text rotation, and font encodings are broken? It is a focused, runnable companion to the broader PDF & HTML scraping pipelines that feed the wider automated zoning change and municipal GIS tracking workflow. The fix is a fallback extraction chain, deterministic coordinate parsing with explicit projection, and a per-document audit artifact you can roll back to.

Diagnosis: identifying why extraction returns garbage jump to heading

PyPDF2 — now merged into the import-compatible successor pypdf (from pypdf import PdfReader) — translates character codes through the PDF’s internal /ToUnicode CMap. Municipal GIS departments routinely export zoning documents from legacy CAD-to-PDF converters that strip or corrupt that mapping, so the same call that works on a born-digital ordinance fails silently on a plat map. The corruption shows up as three distinct, recognizable symptoms.

  1. CMap / encoding drift. The page uses Identity-H or a custom WinAnsi encoding with no Unicode table, so extract_text() returns (cid:NN) tokens or mojibake. PyPDF2 may emit a PdfReadWarning; more often it returns a string that is structurally fine but semantically meaningless, which is far more dangerous because it passes a naive if text: check and corrupts downstream.
  2. Rotated and reordered text streams. Title blocks, north arrows, and margin metadata are drawn with a rotation matrix. PyPDF2 reads glyphs in stream order, not visual order, so a 90°-rotated bearing label arrives with its characters interleaved or reversed — N 45° 30' E becomes E '03 °54 N.
  3. Vector-only geometry. Parcel boundaries, easement polygons, and setback lines are frequently embedded as vector paths, not selectable text. extract_text() ignores them entirely, returning a page that looks empty even though it is dense with spatial primitives.

Reproduce the failure deterministically before changing anything, and record which symptom you have so the fallback can be measured:

Why extracted text interleaves two columns into nonsense A two-column zoning agenda page is shown on the left with numbered text runs in the order the content stream stores them: the numbering jumps between the left and right columns because the generator emitted runs in the order it drew them, not in reading order. On the right, the string that pypdf returns is shown as a single run of interleaved fragments, so a parcel identifier from the left column is followed by a zoning code from the right column. The fix noted underneath is to extract with x positions and sort into columns before joining, rather than trusting the stream order. The page: two columns, drawn in stream order 1 · parcel 0714-22-1 2 · rezone to R-1 3 · parcel 0714-22-2 4 · rezone to C-1 5 · parcel 0714-22-3 6 · rezone to AG-2 Left column: identifiers Right column: actions The generator emitted the runs row by row across both columns, because that is the order it drew them. Nothing in the file records that these are two columns. page.extract_text() walks the stream in that order. What extract_text() hands back 0714-22-1 rezone to R-1 0714-22-2 rezone to C-1 0714-22-3 rezone to AG-2 Readable — and impossible to parse safely. A regex for "parcel then code" pairs them correctly here …and pairs them wrongly the moment one row wraps to two lines. The fix: keep the coordinates extract_words() returns an x0 for every word. Cluster on x0 to recover the columns, sort each cluster by top, then join. The column structure is reconstructed from geometry, not guessed from word order — which is the only version that survives a wrap.
Stream order is drawing order, and a two-column agenda is usually drawn across the columns. The extracted string looks plausible enough to ship, which is the danger: the naive pairing is correct until one row wraps, and then it is silently off by one for the rest of the page.
from pypdf import PdfReader

reader = PdfReader("zoning_amendment.pdf")
page = reader.pages[0]
text = page.extract_text() or ""

print(f"chars={len(text)}  rotation={page.get('/Rotate', 0)}")
print(f"cid_tokens={text.count('(cid:')}")
print(repr(text[:200]))

A non-zero cid_tokens count or an empty text on a visibly populated page confirms an encoding or vector-only problem that no PyPDF2 parameter can fix — extract_text() accepts no encoding or error-handling arguments and reads the embedded mappings as-is. The only reliable path is a fallback chain.

Fallback extraction chain and coordinate parsing lane for zoning PDFs A single zoning PDF enters a three-stage fallback chain: pypdf extract_text, then pdfplumber on pdfminer.six, then pymupdf with visual-order sorting. A branch off each stage labelled "(cid) or empty?" routes failed output to the next tool, and a final branch sends documents that defeat all three to OCR or quarantine. The first extractor returning usable text drops its output into a parse lane, where a coordinate regex feeds a pyproj transform, then a spatial validity check against the parcel-fabric bounding box; in-bounds points become a compliance artifact while out-of-bounds points are quarantined. (cid) / empty? (cid) / empty? all fail usable output → parse (log the extractor that won) per page in bounds out of bounds Zoning PDF CAD export · scan pypdf extract_text() pdfplumber pdfminer.six pymupdf · fitz sort=True · visual order usable page text OCR / quarantine Tesseract · failed_pages/ coordinate regex DMS · decimal pairs pyproj transform always_xy=True spatial validity parcel-fabric bbox compliance artifact pydantic · sha256

Step-by-step implementation jump to heading

Each step isolates one concern. Compose them into the orchestrator in the final step.

Step 1 — Build a fallback extraction chain jump to heading

pdfplumber runs on pdfminer.six and handles a wider range of encoding edge cases than PyPDF2; pymupdf (fitz) gives the most robust coverage for CAD-derived PDFs and also recovers rotated text in visual order. Try the fastest tool first, fall back on empty or cid-laden output, and log which tool won so you can track parsing drift per source.

import re
import logging
from pathlib import Path
from typing import List

logger = logging.getLogger("zoning_ingestion")

ZERO_WIDTH = re.compile(r"[​-‍]")
CID_TOKEN = re.compile(r"\(cid:\d+\)")

def _is_usable(text: str) -> bool:
    """Reject empty or cid-token-laden output that survived a naive truthiness check."""
    if not text or not text.strip():
        return False
    # More than ~5% cid tokens means the CMap is corrupt; fall through.
    return len(CID_TOKEN.findall(text)) / max(len(text.split()), 1) < 0.05

def safe_extract_zoning_text(pdf_path: str) -> List[str]:
    """Extract per-page text, falling back pypdf -> pdfplumber -> pymupdf."""
    from pypdf import PdfReader
    import pdfplumber
    import fitz  # pymupdf

    path = Path(pdf_path)
    cleaned: List[str] = []

    try:
        page_count = len(PdfReader(str(path)).pages)
    except Exception as exc:
        logger.error(f"pypdf could not open {path.name}: {exc}")
        return cleaned

    for page_num in range(page_count):
        raw, tool = "", None

        try:
            raw = PdfReader(str(path)).pages[page_num].extract_text() or ""
            tool = "pypdf" if _is_usable(raw) else None
        except Exception as exc:
            logger.warning(f"pypdf failed p{page_num}: {exc}")

        if not tool:
            try:
                with pdfplumber.open(str(path)) as plumb:
                    raw = plumb.pages[page_num].extract_text() or ""
                tool = "pdfplumber" if _is_usable(raw) else None
            except Exception as exc:
                logger.warning(f"pdfplumber failed p{page_num}: {exc}")

        if not tool:
            try:
                # pymupdf "text" sort reorders rotated streams into visual order
                raw = fitz.open(str(path))[page_num].get_text("text", sort=True) or ""
                tool = "pymupdf" if _is_usable(raw) else None
            except Exception as exc:
                logger.error(f"pymupdf failed p{page_num}: {exc}. Skipping page.")

        if not tool:
            logger.error(f"All extractors failed p{page_num} of {path.name}. Quarantining.")
            continue

        normalized = ZERO_WIDTH.sub("", raw)
        normalized = re.sub(r"\s+", " ", normalized).strip()
        if normalized:
            logger.info(f"p{page_num} extracted via {tool}")
            cleaned.append(normalized)

    return cleaned

Step 2 — Recover rotated text in visual order jump to heading

When the diagnostic showed /Rotate is non-zero or bearings arrive scrambled, the pymupdf branch above already helps because get_text("text", sort=True) reconstructs reading order from glyph coordinates. For pages where only a rotated title block matters, target it directly rather than the whole page so unrelated body text is not reordered:

import fitz

def extract_rotated_block(pdf_path: str, page_num: int) -> str:
    """Pull text in geometric reading order, undoing stream-order scrambling."""
    doc = fitz.open(pdf_path)
    page = doc[page_num]
    # words: (x0, y0, x1, y1, "word", block, line, word_no)
    words = page.get_text("words")
    # Sort top-to-bottom, then left-to-right in visual space.
    words.sort(key=lambda w: (round(w[1] / 5), w[0]))
    return " ".join(w[4] for w in words)

Step 3 — Extract coordinate references with a precise regex jump to heading

Zoning legal descriptions carry degree-minute-second (DMS) bearings and decimal-degree pairs. Match them explicitly so a transposed or whitespace-injected string fails loudly instead of parsing into a plausible-but-wrong number.

import re

COORD_PATTERN = re.compile(
    r"(?P<lat>[-+]?\d{1,3}[°\s]\d{1,2}[′']\d{1,2}[″\"]\s*[NS]?)\s*"
    r"(?P<lon>[-+]?\d{1,3}[°\s]\d{1,2}[′']\d{1,2}[″\"]\s*[EW]?)",
    re.IGNORECASE,
)

Step 4 — Enforce explicit CRS transformation jump to heading

Municipal documents reference local State Plane zones in feet while downstream GIS layers expect WGS84, and an implicit datum assumption is the single most common cause of parcels landing in the wrong county. Pin the source EPSG per jurisdiction and transform deterministically — the same discipline covered in depth under CRS alignment strategies, applied here at parse time.

from pyproj import Transformer

def parse_and_transform_coordinates(
    text_block: str,
    source_epsg: int = 2263,   # NY State Plane Long Island (feet)
    target_epsg: int = 4326,
) -> list:
    """Extract DMS pairs and project to the target CRS."""
    transformer = Transformer.from_crs(
        f"EPSG:{source_epsg}", f"EPSG:{target_epsg}", always_xy=True
    )
    parsed = []
    for match in COORD_PATTERN.finditer(text_block):
        try:
            lat_str = re.sub(r"[°′″]", " ", match.group("lat"))
            lon_str = re.sub(r"[°′″]", " ", match.group("lon"))
            lat_deg = float(lat_str.split()[0])
            lon_deg = float(lon_str.split()[0])
            # always_xy=True prevents axis-order inversion (lon, lat) -> (x, y)
            x, y = transformer.transform(lon_deg, lat_deg)
            parsed.append({
                "lon": x, "lat": y,
                "source_epsg": source_epsg, "target_epsg": target_epsg,
            })
        except (ValueError, IndexError):
            continue
    return parsed

A short reference for the State Plane zones you will hit most often when pinning source_epsg:

EPSG Zone Units
2263 NY State Plane Long Island US feet
2229 California Zone V (LA) US feet
2249 Massachusetts Mainland US feet
2278 Texas North Central US feet
4326 WGS84 (target) degrees

Step 5 — Emit a deterministic compliance artifact jump to heading

Every parsed document must produce an immutable, schema-validated record that ties the extracted district and coordinates back to the source hash. Enforce it with pydantic, the same boundary that downstream schema validation and data quality checks rely on, and normalize district codes against the zoning taxonomy mapping before serialization.

from datetime import datetime, timezone
from pydantic import BaseModel, Field, ValidationError
import hashlib
import json
import logging

logger = logging.getLogger("zoning_ingestion")

class ZoningComplianceArtifact(BaseModel):
    document_hash: str
    municipality: str
    zoning_district: str
    effective_date: datetime
    coordinates: list
    parsing_metadata: dict = Field(default_factory=dict)

def generate_compliance_artifact(
    cleaned_text: list, coords: list, pdf_path: str,
    municipality: str, zoning_district: str, extractor: str,
) -> str:
    with open(pdf_path, "rb") as f:
        doc_hash = hashlib.sha256(f.read()).hexdigest()
    try:
        artifact = ZoningComplianceArtifact(
            document_hash=doc_hash,
            municipality=municipality,
            zoning_district=zoning_district,
            effective_date=datetime.now(timezone.utc),
            coordinates=coords,
            parsing_metadata={
                "pages_processed": len(cleaned_text),
                "extractor": extractor,
                "coord_count": len(coords),
            },
        )
        return json.dumps(artifact.model_dump(mode="json"), indent=2)
    except ValidationError as exc:
        logger.error(f"Compliance artifact validation failed: {exc}")
        raise

Verification & testing jump to heading

Confirm the fallback chain fixed the failure rather than masking it.

What each class of zoning PDF actually needs A five-row by four-column matrix of PDF classes against extraction outcomes. Rows are a born-digital single-column PDF, a born-digital two-column agenda, a scanned page, a mixed document with both scanned and digital pages, and an encrypted or permission-restricted file. Columns are what pypdf text extraction returns, whether word coordinates are available, whether OCR is required, and the check that proves the extraction worked. Single-column born-digital files extract cleanly. Two-column files extract interleaved text and need coordinate clustering. Scans return an empty string and need OCR. Mixed documents need a per-page decision. Encrypted files need the empty owner password supplied first. What each class of zoning PDF actually needs pypdf text extraction word coordinates OCR required? proof it worked Born-digital, one column clean yes no row count Born-digital, two columns interleaved yes no column clustering Scanned image pages empty string none yes ids in registry Mixed scanned + digital partial per page per page zero-word pages flagged Encrypted / restricted raises none after decrypt decrypt("") first works as-is needs handling fails or misleads
The mixed row is the one that produces the worst bugs, because a per-document decision gets it wrong on half the pages: the run succeeds, some pages yield text, the scanned ones yield nothing, and the missing parcels look like they were simply not on the agenda. Route per page, and count pages that produced zero words as a failure rather than as an empty result.
  • No surviving cid tokens. Assert "(cid:" not in "".join(cleaned_pages). A leak means _is_usable let corrupt output through — tighten the threshold or force the pymupdf branch for that source.
  • Coordinates land in the right county. Cross-reference each transformed point’s bounding box against the municipality’s parcel shapefile. A point that falls in the ocean or an adjacent state is almost always an axis-order or source_epsg mistake, not a regex miss.
  • Round-trip the projection. Transform WGS84 back to the source EPSG and assert the result matches the original within tolerance; drift beyond a few feet signals a wrong source zone.
  • Deterministic hash. Re-running the same PDF must reproduce an identical document_hash and coord_count. Any change means non-deterministic extraction ordering — pin the extractor per page in the manifest.
  • Log signature. A healthy run logs one extracted via <tool> line per page and zero Quarantining lines. Track the ratio of non-pypdf extractions as your parsing-drift metric.

Failure recovery jump to heading

When a page defeats the whole chain mid-batch, the run must continue and stay reproducible.

  • Quarantine, never drop. A page that fails all three extractors is written to a failed_pages/ partition with its source hash, page index, /Rotate value, and the last extractor’s exception, then the loop continues. This is the same dead-letter discipline used across error handling and retry logic in the ingestion layer.
  • Route scanned PDFs to OCR. If cid_tokens is zero but chars is also zero across every page, the document is a raster scan with no text layer. Flag it for an OCR pass (Tesseract via pdf2image) rather than retrying text extractors that will always return empty.
  • Resume from the last page index. Persist the last successfully processed page index per document. On restart, skip pages already present in the manifest so reruns are idempotent and never double-write the target table.
  • Backoff on the fetch, not the parse. When PDFs are pulled from a throttled municipal portal, apply exponential backoff with jitter on the download and cap retries before the dead-letter queue — concurrency for large county batches belongs to async batch processing and the portal’s rate limit management layer, not the extractor.
  • Alert on drift. Emit structured logs with pdf_hash, page_index, extractor, and coordinate_validation_status, and trigger an alert when non-pypdf extraction or quarantine exceeds 2% of the daily batch volume.

Frequently asked questions jump to heading

Should I still use PyPDF2, or migrate to pypdf?

PyPDF2 is deprecated; the project merged back into pypdf, which is an import-compatible successor (from pypdf import PdfReader). New pipelines should target pypdf as the first extractor and keep pdfplumber and pymupdf as fallbacks. Existing PyPDF2 code generally works unchanged after swapping the import.

Why does extract_text() return (cid:NN) tokens?

The PDF uses a font encoding (often Identity-H) with no /ToUnicode CMap, so there is no mapping from glyph code to character. PyPDF2 cannot invent one and emits the raw code as a (cid:NN) token. Switch to pymupdf, which reconstructs many of these from font metrics, or OCR the page if it is a scan.

My bearings come out with the characters reversed — why?

The text is drawn with a rotation matrix and PyPDF2 reads glyphs in stream order, not visual order. Use pymupdf’s get_text("text", sort=True) or sort the get_text("words") output by coordinate to rebuild reading order before applying the coordinate regex.

How do I know which source EPSG to pass for a given municipality?

Pin it per jurisdiction from a lookup table keyed on FIPS or municipality name rather than guessing per document. Most US municipal CAD output uses the local State Plane zone in US feet; verify by round-tripping a known landmark coordinate and checking it lands within the parcel fabric. EPSG lookup failures and ambiguous zones are handled under CRS alignment strategies.

For authoritative CRS definitions and transformation matrices, see the OGC Well-Known Text representation of Coordinate Reference Systems specification.