Cookbook#
Recipes for common gotchas. Each one starts with a symptom and points at the smallest change that fixes it. For the full template DSL see Tutorial for Template Creation and Recommended fields.
“No template matches” — first pass#
Feed the raw text through the same backend the extractor uses:
invoice2data --debug-optimized-str invoice.pdf
Copy the shape of a line into a regex, put it in a template, iterate. Or let the builder draft one for you:
invoice2data --new-template invoice.pdf --interactive
“A template matches but the amount is empty”#
Turn on the per-template debug log and see which field regexes failed:
invoice2data --debug invoice.pdf
Usually the fix is one of:
The regex assumes a different separator (
.vs,for thousands / decimal).The regex assumes a currency symbol prefix / suffix that isn’t there.
The amount is on a different line than the label (see the multi-line recipe below).
For the currency-symbol case, opt into extract_number on the field so the
first numeric token gets plucked from the wider capture:
amount:
parser: regex
regex: 'Balance\s+(-?\$?[\d,]+\.\d{2})'
type: float
extract_number: true
“Line items span multiple lines”#
Bracket each item with first_line (the item start), line (continuation
lines — repeated), and last_line (the amounts row). Description values are
concatenated across matches:
lines:
first_line: '^(?P<sku>SKU\d+)\s+(?P<name>.+)$'
line: '^\s{4,}(?P<name>\w[\w\s]*)$'
last_line: '^\s+(?P<qty>\d+)\s+(?P<price>[\d.]+)$'
“Line items span multiple pages”#
Two independent tools:
end_match: last— if theendregex matches a per-page footer (a separator + page total that repeats), tellparse_by_ruleto use the LAST occurrence so the block spans all pages.skip_line— drop lines that would otherwise wrongly match the item regex (page headers, “Page X of Y”, subtotal rows).
lines:
start: ^ITEMS$
end: ^_+\n\s*Total this page
end_match: last
line: (?P<sku>\S+)\s+(?P<qty>\d+)
skip_line:
- ^Page \d+ of \d+
- ^Acme Corp \| Invoice continued
“Two templates both match the invoice”#
Set priority on the more specific one so it wins:
issuer: ACME (invoice)
priority: 10 # default is 5; higher = tried first
keywords:
- ACME Ltd
- Invoice
“The captured value has garbage I want to strip”#
Use replace on the field — one pair or a list of pairs, applied before type
coercion:
vat:
parser: regex
regex: 'VAT\s+(\S+)'
replace: ['\W+', ''] # NL.999,999.999,B01 -> NL999999999B01
“Unit strings should map to UNECE codes”#
invoice2data already maps common uom literals to UNECE Rec 20 codes on each
line (kg → KGM, pcs → H87, l → LTR) when the field is absent — see
Recommended fields for the full table. To add a literal, extend
invoice2data.extract.unece_uom.UNECE_CODES.
“Templates live in a database, not on disk”#
ordered_load parses a template list from a string; hand the result to
extract_data(..., templates=...). Any downstream app (Odoo, a Django admin,
your own service) can hold templates in a table:
from invoice2data import extract_data
from invoice2data.extract.loader import ordered_load
templates = ordered_load(db.query("SELECT body FROM invoice_templates").body)
result = extract_data("invoice.pdf", templates=templates)
For YAML instead of JSON: ordered_load(text, loader=yaml.safe_load).
“I want to catch parse failures precisely (not just an empty dict)”#
Opt in to typed errors:
from invoice2data import extract_data
from invoice2data.exceptions import (
NoTemplateFoundError,
RequiredFieldsMissingError,
)
try:
result = extract_data("invoice.pdf", raise_on_error=True)
except RequiredFieldsMissingError as exc:
print("Template matched but incomplete:", exc.fields)
except NoTemplateFoundError:
print("No template at all — consider --ai-fallback or a new template")
Both inherit InvoiceProcessingError. The default (no raise_on_error) is
still return {} — this is opt-in.
“One CSV row per line item, not one per invoice”#
invoice2data --output-format csv --csv-lines=explode invoice.pdf
Default is --csv-lines=json (one row per invoice, lines cell is a JSON
blob). explode produces one row per line item with the invoice-level columns
duplicated.
“Machine-readable logs for a CI / cron pipeline”#
invoice2data --in-automation --output-format json --output-name - *.pdf 2>logs.jsonl | jq .
--in-automation swaps the log formatter for one-JSON-object-per-line on
stderr; --output-name - streams the extracted-fields JSON on stdout;
so 2>logs.jsonl cleanly separates the two streams.
“OCR a scanned invoice + save the cleaned PDF back”#
from invoice2data.input.ocrmypdf import pre_process_pdf, RECOMMENDED_SCAN_OPTIONS
cleaned_pdf_path = pre_process_pdf("scan.pdf", pre_conf=RECOMMENDED_SCAN_OPTIONS)
# ... store cleaned_pdf_path in your invoice attachment ...
RECOMMENDED_SCAN_OPTIONS turns on deskew, clean, rotate_pages, and
optimize — the typical “make this scan usable” preset. Any
ocrmypdf option can be passed via
pre_conf.
“I need per-page area extraction on a specific field”#
Set an area: on the field with the coordinates + resolution:
fields:
invoice_number:
parser: regex
regex: '(\d{5,})'
area:
f: 1 # first page
l: 1 # last page (same = single page)
r: 300 # DPI the coords are measured in
x: 400 # left
y: 50 # top
W: 200 # width
H: 30 # height
Backends that support area: pdftotext (native, uses -x/-y/-W/-H) and
pdfium (in-process crop). For area-critical templates pin the backend with
input_module: pdftotext at the top level (bundled area templates already do
this).