EXTRACTING DATA FROM PDF INVOICES: A GUIDE TO COMBINING LLMS AND REGEX
EXTRACTING DATA FROM PDF INVOICES: A GUIDE TO COMBINING LLMS AND REGEX
An architecture that redacts sensitive personal data in the browser layer and turns only the lines and amounts you need into structured JSON.
The Problem: Every Invoice Is a Little Different, Every Error Is Silent
At month-end close, most of an accounting team's time goes to supplier invoices. E-invoices arrive structured; the real pain is the ones that arrive as PDFs. Every supplier has its own template, the line-item table sometimes spills onto a second page, and VAT is sometimes on each line and sometimes at the bottom. One request on our board described about 300 invoices a month, a third of them arriving like this and typed in by hand, one by one.
There are two classic ways to automate this, and neither is enough on its own:
- Pure regex: Flawless on one supplier's template, useless on the next. A new rule set for every new template.
- Pure LLM: Template-agnostic, but it means sending the whole document to a model, national ID numbers, IBANs and addresses included.
- Silent errors: With either one, the most expensive outcome is a wrong amount slipping into Excel unnoticed. When a model misreads a number, it doesn't tell you.
We use both, in sequence: regex to find sensitive data and to verify the result, and the LLM only to understand the table.
Architecture: Three Layers, One Direction
PDF Upload
In the browser
Redaction
Personal data
Schema
JSON
The first layer runs in the browser. The PDF Editor's WebAssembly engine reads the file's text layer; at this stage the document goes to no server. This is where sensitive data is found and removed. Matches aren't just replaced in the text; the corresponding text objects are removed from the PDF and the page is regenerated, so there's nothing left underneath to select.
The second layer takes only the line-item part of the redacted text. We send that text to a language model together with a fixed JSON schema: line description, quantity, unit price, VAT rate, line total and invoice total. We don't ask the model for free text; we ask for an object that matches the schema.
The third layer is deterministic again. We check the JSON the model returns with regex and arithmetic. An invoice that doesn't add up is flagged before it ever reaches Excel.
Code: Redaction in the Browser
Catching Turkish national ID numbers (TCKN) and IBANs by pattern alone produces false positives: not every 11-digit number is an ID number; it might just as well be an order number. So we add a checksum on top of the pattern. The last two digits of a TCKN are computed from the first nine; an IBAN has to pass the mod-97 check. In our first trials, these two checks brought false positives down to almost zero.
// Incapon PDF Editor: redaction pass that runs in the browser
const TCKN = /\b[1-9]\d{10}\b/g;
const IBAN_TR = /\bTR\d{2}(?:\s?\d{4}){5}\s?\d{2}\b/g;
function isTckn(s: string): boolean {
const d = s.split("").map(Number);
const odd = d[0] + d[2] + d[4] + d[6] + d[8];
const even = d[1] + d[3] + d[5] + d[7];
const c10 = (((odd * 7 - even) % 10) + 10) % 10;
const c11 = d.slice(0, 10).reduce((a, b) => a + b, 0) % 10;
return d[9] === c10 && d[10] === c11;
}
function isIban(raw: string): boolean {
const s = raw.replace(/\s/g, "");
const moved = s.slice(4) + s.slice(0, 4);
const digits = moved.replace(/[A-Z]/g, (c) => String(c.charCodeAt(0) - 55));
let rem = 0;
for (const ch of digits) rem = (rem * 10 + Number(ch)) % 97;
return rem === 1;
}
export function redact(text: string): string {
return text
.replace(TCKN, (m) => (isTckn(m) ? "[TCKN]" : m))
.replace(IBAN_TR, (m) => (isIban(m) ? "[IBAN]" : m));
}
Schema and Validation: Trust the Model, but Add It Up
We set two rules on the model side. First, the prompt is short and the schema is fixed: we ask only for JSON containing the lines, vat_total and grand_total fields. No free-form explanation, no commentary; a cell it isn't sure about stays null. Second, we never send the model the whole document. The letterhead, the address block and the bank details sit outside the table and never enter the text we send. On an average invoice, that shrinks what goes to the model from three pages to about 1,800 tokens.
We check the returned JSON in three steps:
- Format check: Does every amount match a local pattern such as
^\d{1,3}(\.\d{3})*,\d{2}$, and is the quantity numeric? - Line arithmetic: Does quantity × unit price match the line total within one kuruş?
- Invoice total: Do the lines plus VAT equal the amount at the bottom of the invoice?
In our first run with twenty sample invoices, the table was read completely on seventeen. On the other three a number was misread, but the total check caught all three and the invoice was flagged before it reached Excel. That was the success criterion that mattered to us: errors can happen, but they must not stay silent.
“The model can be wrong. What matters is that the system notices before the model would ever tell you.”
Cost and Limits
Because redaction runs in the browser, that part costs nothing on the server. With the model call and validation, the cost per page is about $0.01. No subscription; for a team processing 100 pages a month, that's roughly a dollar a month. The cost shows up invoice by invoice in the dashboard, with no margin added.
There are known limits too. Scanned pages have no text layer, so they need image-based reading (OCR) first; that step runs on the server, which is why redaction is applied to the OCR output, before the model call. We don't read handwritten lines; we flag them as unreadable. Pattern-based redaction is limited to TCKN and IBAN for now; for free text such as addresses, marking by hand is still the most reliable path. We'd rather have a plain rule set that gives the same result every time than a clever solution that occasionally misses something.
If you want to try it on your own invoices, follow the invoice table request on the board; we share every result we measure on sample invoices there.
NEXT POSTS
How does a 23-model AI consensus detect forged signatures?
Why does a silently broken page return HTTP 200? The silent failure detector
Got a Process Like This Too?
Tell us about the tedious manual task that costs your team at least 30 minutes every day; we will design a working in-browser micro tool for it in 48 hours.