apyhub
Cover illustration for PDF to Markdown with Python: A Resumable OCR Pipeline for RAG
Python · Ocr

PDF to Markdown with Python: A Resumable OCR Pipeline for RAG

PDF to Markdown with Python: A Resumable OCR Pipeline for RAG

01Introduction

A search assistant finds the right paragraph, gives a plausible answer and then cannot tell the reader which page supports it. The problem started before retrieval: the extraction step discarded the page boundary.

For a useful PDF ingestion pipeline, keep the source reference alongside the text from the beginning. Save every completed page, too. If page 37 fails, restarting should not require processing pages 1 through 36 again.

In this tutorial, we'll use the Callable Labs OCR & PDF to Markdown API on ApyHub to build that extraction step. We build this API at Callable Labs. The example produces Markdown and a page manifest; your existing chunker and search index can use those files next.

02Start with one PDF page

Use a PDF you are authorized to process. This example reads a local report.pdf, so you do not need to host it publicly. Create an ApyHub API key, keep it on your server and install the Python dependency:

· bash
python -m pip install requests
export APY_TOKEN='your-apyhub-api-key'

The document endpoint accepts a PDF source and a 1-based page number. Choose JSON output so you receive both markdown and page.count. Reading the entire PDF takes one request per page; a single request does not convert the whole document.

03Make extraction resumable

Save this as extract_pdf.py next to report.pdf:

· python
import base64
import hashlib
import json
import os
import time
from pathlib import Path

import requests

URL = "https://api.eu.apyhub.com/callable-labs/ocr-pdf-to-markdown/v1/ocr/document"
source = Path("report.pdf")
pdf = source.read_bytes()
options = {"format": "json", "mode": "auto", "dpi": 200, "include_blocks": False}
identity = pdf + json.dumps(options, sort_keys=True).encode("utf-8")
folder = Path("extracted") / hashlib.sha256(identity).hexdigest()
folder.mkdir(parents=True, exist_ok=True)
encoded = base64.b64encode(pdf).decode("ascii")
interval = float(os.environ.get("MIN_INTERVAL_SECONDS", "10"))

with requests.Session() as client:
    client.headers["apy-token"] = os.environ["APY_TOKEN"]

    def read_page(number):
        checkpoint = folder / f"page-{number:04d}.json"
        if checkpoint.exists():
            return json.loads(checkpoint.read_text(encoding="utf-8"))
        time.sleep(interval)
        response = client.post(
            URL,
            json={"document": {"base64": encoded}, "page": number, **options},
            timeout=90,
        )
        response.raise_for_status()
        result = response.json()
        if result["page"]["index"] != number or not isinstance(result["markdown"], str):
            raise ValueError("Unexpected page response")
        temporary = checkpoint.with_suffix(".tmp")
        temporary.write_text(json.dumps(result, ensure_ascii=False), encoding="utf-8")
        temporary.replace(checkpoint)
        return result

    first = read_page(1)
    total = first["page"]["count"]
    manifest = []
    markdown = []
    for number in range(1, total + 1):
        result = first if number == 1 else read_page(number)
        markdown.append(f"<!-- source: {source.name}; page: {number} -->\n\n{result['markdown']}")
        manifest.append({"source": source.name, "page": number, "file": f"page-{number:04d}.json"})

(folder / "report.md").write_text("\n\n---\n\n".join(markdown), encoding="utf-8")
(folder / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
print(f"Saved {total} pages to {folder}")

Run python extract_pdf.py. The output directory is keyed by the PDF contents and extraction settings. Changing the input or DPI creates a different directory, which prevents an old checkpoint from silently supplying text for a new job. The script sends requests sequentially; adjust the interval to the limits in your ApyHub workspace.

If a request fails, the script stops and keeps completed pages. After resolving the failure, rerun it to resume. Check the atom balance (atoms are ApyHub's usage unit) before retrying a quota error. After a timeout, processing may already have completed, so another submission can consume additional atoms.

04Carry the page metadata into retrieval

Use the page JSON files as your chunker's inputs. Give every chunk a document ID and page number in its metadata, rather than depending on the Markdown comment surviving later transformations. A citation can then point back to the source page even when the text has been split into smaller passages.

Headings and tables can make chunking more useful, but inspect the extracted structure first. A table split across pages may need application-specific handling. OCR may misread figures or merge columns, so compare representative pages with the source before indexing a large collection. If your workflow needs table cells or bounding boxes, keep include_blocks enabled and use the structured output.

The source PDF limit is 20 MiB, with one page processed per request. Base64 adds roughly a third to upload size, and ApyHub's transport limit can be lower. For a larger permitted upload, use an authorized public URL that remains valid throughout extraction. Review the service's Privacy Notice before processing sensitive documents.

05Let an agent read PDFs as a tool

Through ApyHub MCP, an agent in Claude, ChatGPT, Cursor or any MCP client can call this OCR API directly, like every endpoint in the ApyHub catalog, without a wrapper or tool definition. Your agent can turn a PDF page into Markdown in one call instead of writing its own parsing code, with consistent output and fewer tokens.

Connect ApyHub MCP to your agent →

06Conclusion

Start with a short PDF containing both prose and a table. Compare its extracted pages with the original, then connect the saved page records to your retrieval pipeline. The ApyHub listing has the request reference and playground for the first page.

Try the PDF to Markdown API for free →

07FAQ

How do I convert a PDF to Markdown with Python? Send each page to the OCR & PDF to Markdown API with "format": "json" and save the markdown field. The extract_pdf.py script above does this for every page and writes one combined report.md plus a manifest.json.

Does it work on scanned PDFs? Yes. It runs OCR on scanned and digital PDFs, and also reads single images such as photos and screenshots.

Can I convert a whole PDF in one request? No. Each request reads one page, and every response includes the total page count. PDFs up to 20 MiB and 1,000 pages are supported.

Does it extract tables? Yes. Tables come back as HTML, Markdown and cell rows. Bar, line and pie charts can be turned into data tables too.

How do I keep page numbers for RAG citations? Store the document ID and page number in each chunk's metadata, taken from the page JSON files. Don't rely on Markdown comments, since chunkers often strip them.

How do I test the PDF to Markdown API before writing code? Use the playground on the API page, run the request with curl, or save it in Voiden, an open-source API client that stores requests as Markdown files next to your code.

Is there a free plan? Yes. The ApyHub Starter plan includes 5 API calls a day and 3,000 atoms a month, with no credit card. That covers testing a few pages.

08About Callable Labs

Callable Labs builds APIs for machine-readable workflows, designed for automation and AI agents. Besides OCR and PDF to Markdown, their APIs on ApyHub include:

See all 20 on the Callable Labs provider page.

09About ApyHub

ApyHub is a curated API catalog and trusted operational layer for developers and AI agents. The catalog has over 1,500 endpoints and capabilities and keeps growing, all under one subscription billed in atoms. Every service carries machine-readable certification (GDPR, SOC 2, ISO 27001), is MCP-ready by default and runs in dedicated EU and US regions. More than 65,000 developer workspaces use ApyHub every month, and the free tier needs no credit card. Have an API of your own? Publish it on ApyHub.