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.

May 1, 2026 Updated June 18, 2026 8 min read okraPDF

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.

Interactive · fixed example One invoice, four layouts — one regex vs one schema
Total                2,592.00
✓ total = 2592
Subtotal   Tax      Total
2,400.00   192.00   2,592.00
✗ total = 2400 — grabbed the subtotal sitting under the wrong header
1048 Northstar Labs 2,400.00 192.00 2,592.00
Invoice No. Vendor Subtotal Tax Total
✗ no match — "Total" is a trailing header with no number after it
TotaI 2,592.00      (OCR read the "l" as "I")
✗ no match — the literal characters changed
Regex: 1 / 4 correct

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.

A safer PDF extraction loop starts with the target object, not with a pile of text.
1. Schema Define fields, types, enums, arrays, and required values.
2. Parse Read text, OCR, tables, layout, and page coordinates together.
3. Validate Check types, totals, dates, required fields, and confidence.
4. Store Save JSON plus page evidence so humans can review disputes.

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:

  1. Required fields are present.
  2. Dates parse to the expected format.
  3. Money fields are numbers, not strings with commas.
  4. subtotal + tax is close to total.
  5. Currency is explicit or derived from a clear source.
  6. Arrays contain complete objects, not partial rows.
  7. 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:

  1. Filter by structured fields.
  2. Search by embedding text.
  3. 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:

UseExample
Normalize whitespaceCollapse repeated spaces in a detected address block
Parse a known ID formatPull ABC-1234 from a field already labeled as a policy number
Clean currency stringsConvert $2,592.00 to 2592
Validate a date shapeReject 2026/99/99 before storage

Bad uses:

UseWhy it breaks
Find invoice totals from whole-document text”Total” appears in subtotals, summaries, footers, and tables
Extract table rows with line regexPDF text order is not row order
Infer document type from one keywordTemplates reuse labels across forms
Process scans like digital PDFsOCR 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:

  1. Classify the document type.
  2. Pick or generate the schema for that type.
  3. Parse text, OCR, layout, and tables.
  4. Produce JSON in the schema.
  5. Validate fields and document-level invariants.
  6. Attach page evidence to important values.
  7. Send low-confidence or invalid results to review.
  8. 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:

  1. invoice_number
  2. vendor_name
  3. invoice_date
  4. total
  5. currency
  6. line_items

For bank statements, start with:

  1. account_holder
  2. account_number_last4
  3. statement_period
  4. opening_balance
  5. closing_balance
  6. transactions

For contracts, start with:

  1. parties
  2. effective_date
  3. term
  4. renewal_clause
  5. termination_notice_days
  6. governing_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.