# PDF Text Extractor: PDF to Markdown, OCR & RAG Chunks (`malmurk/pdf-text-extractor`) Actor

Extract text and Markdown from PDFs, Word (DOCX) files and scanned images. Automatic OCR only on scanned pages (13 languages), ruled tables as Markdown, per-page output for citations, RAG chunks and language detection. For LLM, RAG and AI agent pipelines. $1 per 1,000 pages; failed files are free.

- **URL**: https://apify.com/malmurk/pdf-text-extractor.md
- **Developed by:** [Catalin Friptu](https://apify.com/malmurk) (community)
- **Categories:** AI, Developer tools, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.00005 / actor start

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## PDF Text Extractor: PDF to Markdown, OCR & RAG Chunks

Turn **PDFs, Word (.docx) files and scanned images** into clean **text and Markdown** for LLMs, RAG pipelines, search indexes and AI agents. Pages that are scans or photos with no text layer are **OCR'd automatically**. Every other page is read straight from its text layer: that's fast, exact and costs no OCR time.

**$1 per 1,000 pages, +$7 per 1,000 only for pages that need OCR. Failed files are free.**

- **PDF to Markdown that keeps structure.** Headings come from font sizes, bullets and numbered lists are kept, and ruled tables become Markdown tables.
- **OCR only where it's needed**, page by page. A PDF with 40 typed pages and 2 scanned pages gets OCR on those 2 only. 13 OCR languages, including German, French, Spanish, Romanian, Russian and Ukrainian.
- **RAG-ready chunks.** Set a chunk size and overlap and each row comes with pre-split chunks for your embedding model.
- **One row per document or one row per page**, so you can cite page numbers in answers.
- **Language detection** on every row (`en`, `de`, `ro`, `ru`...).
- **PDF, DOCX, PNG, JPEG, TIFF, WEBP, BMP, GIF** from URLs, uploads or an Apify key-value store.

### What does PDF Text Extractor do?

You give it document URLs (or upload files), and it returns one dataset row per document or per page with:

| Field | What you get |
|---|---|
| `text` | Plain text in reading order. |
| `markdown` | The same content as Markdown: headings, lists, tables. |
| `chunks` | Optional pre-split chunks with character offsets, ready to embed. |
| `page`, `page_count` | Page numbers for citations. |
| `used_ocr`, `ocr_pages` | Which pages were scans and went through OCR. |
| `language` | Detected language code. |
| `error` | Why a file failed (and it wasn't charged). |

### Example output

A real run on two PDFs in document mode:

| file_name | pages | characters | OCR | language | price |
|---|---|---|---|---|---|
| 1706.03762v7.pdf ("Attention Is All You Need", born-digital) | 15 | 38,262 | no | en | 15 x $0.001 = $0.015 |
| Treated_Limited_Declassification_of_JUMPSEAT.pdf (scanned memo) | 3 | 5,116 | 3 pages | en | 3 x ($0.001 + $0.007) = $0.024 |

### Who it's for

- **AI engineers** loading company documents, reports, papers or contracts into a vector database.
- **Agent and MCP builders** who need a "read this PDF" tool that also copes with scans.
- **Data teams** digitising archives of scanned letters, invoices or government records.
- **Legal, research and compliance teams** that need searchable text from contracts, reports and filings.

### How to convert a PDF to text or Markdown

1. Click **Try for free** and create a free Apify account (no credit card needed).
2. Paste PDF links into **Document URLs**, or upload files.
3. Pick **One row per document** or **One row per page**, and set a **Chunk size** (e.g. 1500) if you feed a vector database.
4. Click **Start**, then download the text as JSON, CSV or Excel, or read it through the API.

### Input

| Field | Default | Description |
|---|---|---|
| `startUrls` | | Direct links to PDF, DOCX or image files. |
| `files` | | Files you upload in the input form. |
| `keyValueStoreId` | | One of your key-value stores. Every file in it is processed; JSON and text records are skipped. |
| `keyValueStoreKeys` | all | Only these keys from that store. |
| `outputMode` | `document` | `document`: one row per file, all pages joined. `page`: one row per page. |
| `maxPagesPerDocument` | all | Extract only the first N pages of each file. |
| `ocrMode` | `auto` | `auto`: OCR pages with no text layer. `always`: OCR every page, for PDFs whose text layer is garbage. `off`: never OCR. |
| `ocrLanguages` | `["eng"]` | Languages printed in the scans: `eng deu fra spa ita por nld pol ces ron rus ukr tur`. Pick only the ones you need, because each extra language slows OCR. |
| `ocrDpi` | `300` | Render resolution for scanned PDF pages. Use 400 for tiny print and 200 for speed. |
| `detectLanguage` | `true` | Add the `language` field. |
| `chunkSize` | `0` (off) | Split each row's Markdown into chunks of about this many characters. 1000–2000 suits most embedding models. |
| `chunkOverlap` | `200` | Characters shared by neighbouring chunks. |

```json
{
  "startUrls": [
    {"url": "https://arxiv.org/pdf/1706.03762"},
    {"url": "https://example.com/scanned-contract.pdf"}
  ],
  "outputMode": "page",
  "ocrLanguages": ["eng", "deu"],
  "chunkSize": 1500,
  "chunkOverlap": 200
}
```

### Output

Every row has the same fields. A field without a value is `null`, never an empty string.

| Field | Description |
|---|---|
| `source_url` | The URL you gave, or the storage URL of an uploaded file. |
| `file_name` | Taken from the URL, the server's `Content-Disposition` header, or the key-value store key. |
| `document_type` | `pdf`, `docx` or `image`. |
| `page` | Page number (page mode). `null` in document mode. |
| `page_count` | Total pages in the file. |
| `pages_extracted` | Pages included in this row (document mode). |
| `text` | Plain text in reading order. |
| `markdown` | The same content as Markdown: `#` headings, `-` lists, `|` tables. |
| `char_count` | Length of `text`. |
| `used_ocr` | `true` if the row's text came from OCR (in document mode: from at least one page). |
| `ocr_pages` | How many pages were OCR'd (document mode). |
| `language` | ISO 639-1 code of the detected language, or `null` if the text is too short to tell. |
| `chunks` | When `chunkSize` > 0: `[{"index", "start", "end", "text"}, ...]`, where `start` and `end` are character offsets into `markdown`. |
| `full_output_url` | Set only for huge documents whose text doesn't fit in one dataset row (about 8 MB). The row then carries a preview, and this link holds the full text, Markdown and chunks. |
| `error` | Why a file or page failed (`download failed: HTTP 404`, `PDF is password-protected`, `the URL returned an HTML web page...`). `null` on success. |

A born-digital paper in page mode (`text` and chunks shortened):

```json
{
  "source_url": "https://arxiv.org/pdf/1706.03762",
  "file_name": "1706.03762v7.pdf",
  "document_type": "pdf",
  "page": 1,
  "page_count": 15,
  "pages_extracted": null,
  "text": "Provided proper attribution is provided, Google hereby grants permission to ...\nAttention Is All You Need\nAshish Vaswani∗ Noam Shazeer∗ ...",
  "markdown": "### Provided proper attribution is provided, ...\n\n# Attention Is All You Need\n\nAshish Vaswani∗ Noam Shazeer∗ ...\n\n### Abstract\n\nThe dominant sequence transduction models are based on complex recurrent or convolutional neural networks ...",
  "char_count": 2819,
  "used_ocr": false,
  "ocr_pages": null,
  "language": "en",
  "chunks": [{"index": 0, "start": 0, "end": 1341, "text": "### Provided proper attribution ..."}, {"index": 1, "start": 1148, "end": 2462, "text": "..."}],
  "full_output_url": null,
  "error": null
}
```

A 3-page scanned government memo in document mode (no text layer, so every page was OCR'd):

```json
{
  "source_url": "https://upload.wikimedia.org/wikipedia/commons/9/93/Treated_Limited_Declassification_of_JUMPSEAT.pdf",
  "file_name": "Treated_Limited_Declassification_of_JUMPSEAT.pdf",
  "document_type": "pdf",
  "page": null,
  "page_count": 3,
  "pages_extracted": 3,
  "text": "CQ05159643\n\nApproved for Release: 2026/01/07 C05159643z\n\nNATIONAL RECONNAISSANCE OFFICE\n14675 Lee Road ... JUMPSEAT was a Highly Elliptical Orbit (HEO) signals collection satellite program ...",
  "markdown": "## CQ05159643\n\nApproved for Release: 2026/01/07 C05159643z\n\nNATIONAL RECONNAISSANCE OFFICE 14675 Lee Road ...",
  "char_count": 5116,
  "used_ocr": true,
  "ocr_pages": 3,
  "language": "en",
  "chunks": null,
  "full_output_url": null,
  "error": null
}
```

The dataset has four views: **Extracted text**, **Markdown** (with chunks), **Errors**, and **All fields**. Export as JSON, CSV or Excel, or read the rows straight from the API:

```python
from apify_client import ApifyClient

client = ApifyClient("<YOUR_API_TOKEN>")
run = client.actor("malmurk/pdf-text-extractor").call(run_input={
    "startUrls": [{"url": "https://arxiv.org/pdf/1706.03762"}],
    "outputMode": "page", "chunkSize": 1500,
})
for row in client.dataset(run["defaultDatasetId"]).iterate_items():
    for chunk in row["chunks"] or []:
        print(row["file_name"], row["page"], chunk["text"][:80])  # -> your embedding + vector DB
```

### How much does it cost to extract text from a PDF?

**Pay per event** (Apify platform usage is included):

| Event | Price | Charged for |
|---|---|---|
| `page` | **$1 per 1,000 pages** | Every extracted page. |
| `ocr_page` | **+$7 per 1,000 pages** | Extra, only for pages whose text came from OCR. |

A text-layer page costs $1 per 1,000. A scanned page costs $1 + $7 = **$8 per 1,000**. With `ocrMode: auto`, OCR runs only on pages that have no text layer, so a typed 40-page PDF with 2 scanned pages pays the surcharge on those 2 pages only.

- A file that fails (dead link, HTML page, password-protected or damaged PDF, unsupported format) costs **nothing**, and you get an error row explaining why.
- In page mode, a page that fails costs nothing.
- `maxPagesPerDocument` caps what each file can cost.
- A start fee of $0.00005 per GB of run memory ($0.00005 at the default 1 GB) is charged once per run.
- The run stops cleanly at the **maximum charge** you set: no page is extracted that can't be paid for. Before each page that might need OCR, the Actor reserves the full OCR price, so near the limit it stops one page early rather than run OCR it can't bill. With `ocrMode: off`, or for DOCX files, only the $1 rate is reserved.

DOCX files have no fixed pages, so they are counted in pages of about **3,000 characters**, roughly one printed page. An image file is one page per frame (a multi-page TIFF counts each frame).

### Supported files

| Type | How it's read |
|---|---|
| PDF with a text layer | Text layer, with headings, lists and ruled tables as Markdown. |
| Scanned PDF, or a PDF with some scanned pages | Tesseract OCR on the pages without a text layer. |
| DOCX (Word 2007+) | Paragraphs, Word heading styles, lists and tables. |
| PNG, JPEG, TIFF (multi-page), WEBP, BMP, GIF | Tesseract OCR. |

Not supported: legacy `.doc`, `.xls`/`.xlsx`, `.pptx` and password-protected PDFs. Files are limited to 200 MB.

### Limitations

- **Markdown structure is best-effort.** Headings are inferred from font size and tables from ruled lines. Tables drawn without lines (common in scientific papers) come out as text in reading order. Multi-column layouts are read row by row.
- **OCR quality follows scan quality.** Clean 300 DPI scans come out very well. Handwriting, stamps and strike-through marks produce noise. OCR'd pages get paragraph-level Markdown only.
- Rotated text, such as the vertical arXiv stamp in a page margin, is left out of text-layer pages.
- The link must point at the file itself. A page that shows a "Download" button is HTML and will return an error row.

### Use with AI agents and MCP

Give Claude, Cursor, ChatGPT or your own agent a "read this PDF" tool that also copes with scans, through the [Apify MCP server](https://docs.apify.com/platform/integrations/mcp). Add this server URL to your MCP client:

```
https://mcp.apify.com?tools=malmurk/pdf-text-extractor
```

Then ask, for example: *"Read https://arxiv.org/pdf/1706.03762 and summarise section 3."* The agent gets Markdown with page numbers back. For long documents, set `outputMode: "page"` and `maxPagesPerDocument` so the agent only pulls what it needs.

### Integrations: LangChain, LlamaIndex, n8n, Make, Zapier

- **LangChain and LlamaIndex:** load the dataset rows as documents with the [LangChain](https://docs.apify.com/platform/integrations/langchain) or [LlamaIndex](https://docs.apify.com/platform/integrations/llama-index) Apify integrations, or use the `chunks` field directly.
- **n8n, Make and Zapier:** a new PDF arrives in email, Google Drive or a form, the Actor extracts the text, and the next step sends it to an LLM, a vector database or a spreadsheet. See the [n8n](https://docs.apify.com/platform/integrations/n8n), [Make](https://docs.apify.com/platform/integrations/make) and [Zapier](https://docs.apify.com/platform/integrations/zapier) guides.
- **Webhooks:** get notified when a run finishes ([webhooks](https://docs.apify.com/platform/integrations/webhooks)).
- **API:** one HTTP call runs the Actor and returns the text.

```bash
curl -X POST "https://api.apify.com/v2/acts/malmurk~pdf-text-extractor/run-sync-get-dataset-items?token=<YOUR_API_TOKEN>"   -H "Content-Type: application/json"   -d '{"startUrls": [{"url": "https://arxiv.org/pdf/1706.03762"}], "outputMode": "page", "chunkSize": 1500}'
```

### FAQ

**Does it work on scanned PDFs?**
Yes. With `ocrMode: auto` (default), every page without a text layer is OCR'd with Tesseract. Pick the languages printed in the scans with `ocrLanguages`.

**Can I extract tables from a PDF?**
Tables drawn with ruled lines become Markdown tables. Tables without lines come out as text in reading order (see [Limitations](#limitations)).

**What chunk size should I use for RAG?**
1000 to 2000 characters with 200 overlap suits most embedding models. Use `outputMode: "page"` too if you want page numbers on every chunk.

**Can it read Word, PowerPoint or Excel files?**
Word (.docx) yes. Legacy `.doc`, `.pptx` and `.xlsx` are not supported.

**Does it use an LLM or send my documents to a third-party API?**
No. Text extraction and OCR run inside the Actor with open-source libraries (pdfplumber, Tesseract). No LLM is called.

**Am I charged for files that fail?**
No. A dead link, an HTML page, a password-protected or damaged PDF costs nothing and gets an error row.

### Related Actors

- [Website Screenshot & URL to PDF](https://apify.com/malmurk/website-screenshot-pdf): save web pages as PDFs or full-page screenshots in bulk.
- [Businesses Without Websites Finder](https://apify.com/malmurk/osm-no-website-leads): local business leads with no website, a dead link or only a Facebook page.

### How it works

PDF text and tables are read with [pdfplumber](https://github.com/jsvine/pdfplumber) (MIT). Scanned pages are rendered with [pypdfium2](https://github.com/pypdfium2-team/pypdfium2) (Apache-2.0 / BSD-3) and OCR'd with [Tesseract](https://github.com/tesseract-ocr/tesseract) (Apache-2.0). DOCX is read with python-docx (MIT), and language detection uses py3langid (BSD-3). Every component has a permissive license.

### Local development

```bash
python -m venv .venv && .venv/Scripts/pip install -r requirements-dev.txt   # Linux/macOS: .venv/bin/pip
python -m pytest -q                       # OCR tests need tesseract + 'eng' data, else they are skipped
python tests/fixtures/make_fixtures.py    # regenerate the test PDFs/DOCX/PNG
docker build -t pdf-text-extractor . && docker run --rm -v "$PWD/INPUT.json:/usr/src/app/INPUT.json" pdf-text-extractor
```

On Windows without system language data, put `eng.traineddata` from [tessdata_fast](https://github.com/tesseract-ocr/tessdata_fast) in `.tessdata/` (gitignored) and the tests pick it up.

# Actor input Schema

## `startUrls` (type: `array`):

Direct links to PDF, DOCX or image files (PNG, JPEG, TIFF, WEBP, BMP, GIF). A link to a web page that only shows a download button won't work: use the file's own URL.

## `files` (type: `array`):

Upload documents from your computer. They are stored in your Apify account and processed like the URLs above.

## `keyValueStoreId` (type: `string`):

Process files saved in one of your key-value stores (for example by another Actor). Records that are JSON or text are skipped.

## `keyValueStoreKeys` (type: `array`):

Only process these keys from the key-value store above. Leave empty to process every file in the store.

## `outputMode` (type: `string`):

One dataset row per document (all pages joined), or one row per page (best for citations and page-level RAG).

## `maxPagesPerDocument` (type: `integer`):

Extract only the first N pages of each document. Leave empty for all pages. You pay only for pages that are extracted.

## `ocrMode` (type: `string`):

Auto: OCR only pages that have no text layer (scans, photos). Always: OCR every page, for PDFs whose text layer is garbled. Off: never OCR (image files then fail).

## `ocrLanguages` (type: `array`):

Languages printed in your scans. Pick only the ones you need: each extra language makes OCR slower.

## `ocrDpi` (type: `integer`):

Resolution scanned PDF pages are rendered at before OCR. 300 is the usual sweet spot; use 400 for tiny print, 200 for speed.

## `detectLanguage` (type: `boolean`):

Add the detected language (ISO 639-1 code such as 'en', 'ro', 'ru') to every row.

## `chunkSize` (type: `integer`):

Also split each row's Markdown into chunks of about this many characters, ready for embeddings (a 'chunks' field). 0 turns chunking off. 1000-2000 suits most embedding models.

## `chunkOverlap` (type: `integer`):

How many characters neighbouring chunks share, so a sentence cut at a boundary still appears whole in one chunk.

## Actor input object example

```json
{
  "startUrls": [
    {
      "url": "https://arxiv.org/pdf/1706.03762"
    }
  ],
  "outputMode": "document",
  "ocrMode": "auto",
  "ocrLanguages": [
    "eng"
  ],
  "ocrDpi": 300,
  "detectLanguage": true,
  "chunkSize": 0,
  "chunkOverlap": 200
}
```

# Actor output Schema

## `results` (type: `string`):

No description

## `files` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "startUrls": [
        {
            "url": "https://arxiv.org/pdf/1706.03762"
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("malmurk/pdf-text-extractor").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "startUrls": [{ "url": "https://arxiv.org/pdf/1706.03762" }] }

# Run the Actor and wait for it to finish
run = client.actor("malmurk/pdf-text-extractor").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "startUrls": [
    {
      "url": "https://arxiv.org/pdf/1706.03762"
    }
  ]
}' |
apify call malmurk/pdf-text-extractor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,malmurk/pdf-text-extractor"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/mjLG3KS7tG3cWNlkU/builds/buUCCct0BgMErDBEP/openapi.json
