Plug in a flatbed scanner and watch what happens to your filenames. Every document gets named Scan0047.pdf. Photos leave the camera as IMG_4382.jpg. Screenshots pile up as Screenshot 2024-03-14 at 09.42.17.png. Within a week, a Downloads folder turns into a graveyard of meaningless names attached to files that might be anything.
The naive fix is a renaming rule. "Anything prefixed with Scan goes into /documents/scans/." That works until your scanner firmware updates and starts outputting IMG prefixes. Or until you add a second scanner. Rule-based approaches collapse because they operate on filenames, and filenames carry exactly zero semantic information about what's inside the file.
This post walks through the engineering approach we use to solve this: a content-aware renaming pipeline that reads the document, understands what it is, and generates a meaningful name from the content itself.
Why filename metadata is a dead end
Before getting into the solution, it helps to be precise about why this problem is stubborn.
Modern file systems give you: filename, creation date, modification date, file size, MIME type. None of those fields tells you whether a PDF is a tax return, an NDA, or a pizza receipt. MIME type gets you application/pdf. That's the same for all three.
For images, EXIF data adds GPS coordinates, camera model, and focal length. Useful for photographers. Not useful when you're trying to identify a photo of a whiteboard with meeting notes on it.
The only reliable signal about what a file contains is the file contents. Which means you have to read it.
The OCR pipeline
If your input is a native PDF with embedded text (something exported from Word or Google Docs), text extraction is straightforward. pdfplumber, PyMuPDF, and pdfminer all get you there in a few lines. The hard cases are scanned documents and images.
For those, you need OCR. And before you run OCR, you need to preprocess the image. Raw scanner output is noisy in ways that destroy recognition accuracy.
Preprocessing steps that actually matter: convert to grayscale, deskew to correct page rotation (scanners are never perfectly aligned), binarize to black and white to remove noise and uneven lighting, then strip borders and scanner artifacts.
The deskew step gets underestimated. A 2-degree tilt can visibly cut Tesseract word-level accuracy on dense text. Here is the preprocessing function we use:
import cv2
import numpy as np
def preprocess_for_ocr(image_path: str) -> np.ndarray:
img = cv2.imread(image_path)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# Deskew via Hough line detection
edges = cv2.Canny(gray, 50, 150, apertureSize=3)
lines = cv2.HoughLines(edges, 1, np.pi / 180, 200)
if lines is not None:
angles = [line[0][1] for line in lines]
median_angle = np.median(angles) - np.pi / 2
(h, w) = gray.shape
center = (w // 2, h // 2)
M = cv2.getRotationMatrix2D(center, np.degrees(median_angle), 1.0)
gray = cv2.warpAffine(
gray, M, (w, h),
flags=cv2.INTER_CUBIC,
borderMode=cv2.BORDER_REPLICATE
)
# Adaptive threshold handles uneven lighting across the scan
binary = cv2.adaptiveThreshold(
gray, 255,
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY, 11, 2
)
return binary
Once the image is clean, you run OCR and capture confidence scores alongside the text:
import pytesseract
from dataclasses import dataclass, field
@dataclass
class OCRResult:
text: str
confidence: float
word_boxes: list[dict] = field(default_factory=list)
def extract_text(preprocessed_img: np.ndarray, lang: str = "eng") -> OCRResult:
data = pytesseract.image_to_data(
preprocessed_img,
lang=lang,
output_type=pytesseract.Output.DICT,
config="--psm 3" # Auto page segmentation
)
words = []
confidences = []
for i, word in enumerate(data["text"]):
if word.strip() and int(data["conf"][i]) > 0:
words.append(word)
confidences.append(int(data["conf"][i]))
avg_confidence = sum(confidences) / len(confidences) if confidences else 0
return OCRResult(
text=" ".join(words),
confidence=avg_confidence / 100,
word_boxes=[
{
"text": data["text"][i],
"conf": data["conf"][i],
"x": data["left"][i],
"y": data["top"][i],
}
for i in range(len(data["text"]))
if data["text"][i].strip()
],
)
The confidence score is your branching point. We trust Tesseract above 0.85. Below 0.60, we route to a cloud OCR API such as Google Document AI or AWS Textract. In between, we apply additional preprocessing passes before making the call.
Where vision models change things
OCR gives you text. Vision models give you understanding. That distinction is worth sitting with for a moment.
If you feed a scanned receipt to Tesseract, you get a blob of text: prices, a merchant name, some line items, a date. What Tesseract does not tell you is "this is a receipt." You have to figure that out from the extracted text itself, which means writing heuristics that are fragile and specific.
Vision models look at the whole document image and build a representation that fuses visual layout with content semantics. They can classify a document as a receipt, invoice, contract, or driver's license before reading a single word, because the visual structure of these document types is distinct. A receipt looks like a receipt. An invoice has a specific spatial layout with a header, line-item table, and totals block.
That classification matters a lot for renaming. If you know the document type upfront, you know which entities to prioritize. Receipts: merchant name, date, total. Invoices: vendor, invoice number, due date. Contracts: parties, effective date, agreement type. Different types need different templates.
We run classification on the raw image in parallel with OCR, then merge the results:
import anthropic
import base64
import json
from pathlib import Path
DOCUMENT_TYPES = [
"invoice", "receipt", "contract", "tax_form", "bank_statement",
"id_document", "medical_record", "letter", "form", "report", "other",
]
def classify_document(image_path: str) -> dict:
client = anthropic.Anthropic()
image_data = base64.standard_b64encode(
Path(image_path).read_bytes()
).decode("utf-8")
suffix = Path(image_path).suffix.lower()
media_type_map = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".pdf": "image/jpeg", # PDFs rendered to JPEG before this step
}
media_type = media_type_map.get(suffix, "image/jpeg")
response = client.messages.create(
model="claude-opus-4-5",
max_tokens=256,
messages=[
{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": image_data,
},
},
{
"type": "text",
"text": (
"Classify this document. Reply with JSON only:\n"
'{"type": "<one of the DOCUMENT_TYPES list>",'
'"confidence": <0.0-1.0>,'
'"language": "<ISO 639-1 code>"}'
),
},
],
}
],
)
return json.loads(response.content[0].text)
The classification result feeds directly into entity extraction. You get the document type, you select the right template, and you extract the right fields.
From extracted text to a meaningful filename
This is the part that looks simple and isn't. Once you have OCR text and a document type, you run type-specific regex patterns against the text and compose a filename from whatever entities resolve successfully. The key engineering decision is building a fallback chain so you always get something useful, even when entity extraction partially fails.
import re
from datetime import datetime
EXTRACTION_TEMPLATES = {
"invoice": {
"patterns": {
"vendor": r"(?:from|vendor|bill from|billed by)[:\s]+([A-Z][A-Za-z\s&,\.]+?)(?:\n|,|\d)",
"invoice_number": r"(?:invoice\s*#?|inv\.?\s*#?)[:\s]*([A-Z0-9\-]+)",
"date": r"(?:date|issued)[:\s]*(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4}|\w+\s+\d{1,2},?\s*\d{4})",
},
"template": "{vendor}_{date}_Invoice_{invoice_number}",
"fallback": "Invoice_{date}",
},
"receipt": {
"patterns": {
"merchant": r"^([A-Z][A-Za-z\s&]+?)(?:\n|LLC|Inc|Corp|Ltd)",
"date": r"(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})",
},
"template": "Receipt_{merchant}_{date}",
"fallback": "Receipt_{date}",
},
"contract": {
"patterns": {
"parties": r"(?:between|by and between)\s+(.+?)\s+and\s+(.+?)(?:\n|,|\()",
"date": r"(?:dated|effective|as of)[:\s]*(\w+\s+\d{1,2},?\s*\d{4}|\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})",
"type": r"(non-disclosure|employment|service|licensing|partnership)\s+agreement",
},
"template": "{type}_Agreement_{parties}_{date}",
"fallback": "Contract_{date}",
},
}
def normalize_date(raw_date: str) -> str:
formats = ["%m/%d/%Y", "%m-%d-%Y", "%B %d, %Y", "%b %d %Y", "%m/%d/%y"]
for fmt in formats:
try:
return datetime.strptime(raw_date.strip(), fmt).strftime("%Y-%m-%d")
except ValueError:
continue
return raw_date.replace("/", "-").replace(" ", "-")
def sanitize_filename(name: str) -> str:
name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "", name)
name = re.sub(r"\s+", "_", name.strip())
name = re.sub(r"_+", "_", name)
return name[:200]
def generate_filename(ocr_text: str, doc_type: str, original_ext: str) -> str:
config = EXTRACTION_TEMPLATES.get(doc_type, {})
if not config:
first_line = next(
(line.strip() for line in ocr_text.split("\n") if len(line.strip()) > 5),
"document",
)
return sanitize_filename(first_line[:50]) + original_ext
extracted = {}
for field_name, pattern in config["patterns"].items():
match = re.search(pattern, ocr_text, re.IGNORECASE | re.MULTILINE)
if match:
value = match.group(1).strip()
if field_name == "date":
value = normalize_date(value)
extracted[field_name] = sanitize_filename(value)
try:
name = config["template"].format(**extracted)
except KeyError:
try:
name = config["fallback"].format(**extracted)
except KeyError:
name = f"{doc_type}_{datetime.now().strftime('%Y-%m-%d')}"
return sanitize_filename(name) + original_ext
Always have a fallback. If entity extraction fails on a low-quality scan, you want something better than Scan0047.pdf even if you cannot get Acme_Inc_2024-03-14_Invoice_INV-4421.pdf. The fallback chain gives you graceful degradation at every level.
Pipeline architecture
Here is the full flow as it runs in production at handles batch renaming across web, Windows, and Mac and lets you review generated names before anything commits to disk.
Conclusion
The rough priority order if you're building this from scratch:
- Native PDF and DOCX text extraction first -- immediate wins, no OCR complexity
- Tesseract for scanned documents, with confidence thresholds
- Document type classification (even a rule-based classifier improves entity extraction significantly)
- Entity extraction templates for your top three to five document types
- Cloud OCR fallback for low-confidence results
- Vision model classification for ambiguous cases
- Batch throughput optimization once accuracy is solid
Do not start at step 7. Optimizing a pipeline that generates wrong filenames just produces wrong filenames faster. Get accuracy right first, then make it fast.
The core flow -- preprocess, OCR, classify, extract, template, fallback -- is stable enough to build on incrementally. Each stage can be improved in isolation without requiring rewrites downstream. That is the version worth building.
SOCIAL SHARE CARD GENERATOR