Cite the Table Cell, Not the Page
November 13, 2026

When an AI answer quotes a number from a financial table, point to the exact cell, with its row and column, instead of the whole page.
Most figures in annual reports, filings and invoices live in tables. When an AI answer says "operating income was $754 million in 2025", a citation to page 47 is not much help: page 47 may hold a table of fifty numbers, and the reader still has to find the right row and column. Searching for "754" is no better, because the same number can appear several times on a page.
A number only means something together with its labels: which line item, which year. In this article we use PyMuPDF to find the exact table cell behind a figure, read its row and column labels, check a claimed value against it, and show the cell in its table.
Step 1: Read the table cells
Page.find_tables() detects the tables on a page. Each table has rows, and each row has the bounding box of every cell. We read each cell's text with Page.get_text(clip=...), so the text matches what the rest of PyMuPDF returns for that area of the page. Merged cells appear as None.
import re
import pymupdf
def table_grid(page, table):
"""Text and bbox of every cell in a table, as a list of rows."""
grid = []
for row in table.rows:
cells = []
for bbox in row.cells:
if bbox is None: # part of a merged cell
cells.append(None)
continue
rect = pymupdf.Rect(bbox)
text = " ".join(page.get_text("text", clip=rect).split())
cells.append({"text": text, "bbox": rect})
grid.append(cells)
return gridStep 2: Find a value and its labels
Numbers in tables come in many formats: "4,100", "$1,234.5", "12%", or "(52)" for a negative value. Comparing them as numbers avoids false misses. For each matching cell we return its position, its bounding box, and the labels in the first column and the header row.
def to_number(text):
"""'4,100' -> 4100.0, '(52)' -> -52.0, '12%' -> 12.0; None if not a number."""
t = text.replace(",", "").replace("$", "").replace("%", "").strip()
if re.fullmatch(r"\(\d+(\.\d+)?\)", t):
t = "-" + t[1:-1]
try:
return float(t)
except ValueError:
return Nonedef _labels(grid, r, c):
row_label = grid[r][0]["text"] if grid[r][0] else ""
col_label = grid[0][c]["text"] if grid[0][c] else ""
return row_label, col_labeldef find_value(page, value):
"""Every table cell on the page that holds value, with its row and column labels."""
target = to_number(str(value))
hits = []
for t_index, table in enumerate(page.find_tables().tables):
grid = table_grid(page, table)
for r, row in enumerate(grid):
for c, cell in enumerate(row):
if cell is None:
continue
n = to_number(cell["text"])
same = (n == target) if target is not None else cell["text"] == str(value)
if same:
row_label, col_label = _labels(grid, r, c)
hits.append({"table": t_index, "row": r, "col": c, "text": cell["text"],
"bbox": cell["bbox"], "row_label": row_label,
"col_label": col_label, "table_bbox": pymupdf.Rect(table.bbox)})
return hitsdoc = pymupdf.open("acme_financials.pdf")
page = doc[0]
for hit in find_value(page, "754"):
print(f'{hit["text"]}: {hit["row_label"]}, {hit["col_label"]} (page {page.number + 1}, table {hit["table"] + 1})')Output:
754: Operating income, 2025 (page 1, table 1)If a value appears in several cells, you get every match with its labels, and the labels tell you which one the answer meant.
Step 3: Check a claimed figure by its labels
To verify an answer, go the other way: look up the cell where a row label and a column label meet, and compare the value.
def lookup(page, row_label, col_label):
"""The cell where a row label and a column label meet, or None."""
for t_index, table in enumerate(page.find_tables().tables):
grid = table_grid(page, table)
header = [cell["text"].lower() if cell else "" for cell in grid[0]]
cols = [c for c, h in enumerate(header) if col_label.lower() in h]
for r, row in enumerate(grid[1:], start=1):
if row[0] and row_label.lower() in row[0]["text"].lower():
for c in cols:
if row[c]:
return {"table": t_index, "row": r, "col": c, "text": row[c]["text"],
"bbox": row[c]["bbox"], "row_label": row[0]["text"],
"col_label": grid[0][c]["text"], "table_bbox": pymupdf.Rect(table.bbox)}
return Nonecell = lookup(page, "Net revenue", "2025")
claimed = 4100 # "$4.1 billion", in the table's unit ($ millions)
print(cell["text"], to_number(cell["text"]) == claimed) # 4,100 True
cell = lookup(page, "Operating income", "2025")
print(cell["text"], to_number(cell["text"]) == 760) # 754 False: flag for reviewUnits usually come from the table's caption, such as "in $ millions" in the sample report. Convert the claimed figure to the table's unit before comparing.
Step 4: Show the cell in its table
A reader checks a figure fastest when they see it in context: the whole table, with the cited cell marked. This function draws a temporary marker on the cell, renders the table and removes the marker again, so the document is left unchanged.
def cell_image(page, hit, dpi=150, margin=8):
"""Render the whole table with the cited cell highlighted; the page is left unchanged."""
annot = page.add_rect_annot(hit["bbox"]) # temporary marker on the cell
annot.set_colors(stroke=(0.9, 0.5, 0), fill=(1, 0.9, 0))
annot.set_border(width=1.5)
annot.set_opacity(0.5)
annot.update()
clip = (hit["table_bbox"] + (-margin, -margin, margin, margin)) & page.rect
pix = page.get_pixmap(clip=clip, dpi=dpi)
page.delete_annot(annot)
return pixcell_image(page, lookup(page, "Net revenue", "2025")).save("evidence_cell.png")Output:

Connecting it to an AI answer
Text citations work well for sentences, but they suit tables less well: a model quoting a table often reproduces a flattened row of text. Two approaches work better for figures.
- Use the cited page as a filter. With Claude citations, take the page from
start_page_numberand callfind_value()on that page only. The labels of each match show whether it is the figure the answer meant. - Ask the model for table references. Add an instruction such as the one below to your prompt, then call
lookup()for each reference and compare the values.
Sample prompt:
For every figure you report from a table, add a line in this form:
TABLE: page=<page number> | row=<row label> | column=<column label> | value=<value as printed>Limits to know
- Table detection works best on tables with ruling lines. For tables without lines, check the results, and try
page.find_tables(strategy="text"). - Labels come from the first column and the first row. Tables with multi-level headers or labels spread over several cells need extra handling.
- Merged cells return
Nonefor the covered positions. - Units and scale (thousands, millions) are in captions and footnotes, not in the cells. Handle them before comparing values.
Why this matters
Errors in figures are the costliest errors an AI answer can make in finance, audit and legal work, and they are the hardest for a reader to spot. Pointing to the exact cell, with its row and column, lets a reviewer confirm a number in seconds, and checking the value by its labels catches a wrong figure before anyone reads it.
Next steps
- Get the complete notebook from the PyMuPDF cookbook repository.
- Read the first article in the series: Cite Claude's answers at the exact spot in the PDF.
- See everything on grounding with PyMuPDF | Grounding.
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.