Agents and perception · Medium · 25 min

Invoices to data: local OCR

OCR gives you text with typos. Pull out number, date, CIF, NIF and amounts; check the NIF letter, the CIF digit and that base plus VAT is the total.

OCR_SAMPLE is what tesseract.js read on a synthetic invoice of ours, typos included. OCR is that text, or your own image's if you switch on "A model in your browser". Write five functions:

validateNIF(s): a DNI is 8 digits and a letter: the letter is NIF_LETTERS[n % 23]. A NIE starts with X, Y or Z, worth 0, 1 and 2 in front of 7 digits, then the same rule. Accept lower case and surrounding spaces.

validateCIF(s): a letter, 7 digits and a control. Add the digits in even positions (2nd, 4th, 6th) and, for the odd ones, their double reduced to one digit (by adding its digits); c = (10 - sum % 10) % 10. When the first letter is K, P, Q, S, N, W or R the control must be the letter "JABCDEFGHI"[c]; when it is A, B, E or H, the digit c; with any other letter, either is fine.

parseAmount(s): "1.250,00 €" is 1250; with no digits, null.

extractFields(text): { numero, fecha, cif, nif, base, iva, total } with null for whatever is missing. numero looks like 2026-0147, fecha like dd/mm/yyyy; base, iva and total are the first amount with decimals on the "Base imponible", "IVA" and "TOTAL" lines.

checkTotals(f): { ok, diff } with diff = base + iva - total rounded to cents, and ok only when all three exist and diff is 0.

Challenges 0/6

  • The NIF letter, for DNI and NIE, lower case and spaces
  • The CIF control: a digit for an S.L., a letter for a public body
  • Amounts with a thousands dot, a decimal comma and a symbol
  • The seven fields of the sample invoice, typos included
  • Base plus VAT is the total; a difference is measured in cents
  • With the model's text (your image) or the sample: fields and verdict

function validateNIF(s) {
  // trim + upper case; DNI: 8 digits + NIF_LETTERS[n % 23]; NIE: X/Y/Z = 0/1/2 in front of 7 digits, same rule
  return false;
}

function validateCIF(s) {
  // letter + 7 digits + control; even positions as they are, odd ones doubled and reduced to one digit
  // c = (10 - sum % 10) % 10; K P Q S N W R want the letter "JABCDEFGHI"[c]; A B E H the digit; the rest either
  return false;
}

function parseAmount(s) {
  // drop thousands dots, comma to point, Number; null when there are no digits
  return null;
}

function extractFields(text) {
  // numero 9999-9999, fecha dd/mm/yyyy, cif letter+7 digits+control, nif 8 digits+letter or NIE
  // base / iva / total: first amount with two decimals on the line of "Base imponible" / "IVA" / "TOTAL"
  return { numero: null, fecha: null, cif: null, nif: null, base: null, iva: null, total: null };
}

function checkTotals(f) {
  // diff = base + iva - total rounded to cents; ok only when all three exist and diff is 0
  return { ok: false, diff: null };
}

console.log(extractFields(OCR), checkTotals(extractFields(OCR)));
Console output appears here (console.log).

The idea

An OCR does not return an invoice: it returns text, and text with mistakes. On our sample it read "FACTU RA", split the number over two lines and wrote "ESOO" in the IBAN. Turning that into data is your job, and it is done with rules you can defend in front of your accountant: the number has this shape, the date that one, and the amounts sit on the line that starts with "Base imponible".

The checks are what separates an extractor from a toy. The NIF letter and the CIF digit are checksums: if the OCR read a 3 for an 8, they do not match and you know before anything is booked. And if base plus VAT is not the total, either the OCR failed or the invoice is wrong; in both cases a person reviews it.

What we measured: tesseract.js 6 with the fast Spanish model (1.1 MB compressed) read the 1240 × 1000 px invoice in 290–475 ms on the DGX Spark on 2026-10-18, with a mean confidence of 92 %, in Node. In the browser it takes a bit longer and runs in a worker. The "best" model (8.4 MB) did not improve this sample.

Everything happens in your tab. Pressing "Load the model" downloads 5.3 MB from this very site; an image you upload never leaves your browser, which is exactly what you need to process invoices holding third-party data without one more data processor.

Run it on your machine

With Python, on the text the OCR produced (CC BY 4.0). With Tesseract installed, tesseract factura-muestra.png out -l spa reproduces it from the image:

curl -O https://vorluxai.com/data/labs/factura-ocr.txt
python3 -c "
import re
t = open('factura-ocr.txt').read()
amount = lambda label: float(re.search(label + r'[^\n]*?(\d[\d.]*,\d{2})', t, re.I).group(1).replace('.', '').replace(',', '.'))
nif = re.search(r'\b(\d{8})([A-Z])\b', t)
print('NIF ok:', 'TRWAGMYFPDXBNJZSQVHLCKE'[int(nif.group(1)) % 23] == nif.group(2))
base, iva, total = amount('Base imponible'), amount('IVA'), amount('TOTAL')
print('base + IVA == total:', round(base + iva - total, 2) == 0, base, iva, total)
"

Check what you learned

1. The OCR read a 3 where the NIF had an 8. How do you find out?
2. Why is the VAT amount searched with a lazy `*?` on the line?
3. Base plus VAT is not the total. What do you conclude?
4. When you press "Load the model", where does the invoice you upload go?

Go deeper: the Tesseract reference →

This in production, with your data? Let's talk for 15 minutes →