document-processing

Parse, clean, validate, and convert PDFs, spreadsheets, CSVs, and documents into structured data.

Document Processing

Parse, clean, validate, and convert documents (PDF, Excel, CSV, DOCX) into structured data.

When to Use

  • Extracting data from PDFs, invoices, reports, forms
  • Processing Excel/CSV files with Pandas
  • Converting DOCX to structured text
  • Validating document schemas
  • Building data ingestion pipelines

PDF Processing

Text Extraction

import pdfplumber

def extract_text_from_pdf(path: str) -> str:
    """Extract all text from a PDF file."""
    with pdfplumber.open(path) as pdf:
        return "\n".join(page.extract_text() or "" for page in pdf.pages)

def extract_tables_from_pdf(path: str) -> list[pd.DataFrame]:
    """Extract all tables from a PDF as DataFrames."""
    tables = []
    with pdfplumber.open(path) as pdf:
        for page in pdf.pages:
            for table in page.extract_tables() or []:
                if table:
                    header = table[0]
                    rows = table[1:]
                    tables.append(pd.DataFrame(rows, columns=header))
    return tables

OCR for Scanned PDFs

from pdf2image import convert_from_path
import pytesseract

def ocr_pdf(path: str) -> str:
    """Extract text from scanned PDFs using Tesseract OCR."""
    images = convert_from_path(path, dpi=300)
    return "\n".join(pytesseract.image_to_string(img) for img in images)

LLM-Based Document Extraction

from pydantic import BaseModel, Field
from openai import OpenAI

class InvoiceData(BaseModel):
    invoice_number: str
    date: str
    vendor: str
    total: float
    line_items: list[dict]

def extract_invoice(text: str) -> InvoiceData:
    """Extract structured invoice data from document text."""
    client = OpenAI()
    resp = client.beta.chat.completions.parse(
        model="gpt-4o",
        messages=[
            {"role": "system", "content": "Extract invoice data. Return null for unknown fields."},
            {"role": "user", "content": text},
        ],
        response_format=InvoiceData,
    )
    return resp.choices[0].message.parsed

Excel Processing

Read with Validation

import pandas as pd
from pydantic import BaseModel, ValidationError

class Row(BaseModel):
    id: int
    name: str
    email: str
    amount: float
    date: str

def process_excel(path: str, sheet: str | None = None) -> tuple[list[Row], list[dict]]:
    """Process Excel file, return valid rows and errors."""
    df = pd.read_excel(path, sheet_name=sheet or 0)
    valid = []
    errors = []
    for idx, row in df.iterrows():
        try:
            valid.append(Row(**row.to_dict()))
        except ValidationError as e:
            errors.append({"row": idx + 2, "error": str(e)})
    return valid, errors

Write with Formatting

from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment

def write_excel(path: str, data: list[dict], headers: list[str]):
    """Write data to Excel with header styling."""
    wb = Workbook()
    ws = wb.active
    ws.append(headers)

    header_font = Font(bold=True, color="FFFFFF")
    header_fill = PatternFill(start_color="4472C4", fill_type="solid")
    for cell in ws[1]:
        cell.font = header_font
        cell.fill = header_fill
        cell.alignment = Alignment(horizontal="center")

    for row in data:
        ws.append([row.get(h) for h in headers])

    wb.save(path)

CSV Processing

Stream Processing for Large Files

import csv

def process_large_csv(path: str, chunk_size: int = 10000):
    """Process large CSV files in chunks to avoid memory issues."""
    chunk = []
    with open(path, newline="", encoding="utf-8") as f:
        reader = csv.DictReader(f)
        for row in reader:
            chunk.append(row)
            if len(chunk) >= chunk_size:
                yield chunk
                chunk = []
        if chunk:
            yield chunk

def clean_csv(input_path: str, output_path: str, required_cols: list[str]):
    """Clean CSV: remove empty rows, validate columns, strip whitespace."""
    with open(input_path) as fin, open(output_path, "w", newline="") as fout:
        reader = csv.DictReader(fin)
        writer = csv.DictWriter(fout, fieldnames=required_cols)
        writer.writeheader()
        for row in reader:
            if all(row.get(c, "").strip() for c in required_cols):
                writer.writerow({c: row[c].strip() for c in required_cols})

Pandas CSV with Type Coercion

def load_csv_typed(path: str, schema: dict) -> pd.DataFrame:
    """Load CSV with explicit type coercion."""
    df = pd.read_csv(path)
    for col, dtype in schema.items():
        if col not in df.columns:
            raise ValueError(f"Missing column: {col}")
        df[col] = pd.to_numeric(df[col], errors="coerce") if dtype in (int, float) else df[col].astype(str)
    return df.dropna(subset=schema.keys())

DOCX Processing

from docx import Document

def extract_docx(path: str) -> dict:
    """Extract text, tables, and headings from DOCX."""
    doc = Document(path)
    return {
        "paragraphs": [p.text for p in doc.paragraphs if p.text.strip()],
        "tables": [[cell.text for cell in row.cells] for table in doc.tables for row in table.rows],
        "headings": [p.text for p in doc.paragraphs if p.style.name.startswith("Heading")],
    }

Data Validation Pipeline

from pydantic import BaseModel, field_validator
import pandas as pd

class DataRecord(BaseModel):
    id: str
    email: str
    amount: float
    status: str

    @field_validator("email")
    @classmethod
    def valid_email(cls, v: str) -> str:
        if "@" not in v:
            raise ValueError("invalid email")
        return v.lower()

    @field_validator("amount")
    @classmethod
    def positive_amount(cls, v: float) -> float:
        if v <= 0:
            raise ValueError("amount must be positive")
        return round(v, 2)

def validate_records(df: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]:
    """Split DataFrame into valid and invalid records with error details."""
    valid = []
    invalid = []
    for idx, row in df.iterrows():
        try:
            DataRecord(**row.to_dict())
            valid.append(row)
        except Exception as e:
            invalid.append({**row.to_dict(), "_error": str(e), "_row": idx})
    return pd.DataFrame(valid), pd.DataFrame(invalid)

Ingestion Pipeline Pattern

from pathlib import Path
import hashlib

class IngestionPipeline:
    """Process documents with deduplication and audit trail."""

    def __init__(self, output_dir: str, processed_log: str):
        self.output = Path(output_dir)
        self.log = Path(processed_log)

    def _fingerprint(self, data: bytes) -> str:
        return hashlib.sha256(data).hexdigest()

    def is_duplicate(self, path: Path) -> bool:
        fp = self._fingerprint(path.read_bytes())
        if self.log.exists():
            return fp in self.log.read_text()
        return False

    def process(self, path: Path) -> dict:
        if self.is_duplicate(path):
            return {"status": "skipped", "reason": "duplicate"}
        result = self._extract(path)
        self._log(path)
        return result

    def _extract(self, path: Path) -> dict:
        # Dispatch based on file extension
        suffix = path.suffix.lower()
        if suffix == ".pdf":
            return extract_text_from_pdf(str(path))
        elif suffix == ".xlsx":
            return {"sheets": pd.ExcelFile(path).sheet_names}
        elif suffix == ".csv":
            return pd.read_csv(path).to_dict(orient="records")
        elif suffix == ".docx":
            return extract_docx(str(path))
        raise ValueError(f"Unsupported format: {suffix}")

    def _log(self, path: Path):
        fp = self._fingerprint(path.read_bytes())
        with open(self.log, "a") as f:
            f.write(f"{fp}\n")

Verification

  • Test each extractor with valid and invalid documents
  • Validate schema coercion catches bad types
  • Confirm deduplication skips already-processed files
  • Check error reporting includes row numbers and field names
  • Benchmark memory usage for large files (>100MB)