Skip to main content

How to convert PDF to Markdown in Python (with page numbers for RAG)

Turn PDF links into clean Markdown, per-page rows or RAG chunks that keep their page citations, with apify-client in Python or JavaScript.

Updated 7 min readMarkdown

By Ryu Moon

"How do I convert a PDF to Markdown in Python?" usually comes up for one reason: you want to feed documents to an LLM, a RAG pipeline or a notes app like Obsidian, and raw PDF text is a mess. Lines break mid-sentence, two-column papers come out interleaved, tables turn into strings of numbers, and running headers end up inside every paragraph.

This guide shows how to turn PDF links into clean Markdown from Python (or JavaScript) with a few lines of code, keeping the page number of every passage so your answers can cite "p. 12". It uses PDF to Markdown with Page Numbers – Tables, RAG Chunks, an Apify Actor I publish.

When a local library is enough, and when it isn't

If you have a handful of clean, single-column PDFs on your laptop, an open-source library such as PyMuPDF (with its pymupdf4llm helper) or Microsoft's MarkItDown is a perfectly good place to start. Try those first.

A hosted Actor earns its place when:

  • your PDFs are URLs (arXiv papers, annual reports, docs pages, Google Drive or Dropbox share links) rather than files on disk;
  • you want page numbers kept for citations, not one long blob of text;
  • you want RAG-ready chunks with their page range and section headings already attached;
  • you want to call it from n8n, Make, Zapier or a scheduled job without installing anything.

What the Actor returns

You choose one of three output formats:

outputFormat One row per Useful fields
document (default) PDF markdown with <!-- page N --> markers, metadata, pageCount, needsOcrPages, markdownFileUrl
pages page page, status, markdown, headings, citation
chunks chunk text, pageStart, pageEnd, headings, tokenEstimate, citation

The page markers are HTML comments, so they are invisible when the Markdown is rendered but easy for code or an LLM to find.

Here is a shortened chunk from the Actor's README, taken from a run on the BERT paper:

{
  "citation": "BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding, p. 1",
  "pageStart": 1,
  "pageEnd": 1,
  "headings": ["BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding", "1 Introduction"],
  "tokenEstimate": 351,
  "text": "## 1 Introduction\n\nLanguage model pre-training has been shown to be effective ..."
}

Statement-style tables (labels on the left, numbers in columns) and ruled tables come out as real Markdown tables, and two-column pages are read left column first.

Option 1: no code, in Apify Console

  1. Open apify.com/ryumoon-data/pdf-to-markdown and try it in Apify Console (a free Apify account is enough).
  2. Paste direct PDF links into PDF URLs. arXiv /abs/ pages, Google Drive file links and Dropbox share links are converted to download links automatically.
  3. Pick an Output format.
  4. Keep Max pages per PDF low for your first run (the input default is 20), or set a Page range such as 1-5, 8.
  5. Click Start, then download the results as JSON, CSV or Excel. With Save .md files on (the default), each PDF also gets a downloadable .md file.

Option 2: convert a PDF to Markdown in Python

Install the official client and put your API token (Apify Console → Settings → API & Integrations) in an environment variable:

pip install apify-client
export APIFY_TOKEN="your-own-token-here"

This script converts one or more PDF URLs and writes each as a .md file:

import os
import re
from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])

run_input = {
    "urls": [
        "https://arxiv.org/pdf/1706.03762",
    ],
    "outputFormat": "document",
    "maxPagesPerPdf": 20,   # safety cap; raise it for long reports
    "pageMarkers": True,    # keeps <!-- page N --> comments
}

run = client.actor("ryumoon-data/pdf-to-markdown").call(run_input=run_input)

for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    if item.get("status") == "failed":
        print("Failed:", item.get("url"), item.get("errorCode"), item.get("error"))
        continue
    title = item.get("documentTitle") or (item.get("metadata") or {}).get("title") or "document"
    name = re.sub(r"[^A-Za-z0-9]+", "-", title).strip("-").lower()[:60] or "document"
    with open(f"{name}.md", "w", encoding="utf-8") as f:
        f.write(item.get("markdown") or "")
    print("Saved", f"{name}.md", "-", item.get("pageCount"), "pages")

Failed PDFs don't crash the run. They come back as rows with status: "failed", an errorCode such as HTTP_404, NOT_A_PDF, TOO_LARGE, ENCRYPTED or TIMEOUT, and a plain-English error, which is why the loop checks for them.

Option 3: RAG chunks with page citations

For retrieval you usually want chunks, not whole documents. Switch the output format and choose a size. chunkSize is in characters (about 4 characters per token, so 1,500 is roughly 375 tokens). Chunks break at headings, paragraphs and sentences, and tables are split by rows with the header repeated.

import json
import os
from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])

run = client.actor("ryumoon-data/pdf-to-markdown").call(run_input={
    "urls": ["https://arxiv.org/pdf/1810.04805"],
    "outputFormat": "chunks",
    "chunkSize": 1500,
    "chunkOverlap": 150,
    "maxPagesPerPdf": 20,
})

with open("chunks.jsonl", "w", encoding="utf-8") as f:
    for chunk in client.dataset(run["defaultDatasetId"]).iterate_items():
        if not chunk.get("text"):
            continue  # skip error rows
        record = {
            "text": chunk["text"],
            "metadata": {
                "citation": chunk.get("citation"),
                "pageStart": chunk.get("pageStart"),
                "pageEnd": chunk.get("pageEnd"),
                "headings": chunk.get("headings"),
            },
        }
        f.write(json.dumps(record, ensure_ascii=False) + "\n")

Each line of chunks.jsonl is now ready for whatever vector store you use (Chroma, pgvector, Pinecone, a LangChain or LlamaIndex loader). Store citation as metadata and you can show the source page next to every answer your model gives.

The same thing in JavaScript

import { ApifyClient } from 'apify-client';

const client = new ApifyClient({ token: process.env.APIFY_TOKEN });

const run = await client.actor('ryumoon-data/pdf-to-markdown').call({
    urls: ['https://arxiv.org/pdf/1706.03762'],
    outputFormat: 'pages',
    maxPagesPerPdf: 20,
});

const { items } = await client.dataset(run.defaultDatasetId).listItems();
for (const page of items) {
    console.log(page.page, page.status, (page.markdown || '').slice(0, 80));
}

Install it with npm install apify-client and run it as an ES module (for example save it as pages.mjs).

Converting only the pages you need

Long reports are where costs and noise add up. Two inputs help:

  • pageRange: convert only certain pages, e.g. "1-5, 8, 12-" (12- means page 12 to the end). Pages outside the range are not charged.
  • maxPagesPerPdf: a hard cap per document. Bigger PDFs are converted up to the cap and the row is marked partial with a warning.

For example, to pull just the financial statements out of an annual report, find their page numbers once and set pageRange to that span.

What it costs

Pricing on the Apify Store when I wrote this (11 November 2026) is $0.0005 per page converted, which is $0.50 per 1,000 pages, with no start fee. A 15-page paper is $0.0075. Pages flagged as needing OCR, blank pages, pages outside your range and PDFs that fail to download or open are not charged. Check the Store page for the current price before large runs, and set a maximum cost per run in Apify if you want a hard ceiling.

Limits worth knowing

  • No OCR. Scanned, image-only pages are reported as needs_ocr with their page numbers. The Actor does not invent text for them. Run those pages through an OCR tool.
  • Layout is detected heuristically, because PDFs carry no real structure. Dense multi-level tables, maths, chart labels and heavily designed layouts can come out flatter. Pages with three or more columns are read as one column.
  • Links must point at the PDF file itself, not a web page that displays it. You get a NOT_A_PDF row otherwise.
  • Only use it on documents you are allowed to process. It does not get past logins, paywalls or DRM. Password-protected PDFs work only if you supply the password.

Wrapping up

Converting PDF to Markdown in Python takes one API call: send the URLs, pick document, pages or chunks, and you get structured Markdown back with page numbers intact. If your source is web pages rather than PDFs, the companion Website to Markdown for AI, LLM & RAG Actor produces the same kind of output for HTML.

Try it here: PDF to Markdown with Page Numbers on Apify.

No comments yet. Be the first to comment.