Cite Claude Answers
Sends a PDF to Claude with citations enabled, locates each cited passage on the page with PyMuPDF, highlights it, and exports coordinates for a web viewer.
November 10, 20263 min readIntermediateAICitationsPDFGrounding
This notebook sends a PDF to Claude with citations enabled, locates each cited passage on the page with PyMuPDF, highlights it, and exports coordinates for a web viewer.
Requires ANTHROPIC_API_KEY in the environment.
%pip install -q pymupdf anthropicSample document
Creates a two-page sample report. Replace path with your own PDF (it needs a text layer).
import pymupdf
doc = pymupdf.open()
p1 = """Acme Corporation Annual Report 2025
Financial highlights. Net revenue increased 12% to $4.1 billion, driven by growth in the enterprise software segment. Operating margin improved to 18.4%, compared with 16.9% in the prior year. The company returned $350 million to shareholders through dividends and share repurchases.
Outlook. Management expects revenue growth of 8% to 10% in fiscal 2026, with continued invest-\nment in cloud infrastructure and data centers in Europe and Asia. Capital expenditure is expected to reach $600 million."""
p2 = """Risk factors. The company depends on a small number of large customers. In fiscal 2025, the three largest customers accounted for 31% of net revenue. The loss of any of these customers could materially affect results.
Supply chain disruptions in 2024 delayed shipments of hardware appliances by up to six weeks. These delays did not recur in 2025."""
for txt in (p1, p2):
page = doc.new_page()
page.insert_textbox(pymupdf.Rect(72, 72, 400, 760), txt, fontsize=11, fontname="helv")
doc.save("acme_annual_report.pdf")
path = "acme_annual_report.pdf"Grounding helpers
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)
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 []
def ground_citations(response, docs):
"""Map each page_location citation in a Claude response to quads on the page.
response: the Message returned by client.messages.create()
docs: the PyMuPDF documents, in the order they were sent to Claude
"""
results = []
for block in response.content:
if block.type != "text" or not getattr(block, "citations", None):
continue
for cit in block.citations:
if cit.type != "page_location":
continue
doc = docs[cit.document_index]
# Claude's page numbers are 1-based; end_page_number is exclusive
for pno in range(cit.start_page_number - 1, cit.end_page_number - 1):
quads = locate(doc[pno], cit.cited_text)
if quads:
results.append({"claim": block.text, "cited_text": cit.cited_text,
"doc": cit.document_index, "page": pno, "quads": quads})
break
else:
results.append({"claim": block.text, "cited_text": cit.cited_text,
"doc": cit.document_index, "page": None, "quads": []})
return results
def highlight(docs, results):
for r in results:
if not r["quads"]:
continue
page = docs[r["doc"]][r["page"]]
annot = page.add_highlight_annot(r["quads"])
annot.set_info(content=r["claim"])
annot.update()
def to_json(results, docs):
"""Page number (1-based) and normalized 0-1 box for each citation, for a web viewer."""
out = []
for r in results:
item = {"claim": r["claim"], "cited_text": r["cited_text"], "found": bool(r["quads"])}
if r["quads"]:
page = docs[r["doc"]][r["page"]]
box = pymupdf.Rect()
for q in r["quads"]:
box |= q.rect
w, h = page.rect.width, page.rect.height
item["page_number"] = r["page"] + 1
item["box"] = [round(box.x0 / w, 4), round(box.y0 / h, 4),
round(box.x1 / w, 4), round(box.y1 / h, 4)]
out.append(item)
return outAsk Claude with citations enabled
import base64, pathlib
import anthropic
pdf_data = base64.standard_b64encode(pathlib.Path(path).read_bytes()).decode()
client = anthropic.Anthropic()
response = client.messages.create(
model="claude-opus-5-5",
max_tokens=1024,
messages=[{
"role": "user",
"content": [
{
"type": "document",
"source": {"type": "base64", "media_type": "application/pdf", "data": pdf_data},
"title": "Acme Annual Report 2025",
"citations": {"enabled": True},
},
{"type": "text", "text": "What were Acme's key financial results, and what risks does it report?"},
],
}],
)Locate, highlight and export
import json
doc = pymupdf.open(path)
results = ground_citations(response, [doc])
highlight([doc], results)
doc.save("acme_highlighted.pdf")
for r in results:
status = f"page {r['page'] + 1}" if r["quads"] else "NOT FOUND"
print(f"{status:10} {r['cited_text'][:60]}")
print(json.dumps(to_json(results, [doc]), indent=2))Output
Show full output
page 1 Net revenue increased 12% to $4.1 billion,
driven by growth
page 1 Operating
margin improved to 18.4%, compared with 16.9% in
page 1 The company returned $350 million to shareholders through
d
NOT FOUND Management expects revenue growth of 8% to 10% in
fiscal 20
page 1 Capital expenditure is expected to reach $600 million.
page 2 The company depends on a small number of large
customers.
page 2 In fiscal 2025, the three largest customers accounted
for 3
page 2 The loss of any of these customers could
materially affect
page 2 Supply chain disruptions in 2024 delayed shipments of hardwa
page 2 These delays did not recur in 2025.
[
{
"claim": "Net revenue increased 12% to $4.1 billion, driven by growth in the enterprise software segment.",
"cited_text": "Net revenue increased 12% to $4.1 billion,\r\ndriven by growth in the enterprise software segment. ",
"found": true,
"page_number": 1,
"box": [
0.121,
0.1241,
0.6358,
0.1613
]
},
{
"claim": "Operating margin improved to 18.4%, compared with 16.9% in the prior year.",
"cited_text": "Operating\r\nmargin improved to 18.4%, compared with 16.9% in the prior year.\r\n",
"found": true,
"page_number": 1,
"box": [
0.121,
0.1434,
0.6635,
0.1806
]
},
{
"claim": "The company returned $350 million to shareholders through dividends and share repurchases.",
"cited_text": "The company returned $350 million to shareholders through\r\ndividends and share repurchases.\r\n",
"found": true,
"page_number": 1,
"box": [
0.121,
0.182,
0.6111,
0.2192
]
},
{
"claim": "Management expects revenue growth of 8% to 10% in fiscal 2026, with continued investment in cloud infrastructure and data centers in Europe and Asia.",
"cited_text": "Management expects revenue growth of 8% to 10% in\r\nfiscal 2026, with continued invest\u0002ment in cloud infrastructure and data centers in Europe and Asia.\r\n",
"found": false
},
{
"claim": "Capital expenditure is expected to reach $600 million.",
"cited_text": "Capital expenditure is expected to reach $600 million.",
"found": true,
"page_number": 1,
"box": [
0.121,
0.2978,
0.5598,
0.3157
]
},
{
"claim": "The company depends on a small number of large customers.",
"cited_text": "The company depends on a small number of large\r\ncustomers. ",
"found": true,
"page_number": 2,
"box": [
0.121,
0.0855,
0.6399,
0.1228
]
},
{
"claim": "In fiscal 2025, the three largest customers accounted for 31% of net revenue.",
"cited_text": "In fiscal 2025, the three largest customers accounted\r\nfor 31% of net revenue. ",
"found": true,
"page_number": 2,
"box": [
0.121,
0.1048,
0.6502,
0.1421
]
},
{
"claim": "The loss of any of these customers could materially affect results.",
"cited_text": "The loss of any of these customers could\r\nmaterially affect results.\r\n",
"found": true,
"page_number": 2,
"box": [
0.121,
0.1241,
0.6543,
0.1613
]
},
{
"claim": "Supply chain disruptions in 2024 delayed shipments of hardware appliances by up to six weeks.",
"cited_text": "Supply chain disruptions in 2024 delayed shipments of hardware\r\nappliances by up to six weeks. ",
"found": true,
"page_number": 2,
"box": [
0.121,
0.182,
0.6492,
0.2192
]
},
{
"claim": "these delays did not recur in 2025.",
"cited_text": "These delays did not recur in 2025.",
"found": true,
"page_number": 2,
"box": [
0.3758,
0.2013,
0.6646,
0.2192
]
}
]Preview the first highlighted page
from IPython.display import Image
Image(doc[0].get_pixmap(dpi=100).tobytes("png"))Output
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.