Verify AI-Extracted Values Against the PDF

An LLM pulls fields out of an invoice or contract. Before anyone relies on them, check each value against the document and show where it is printed.

November 13, 20269 min readIntermediateAICitationsPDFGroundingLLM

Take the fields an LLM extracted from an invoice, find every value in the PDF, check that it is printed next to the right label, and mark each one as verified or wrong.

Companion notebook for the article Verify AI-Extracted Values Against the PDF in the PyMuPDF grounding series. Tested with PyMuPDF 2.1.

%pip install -q pymupdf
Output
[notice] A new release of pip is available: 26.0.1 -> 26.3
[notice] To update, run: pip install --upgrade pip
Note: you may need to restart the kernel to use updated packages.

Sample document

Creates the sample PDF used in the article. Replace the path with your own PDF to try it on real documents.

import pymupdf
doc = pymupdf.open(); p = doc.new_page()
B, R = "hebo", "helv"
p.insert_text((72, 80), "Northwind Cloud Services Ltd.", fontsize=16, fontname=B)
p.insert_text((72, 98), "12 Harbour Street, London EC2A 4BX, United Kingdom", fontsize=9, fontname=R)
p.insert_text((400, 80), "INVOICE", fontsize=20, fontname=B)
labels = [("Invoice number:", "INV-2026-0417"), ("Invoice date:", "October 15, 2026"),
          ("Due date:", "November 14, 2026"), ("PO number:", "PO-88213")]
y = 120
for l, v in labels:
    p.insert_text((360, y), l, fontsize=10, fontname=B); p.insert_text((450, y), v, fontsize=10, fontname=R); y += 16
p.insert_text((72, 140), "Bill to:", fontsize=10, fontname=B)
p.insert_text((72, 156), "Acme Corporation", fontsize=10, fontname=R)
p.insert_text((72, 170), "500 Market Street, San Francisco, CA 94105", fontsize=10, fontname=R)
rows = [["Description", "Qty", "Unit price", "Amount"],
        ["Cloud hosting, October 2026", "1", "8,400.00", "8,400.00"],
        ["Managed database service", "3", "1,250.00", "3,750.00"],
        ["Premium support plan", "1", "1,200.00", "1,200.00"]]
x = [72, 312, 362, 442, 522]; y0, h = 210, 20
for i, row in enumerate(rows):
    for j, cell in enumerate(row):
        r = pymupdf.Rect(x[j], y0 + i*h, x[j+1], y0 + (i+1)*h)
        p.draw_rect(r, color=(0.6, 0.6, 0.6), width=0.5)
        font = B if i == 0 else R
        w = pymupdf.get_text_length(cell, fontname=font, fontsize=9)
        xpos = r.x0 + 4 if j == 0 else r.x1 - 4 - w
        p.insert_text((xpos, r.y1 - 6), cell, fontsize=9, fontname=font)
tot = [("Subtotal", "13,350.00"), ("VAT (20%)", "2,670.00"), ("Total due (GBP)", "16,020.00")]
y = 310
for l, v in tot:
    font = B if l.startswith("Total") else R
    p.insert_text((362, y), l, fontsize=10, fontname=font)
    w = pymupdf.get_text_length(v, fontname=font, fontsize=10)
    p.insert_text((518 - w, y), v, fontsize=10, fontname=font); y += 18
p.insert_text((72, 400), "Payment terms: 30 days. Bank: Barclays, sort code 20-00-00, account 43210987.", fontsize=9, fontname=R)
doc.save("invoice.pdf")

Grounding helpers

locate() comes from the first article in the series, Cite Claude's Answers at the Exact Spot in the PDF. It is also part of the PyMuPDF grounding skill.

import re
import pymupdf


def locate(page, cited_text):
    """Return the quads of cited_text on a page, or [] if not found."""
    text = " ".join(cited_text.split())          # collapse whitespace and line breaks
    quads = page.search_for(text, quads=True)    # case-insensitive, joins hyphenated words
    if quads:
        return quads
    return _locate_by_words(page, text)          # fallback for small differences


def _norm(word):
    return re.sub(r"[^\w%$.]", "", word).lower().rstrip(".")


def _locate_by_words(page, text):
    """Match the quote word by word, ignoring punctuation, case and hyphenation."""
    target = [_norm(w) for w in text.split() if _norm(w)]
    words = page.get_text("words", flags=pymupdf.TEXT_DEHYPHENATE)
    tokens = [(_norm(w[4]), pymupdf.Rect(w[:4])) for w in words]
    tokens = [t for t in tokens if t[0]]
    n = len(target)
    for i in range(len(tokens) - n + 1):
        if all(tokens[i + k][0] == target[k] for k in range(n)):
            return [r.quad for _, r in tokens[i:i + n]]
    return []

Step 1: Extract the fields

Use any model and any extraction method. Ask for dates in ISO format and amounts as plain numbers, so they can be compared reliably:

Extract these fields from the invoice and answer with JSON only: vendor, invoice_number, invoice_date, due_date, po_number, subtotal, tax, total, bank_account, vat_number. Write dates as YYYY-MM-DD and amounts as plain numbers. Use null for missing fields.

For the sample invoice, the model returned the following. Two values are wrong: the total has two digits swapped (the invoice says 16,020.00), and the VAT number does not appear on the invoice at all.

extracted = {                       # the model's answer
    "vendor": "Northwind Cloud Services Ltd.",
    "invoice_number": "INV-2026-0417",
    "invoice_date": "2026-10-15",
    "due_date": "2026-11-14",
    "po_number": "PO-88213",
    "subtotal": 13350.00,
    "tax": 2670.00,
    "total": 16200.00,
    "bank_account": "43210987",
    "vat_number": "GB 123 4567 89",
}

The same check works on JSON from any extractor, including PDF.co's AI Invoice Parser: map its field names to the labels printed on your invoices.

Step 2: Find values by type

A value can be printed differently from how it was extracted. "16020.0" is printed as "16,020.00", and "2026-10-15" as "October 15, 2026". So each field is compared by its kind: text is located with the locate() function from the first article in this series, amounts are compared as numbers, and dates are compared as dates.

import re
from datetime import date, datetime

import pymupdf

DATE_PATTERNS = [
    (r"[A-Z][a-z]+ \d{1,2}, \d{4}", "%B %d, %Y"),     # October 15, 2026
    (r"\d{1,2} [A-Z][a-z]+ \d{4}", "%d %B %Y"),       # 15 October 2026
    (r"\d{4}-\d{2}-\d{2}", "%Y-%m-%d"),               # 2026-10-15
    (r"\d{1,2}/\d{1,2}/\d{4}", "%m/%d/%Y"),           # 10/15/2026 (US order)
    (r"\d{1,2}\.\d{1,2}\.\d{4}", "%d.%m.%Y"),         # 15.10.2026
]

def to_number(text):
    """'16,020.00' -> 16020.0, '(52)' -> -52.0, '$1,200' -> 1200.0; None if not a number."""
    t = re.sub(r"[,$€£\s]", "", str(text))
    if re.fullmatch(r"\(\d+(\.\d+)?\)", t):
        t = "-" + t[1:-1]
    try:
        return float(t)
    except ValueError:
        return None

def find_amount(page, value):
    """Rects of words on the page whose numeric value equals value."""
    target = to_number(value)
    return [pymupdf.Rect(w[:4]) for w in page.get_text("words")
            if (n := to_number(w[4])) is not None and abs(n - target) < 0.005]

def find_dates(page):
    """Every date printed on the page, as (date, rect)."""
    found = []
    for block in page.get_text("dict")["blocks"]:
        for line in block.get("lines", []):
            text = "".join(span["text"] for span in line["spans"])
            for pattern, fmt in DATE_PATTERNS:
                for m in re.finditer(pattern, text):
                    try:
                        d = datetime.strptime(m.group(), fmt).date()
                    except ValueError:
                        continue
                    for rect in page.search_for(m.group(), clip=line["bbox"]):
                        found.append((d, rect))
    return found

def find_value(page, value, kind):
    """Rects where value appears on the page, comparing by kind: text, amount or date."""
    if kind == "amount":
        return find_amount(page, value)
    if kind == "date":
        target = date.fromisoformat(value)
        return [rect for d, rect in find_dates(page) if d == target]
    quads = locate(page, str(value))
    return [q.rect for q in quads]

Step 3: Check each value next to its label

Finding a value somewhere on the page is not enough. On the sample invoice, 1,200.00 appears twice in the line items: if the model had returned 1,200.00 as the tax, a plain search would confirm it. So when a field has a label, the value must be printed next to that label. If a different value is printed there, the field is a mismatch, and we report what the document actually says.

def same_line_right(rect, label):
    """True if rect is on the label's line, to its right."""
    middle = (rect.y0 + rect.y1) / 2
    return rect.x0 >= label.x1 - 1 and label.y0 <= middle <= label.y1

def printed_next_to(page, label_rect):
    """The text printed to the right of a label on the same line, with its rect."""
    words = [w for w in page.get_text("words") if same_line_right(pymupdf.Rect(w[:4]), label_rect)]
    if not words:
        return None, None
    rect = pymupdf.Rect()
    for w in words:
        rect |= pymupdf.Rect(w[:4])
    return " ".join(w[4] for w in words), rect

def verify_field(doc, name, value, kind="text", label=None):
    """Check one extracted value against the document.

    With a label, the value must be printed next to that label:
      'verified'  - the value is printed next to the label
      'mismatch'  - a different value is printed next to the label
    Without a label, the value only has to appear somewhere in the document.
    'not found' means neither the value nor the label was found.
    """
    elsewhere = None
    for page in doc:
        rects = find_value(page, value, kind)
        labels = page.search_for(label) if label else []
        if label and labels:
            near = [r for r in rects if same_line_right(r, labels[0])]
            if near:
                return {"field": name, "value": value, "status": "verified",
                        "page": page.number + 1, "rect": near[0]}
            printed, rect = printed_next_to(page, labels[0])
            return {"field": name, "value": value, "status": "mismatch",
                    "printed": printed, "page": page.number + 1, "rect": rect,
                    "found_elsewhere": bool(rects)}
        if rects and elsewhere is None:
            elsewhere = {"field": name, "value": value, "status": "verified",
                         "page": page.number + 1, "rect": rects[0]}
    if elsewhere:                                  # value found, but its label was not
        if label:
            elsewhere["note"] = "label not found"
        return elsewhere
    return {"field": name, "value": value, "status": "not found"}

The schema says what kind each field is and which label it sits next to. Fields without a fixed label, such as the vendor name, only have to appear somewhere in the document.

schema = {                          # field: (kind, label printed next to the value)
    "vendor": ("text", None),
    "invoice_number": ("text", "Invoice number:"),
    "invoice_date": ("date", "Invoice date:"),
    "due_date": ("date", "Due date:"),
    "po_number": ("text", "PO number:"),
    "subtotal": ("amount", "Subtotal"),
    "tax": ("amount", "VAT (20%)"),
    "total": ("amount", "Total due (GBP)"),
    "bank_account": ("text", None),
    "vat_number": ("text", "VAT number"),
}

doc = pymupdf.open("invoice.pdf")
results = [verify_field(doc, name, value, *schema[name]) for name, value in extracted.items()]
for r in results:
    extra = f'  printed: {r["printed"]}' if r["status"] == "mismatch" else ""
    where = f'page {r["page"]}' if "page" in r else ""
    print(f'{r["field"]:15} {str(r["value"]):30} {r["status"]:10} {where}{extra}')
Output
vendor          Northwind Cloud Services Ltd.  verified   page 1
invoice_number  INV-2026-0417                  verified   page 1
invoice_date    2026-10-15                     verified   page 1
due_date        2026-11-14                     verified   page 1
po_number       PO-88213                       verified   page 1
subtotal        13350.0                        verified   page 1
tax             2670.0                         verified   page 1
total           16200.0                        mismatch   page 1  printed: 16,020.00
bank_account    43210987                       verified   page 1
vat_number      GB 123 4567 89                 not found

Eight values are confirmed. The total is caught as a mismatch, with the correct figure from the invoice. The VAT number is reported as not found, because neither the value nor a "VAT number" label is on the invoice.

Step 4: Mark the results on the invoice

A reviewer should see the result on the document itself. This marks each checked value with a green or red box and attaches a note with the extracted and printed values:

COLORS = {"verified": (0, 0.6, 0), "mismatch": (0.85, 0, 0)}

def mark_results(doc, results):
    """Box each checked value: green if verified, red if it does not match, with a note."""
    for r in results:
        if "rect" not in r:
            continue
        page = doc[r["page"] - 1]
        annot = page.add_rect_annot(r["rect"] + (-2, -2, 2, 2))
        annot.set_colors(stroke=COLORS[r["status"]])
        annot.set_border(width=1.2)
        note = f'{r["field"]}: extracted {r["value"]}'
        if r["status"] == "mismatch":
            note += f', document says {r["printed"]}'
        annot.set_info(content=note)
        annot.update()

mark_results(doc, results)
doc.save("invoice_checked.pdf")             # a new file; the original is unchanged
from IPython.display import Image, display
display(Image(pymupdf.open("invoice_checked.pdf")[0].get_pixmap(dpi=100).tobytes("png")))
Output
Cell output

Going further

  • Check the arithmetic. Once the values are verified against the document, check that they are consistent with each other:
values = {r["field"]: r["value"] for r in results if r["status"] == "verified"}
if {"subtotal", "tax", "total"} <= values.keys():
    if abs(values["subtotal"] + values["tax"] - values["total"]) > 0.005:
        print("Totals do not add up")
  • Line items. For tables of line items, use the cell lookup from the table article in this series to check each row by its description and column.
  • Scanned invoices. Combine this with the OCR article: report which verified values were read from a scan, so the reviewer can check them against the image.
  • An evidence pack. Feed the results into the evidence pack from earlier in this series to produce one file per invoice for the audit trail.

Limits to know

  • The label check assumes the value is printed to the right of its label, on the same line. Layouts with values under their labels need a small extension of same_line_right().
  • to_number() reads US and UK number formats. European formats such as 1.234,56 need their own parser, and so do dates such as 03/04/2026, where the order of day and month depends on the country.
  • Labels vary between vendors. Keep a small label list per vendor or document type, or let the LLM return the label it used for each field.
  • A verified value is printed in the document; that does not make it correct. The check confirms that the extraction matches the invoice, not that the invoice is right.

Next steps


Discuss This Article with the Community

Have a question, a different approach, or something you built after reading this? Share it on the forum or join the Discord, we'd love to hear from you.

PyMuPDF Logo

© 2026 Artifex Software Inc. All rights reserved.