PDF extraction
Stop writing regex for PDF extraction
Regex works until the PDF changes. Use schemas, source evidence, and validation when invoices, bank statements, or contracts become JSON.
Regex is a useful tool after you already know what text you have. It is a bad system for understanding PDFs.
That distinction matters. A lot of PDF extraction projects start with a quick script:
const total = text.match(/Total\s+\$?([0-9,.]+)/)?.[1];
const invoiceNumber = text.match(/Invoice\s+#?\s*([A-Z0-9-]+)/)?.[1];
It works on the first file. It works on the next five files if they came from the same template. Then finance uploads a scan, a vendor changes “Invoice #” to “Inv No.”, a table wraps onto page two, or the PDF text layer reports columns in the wrong order. The script still runs, but the data is wrong.
Bad extraction is worse than failed extraction. A failed job pages someone. A quiet wrong total enters a database.
Total 2,592.00
Subtotal Tax Total 2,400.00 192.00 2,592.00
1048 Northstar Labs 2,400.00 192.00 2,592.00 Invoice No. Vendor Subtotal Tax Total
TotaI 2,592.00 (OCR read the "l" as "I")
Total 2,592.00
Subtotal Tax Total 2,400.00 192.00 2,592.00
1048 Northstar Labs 2,400.00 192.00 2,592.00 Invoice No. Vendor Subtotal Tax Total
TotaI 2,592.00 (OCR read the "l" as "I")
A fixed example for illustration — it shows the failure modes described in this post, not a live parse of your file. Run your own on the tool.
PDFs do not store paragraphs
A PDF is closer to drawing instructions than to a Word document. Text can be positioned one glyph at a time. Tables are often just text coordinates and lines. Multi-column pages can read left-to-right across columns, down one column, or in the order objects were written by the export tool. Scanned PDFs may not have a text layer at all.
That means this invoice:
Invoice No. 1048
Vendor: Northstar Labs
Subtotal 2,400.00
Tax 192.00
Total 2,592.00
might arrive from a parser like this:
Invoice
Subtotal Tax Total
No.
1048
2,400.00 192.00 2,592.00
Vendor:
Northstar Labs
Or like this:
1048 Northstar Labs 2,400.00 192.00 2,592.00
Invoice No. Vendor Subtotal Tax Total
Or as OCR text with a typo:
lnvoice No. 1048
TotaI 2,592.00
Regex can patch any one of these. It does not give you a stable model for all of them.
The shape you want is not text
Most applications do not actually want text. They want a typed object:
{
"invoice_number": "1048",
"vendor_name": "Northstar Labs",
"subtotal": 2400,
"tax": 192,
"total": 2592,
"currency": "USD",
"line_items": [
{
"description": "Parser evaluation package",
"quantity": 1,
"unit_price": 2400,
"amount": 2400
}
]
}
Once you admit that the output is structured data, the extraction system changes. You stop asking, “What regex finds this string?” and start asking, “What fields should exist, what types are allowed, and what evidence supports each value?”
That is the jump from text scraping to PDF to JSON.
Start with a schema
Use a schema even if the first version is small. The schema is not bureaucracy. It is the contract between your PDF parser and the rest of your application.
{
"type": "object",
"required": ["invoice_number", "vendor_name", "total", "currency"],
"properties": {
"invoice_number": {
"type": "string",
"description": "Invoice identifier exactly as printed on the document"
},
"vendor_name": {
"type": "string"
},
"invoice_date": {
"type": "string",
"format": "date"
},
"currency": {
"type": "string",
"enum": ["USD", "EUR", "GBP"]
},
"subtotal": {
"type": "number"
},
"tax": {
"type": "number"
},
"total": {
"type": "number"
},
"line_items": {
"type": "array",
"items": {
"type": "object",
"required": ["description", "amount"],
"properties": {
"description": { "type": "string" },
"quantity": { "type": "number" },
"unit_price": { "type": "number" },
"amount": { "type": "number" }
}
}
}
}
}
Then extraction becomes a repeatable operation:
okra extract ./invoice.pdf --schema ./invoice.schema.json
You can use the same idea for bank statements, leases, insurance forms, 10-K tables, timesheets, loan packages, and lab reports. The document changes. The contract stays explicit.
Validate the boring stuff
Do not send PDF output straight to production tables. Validate it first.
At minimum, check:
- Required fields are present.
- Dates parse to the expected format.
- Money fields are numbers, not strings with commas.
subtotal + taxis close tototal.- Currency is explicit or derived from a clear source.
- Arrays contain complete objects, not partial rows.
- Each important value has source evidence.
For invoices, a simple totals check catches a lot:
function assertInvoiceMath(invoice: {
subtotal?: number;
tax?: number;
total: number;
}) {
if (invoice.subtotal == null || invoice.tax == null) return;
const expected = invoice.subtotal + invoice.tax;
const delta = Math.abs(expected - invoice.total);
if (delta > 0.01) {
throw new Error(
`Invoice total mismatch: expected ${expected}, got ${invoice.total}`,
);
}
}
This is not glamorous code. It is the code that keeps bad OCR from becoming bad accounting data.
Keep source evidence
Every serious extraction result should include evidence. Not just the value, but where it came from.
For a human review UI, coordinates matter:
{
"field": "total",
"value": 2592,
"source": {
"page": 1,
"text": "Total 2,592.00",
"bbox": {
"left": 0.69,
"top": 0.82,
"width": 0.18,
"height": 0.03
}
}
}
For RAG, source evidence prevents a different class of bug. If your retrieval system only stores raw chunks, your model has to rediscover the same fields every time. If you store typed JSON with page evidence, the model can answer from structured data and still cite the document.
A practical RAG record often looks like this:
{
"document_id": "doc_abc123",
"document_type": "invoice",
"fields": {
"vendor_name": "Northstar Labs",
"total": 2592,
"currency": "USD"
},
"evidence": [
{
"field": "total",
"page": 1,
"text": "Total 2,592.00"
}
],
"embedding_text": "Invoice from Northstar Labs. Total USD 2,592.00."
}
That gives you three retrieval paths:
- Filter by structured fields.
- Search by embedding text.
- Open the exact page evidence when the answer matters.
Regex still has a place
This is not an anti-regex argument. Regex is good for local cleanup after layout and field detection are already done.
Good uses:
| Use | Example |
|---|---|
| Normalize whitespace | Collapse repeated spaces in a detected address block |
| Parse a known ID format | Pull ABC-1234 from a field already labeled as a policy number |
| Clean currency strings | Convert $2,592.00 to 2592 |
| Validate a date shape | Reject 2026/99/99 before storage |
Bad uses:
| Use | Why it breaks |
|---|---|
| Find invoice totals from whole-document text | ”Total” appears in subtotals, summaries, footers, and tables |
| Extract table rows with line regex | PDF text order is not row order |
| Infer document type from one keyword | Templates reuse labels across forms |
| Process scans like digital PDFs | OCR errors change the actual characters |
The rule is simple: regex is a cleanup tool, not the extraction engine.
A better production loop
For production PDF extraction, use a loop that can fail loudly:
- Classify the document type.
- Pick or generate the schema for that type.
- Parse text, OCR, layout, and tables.
- Produce JSON in the schema.
- Validate fields and document-level invariants.
- Attach page evidence to important values.
- Send low-confidence or invalid results to review.
- Store both the accepted JSON and the original PDF reference.
That loop has a little more machinery than a script with five regexes. It also gives you a system you can debug.
When a user says the total is wrong, you can inspect the evidence. When a vendor changes templates, your validator catches the mismatch. When you add a new document type, you add a schema instead of adding another branch to a brittle parser.
What to do next
If you are still early, do not overbuild. Pick one document class. Define the smallest useful schema. Run twenty real PDFs through it and track three numbers:
- Field accuracy — how many fields came back correct. This is your success metric; pick the fields that have to be exact and weight those.
- Validation failures — how many results failed the checks above, and what happens to them. Failed results get queued for review, never silently dropped.
- Review rate — how many needed a human to look. That’s your review threshold; if it’s too high, the schema or the parser needs work before you scale.
Twenty documents is enough to surface the failure modes — you don’t need a thousand to start.
For invoices, start with:
invoice_numbervendor_nameinvoice_datetotalcurrencyline_items
For bank statements, start with:
account_holderaccount_number_last4statement_periodopening_balanceclosing_balancetransactions
For contracts, start with:
partieseffective_datetermrenewal_clausetermination_notice_daysgoverning_law
Then decide what has to be exact. Not every field deserves the same review threshold. A contract summary can tolerate a missing optional clause. An invoice total cannot.
Try it with your own schema
okraPDF has a browser flow for exactly this: upload a PDF, define the fields you care about, and inspect the JSON before wiring it into a pipeline. The point isn’t to avoid code — it’s to write code against a typed object instead of reverse-engineering every new PDF template from raw text.
Try PDF → JSON in your schema →
Building this into a pipeline? A free account mints an API key so you can run the same extraction from your code.