# PDF to Text, Markdown & Tables (CSV) (`isnard/pdf-text-tables-extractor`) Actor

Extract text, Markdown and tables as CSV from PDF URLs, with optional RAG chunks that keep page numbers. $1 per 1,000 PDFs plus $0.20 per 1,000 pages.

- **URL**: https://apify.com/isnard/pdf-text-tables-extractor.md
- **Developed by:** [Matias Isnard](https://apify.com/isnard) (community)
- **Categories:** Developer tools, AI
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.00 / 1,000 pdf processeds

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 to Text, Markdown & Tables (CSV)

Extract text, Markdown and every table as a downloadable CSV from PDF URLs or your own files, with optional RAG chunks that keep the first and last page of each chunk. Scanned pages are flagged with `needsOcr` instead of coming back silently empty. $1 per 1,000 PDFs plus $0.20 per 1,000 pages, and files that fail are free.

Give it PDF URLs, or a key-value store with your own PDF files. For each PDF you get one dataset item with:

- the plain text of the whole file and of each page
- Markdown, with headings guessed from font size and tables as Markdown tables
- every table found, as a CSV file (with a URL you can download without a token) and as JSON rows
- metadata: title, author, subject, creator, producer, created and modified dates, page count
- optional chunks for RAG, with an approximate token count and the first and last page of each chunk

Scanned PDFs (pages that are only images, with no text layer) are flagged with `needsOcr: true` and the list of those pages, instead of silently returning empty text. The Actor does not run OCR.

A PDF that cannot be downloaded or read gets one item with `status: "error"` and a readable message, and the run goes on with the next one.

### Input

```json
{
    "pdfUrls": [
        "https://arxiv.org/pdf/1706.03762",
        "https://www.irs.gov/pub/irs-pdf/fw9.pdf"
    ],
    "keyValueStoreId": "",
    "outputText": true,
    "outputMarkdown": true,
    "outputTables": true,
    "outputChunks": true,
    "chunkSize": 500,
    "chunkOverlap": 50,
    "maxPagesPerPdf": 0,
    "password": "",
    "maxFileSizeMb": 50,
    "downloadTimeoutSecs": 120
}
```

- `pdfUrls`: direct links to PDF files, one per line. Duplicates are processed once. Lines that are not http(s) URLs are skipped with a warning.
- `keyValueStoreId`: a key-value store from your account with your own files. See "Your own files" below.
- `outputText` (default true): `text` with the whole document and `pages` with the text of each page.
- `outputMarkdown` (default true): `markdown`, one block per page, each starting with `<!-- page N -->`.
- `outputTables` (default true): `tables`, and one CSV file per table in the run's key-value store.
- `outputChunks` (default false): `chunks` for RAG.
- `chunkSize` (default 500) and `chunkOverlap` (default 50): in tokens, counted as characters divided by 4. That is close to what OpenAI and similar tokenizers give for English text, but it is an estimate. Chunks are cut at a space or line break, never inside a word. The overlap is capped at half the chunk size.
- `maxPagesPerPdf` (default 0, meaning all): only the first N pages are processed and charged. `pageCount` still shows the real number of pages.
- `password`: for encrypted PDFs. The same password is tried on every file. It is a secret field, stored encrypted and not shown in the run input.
- `maxFileSizeMb` (default 50): bigger files get an error item and are not charged.
- `downloadTimeoutSecs` (default 120): maximum time to download one file.

### Your own files

1. In Apify Console go to Storage > Key-value stores and create a store (or use an existing one).
2. Upload your PDFs as records. Give each key a `.pdf` ending, for example `invoice-2026-01.pdf`, or set the content type to `application/pdf`. From the command line: `apify key-value-stores set-value <storeId> report.pdf --content-type application/pdf < report.pdf`. With the API: `PUT https://api.apify.com/v2/key-value-stores/<storeId>/records/report.pdf` with header `Content-Type: application/pdf` and the file as the body.
3. In the Actor input, pick the store in "Key-value store with your PDFs".

Every `.pdf` record of the store is processed, together with the URLs in `pdfUrls`. The Actor gets read access to that store only, nothing else in your account. The `url` of these items is the record's API URL.

You can also put a record URL like `https://api.apify.com/v2/key-value-stores/<storeId>/records/<key>` in `pdfUrls`. That works when the store is picked in the field above, or when the store's General access is set to "Anyone with link can read".

### Output

One dataset item per PDF. A real example from a test run, shortened (more in `ejemplos/salida.json` in the source):

```json
{
    "url": "https://slicedinvoices.com/pdf/wordpress-pdf-invoice-plugin-sample.pdf",
    "status": "ok",
    "processedAt": "2026-10-30T04:21:18+00:00",
    "fileSizeBytes": 43627,
    "pageCount": 1,
    "pagesProcessed": 1,
    "needsOcr": false,
    "pagesNeedingOcr": [],
    "metadata": {
        "title": "PDF Invoice Example",
        "author": "Sliced Invoices",
        "subject": "Example PDF invoice that was created using the Sli",
        "creator": "Sliced Invoices",
        "producer": "Mac OS X 10.10.4 Quartz PDFContext",
        "created": "2016-02-04T00:05:02+00:00",
        "modified": "2016-02-04T00:05:02+00:00"
    },
    "error": null,
    "text": "Invoice\nInvoice Number INV-3337\nFrom:\nDEMO - Sliced Invoices Order Number 12345\n...",
    "pages": [{ "page": 1, "text": "Invoice\nInvoice Number INV-3337\n...", "chars": 567 }],
    "markdown": "<!-- page 1 -->\n\n# Invoice\n\nPayment is due within 30 days from date of invoice. ...",
    "tables": [
        {
            "page": 1,
            "index": 2,
            "rowCount": 5,
            "columnCount": 2,
            "csvKey": "table-006-p1-2.csv",
            "csvUrl": "https://api.apify.com/v2/key-value-stores/6bgsR9UkDRGs36GCl/records/table-006-p1-2.csv?signature=8Lcot9TKBOXSWvTXihSb",
            "rows": [["Invoice Number", "INV-3337"], ["Order Number", "12345"], ["Invoice Date", "January 25, 2016"], ["Due Date", "January 31, 2016"], ["Total Due", "$93.50"]]
        }
    ],
    "chunks": [
        { "index": 0, "text": "Invoice\nInvoice Number INV-3337\n...", "pageStart": 1, "pageEnd": 1, "approxTokens": 170 }
    ]
}
```

- `status`: `ok` or `error`. On error only `url`, `status`, `processedAt` and `error` are set, for example `"HTTP 404 Not Found"`, `"Got an HTML page (content type text/html), not a PDF"`, `"PDF is password protected, set the password input"` or `"File is larger than the 50 MB limit"`.
- `needsOcr`: `true` when at least half of the processed pages are images without a text layer. `pagesNeedingOcr` lists those pages, also when the rest of the file has text. Send those files to an OCR tool.
- `metadata`: dates are converted to ISO 8601. Fields the file does not have are `null`.
- `tables[].csvUrl`: a signed link that works without an Apify token. The CSV files are in the run's default key-value store, named `table-<file number>-p<page>-<table number>.csv`, and they are kept as long as that store (the data retention of your plan).
- `pageErrors`: only present when some pages could not be read. The other pages are still returned.
- `fullResultUrl`: only present when the item would be bigger than 8 MB (Apify's limit for one dataset item is 9 MB). Then `text`, `pages`, `markdown`, `chunks` and the table rows are left out of the dataset and the full item is saved as JSON in the key-value store, at this link.

The "Overview" view of the dataset shows URL, status, pages, needs OCR, title, author, created date and error.

### How it works

Each file is downloaded once with a plain GET, with the size limit and timeout from the input. The file must start with `%PDF-`, otherwise it is reported as not a PDF. Requests identify themselves with the User-Agent `Mozilla/5.0 (compatible; ApifyPdfExtractor/1.0; +https://apify.com/isnard/pdf-text-tables-extractor)`. Files are processed one after the other.

Text and tables are extracted with [pdfplumber](https://github.com/jsvine/pdfplumber), which is built on [pdfminer.six](https://github.com/pdfminer/pdfminer.six). Both are MIT licensed. PyMuPDF is faster and its Markdown is better, but it is AGPL, which would require publishing the source of a hosted service built on it, so it is not used.

- A page counts as having no text layer when it has fewer than 20 non-space characters and contains at least one image.
- Tables are found from the lines and cell borders drawn in the PDF. A table needs at least two rows and two columns.
- In the Markdown, text inside a table is written only once, as the table. A line is a heading when its font is at least 1.25 times the size of the page's body text (`##`) or at least 1.6 times (`#`).

### Pricing

Pay per event:

- `pdf-processed`: each PDF that was downloaded and read, when at least one page could be read.
- `page-processed`: each page extracted, up to `maxPagesPerPdf`. Pages listed in `pageErrors` are not charged.

Error items (404, not a PDF, too big, wrong password, no readable page, private or local address) are not charged. You can cap the cost of a run with the "Maximum cost per run" setting. When the cap is reached the Actor stops after the current file.

### Limitations

- No OCR. Scanned pages are flagged, not read.
- Tables without drawn borders (columns aligned only by spaces) are usually not detected as tables. Their text is still in `text` and `markdown`.
- Layout boxes, for example the frame around an invoice, can come out as a table, and a table inside another one appears twice.
- Multi-column layouts can mix columns in `text`. Rotated text and some form fields come out in odd order or reversed.
- Some PDFs space out every letter (`0 3 / 2 5`), and the text comes out like that.
- Tokens are an estimate (characters divided by 4), not a real tokenizer.
- Only files reachable with a plain GET. No cookies, login or custom headers. Sites that block automated requests answer 403, which is reported as an error.
- Very large or complex files (thousands of pages, heavy vector drawings) are slow, around 1 to 2 seconds per page in the worst case seen in testing. Use `maxPagesPerPdf` to limit them. Each PDF gets at most 300 seconds: pages after that are left out, not charged, and the item gets a `warning`.

### Run locally

```bash
python -m venv .venv
.venv/Scripts/pip install -r requirements.txt   # on Linux/macOS: .venv/bin/pip
## put input in storage/key_value_stores/default/INPUT.json
.venv/Scripts/python -m my_actor
python -m unittest discover -s tests -t .
```

The test fixtures in `tests/fixtures` are written by `tests/make_fixtures.py` without any PDF library.

Author: Matias Isnard.

### FAQ

**Does it read scanned PDFs?**
No OCR. Scanned pages are listed in `pagesNeedingOcr` so you can send them to an OCR tool.

**Which tables are detected?**
Tables with drawn lines or cell borders. Columns aligned only with spaces come out as text.

**What if a PDF fails?**
A 404, a non-PDF, an oversized file or a wrong password gives an error item that is not charged.

**Size limits?**
50 MB per file by default (`maxFileSizeMb`). `maxPagesPerPdf` caps pages processed and charged.

### Related tools

- [OCR: Image & Scanned PDF to Text (Spanish, English, Portuguese)](https://apify.com/isnard/ocr-image-to-text): read the scanned pages this Actor lists in pagesNeedingOcr.
- [Bulk Article Extractor: Clean Text & Markdown for LLMs](https://apify.com/isnard/article-extractor): extract text from web pages.

# Actor input Schema

## `pdfUrls` (type: `array`):

Direct links to PDF files, one per line. For your own files, use the key-value store field below instead.

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

Pick a key-value store from your account. Every record whose key ends in .pdf or whose content type is application/pdf is processed, in addition to the URLs above. Only this store is shared with the Actor.

## `outputText` (type: `boolean`):

Plain text of the whole file and of each page.

## `outputMarkdown` (type: `boolean`):

Markdown with headings guessed from font size, paragraphs and tables as Markdown tables.

## `outputTables` (type: `boolean`):

Tables found on each page. Each table is saved as a CSV file in the key-value store, with its URL and its rows (JSON) in the dataset.

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

Split the text into overlapping chunks, each with the first and last page it comes from.

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

Approximate size of each chunk. Tokens are counted as characters divided by 4.

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

Approximate overlap between consecutive chunks. Capped at half the chunk size.

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

Only the first N pages of each PDF are processed and charged. 0 means all pages.

## `password` (type: `string`):

Password for encrypted PDFs. The same password is tried on every file. Stored encrypted.

## `maxFileSizeMb` (type: `integer`):

Bigger files are skipped with an error and not charged.

## `downloadTimeoutSecs` (type: `integer`):

Maximum time to download one file.

## Actor input object example

```json
{
  "pdfUrls": [
    "https://arxiv.org/pdf/1706.03762"
  ],
  "outputText": true,
  "outputMarkdown": true,
  "outputTables": true,
  "outputChunks": false,
  "chunkSize": 500,
  "chunkOverlap": 50,
  "maxPagesPerPdf": 0,
  "maxFileSizeMb": 50,
  "downloadTimeoutSecs": 120
}
```

# Actor output Schema

## `results` (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 = {
    "pdfUrls": [
        "https://arxiv.org/pdf/1706.03762"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("isnard/pdf-text-tables-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 = { "pdfUrls": ["https://arxiv.org/pdf/1706.03762"] }

# Run the Actor and wait for it to finish
run = client.actor("isnard/pdf-text-tables-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 '{
  "pdfUrls": [
    "https://arxiv.org/pdf/1706.03762"
  ]
}' |
apify call isnard/pdf-text-tables-extractor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,isnard/pdf-text-tables-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/vK27ZPfEap3IgeKSK/builds/fDj8H8Guq3XdJXfTQ/openapi.json
