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 anthropic

Sample 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 out

Ask 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
Cell 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.

PyMuPDF Logo

© 2026 Artifex Software Inc. All rights reserved.