# Introduction
Run pdfplumber on a scanned bill, and also you get nothing. Run it on a multi-column analysis paper, and also you get a stream of textual content that has misplaced each spatial relationship the structure encoded. Run it on a crammed PDF type, and also you get the sector labels concatenated with the values in studying order, with no strategy to inform which belongs to which.
Textual content-extraction instruments have one assumption baked in: the PDF has a selectable textual content layer. The second that assumption fails — scanned paperwork, image-only PDFs, advanced type layouts, something with merged desk cells — the instruments fail silently. You get empty output or garbled textual content, and the failure mode offers you no sign about what went incorrect.
The picture strategy sidesteps this fully. Render every PDF web page to a high-resolution picture. Feed that picture to a vision-language mannequin. Ask it what you want in plain language. No optical character recognition (OCR) pipeline, no structure parser, no template matching per doc sort. The mannequin reads the web page the way in which a human reads a printed web page.
Gemma 4, launched by Google DeepMind on April 2, 2026, with a full Apache 2.0 license, lists Doc/PDF parsing as an specific functionality alongside OCR, chart comprehension, handwriting recognition, and display screen understanding. It runs fully regionally. No API key, no cloud name, no information leaving your server.
The undertaking thread via this text is a neighborhood doc consumption pipeline that processes provider invoices, extracting vendor identify, bill quantity, line gadgets, totals, and due date, and outputs structured JSON. It really works on scanned and digital PDFs alike.
# Why Deal with a PDF as an Picture?
There are two distinct PDF worlds, and most doc processing instruments solely work in certainly one of them.
- Digital PDFs have an embedded textual content layer; the textual content is selectable, searchable, and extractable. Instruments like
pdfplumber,PyPDF2, andpdfminerwork right here. Feed them a clear, machine-generated PDF, and so they return textual content in studying order. For easy single-column paperwork, that is typically adequate. - Scanned PDFs are photos saved inside a PDF container. There is no such thing as a textual content layer. Each phrase exists as pixel information.
pdfplumberreturns an empty string. PyMuPDF‘s textual content extraction returns nothing. The one strategy to learn the content material is to learn the picture.
That is the primary argument for the picture strategy: it unifies each worlds. Render the web page, whether or not the content material got here from a scanner, a printer, or a PDF generator, and also you all the time have a picture. The mannequin by no means must know which form of PDF it is working with.
The second argument is structure. Even for digital PDFs with selectable textual content, extraction instruments return textual content in doc order, which destroys construction. A two-column bill with line gadgets on the left and totals on the suitable will get returned as alternating fragments: left column textual content, then proper column textual content, interleaved in ways in which break downstream parsing. Tables with merged cells are worse; the extracted textual content loses all row and column context.
A vision-language mannequin reads the picture as a visible artifact. It sees the desk as a desk, the columns as columns, the shape as a type. It reads line gadgets row by row as a result of it could possibly see the rows.
Gemma 4 helps variable visible token budgets of 70, 140, 280, 560, and 1120 tokens per picture, which provides you a direct knob for the accuracy-versus-speed trade-off. For dense doc parsing with fine-grained line gadgets, use 1120. For fast web page classification or single-field extraction, 280 works properly and is considerably sooner. You set this per-call, not globally.
# Gemma 4
Gemma 4 is available in 4 sizes. The selection between them is primarily a {hardware} query.
| Mannequin | Efficient Params | Context | VRAM (bf16) | Modalities | OmniDocBench ↓ |
|---|---|---|---|---|---|
| E2B-it | 2.3B | 128K | ~6 GB | Textual content, Picture, Audio | 0.290 |
| E4B-it | 4.5B | 128K | ~10 GB | Textual content, Picture, Audio | 0.181 |
| 26B-A4B-it | 3.8B energetic | 256K | ~14 GB | Textual content, Picture | 0.149 |
| 31B-it | 30.7B | 256K | ~62 GB | Textual content, Picture | 0.131 |
OmniDocBench 1.5 is the doc parsing benchmark; decrease edit distance is best. The 31B scores greatest, however for many bill and type parsing duties, E4B-it delivers production-viable outcomes at a fraction of the {hardware} requirement. This text makes use of google/gemma-4-E4B-it all through, however each code instance works identically with some other dimension; simply change the mannequin ID.
Two architectural options make Gemma 4 notably sturdy at doc understanding.
- 2D Rotary Place Embedding (RoPE): Normal transformers encode place in a single dimension: token sequence order. Gemma 4 independently rotates consideration head dimensions for the x and y axes, giving the mannequin real spatial understanding. It is aware of what “above,” “under,” “left of,” and “proper of” imply within the visible sense. On a two-column bill, this implies the mannequin reads every column independently somewhat than mixing them. On a desk, it reads rows as rows.
- Per-Layer Embeddings (PLE): Reasonably than counting on a single shared token embedding on the enter, Gemma 4 feeds an auxiliary residual sign into every decoder layer. This structure, used within the E2B and E4B fashions, permits smaller efficient parameter counts to punch above their weight on structured visible duties. The OmniDocBench hole between E2B (0.290) and E4B (0.181) displays how a lot PLE contributes to increased parameter effectivity.
# Conditions
{Hardware} necessities:
| Characteristic | Minimal | Really useful |
|---|---|---|
| GPU VRAM (E4B-it) | 10 GB | 12 GB+ (RTX 3080 Ti / RTX 4080) |
| GPU VRAM (E2B-it) | 6 GB | 8 GB+ (RTX 3060 / RTX 4060 Ti) |
| System RAM | 16 GB | 32 GB |
| Apple Silicon | M2 Professional 16 GB | M3 Max 36 GB |
| Disk | 15 GB free | 30 GB+ SSD |
CPU-only inference works however is gradual; anticipate 30–90 seconds per web page relying on token price range and machine. Use Google Colab‘s free T4 GPU (15 GB VRAM) if you do not have a neighborhood GPU.
Hugging Face entry required. The Gemma 4 fashions are gated. Create a free account at huggingface.co, go to google/gemma-4-E4B-it or google/gemma-4-E2B-it, and settle for the mannequin phrases. Then generate a learn token at huggingface.co/settings/tokens.
Set up dependencies:
# Python 3.10+ required
python --version
# Create a digital setting
python -m venv gemma4-env
supply gemma4-env/bin/activate # macOS / Linux
gemma4-envScriptsactivate # Home windows
# Set up packages
pip set up
"transformers>=4.51.0"
"torch>=2.3.0"
"speed up>=0.30.0"
"pymupdf>=1.24.0"
"Pillow>=10.0.0"
"bitsandbytes>=0.43.0"
# Log into Hugging Face (paste your learn token when prompted)
pip set up huggingface_hub
huggingface-cli login
Confirm your setup:
# device_check.py
# Run this earlier than loading Gemma 4 to verify your compute setting.
# Save as device_check.py and run: python device_check.py
def detect_device():
"""
Detect the most effective obtainable compute gadget.
Returns (device_str, dtype, load_kwargs) to be used with from_pretrained.
"""
attempt:
import torch
besides ImportError:
elevate RuntimeError("PyTorch not discovered. Set up: pip set up torch")
if torch.cuda.is_available():
identify = torch.cuda.get_device_name(0)
vram = torch.cuda.get_device_properties(0).total_memory / 1e9
print(f"CUDA GPU: {identify} ({vram:.1f} GB VRAM)")
return "cuda", torch.bfloat16, {"device_map": "auto", "torch_dtype": torch.bfloat16}
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
print("Apple Silicon MPS detected")
return "mps", torch.float16, {"device_map": "mps", "torch_dtype": torch.float16}
else:
print("No GPU discovered -- CPU fallback (gradual however useful)")
return "cpu", None, {"device_map": "cpu"}
if __name__ == "__main__":
gadget, dtype, kwargs = detect_device()
print(f"Machine : {gadget}")
print(f"Dtype : {dtype}")
print(f"Prepared for Gemma 4 loading with: {kwargs}")
Methods to run:
# Rendering PDF Pages as Pictures with PyMuPDF
PyMuPDF (imported as pymupdf or fitz) is the suitable software for the PDF-to-image conversion step. It has no exterior dependencies, no Poppler, no Ghostscript, renders pages at arbitrary dots per inch (DPI), and produces PIL-compatible output that Gemma 4’s processor accepts instantly.
DPI issues greater than it might sound. PyMuPDF’s default renders at 72 DPI, display screen decision. At 72 DPI, small textual content in a dense bill turns into sub-pixel artifacts. At 200 DPI, every part is legible. At 300 DPI, you get scanner-equivalent high quality for handwritten content material and multilingual paperwork with small glyphs. The price is a proportionally bigger picture and extra visible tokens consumed from Gemma 4’s context window.
# pdf_renderer.py
# Conditions: pip set up pymupdf Pillow
# Utilization: import and instantiate PDFRenderer; name render_page() or render_all()
import pymupdf
from PIL import Picture
from pathlib import Path
class PDFRenderer:
"""
Converts PDF pages to PIL Pictures for downstream VLM inference.
No exterior dependencies past PyMuPDF -- no Poppler, no Ghostscript.
Output photos are in RGB mode, prepared for direct use with Gemma 4's AutoProcessor.
"""
def __init__(self, dpi: int = 200):
"""
Args:
dpi: Render decision.
150 -- quick classification move (fewer tokens, decrease high quality)
200 -- manufacturing commonplace for typed textual content and printed paperwork
300 -- high-fidelity, advisable for handwriting or small glyphs
"""
self.dpi = dpi
# PyMuPDF makes use of a zoom issue relative to the 72 DPI PDF baseline.
# zoom=1.0 = 72 DPI, zoom=2.78 = 200 DPI, zoom=4.17 = 300 DPI.
self._zoom = dpi / 72.0
self._matrix = pymupdf.Matrix(self._zoom, self._zoom)
def render_page(self, pdf_path: str, page_index: int = 0) -> Picture.Picture:
"""
Render a single PDF web page to a PIL Picture.
Args:
pdf_path: Path to the PDF file
page_index: Zero-based web page index (0 = first web page)
Returns:
PIL.Picture.Picture in RGB mode, prepared for Gemma 4's processor
Raises:
IndexError: If page_index is out of vary for this PDF
FileNotFoundError: If the PDF path doesn't exist
"""
path = Path(pdf_path)
if not path.exists():
elevate FileNotFoundError(f"PDF not discovered: {pdf_path}")
doc = pymupdf.open(str(path))
if page_index >= len(doc):
doc.shut()
elevate IndexError(
f"Web page index {page_index} out of vary -- "
f"this PDF has {len(doc)} web page(s)"
)
web page = doc[page_index]
# get_pixmap renders the web page on the zoom matrix outlined in __init__.
# The ensuing pixmap accommodates uncooked RGB bytes at self.dpi decision.
pix = web page.get_pixmap(matrix=self._matrix)
doc.shut()
# Convert uncooked bytes to PIL Picture -- that is the format Gemma 4 expects
return Picture.frombytes("RGB", [pix.width, pix.height], pix.samples)
def render_all(self, pdf_path: str) -> listing[Image.Image]:
"""
Render each web page of a PDF to a listing of PIL Pictures.
Returns pages so as: index 0 = first web page, index -1 = final web page.
The listing is absolutely materialized -- for very giant PDFs, use render_range
to course of in chunks.
"""
doc = pymupdf.open(pdf_path)
photos = []
for i in vary(len(doc)):
pix = doc[i].get_pixmap(matrix=self._matrix)
photos.append(Picture.frombytes("RGB", [pix.width, pix.height], pix.samples))
doc.shut()
return photos
def render_range(
self, pdf_path: str, begin: int, finish: int
) -> listing[Image.Image]:
"""
Render a particular web page vary (begin inclusive, finish unique).
Use this for the extraction move within the two-pass pipeline -- solely pages
that the classification move recognized as content-bearing have to be
rendered at excessive decision.
"""
doc = pymupdf.open(pdf_path)
photos = []
finish = min(finish, len(doc)) # Clamp to precise web page rely
for i in vary(begin, finish):
pix = doc[i].get_pixmap(matrix=self._matrix)
photos.append(Picture.frombytes("RGB", [pix.width, pix.height], pix.samples))
doc.shut()
return photos
def page_count(self, pdf_path: str) -> int:
"""Return the variety of pages in a PDF with out rendering any of them."""
doc = pymupdf.open(pdf_path)
rely = len(doc)
doc.shut()
return rely
Methods to run a fast smoke check:
# Fast check -- add this after the category definition and run the file instantly
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Utilization: python pdf_renderer.py ")
sys.exit(1)
pdf_path = sys.argv[1]
renderer = PDFRenderer(dpi=200)
pages = renderer.render_all(pdf_path)
print(f"Rendered {len(pages)} web page(s)")
for i, img in enumerate(pages):
print(f" Web page {i}: {img.width} x {img.top} px")
# Save web page 0 as a PNG for visible inspection
pages[0].save("page_0_preview.png")
print("Saved page_0_preview.png -- confirm it seems to be appropriate earlier than operating inference")
Methods to run:
python pdf_renderer.py your_invoice.pdf
# Loading Gemma 4 and Your First Doc Inference
With the renderer confirmed, right here is the total load-and-query sample. Gemma 4 makes use of Gemma4ForConditionalGeneration because the mannequin class and AutoProcessor for the mixed tokenizer and picture processor.
One essential ordering rule from the official mannequin card: place picture content material earlier than textual content in your immediate. The processor handles this routinely while you observe the message format, however in the event you construct prompts manually, picture first.
# gemma4_loader.py
# Conditions: pip set up transformers>=4.51.0 torch speed up
# Run: python gemma4_loader.py
# First run downloads ~10 GB of weights -- subsequent runs load from cache
import re
import torch
from PIL import Picture
from transformers import AutoProcessor, Gemma4ForConditionalGeneration
MODEL_ID = "google/gemma-4-E4B-it"
# Use "google/gemma-4-E2B-it" for six GB VRAM machines
def load_model(model_id: str = MODEL_ID):
"""
Load Gemma 4 and its processor.
device_map="auto" distributes throughout all obtainable GPUs,
or falls again to CPU if none are discovered.
"""
print(f"Loading {model_id}...")
processor = AutoProcessor.from_pretrained(model_id)
mannequin = Gemma4ForConditionalGeneration.from_pretrained(
model_id,
torch_dtype=torch.bfloat16, # Coaching dtype -- highest quality/reminiscence steadiness
device_map="auto", # Auto-distributes throughout GPUs or falls again to CPU
)
mannequin.eval()
print(f"Mannequin prepared on: {mannequin.gadget}")
return mannequin, processor
def query_document_page(
mannequin,
processor,
page_image: Picture.Picture,
immediate: str,
token_budget: int = 1120,
enable_thinking: bool = False,
max_new_tokens: int = 1024,
) -> str:
"""
Ship a single doc web page picture + textual content immediate to Gemma 4.
Args:
page_image: PIL Picture of the PDF web page (from PDFRenderer)
immediate: What you need extracted or answered about this web page
token_budget: Visible token price range -- 70/140/280/560/1120.
Increased = extra element, extra VRAM, slower inference.
Use 1120 for dense invoices, 280 for fast classification.
enable_thinking: If True, the mannequin causes step-by-step earlier than answering.
Improves accuracy on advanced layouts at the price of latency.
max_new_tokens: Most tokens to generate within the response
Returns:
Mannequin response as a plain string, with block stripped if current
"""
messages = [
{
"role": "user",
"content": [
# Image comes first -- this is required for optimal Gemma 4 performance
{"type": "image", "image": page_image},
{"type": "text", "text": prompt},
],
}
]
# apply_chat_template codecs the messages and injects the visible token price range
inputs = processor.apply_chat_template(
messages,
add_generation_prompt=True,
tokenize=True,
return_dict=True,
return_tensors="pt",
# token_budget controls what number of visible tokens symbolize the picture
# Increased price range = extra spatial element preserved = higher for dense docs
num_image_tokens=token_budget,
# Toggles the mannequin's chain-of-thought reasoning move earlier than the ultimate reply
enable_thinking=enable_thinking,
).to(mannequin.gadget)
with torch.no_grad():
output_ids = mannequin.generate(
**inputs,
max_new_tokens=max_new_tokens,
temperature=0.1, # Low temperature for structured extraction -- deterministic
top_p=0.95,
do_sample=True,
)
# Decode solely the newly generated tokens, not the enter immediate
new_tokens = output_ids[0][inputs["input_ids"].form[-1]:]
uncooked = processor.decode(new_tokens, skip_special_tokens=True).strip()
# Strip the chain-of-thought block when pondering mode was enabled.
# The ... content material is the mannequin's reasoning course of,
# not a part of the structured output. Callers solely want the ultimate reply.
return re.sub(r".*? ", "", uncooked, flags=re.DOTALL).strip()
# ── Fast first check ──────────────────────────────────────────────────────────
if __name__ == "__main__":
from pdf_renderer import PDFRenderer
import sys
if len(sys.argv) < 2:
print("Utilization: python gemma4_loader.py ")
sys.exit(1)
mannequin, processor = load_model()
renderer = PDFRenderer(dpi=200)
# Render the primary web page
page_img = renderer.render_page(sys.argv[1], page_index=0)
print(f"Web page rendered: {page_img.width}x{page_img.top} px")
# Plain description immediate -- good sanity examine earlier than structured extraction
description = query_document_page(
mannequin, processor,
page_image=page_img,
immediate="Describe what you see on this doc. What sort of doc is it and what data does it include?",
token_budget=560,
enable_thinking=False,
)
print("n── Doc description ──")
print(description)
Methods to run:
python gemma4_loader.py your_invoice.pdf
The outline output is your sanity examine. If Gemma 4 appropriately identifies the doc sort and mentions the important thing fields you anticipate to extract, you are prepared for the total pipeline. If it misses essential fields, improve the token price range to 1120 and take a look at once more, the advance is often seen.
# Constructing a Actual-World Bill Extraction Pipeline
That is the total production-grade pipeline. The InvoiceParser class accepts any PDF, renders every web page, runs structured extraction with Gemma 4, parses the JSON output right into a typed ParsedInvoice dataclass, and flags any fields the place extraction was unsure.
# invoice_parser.py
# Conditions: pdf_renderer.py and gemma4_loader.py in the identical listing
# Run: python invoice_parser.py
import re
import json
import torch
from dataclasses import dataclass, area
from pathlib import Path
from typing import Elective
from PIL import Picture
from transformers import AutoProcessor, Gemma4ForConditionalGeneration
from pdf_renderer import PDFRenderer
from gemma4_loader import load_model, query_document_page
MODEL_ID = "google/gemma-4-E4B-it"
# ── Knowledge mannequin ────────────────────────────────────────────────────────────────
@dataclass
class LineItem:
description: str
amount: Elective[float]
unit_price: Elective[str]
whole: Elective[str]
@dataclass
class ParsedInvoice:
vendor_name: Elective[str]
invoice_number: Elective[str]
invoice_date: Elective[str]
due_date: Elective[str]
line_items: listing[LineItem] = area(default_factory=listing)
subtotal: Elective[str] = None
tax: Elective[str] = None
total_due: Elective[str] = None
forex: Elective[str] = None
# Fields the place the mannequin returned None, empty, or "unknown"
# -- route these for human overview
low_confidence_fields: listing[str] = area(default_factory=listing)
raw_output: str = ""
# ── Extraction immediate ─────────────────────────────────────────────────────────
EXTRACTION_PROMPT = """This can be a web page from a provider bill. Extract all obtainable data and return it as a single JSON object.
Required JSON format:
{
"vendor_name": "string or null",
"invoice_number": "string or null",
"invoice_date": "string or null",
"due_date": "string or null",
"line_items": [
{
"description": "string",
"quantity": number or null,
"unit_price": "string or null",
"total": "string or null"
}
],
"subtotal": "string or null",
"tax": "string or null",
"total_due": "string or null",
"forex": "string or null"
}
Guidelines:
- Return ONLY the JSON object -- no preamble, no rationalization, no markdown fences.
- If a area isn't seen on this web page, set it to null.
- Don't invent values. When you can't learn a quantity clearly, set it to null.
- Protect the unique forex image (e.g. $, €, £, ₦) in financial fields.
- Embody ALL line gadgets you may see, even when the desk runs off the seen space."""
# ── Output parser ─────────────────────────────────────────────────────────────
def extract_json_block(textual content: str) -> Elective[dict]:
"""
Discover the primary JSON object within the mannequin's output.
Handles each naked JSON and markdown-fenced ```json ... ``` blocks,
since some mannequin outputs embrace fences regardless of being informed to not.
"""
# Attempt markdown fence first
fence = re.search(r"```(?:json)?s*({.*?})s*```", textual content, re.DOTALL)
if fence:
attempt:
return json.masses(fence.group(1))
besides json.JSONDecodeError:
move
# Fall again to any naked JSON object within the output
naked = re.search(r"({.*})", textual content, re.DOTALL)
if naked:
attempt:
return json.masses(naked.group(1))
besides json.JSONDecodeError:
move
return None
def build_parsed_invoice(raw_output: str) -> ParsedInvoice:
"""
Convert uncooked mannequin output to a typed ParsedInvoice.
By no means raises -- fields that can not be parsed default to None
and are added to low_confidence_fields for downstream dealing with.
"""
information = extract_json_block(raw_output)
if information is None:
return ParsedInvoice(
vendor_name=None, invoice_number=None,
invoice_date=None, due_date=None,
low_confidence_fields=["all_fields -- JSON parse failed"],
raw_output=raw_output,
)
low_conf = []
def safe_str(key: str) -> Elective[str]:
"""Extract a string area; add to low_confidence if lacking or placeholder."""
val = information.get(key)
if val is None or str(val).strip().decrease() in ("", "null", "unknown", "n/a"):
low_conf.append(key)
return None
return str(val).strip()
# Parse line gadgets from the JSON array
gadgets = []
for merchandise in information.get("line_items", []):
if not isinstance(merchandise, dict):
proceed
qty = merchandise.get("amount")
gadgets.append(LineItem(
description=str(merchandise.get("description", "")).strip(),
amount=float(qty) if qty isn't None else None,
unit_price=merchandise.get("unit_price"),
whole=merchandise.get("whole"),
))
return ParsedInvoice(
vendor_name=safe_str("vendor_name"),
invoice_number=safe_str("invoice_number"),
invoice_date=safe_str("invoice_date"),
due_date=safe_str("due_date"),
line_items=gadgets,
subtotal=safe_str("subtotal"),
tax=safe_str("tax"),
total_due=safe_str("total_due"),
forex=safe_str("forex"),
low_confidence_fields=low_conf,
raw_output=raw_output,
)
# ── Bill Parser ────────────────────────────────────────────────────────────
class InvoiceParser:
"""
Finish-to-end native bill extraction utilizing Gemma 4.
Processes every PDF web page as a picture -- works on scanned and digital PDFs alike.
"""
def __init__(self, model_id: str = MODEL_ID, dpi: int = 200):
self.mannequin, self.processor = load_model(model_id)
self.renderer = PDFRenderer(dpi=dpi)
def parse(self, pdf_path: str, token_budget: int = 1120) -> ParsedInvoice:
"""
Parse a single bill PDF.
For multi-page invoices, extracts from all pages and merges outcomes,
with later pages filling in fields not discovered on earlier pages.
Args:
pdf_path: Path to the bill PDF
token_budget: Visible token price range per web page (560 or 1120 advisable)
Returns:
ParsedInvoice with all extracted fields and low_confidence_fields listing
"""
path = Path(pdf_path)
if not path.exists():
elevate FileNotFoundError(f"File not discovered: {pdf_path}")
pages = self.renderer.render_all(pdf_path)
print(f"Processing {len(pages)} web page(s) from {path.identify}...")
merged: Elective[ParsedInvoice] = None
for i, page_img in enumerate(pages):
print(f" Extracting web page {i + 1}/{len(pages)}...")
uncooked = query_document_page(
self.mannequin, self.processor,
page_image=page_img,
immediate=EXTRACTION_PROMPT,
token_budget=token_budget,
enable_thinking=False, # Use pondering=True for advanced multi-column layouts
)
page_result = build_parsed_invoice(uncooked)
if merged is None:
merged = page_result
else:
# Merge: later pages fill in fields that had been null on earlier pages.
# Line gadgets are all the time collected throughout pages (handles multi-page tables).
merged = self._merge(merged, page_result)
return merged or ParsedInvoice(
vendor_name=None, invoice_number=None,
invoice_date=None, due_date=None,
low_confidence_fields=["no_pages_processed"],
)
def _merge(self, base: ParsedInvoice, replace: ParsedInvoice) -> ParsedInvoice:
"""
Merge two ParsedInvoice outcomes.
Scalar fields: maintain base worth until it is None (replace fills in).
line_items: accumulate from each pages.
low_confidence_fields: intersection of each -- a area is just "assured"
if it was discovered on at the least one web page.
"""
def decide(a, b):
return a if a isn't None else b
all_low_conf = listing(set(base.low_confidence_fields) & set(replace.low_confidence_fields))
return ParsedInvoice(
vendor_name=decide(base.vendor_name, replace.vendor_name),
invoice_number=decide(base.invoice_number, replace.invoice_number),
invoice_date=decide(base.invoice_date, replace.invoice_date),
due_date=decide(base.due_date, replace.due_date),
line_items=base.line_items + replace.line_items,
subtotal=decide(base.subtotal, replace.subtotal),
tax=decide(base.tax, replace.tax),
total_due=decide(base.total_due, replace.total_due),
forex=decide(base.forex, replace.forex),
low_confidence_fields=all_low_conf,
raw_output=base.raw_output + "n---page---n" + replace.raw_output,
)
def parse_directory(self, dir_path: str, token_budget: int = 1120) -> dict[str, ParsedInvoice]:
"""
Batch-process all PDFs in a listing.
Returns a dict mapping filename -> ParsedInvoice.
Failed recordsdata are logged and skipped somewhat than elevating.
"""
outcomes = {}
pdfs = listing(Path(dir_path).glob("*.pdf"))
print(f"Discovered {len(pdfs)} PDF(s) in {dir_path}")
for pdf_path in pdfs:
attempt:
outcomes[pdf_path.name] = self.parse(str(pdf_path), token_budget)
standing = "OK" if not outcomes[pdf_path.name].low_confidence_fields else
f"LOW CONF: {outcomes[pdf_path.name].low_confidence_fields}"
print(f" {pdf_path.identify}: {standing}")
besides Exception as e:
print(f" {pdf_path.identify}: FAILED -- {e}")
return outcomes
# ── Run it ────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Utilization: python invoice_parser.py ")
sys.exit(1)
parser = InvoiceParser()
outcome = parser.parse(sys.argv[1])
print("n── Extracted Bill ──")
print(f"Vendor : {outcome.vendor_name}")
print(f"Bill # : {outcome.invoice_number}")
print(f"Bill Date : {outcome.invoice_date}")
print(f"Due Date : {outcome.due_date}")
print(f"Foreign money : {outcome.forex}")
print(f"Whole Due : {outcome.total_due}")
print(f"Line Objects : {len(outcome.line_items)}")
for merchandise in outcome.line_items:
print(f" - {merchandise.description}: qty={merchandise.amount}, unit={merchandise.unit_price}, whole={merchandise.whole}")
if outcome.low_confidence_fields:
print(f"n⚠ Low-confidence fields (overview manually): {outcome.low_confidence_fields}")
Methods to run:
python invoice_parser.py supplier_invoice.pdf
Methods to run the batch listing processor:
parser = InvoiceParser()
outcomes = parser.parse_directory("./invoices/")
# outcomes is a dict: {"invoice_001.pdf": ParsedInvoice, "invoice_002.pdf": ParsedInvoice, ...}
The low_confidence_fields listing is your routing sign. Any bill the place it is non-empty will get flagged for human overview. Any bill the place it is empty may be dedicated to your accounting system routinely.
# Optimizing Token Budgets for Multi-Web page Paperwork
A five-page provider bill usually breaks down as: cowl web page, two pages of line gadgets, a totals and cost web page, and a phrases and situations web page. The quilt and T&C pages haven’t any extractable structured information. Working the total 1120-token extraction move on all 5 pages wastes roughly 40% of your inference price range.
The 2-pass sample fixes this: a fast 280-token classification move first to establish which pages are value processing, then the total 1120-token extraction move solely on these pages.
# two_pass_pipeline.py
# Conditions: pdf_renderer.py, gemma4_loader.py, and invoice_parser.py in the identical listing
from pdf_renderer import PDFRenderer
from gemma4_loader import load_model, query_document_page
from invoice_parser import EXTRACTION_PROMPT
CLASSIFICATION_PROMPT = """Take a look at this doc web page and classify it with a single label.
Select precisely one:
- invoice_header (firm logos, vendor handle, bill quantity, date)
- line_items (desk of merchandise/companies with portions and costs)
- totals (subtotals, taxes, grand whole, cost directions)
- phrases (phrases and situations, authorized textual content, return coverage)
- cowl (title web page, desk of contents, doc cowl)
- clean (empty or practically empty web page)
- different (anything)
Reply with ONLY the label -- no rationalization, no punctuation."""
EXTRACTABLE_CLASSES = {"invoice_header", "line_items", "totals"}
def two_pass_parse(pdf_path: str, mannequin, processor) -> dict:
"""
Two-pass bill extraction pipeline.
Go 1 (token_budget=280): Classify every web page cheaply.
Go 2 (token_budget=1120): Full extraction solely on content material pages.
Returns a dict with extracted fields and page-level classification metadata.
"""
renderer_fast = PDFRenderer(dpi=150) # Decrease DPI for classification -- sooner
renderer_full = PDFRenderer(dpi=200) # Full DPI for extraction
# ── Go 1: Classify all pages at 280-token price range ─────────────────────
print("Go 1: Web page classification...")
fast_pages = renderer_fast.render_all(pdf_path)
page_labels = {}
for i, page_img in enumerate(fast_pages):
label = query_document_page(
mannequin, processor,
page_image=page_img,
immediate=CLASSIFICATION_PROMPT,
token_budget=280, # Low-cost move -- simply must learn the web page sort
enable_thinking=False,
max_new_tokens=16, # Classification response is all the time brief
).strip().decrease()
page_labels[i] = label
standing = "EXTRACT" if label in EXTRACTABLE_CLASSES else "skip"
print(f" Web page {i}: '{label}' -> {standing}")
extraction_pages = [i for i, l in page_labels.items() if l in EXTRACTABLE_CLASSES]
if not extraction_pages:
print("No extractable pages discovered -- falling again to full-document extraction")
extraction_pages = listing(vary(len(fast_pages)))
skipped = len(fast_pages) - len(extraction_pages)
print(f"nPass 1 full: {len(extraction_pages)} pages to extract, "
f"{skipped} skipped ({skipped/len(fast_pages)*100:.0f}% token saving)")
# ── Go 2: Full extraction at 1120-token price range ────────────────────────
print("nPass 2: Full extraction...")
full_pages = renderer_full.render_all(pdf_path)
extracted_outputs = []
for i in extraction_pages:
print(f" Extracting web page {i} ({page_labels[i]})...")
uncooked = query_document_page(
mannequin, processor,
page_image=full_pages[i],
immediate=EXTRACTION_PROMPT,
token_budget=1120,
enable_thinking=False,
max_new_tokens=1024,
)
extracted_outputs.append({"web page": i, "label": page_labels[i], "output": uncooked})
return {
"page_labels": page_labels,
"extraction_pages": extraction_pages,
"outputs": extracted_outputs,
}
Methods to run:
mannequin, processor = load_model()
outcome = two_pass_parse("supplier_invoice_5pages.pdf", mannequin, processor)
On a typical 5-page bill, the two-pass strategy reduces costly inference calls from 5 to three, slicing whole processing time by 35–40% with no loss in extraction high quality.
# Enabling Considering Mode for Complicated Layouts
Most invoices are easy sufficient that enable_thinking=False is the suitable alternative; it is sooner, and the output is instantly structured JSON. However some paperwork genuinely want the reasoning move: two-column layouts the place the spatial relationships are ambiguous, handwritten varieties, scanned paperwork with rotation or skew, tables with merged or spanned cells.
Whenever you flip pondering on by setting enable_thinking=True in query_document_page, Gemma 4 generates a chain-of-thought reasoning hint inside tags earlier than producing the ultimate reply. For a fancy desk, it’d work via “the header row seems to span each columns, under which I see three information rows…” earlier than committing to the structured JSON. That reasoning step is what bumps extraction accuracy on tough layouts.
# Allow pondering mode for advanced paperwork
outcome = query_document_page(
mannequin, processor,
page_image=page_img,
immediate=EXTRACTION_PROMPT,
token_budget=1120,
enable_thinking=True, # Provides reasoning hint earlier than structured output
max_new_tokens=2048, # Considering outputs are longer -- improve the price range
)
# The ... block is already stripped by query_document_page
# outcome accommodates solely the ultimate JSON reply
The latency value is actual; pondering mode usually generates 2 to 4 occasions as many tokens earlier than arriving on the reply. The sample that works properly in observe: run enable_thinking=False first. If low_confidence_fields within the result’s non-empty for vital fields (vendor identify, whole due, bill quantity), retry that web page with enable_thinking=True. This retains the quick path quick and solely pays the pondering value when the primary move indicators uncertainty.
# Validating and Publish-Processing Structured Output
The extraction and parsing code in invoice_parser.py already handles the commonest failure modes: JSON parse errors, lacking fields, placeholder values. The low_confidence_fields listing is the sign {that a} human ought to take a look at a particular bill.
For manufacturing use, add a Pydantic validation layer on prime of ParsedInvoice to implement enterprise guidelines that the mannequin can’t know:
# validation.py
# pip set up pydantic>=2.0
from pydantic import BaseModel, field_validator
from typing import Elective
import re
class InvoiceValidator(BaseModel):
"""
Enterprise-rule validation on prime of uncooked ParsedInvoice output.
Use this earlier than committing to an accounting system.
"""
vendor_name: Elective[str]
invoice_number: Elective[str]
invoice_date: Elective[str]
due_date: Elective[str]
total_due: Elective[str]
forex: Elective[str]
@field_validator("invoice_number")
@classmethod
def invoice_number_format(cls, v):
"""Flag bill numbers that appear to be they had been hallucinated."""
if v and never re.match(r"^[A-Z0-9-/.]+$", v.strip()):
elevate ValueError(f"Surprising bill quantity format: {v!r}")
return v
@field_validator("total_due")
@classmethod
def total_due_has_value(cls, v):
"""Invoices with out a total_due ought to by no means auto-commit."""
if not v:
elevate ValueError("total_due is required for automated processing")
return v
@field_validator("forex")
@classmethod
def currency_is_known(cls, v):
KNOWN = {"USD", "EUR", "GBP", "NGN", "CAD", "AUD", "JPY", "CNY"}
if v and v.higher() not in KNOWN:
elevate ValueError(f"Unrecognized forex: {v!r}")
return v.higher() if v else v
def validate_for_commit(bill) -> tuple[bool, list[str]]:
"""
Validate a ParsedInvoice earlier than committing to accounting system.
Returns (can_commit, list_of_errors).
"""
errors = []
attempt:
InvoiceValidator(
vendor_name=bill.vendor_name,
invoice_number=bill.invoice_number,
invoice_date=bill.invoice_date,
due_date=bill.due_date,
total_due=bill.total_due,
forex=bill.forex,
)
besides Exception as e:
errors = [str(err) for err in e.errors()] if hasattr(e, "errors") else [str(e)]
# Additionally block commit if any vital fields are low-confidence
vital = {"vendor_name", "invoice_number", "total_due"}
low_critical = vital & set(bill.low_confidence_fields)
if low_critical:
errors.append(f"Low-confidence on vital fields: {low_critical}")
return len(errors) == 0, errors
The three-tier consequence:
can_commit=True, errors=[]: all vital fields extracted cleanly, enterprise guidelines move, path to accounting system routinely.can_commit=False, low_confidence_fields non-empty: the mannequin flagged uncertainty. Path to human overview queue with the uncooked PDF and extracted fields facet by facet.can_commit=False, validation error: the extracted information violates a enterprise rule. Path to exception dealing with.
# Conclusion
Textual content extraction instruments require selectable textual content. Imaginative and prescient-language fashions require a legible picture. The primary assumption fails on roughly a 3rd of real-world paperwork — scanned invoices, photographed receipts, printed varieties. The second assumption holds throughout every part.
Treating PDFs as photos and feeding these photos to Gemma 4 dissolves the scanned-versus-digital distinction that makes each text-extraction pipeline fragile. The pipeline on this article works on a laser-printed bill and a low-resolution fax scan with zero configuration adjustments between them.
Gemma 4’s spatial place embeddings and variable token price range offer you direct management over the accuracy-versus-speed trade-off. Begin with 560 tokens and enable_thinking=False. Add the two-pass classification for multi-page paperwork. Escalate to pondering mode and 1120 tokens for the precise pages the place low_confidence_fields indicators uncertainty.
The pipeline runs absolutely regionally beneath Apache 2.0. No API key, no utilization meter, no information leaving your server, which issues for something touching monetary paperwork.
Shittu Olumide is a software program engineer and technical author enthusiastic about leveraging cutting-edge applied sciences to craft compelling narratives, with a eager eye for element and a knack for simplifying advanced ideas. You too can discover Shittu on Twitter.
