# PDF Text Extractor & Markdown for LLM RAG: OCR, Tables, Chunks (`pankovs/pdf-to-llm-markdown`) Actor

Convert PDF to clean Markdown for LLM & RAG pipelines: text extractor with OCR fallback (50 languages), GitHub-flavored table reconstruction (incl. tables spanning pages), right-to-left script support, and optional RAG chunking. ~$0.005 for a 20-page text PDF. Pay only for pages with real text.

- **URL**: https://apify.com/pankovs/pdf-to-llm-markdown.md
- **Developed by:** [Pankovs](https://apify.com/pankovs) (community)
- **Categories:** AI, Developer tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per event

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 & Markdown for LLM RAG: OCR, Tables, Chunks

Turn any PDF into clean Markdown for LLMs and RAG: headings, lists and GitHub-flavored tables rebuilt from the page,
OCR for scanned pages in 50 languages, and ready-to-embed chunks with page and section citations. About $0.008 for a 20-page text PDF.

Handles what most PDF to Markdown Actors don't: tables that span pages, right-to-left scripts (Arabic/Hebrew) in
correct reading order, filled form fields, and RAG chunks that never cut a table in half.

### Use cases

- **RAG and chatbots:** feed contracts, manuals or research papers into a vector store, with `chunksMeta` page ranges and
  section paths so answers can cite "page 4, section 3.2".
- **LLM data pipelines:** turn reports, filings and invoices into Markdown an LLM reads reliably, tables included.
- **Scanned archives:** OCR old scans and image-only PDFs in 50 languages; pages with a real text layer skip OCR and cost less.
- **Multilingual documents:** Arabic, Hebrew, Persian and Urdu text comes out in reading order; Cyrillic, Greek, CJK and Indic scripts are supported.

### How to use

1. Paste one or more PDF links into `pdfs` (or upload a file as `pdfFile`).
2. Pick options if you need them: `ocrLanguages` for scans, `outputChunks` for RAG chunks, `includePageMarkers`.
3. Run the Actor and download the dataset (JSON, CSV, Excel), or call it from the API, Make, Zapier or LangChain.

### Input

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `pdfs` | array of strings | `[]` | http(s) URLs of PDF files to convert. |
| `pdfFile` | string | none | Optional single PDF uploaded via the Apify Console. |
| `ocrMode` | `auto` | `always` | `never` | `auto` | `auto` OCRs only low-text pages; `always` OCRs every page; `never` uses the text layer only. |
| `ocrLanguages` | string | `eng` | OCR languages for scanned pages, Tesseract codes joined with `+` (e.g. `eng+rus`), up to 5. See below. |
| `maxPagesPerPdf` | integer | `200` | Stop processing a PDF after this many pages. |
| `extractTables` | boolean | `true` | Rebuild tables as GitHub-flavored Markdown tables. See below. |
| `outputChunks` | boolean | `false` | Also split the Markdown into RAG chunks. |
| `chunkSize` | integer | `2000` | Target chunk length in characters (chunks end on a boundary within 15%). |
| `chunkOverlap` | integer | `200` | Overlap between consecutive chunks, at most half of `chunkSize` (a larger value is reduced to `chunkSize / 2`, with a warning). |
| `includePageMarkers` | boolean | `false` | Insert `<!-- page: N -->` markers before each page's Markdown. |

At least one of `pdfs` or `pdfFile` must be set.

#### OCR languages

50 languages, all built into the actor (no downloads at run time). Combine up to 5 codes with `+` (`eng+deu`).
Any other code, or more than 5 codes, is rejected as an input error (run fails up front, nothing charged).
OCR models: Tesseract `tessdata_best` (Apache-2.0) via `@tesseract.js-data`.

`eng` English, `rus` Russian, `deu` German, `fra` French, `spa` Spanish, `por` Portuguese, `ita` Italian, `pol` Polish, `lav` Latvian, `ukr` Ukrainian, `nld` Dutch, `swe` Swedish, `dan` Danish, `nor` Norwegian, `fin` Finnish, `ces` Czech, `slk` Slovak, `hun` Hungarian, `ron` Romanian, `tur` Turkish, `ell` Greek, `ara` Arabic, `heb` Hebrew, `fas` Persian, `urd` Urdu, `hin` Hindi, `ben` Bengali, `tam` Tamil, `tel` Telugu, `mar` Marathi, `tha` Thai, `vie` Vietnamese, `ind` Indonesian, `msa` Malay, `chi_sim` Chinese (Simplified), `chi_tra` Chinese (Traditional), `jpn` Japanese, `kor` Korean, `bul` Bulgarian, `srp` Serbian (Cyrillic), `hrv` Croatian, `slv` Slovenian, `lit` Lithuanian, `est` Estonian, `cat` Catalan, `glg` Galician, `eus` Basque, `afr` Afrikaans, `swa` Swahili, `fil` Filipino.

List only the languages your scans contain. Each extra language slows OCR and can hurt accuracy
(`eng+rus` on an English scan puts Cyrillic look-alike letters into English words; measured with
`scripts/ocr-bench.mjs`), so the default is `eng`.

Script auto-detect: if a page's OCR confidence is below 70%, the page's script is detected (Tesseract
OSD) and, if none of your `ocrLanguages` covers it, the page is re-read with that script's language
added: Latin `eng`, Cyrillic `rus`, Greek `ell`, Arabic `ara`, Hebrew `heb`, Devanagari `hin`, Bengali
`ben`, Tamil `tam`, Telugu `tel`, Thai `tha`, Chinese `chi_sim`, Japanese `jpn`, Korean `kor` (e.g. a Greek
scan with the default `eng` is re-read with `eng+ell`). The better result is kept and a warning suggests
fixing `ocrLanguages`. Detection needs a page's worth of text; very short pages are not re-read. If
confidence is still below 60% (e.g. a Georgian or Armenian scan with no model), the page text is kept but
flagged in `warnings` and the page is not charged.

Text-layer PDFs work in any language/script, including Arabic/Hebrew (right-to-left runs are
re-ordered into reading order, including PDFs that map whole Arabic words to one glyph, e.g.
WeasyPrint/cairo; Chrome-printed Arabic, which encodes medial yeh/heh as Persian/Urdu letters, is
folded back to Arabic yeh/heh unless the page contains Persian/Urdu-only letters).

#### Tables

Tables are rebuilt from the positions of the text on the page (aligned columns + rows), so bordered
and borderless tables both work. They are emitted as GitHub-flavored Markdown:

```
| Quarter | Revenue | Profit |
| --- | --- | --- |
| Q1 2025 | 1,250.00 | 269.50 |
```

A table that continues on the next page (with or without a repeated header row) is merged into one
table unless `includePageMarkers` is on. RAG chunks never cut a table in half: a table longer than
`chunkSize` becomes its own chunk. When the layout is ambiguous (multi-column prose, sparse
figure labels, fewer than 3 rows), the text stays plain paragraphs instead of a broken table.
Every printed line stays its own row, even in dense tables grouped in blocks of five (tax tables).
Tables printed side by side (or next to a prose column) come out as separate tables. A cell merged
across columns goes into its first column; a group label centered over several rows (rowspan, e.g.
`LLaMA` next to `7B`, `13B`, `33B`) is repeated on each of those rows.
Pages packed with more than 4,000 text cells or 600 multi-cell lines, or laid out so that table search would
have to recheck the same text hundreds of times, skip table search: their text comes out as plain paragraphs
and the item gets a warning naming the page.
Limits: multi-level headers come out as separate header rows, header text stacked over several
lines may stay above the table as plain text, and tables on scanned (OCR) pages stay plain text.

### Output

One dataset item per PDF, for example (arXiv 1706.03762 with `outputChunks` on, shortened):

```json
{
  "url": "https://arxiv.org/pdf/1706.03762v7",
  "fileName": "...",
  "pageCount": 15,
  "pagesProcessed": 15,
  "pagesOcr": 0,
  "markdown": "## Attention Is All You Need

Ashish Vaswani Google Brain ...

#### Abstract

The dominant sequence transduction models ...",
  "chunks": ["## Attention Is All You Need

...", "..."],
  "chunksMeta": [
    { "index": 0, "pageStart": 1, "pageEnd": 1, "headings": [] },
    { "index": 3, "pageStart": 4, "pageEnd": 5, "headings": ["Attention Is All You Need", "3 Model Architecture"] }
  ],
  "metadata": { "title": "...", "author": "...", "created": "..." },
  "warnings": [],
  "error": null
}
```

- `url`, `fileName`, `pageCount` (pages in the PDF), `pagesProcessed` (pages converted), `pagesOcr` (pages billed as `ocr-page`)
- `markdown`: the whole document. Filled-in form fields (text and choice fields, checked checkboxes, the selected radio
  option) are appended to their page as `- field name: value` lines. With `includePageMarkers`, pages without text keep a bare marker.
- `chunks` (with `outputChunks`): pieces of `markdown` of about `chunkSize` characters (within 15% either way). A chunk ends at
  the best boundary in that range: before a heading, then a paragraph break, a line break, a sentence end, and only then
  a space between words. Words are never cut, a heading is never left at the end of a chunk, and the overlap with the
  previous chunk starts at the beginning of a word (at most `chunkOverlap` characters). Tables are never split: a table
  longer than `chunkSize` becomes its own chunk, together with the heading right above it.
- `chunksMeta` (with `outputChunks`): one entry per chunk, in the same order, so you can cite pages and sections:

  ```json
  { "index": 3, "pageStart": 4, "pageEnd": 5, "headings": ["Attention Is All You Need", "3 Model Architecture"] }
  ```

  `pageStart`/`pageEnd` are the PDF page numbers the chunk text comes from; `headings` is the section path where the
  chunk's new text begins (top level first, empty before the first heading). Numbered section titles in bold at the
  normal text size (`2.2 Architecture` in a two-column paper) count as headings too, nested by their number.
- `metadata`: `title`, `author`, `created` (ISO 8601 in UTC, e.g. `1999-09-21T17:45:50Z`; the raw PDF value if it is not a date)
- `warnings`: per-page notes (low OCR confidence, OCR language switch, dense table pages) and a note when
  `maxPagesPerPdf` stopped before the end: `Stopped at maxPagesPerPdf=6 of 28 pages: pages 7-28 were not converted or charged.`
- `error`: set on failure (for example `The URL returned an empty response (0 bytes), not a PDF`), or on an early stop once
  the run's charge limit is reached; the item still contains whatever was processed.

### Pricing (pay-per-event)

| Event | Price | When |
| --- | --- | --- |
| `document` | $0.00416 | once per PDF that produced at least one billed page |
| `text-page` | $0.000192 | per page extracted from the text layer |
| `ocr-page` | $0.01 | per page whose text came from OCR |
| `apify-actor-start` | $0.00005 | Apify's standard start event, once per GB of memory (at most twice, runs are capped at 2 GB) |

Worked examples:

1. **One 20-page text PDF:** $0.00416 + 20 x $0.000192 = **$0.008**, plus $0.0001 for the run start.
2. **A nightly job converting 50 new 20-page reports and one 10-page scan:** 50 x $0.008 + ($0.00416 + 10 x $0.01) +
   $0.0001 = **$0.504 per night**, about $15 a month.

Failed PDFs (download error, password-protected, corrupt, not a PDF) and PDFs without any extractable text are never
charged, not even the `document` event.

### FAQ and limits

**Am I charged for OCR that found nothing?** No. If OCR returns no text, or less text than the page's own text layer, the
text layer is used and the page is billed as `text-page` (or not at all if the page has no text). If OCR confidence is
below 60% (usually a language or script missing from `ocrLanguages`), the page is flagged in `warnings` and not charged.
Empty pages are free. With `ocrMode=never`, a page with no text layer is free and flagged in `warnings`, so a scan does
not come back as a silently empty page.

**What if a link is flaky?** Downloads are retried twice (1 s, 3 s backoff) on HTTP 502/503/504 and connection resets.

**How big can a PDF be?** `maxPagesPerPdf` (default 200) caps the pages converted per PDF; the item says where it stopped.
Set a maximum charge per run in the Apify Console to cap the cost; the Actor stops cleanly when it is reached.

**Which tables are not rebuilt?** Tables on scanned (OCR) pages stay plain text. Multi-level headers come out as separate
header rows, and header text stacked over several lines may stay above the table as plain text.

### More

Real output, prices and changelog: [PDF to Markdown for RAG, real output and cost](https://pankovs.com/pdf-to-markdown).

# Changelog

This Actor's version history is a separate document: https://apify.com/pankovs/pdf-to-llm-markdown/changelog.md

# Actor input Schema

## `pdfs` (type: `array`):

http(s) URLs of PDF files to convert. At least one URL is required unless a file is uploaded via pdfFile.

## `pdfFile` (type: `string`):

Upload a single PDF from your computer via the Apify Console. Processed in addition to any URLs in pdfs.

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

auto: OCR only pages with too little extractable text. always: OCR every page. never: text layer only.

## `ocrLanguages` (type: `string`):

Tesseract language codes for scanned pages, joined with '+', e.g. eng+deu (up to 5). 50 languages: eng (English), rus (Russian), deu (German), fra (French), spa (Spanish), por (Portuguese), ita (Italian), pol (Polish), lav (Latvian), ukr (Ukrainian), nld (Dutch), swe (Swedish), dan (Danish), nor (Norwegian), fin (Finnish), ces (Czech), slk (Slovak), hun (Hungarian), ron (Romanian), tur (Turkish), ell (Greek), ara (Arabic), heb (Hebrew), fas (Persian), urd (Urdu), hin (Hindi), ben (Bengali), tam (Tamil), tel (Telugu), mar (Marathi), tha (Thai), vie (Vietnamese), ind (Indonesian), msa (Malay), chi_sim (Chinese (Simplified)), chi_tra (Chinese (Traditional)), jpn (Japanese), kor (Korean), bul (Bulgarian), srp (Serbian (Cyrillic)), hrv (Croatian), slv (Slovenian), lit (Lithuanian), est (Estonian), cat (Catalan), glg (Galician), eus (Basque), afr (Afrikaans), swa (Swahili), fil (Filipino). All are built in. Pick only the languages present in your scans: each extra language makes OCR slower. If OCR confidence is low, the page's script is detected automatically (Latin, Cyrillic, Greek, Arabic, Hebrew, Devanagari, Bengali, Tamil, Telugu, Thai, Chinese, Japanese, Korean) and the page is re-read with that script's language added. Pages OCR'd with low confidence (below 60%) are flagged in warnings and not charged.

## `maxPagesPerPdf` (type: `integer`):

Stop processing a PDF after this many pages.

## `extractTables` (type: `boolean`):

Rebuild tables from the text layer as GitHub-flavored Markdown tables (| cell | cell |), kept whole in RAG chunks. Low-confidence layouts stay plain text. Not applied to OCR pages.

## `outputChunks` (type: `boolean`):

Also split the Markdown into overlapping chunks for retrieval-augmented generation.

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

Target chunk length in characters, used when outputChunks is enabled.

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

Number of characters each chunk overlaps with the previous one. At most half of chunkSize: a larger value is reduced to chunkSize / 2 and the item gets a warning.

## `includePageMarkers` (type: `boolean`):

Insert an HTML comment marker before each page's Markdown, e.g. .

## Actor input object example

```json
{
  "pdfs": [
    "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
  ],
  "ocrMode": "auto",
  "ocrLanguages": "eng",
  "maxPagesPerPdf": 200,
  "extractTables": true,
  "outputChunks": false,
  "chunkSize": 2000,
  "chunkOverlap": 200,
  "includePageMarkers": false
}
```

# Actor output Schema

## `documents` (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 = {
    "pdfs": [
        "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("pankovs/pdf-to-llm-markdown").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 = { "pdfs": ["https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"] }

# Run the Actor and wait for it to finish
run = client.actor("pankovs/pdf-to-llm-markdown").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 '{
  "pdfs": [
    "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
  ]
}' |
apify call pankovs/pdf-to-llm-markdown --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,pankovs/pdf-to-llm-markdown"
        }
    }
}
```

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/7lNBYEfCigdCmH0hI/builds/7djc1NMoIxKtkVGph/openapi.json
