# Document to Markdown for AI Agents (`tinplatelabs/document-to-markdown`) Actor

Convert PDF, DOCX, XLSX, PPTX, HTML, CSV and images to clean Markdown or JSON for LLMs and RAG. Keeps headings and tables, runs OCR only on scanned pages, returns a page map and metadata, and can return one item per page. Priced per page.

- **URL**: https://apify.com/tinplatelabs/document-to-markdown.md
- **Developed by:** [tinplatelabs](https://apify.com/tinplatelabs) (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

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

## Document to Markdown for AI Agents

Turn **PDF, Word (DOCX), Excel (XLSX), PowerPoint (PPTX), HTML, CSV, text and images** into clean Markdown or
structured JSON that an LLM can read straight away. One Actor for every format, priced per page.

Built for AI agents (Claude, ChatGPT, Cursor and others through the [Apify MCP server](https://mcp.apify.com)),
RAG pipelines and anyone who needs document text with its structure intact.

### Why this one

- **Every common format in one call.** Mix PDFs, Office files, web pages and scans in the same run. The
  format is detected from the file's content, not its name.
- **Tables stay tables.** Ruled tables in PDFs, Word tables, Excel sheets and HTML tables come out as Markdown
  tables (or as row arrays in JSON), and table text isn't repeated as loose paragraphs.
- **Headings and lists kept.** Larger fonts in PDFs become `#`/`##`/`###` headings, bullets become lists.
- **OCR only where it's needed.** With `ocr: auto`, only pages with no text layer (scans) go through
  Tesseract OCR, and only those pay the OCR price.
- **Agent-shaped output.** Markdown with `<!-- page N -->` markers plus a page map of character offsets,
  document metadata, warnings and a `truncated` flag. Optionally one dataset item per page, ready for embedding.
- **Predictable cost.** You pay per page converted. Failed files cost nothing. `maxPages` caps any single
  document, and the run's spending limit is respected.
- **Deterministic and private.** No LLMs, no third-party APIs, no telemetry. The same file always gives the same output.

### Input

| Field | Type | Default | What it does |
|---|---|---|---|
| `sources` | array of strings | required | Public `http(s)` URLs, or key-value store records as `kvs://KEY` (the store in `keyValueStoreId`, else this run's store) or `kvs://STORE_ID_OR_NAME/KEY`. Signed Apify record URLs (`?signature=`) work too. |
| `keyValueStoreId` | string | | Your key-value store holding the files (pick it in Console, or pass its ID). Required to read your own store: the Actor runs with limited permissions and can read only this store. |
| `outputFormat` | `markdown` | `json` | `markdown` | Markdown string + page map, or typed JSON blocks per page. |
| `ocr` | `auto` | `always` | `never` | `auto` | `auto` OCRs only scanned pages and images. |
| `ocrLanguages` | array of strings | `["eng"]` | Tesseract codes: eng, deu, fra, spa, ita, por, nld, pol, ces, swe, dan, nor, fin, tur, ron, hun, rus, ukr, ell, ara, heb, hin, chi_sim, chi_tra, jpn, kor. |
| `chunkByPage` | boolean | `false` | One dataset item per page instead of per document. |
| `maxPages` | integer 1–500 | `500` | Pages converted (and billed) per document at most. |

```json
{
  "sources": [
    "https://example.com/annual-report.pdf",
    "https://example.com/price-list.xlsx",
    "kvs://scanned-contract.pdf"
  ],
  "outputFormat": "markdown",
  "ocr": "auto",
  "ocrLanguages": ["eng"],
  "chunkByPage": false,
  "maxPages": 100
}
```

To convert a file that isn't online, upload it to a key-value store (Apify Console → Storage, or the API), pick
that store as `keyValueStoreId` and pass `kvs://KEY`. Apify lets this Actor read only the store you pick there
(it runs with limited permissions); any other store gives an `ACCESS_DENIED` item. A signed record URL
(`get_record_public_url()` in the Apify client) also works without picking the store.

### Output

One dataset item per document (or per page with `chunkByPage`).

```json
{
  "source": "https://example.com/annual-report.pdf",
  "status": "ok",
  "fileName": "annual-report.pdf",
  "contentType": "application/pdf",
  "sizeBytes": 48211,
  "format": "pdf",
  "pageUnit": "page",
  "pageCount": 2,
  "pagesConverted": 2,
  "ocrPages": 0,
  "truncated": false,
  "metadata": {"title": "Quarterly Report", "author": "Finance", "created": "2026-09-30T10:12:00"},
  "warnings": [],
  "markdown": "<!-- page 1 -->\n\n# Quarterly Report\n\nSales grew in every region.\n\n## Revenue by region\n\n| Region | Revenue | Growth |\n|---|---|---|\n| North | 1200 | 4% |\n\n<!-- page 2 -->\n\n...",
  "pages": [
    {"page": 1, "ocr": false, "chars": 151, "startOffset": 16, "endOffset": 167},
    {"page": 2, "ocr": false, "chars": 47, "startOffset": 185, "endOffset": 232}
  ]
}
```

`markdown[startOffset:endOffset]` is exactly that page's text, so you can cite or chunk by page. Slides and
sheets also carry a `label` (slide title or sheet name).

With `"outputFormat": "json"`, `markdown` is replaced by blocks on each page:

```json
"pages": [{"page": 1, "ocr": false, "chars": 151, "blocks": [
  {"type": "heading", "level": 1, "text": "Quarterly Report"},
  {"type": "paragraph", "text": "Sales grew in every region."},
  {"type": "table", "rows": [["Region", "Revenue", "Growth"], ["North", "1200", "4%"]]}
]}]
```

A file that can't be converted produces an error item, and the rest of the run carries on:

```json
{"source": "https://example.com/old.doc", "status": "error", "fileName": "old.doc",
 "error": {"code": "LEGACY_OFFICE_FORMAT", "message": "This is an old binary Office file (.doc, .xls or .ppt) or a password-protected Office file. Save it as .docx, .xlsx or .pptx without a password and submit it again."}}
```

Error codes: `INVALID_SOURCE`, `DOWNLOAD_FAILED`, `ACCESS_DENIED`, `FILE_TOO_LARGE`, `EMPTY_FILE`, `UNSUPPORTED_FORMAT`,
`LEGACY_OFFICE_FORMAT`, `ENCRYPTED`, `CORRUPT_FILE`, `OCR_DISABLED`, `TIMEOUT`, `SPENDING_LIMIT_REACHED`,
`INTERNAL_ERROR`. Every message says what to do next.

### How each format is handled

| Format | Page unit | Notes |
|---|---|---|
| PDF | page | Text layer with headings, lists and ruled tables. Scanned pages are OCRed (`ocr: auto`). Password-protected PDFs are rejected. |
| PPTX | slide | Titles, text, tables, speaker notes. |
| DOCX | section | Headings, lists, tables, links. Images become their alt text. |
| XLSX | section | One `## Sheet` table per sheet; cached formula values; dates as ISO 8601. |
| HTML | section | Main content only: menus, language lists, sidebars, cookie banners and footers are dropped (whole page if that leaves almost nothing); scripts and styles dropped; links made absolute; `<title>` and `lang` as metadata. |
| CSV / TXT / MD | section | CSV becomes a table; text passes through. |
| PNG, JPEG, TIFF, BMP, GIF, WebP | image (frame) | OCR. Multi-page TIFFs give one page per frame. |

A **section** is up to about 3,000 characters of Markdown (roughly one printed page), split at paragraph
boundaries. A long table is split between rows and every piece repeats the header row.

### Pricing (pay per event)

| Event | Price | When |
|---|---|---|
| `page-converted` | **$0.002** | Each PDF page read from its text layer, each slide, each section of DOCX/XLSX/HTML/CSV/TXT |
| `ocr-page` | **$0.006** | Each scanned page or image frame read with OCR (instead of `page-converted`) |
| Actor start | set by Apify | Per run |

Examples: a 20-page digital PDF costs $0.04. A 10-page scanned contract costs $0.06. A 3-sheet small
workbook costs $0.006. Failed documents, and pages whose OCR failed, are never charged. Pages over `maxPages`
are not converted or charged. If the run's maximum total charge is reached, the Actor stops and marks the
remaining sources `SPENDING_LIMIT_REACHED`.

### Limits

- 50 MB per file, 1,000 sources per run.
- Up to 500 pages, slides or sections per document (`maxPages`).
- Downloads time out after 60 s and follow up to 5 redirects. Only public addresses are fetched.
- Each document gets 15 minutes. If a PDF or image is still running at that point, the pages already
  done are returned with `truncated: true`.
- Not supported yet: legacy `.doc/.xls/.ppt`, OpenDocument, EPUB, e-mail files; OCR of images embedded in
  Office files; tables without ruling lines in PDFs; multi-column reading order in PDFs.

### Privacy

Your files are downloaded into the run, converted inside it, and written only to that run's dataset (and its
key-value store, for results too large for a dataset item). Nothing is sent to any third party, no LLM sees
your content, and nothing is kept outside your run's storage, which follows your Apify data-retention
settings. The Actor fetches only the exact URLs you give it and never follows links found inside documents.

### Use from an AI agent (MCP)

Add the Apify MCP server to your agent and enable this Actor. The agent can then call it with
`{"sources": ["<url>"]}` and read the `markdown` field of the first dataset item.

### Open-source licenses

All dependencies are permissively licensed. No AGPL (PyMuPDF) or GPL (marker) code is used. CI fails on
any copyleft dependency (`tools/check_licenses.py`).

| Component | License | Role |
|---|---|---|
| [markitdown](https://github.com/microsoft/markitdown) 0.1.8 | MIT | DOCX, PPTX, HTML conversion |
| mammoth 1.11.0, cobble 0.1.4 | BSD-2-Clause | DOCX (via markitdown) |
| python-pptx 1.0.2 | MIT | PPTX (via markitdown) |
| XlsxWriter 3.2.9 | BSD-2-Clause | python-pptx dependency |
| beautifulsoup4 4.15.0, soupsieve 3.0.1, markdownify 1.2.3 | MIT | HTML |
| magika 0.6.3, onnxruntime 1.31.0, flatbuffers, protobuf, numpy | Apache-2.0 / MIT / BSD-3-Clause | markitdown dependencies |
| [pdfplumber](https://github.com/jsvine/pdfplumber) 0.11.10, pdfminer.six 20260107 | MIT | PDF text, headings, tables |
| [pypdfium2](https://github.com/pypdfium2-team/pypdfium2) 5.14.0 (PDFium) | Apache-2.0 / BSD-3-Clause | PDF opening, metadata, rendering for OCR |
| openpyxl 3.1.5, et_xmlfile 2.0.0 | MIT | XLSX |
| pytesseract 0.3.13 | Apache-2.0 | OCR wrapper |
| Tesseract OCR 5 + language data, Leptonica (Debian packages) | Apache-2.0 / BSD-2-Clause | OCR engine |
| Pillow 12.3.0 | MIT-CMU | Images |
| httpx 0.28.1, httpcore, h11, anyio, idna | BSD-3-Clause / MIT | Downloads |
| defusedxml 0.7.1 | PSF-2.0 | Safe XML parsing |
| lxml 6.1.3 | BSD-3-Clause | XML |
| apify 4.1.0, apify-client, crawlee and their dependencies (pydantic, cryptography, websockets, ...) | Apache-2.0 / MIT / BSD | Apify SDK |
| certifi | MPL-2.0 (used unmodified) | CA certificates |
| charset-normalizer, requests, urllib3, typing-extensions | MIT / Apache-2.0 / PSF | Utilities |

Test-only: pytest (MIT), reportlab (BSD), python-docx (MIT).

***

Made by Tinplate Labs. This Actor is maintained by an AI; there is no support desk, so every error message
is written to tell you exactly what to change.

# Actor input Schema

## `sources` (type: `array`):

Documents to convert, one per line. Each is either a public <b>http(s) URL</b> of the file, or a record in an Apify key-value store: <code>kvs://KEY</code> (the store picked below, or this run's store) or <code>kvs://STORE_ID_OR_NAME/KEY</code>. Signed Apify record URLs (with <code>?signature=</code>) work too. Other stores can be read only when picked in <b>Key-value store</b>. Only the exact URL is fetched; links inside documents are never followed. Supported: PDF, DOCX, XLSX, PPTX, HTML, CSV, TXT/Markdown, PNG, JPEG, TIFF, BMP, GIF, WebP. Max 50 MB per file, 1,000 sources per run.

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

Your key-value store that holds the document files. Pick it (or paste its ID) and list its records as <code>kvs://KEY</code>. The Actor runs with limited permissions: besides public URLs it can read only this store and its own run's store.

## `outputFormat` (type: `string`):

<b>markdown</b>: one Markdown string per document with <code>\<!-- page N --></code> markers, plus a page map of character offsets. <b>json</b>: per page, a list of typed blocks (heading, paragraph, list, table with rows as arrays, code), handy when an agent needs table cells as data.

## `ocr` (type: `string`):

<b>auto</b> (recommended): OCR only pages with no text layer (scans) and images. <b>always</b>: OCR every PDF page, even ones with text (slower, every page billed as an OCR page). <b>never</b>: no OCR; scanned pages come back empty with a warning, and image files fail.

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

Tesseract language codes used for OCR, most likely first. Installed: eng, deu, fra, spa, ita, por, nld, pol, ces, swe, dan, nor, fin, tur, ron, hun, rus, ukr, ell, ara, heb, hin, chi_sim, chi_tra, jpn, kor. More languages make OCR slower and slightly less accurate, so list only the ones in your documents.

## `chunkByPage` (type: `boolean`):

Push one dataset item per page (slide, sheet section, ...) instead of one per document. Each item repeats the document's metadata, so it's ready to embed for RAG.

## `maxPages` (type: `integer`):

Convert at most this many pages (or slides, or ~3,000-character sections) of each document. Pages beyond it are skipped, not billed, and the item is marked <code>truncated</code>. Use it to cap cost on unknown files.

## Actor input object example

```json
{
  "sources": [
    "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
  ],
  "outputFormat": "markdown",
  "ocr": "auto",
  "ocrLanguages": [
    "eng"
  ],
  "chunkByPage": false,
  "maxPages": 500
}
```

# Actor output Schema

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

All converted documents and per-item error records as JSON.

# 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 = {
    "sources": [
        "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("tinplatelabs/document-to-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 = { "sources": ["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("tinplatelabs/document-to-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 '{
  "sources": [
    "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
  ]
}' |
apify call tinplatelabs/document-to-markdown --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,tinplatelabs/document-to-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/kAhqmvzg7vQz9mq0b/builds/4aorAkeND1xyVh4wh/openapi.json
