# PDF, DOCX & XLSX to Markdown Converter for RAG (`leadharbor/document-to-markdown`) Actor

Turn PDF, DOCX, XLSX, HTML, CSV and text links into clean Markdown with headings, lists and tables, plus heading-based chunks with token counts for RAG. No browser, no OCR. Pay per document converted.

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

## Pricing

$3.00 / 1,000 document converteds

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, DOCX & XLSX to Markdown Converter for RAG

Turn links to PDF, DOCX, XLSX, HTML, CSV and plain-text files into clean Markdown that keeps headings, lists and tables, then get ready-made chunks with the heading path and an approximate token count. Built for people who feed documents to an LLM, a vector database or a search index and do not want to write a parser for each file type.

Everything runs over plain HTTP inside the Actor. There is no browser, no proxy, no login and no OCR. You pay one small price for each document that was converted, and nothing for a link that failed.

### What you get

- One dataset row per link, with the Markdown, chunks, word count, page count, title and a `warnings` list.
- **PDF with a text layer:** reading order rebuilt from text positions (two-column pages are read left column first), headings found from section numbers (1, 4.1, A.2), keywords (Abstract, References), small capitals, bold run-in lead-ins and font size, a wrapped title joined into one heading, author lines kept as text, bullet and numbered lists, simple tables, wrapped lines joined into paragraphs, repeated page headers and footers removed, and a `<!-- page N -->` marker before each page.
- **DOCX:** headings, bold and italic, lists (numbering continues across nested items) and tables (several paragraphs in one cell are separated with `<br>`).
- **XLSX:** one Markdown table per visible sheet, dates as ISO text. Formulas are never evaluated: a formula cell shows the value stored in the file.
- **HTML:** the main content of the page, without navigation, footers, cookie banners, scripts, edit links, navigation and category boxes, or citation markers. Images are never emitted.
- **CSV and text:** CSV becomes a table, text stays text.
- The file type is decided from the file's bytes, not from the link or the `Content-Type` header.

### Sample input

```json
{
  "urls": [
    "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
    "https://raw.githubusercontent.com/apache/poi/trunk/test-data/document/SampleDoc.docx",
    "https://raw.githubusercontent.com/apache/poi/trunk/test-data/spreadsheet/SampleSS.xlsx"
  ],
  "chunking": "by-heading",
  "maxChunkTokens": 800
}
```

This is the prefilled input. In a local run it finished in about two seconds.

### Sample output

Real rows from a local run of this Actor (long text shortened with `...`).

An XLSX file:

```json
{
  "schemaVersion": 1,
  "url": "https://raw.githubusercontent.com/apache/poi/trunk/test-data/spreadsheet/SampleSS.xlsx",
  "finalUrl": "https://raw.githubusercontent.com/apache/poi/trunk/test-data/spreadsheet/SampleSS.xlsx",
  "source": "https://raw.githubusercontent.com/apache/poi/trunk/test-data/spreadsheet/SampleSS.xlsx",
  "format": "xlsx",
  "status": "ok",
  "pages": null,
  "title": "Sample Spreadsheet",
  "markdown": "## First Sheet\n\n| Test spreadsheet |  |\n| --- | --- |\n| 2nd row | 2nd row 2nd column |\n...",
  "chunks": [
    {
      "index": 0,
      "headingPath": ["First Sheet"],
      "text": "## First Sheet\n\n| Test spreadsheet |  |\n| --- | --- |\n| 2nd row | 2nd row 2nd column |\n| This one is red |  |",
      "approxTokens": 28,
      "pageStart": null,
      "pageEnd": null
    }
  ],
  "wordCount": 41,
  "warnings": ["Sheet \"Sheet3\" is empty."],
  "error": null,
  "errorKind": null,
  "charged": true
}
```

A 15-page research paper (PDF, text layer), shortened:

```json
{
  "format": "pdf",
  "status": "ok",
  "pages": 15,
  "title": "Attention Is All You Need",
  "wordCount": 5700,
  "warnings": [
    "283 rotated text fragments ignored.",
    "Removed repeated header/footer lines (text that appears at the top or bottom of most pages).",
    "Tables with unreliable columns: table kept as aligned text in a code block on pages 5, 6, 8, 9, 10."
  ],
  "chunks": [
    {
      "index": 5,
      "headingPath": ["Attention Is All You Need", "3 Model Architecture"],
      "text": "## 3 Model Architecture\n\nMost competitive neural sequence transduction models have an encoder-decoder structure [5, 2, 35]. Here, the encoder maps an input seq...",
      "approxTokens": 193,
      "pageStart": 2,
      "pageEnd": 3
    }
  ]
}
```

A link that could not be converted keeps its place in the dataset and says why:

```json
{
  "url": "https://www.w3.org/this-file-does-not-exist.pdf",
  "status": "error",
  "error": "The server answered HTTP 404, so there is no document to convert.",
  "errorKind": "http_error",
  "charged": false
}
```

### Input

| Field | Type | Default | What it does |
|---|---|---|---|
| `urls` | list of links | required | Up to 200 public links per run. Duplicates are converted once. |
| `chunking` | `by-heading`, `by-page`, `none` | `by-heading` | `by-heading`: one chunk per section with its heading path. `by-page`: one chunk per PDF page. `none`: no chunks. |
| `maxChunkTokens` | 100 to 8000 | 800 | No chunk is longer than this. Long sections are split at paragraph boundaries, long tables by rows with the header repeated. Tokens are approximate (characters / 4). |
| `maxPagesPerDocument` | 1 to 2000 | 100 | Only the first N PDF pages are converted. The row says so in `warnings`. |
| `includePageMarkers` | true / false | true | Adds `<!-- page N -->` before each PDF page. Chunks keep their page numbers either way. |

### Output fields

| Field | Meaning |
|---|---|
| `schemaVersion` | Always `1` for this layout. |
| `url`, `finalUrl`, `source` | The link you gave, the address after redirects, and the address the text came from. |
| `format` | `pdf`, `docx`, `xlsx`, `html`, `csv` or `txt`, from the file content. |
| `status` | `ok`, `no_text_layer`, `empty_after_conversion`, `error`, `skipped_charge_limit`, or `skipped_repeated_failures`. |
| `pages` | PDF page count. `null` for other formats. |
| `title` | Document title if one could be found, else `null`. |
| `markdown` | The converted document. |
| `chunks` | List of `{ index, headingPath, text, approxTokens, pageStart, pageEnd }`. Page numbers are only set for PDFs. |
| `wordCount` | Words of text, not counting table and heading symbols. |
| `warnings` | Anything the converter skipped or could not do: pages left out, scanned pages, images skipped, thin web pages. |
| `error` | Specific reason when `status` is `error`, else `null`. |
| `errorKind` | Machine-readable reason: `http_error`, `refused_address`, `download_failed`, `file_too_large`, `content_type_mismatch`, `password_protected`, `unreadable_document`, `unsupported_format`, `empty_file`, `too_large_uncompressed`, `html_too_large`, `html_too_deep`, `conversion_timeout`, `conversion_memory`, `conversion_crashed`, `charge_limit`, `circuit_open`, `document_too_deeply_nested`. `null` when there is no error. |
| `charged` | Whether this document was billed. |

A run summary (`SUMMARY` in the key-value store) counts converted, no-text-layer, failed and skipped documents.

### Pricing

Pay per event: **$0.003 for each document converted** (event `document-converted`). The price does not depend on page count or file size.

| Documents | Price |
|---|---|
| 100 | $0.30 |
| 1,000 | $3.00 |

Not charged: results with no text in them (`no_text_layer`, `empty_after_conversion`), links that fail (HTTP errors, refused addresses, files over 25 MB, password-protected or damaged files, wrong file type) and PDFs with no text layer. If pay-per-event pricing is not active for a run, converted documents are still delivered, `charged` stays `false`, and the run status says so once. Set a maximum cost per run and the Actor stops when it is reached; the remaining links get a row with `status: "skipped_charge_limit"` and are not converted.

### Use with AI agents / MCP

Send this input:

```json
{ "urls": ["https://example.org/report.pdf"], "chunking": "by-heading", "maxChunkTokens": 800 }
```

Read these fields from each dataset item:

1. `status` must be `ok`. If it is `error`, read `error`. If it is `no_text_layer`, the PDF is a scan and needs OCR elsewhere.
2. `chunks[].text` with `chunks[].headingPath` to index or quote a section. `chunks[].pageStart` and `pageEnd` give the PDF pages to cite.
3. `warnings` for anything the answer might be missing (skipped pages, skipped images).

### Limits

- **No OCR.** A scanned PDF returns `status: "no_text_layer"` and is not charged. If only some pages are scans, the other pages are converted and the empty ones are listed in `warnings`.
- **File size:** 25 MB per file; larger files are refused. The XML inside a DOCX or XLSX is checked by its real unzipped size (never the size the file claims): more than 64 MB in one part or 100 MB in total (XLSX: 80 MB) is refused. HTML over 5 MB or nested more than 2,000 levels is refused. A download that stalls or is cut off is an error after 30 seconds, never a partial document.
- **Every document is converted in its own process** with a 20 second budget and a memory ceiling of 60 percent of the Actor memory shared between the two documents converted at once (307 MB at the default 1024 MB). A document that hangs or needs more is stopped and becomes an error row (`conversion_timeout` or `conversion_memory`) that is not charged; the other links in the run carry on. The Actor memory is set to 1024 MB. Measured on Linux with Node 20 in a 1 GB container: a 20-page text PDF used 86 MB, a 100-page one 91 MB, real research papers 95 to 113 MB and a 59-page, 48,000-word report 134 MB, each converted in under 5 seconds; a hostile file is stopped within 20 seconds. If 10 documents in a row have to be stopped, the run ends early (circuit breaker) and the rest get `status: "skipped_repeated_failures"`, not charged.
- **Scans:** a page counts as having text only above a minimum amount of text (25 characters, 8 for a one-page file). If fewer than 20 percent of the pages have text the result is `no_text_layer`; if some pages are image-only the others are converted and the empty pages are named in `warnings`.
- **Text from documents is neutralised:** a tag-like `<` becomes `&lt;`, `![` and `](` cannot open an image or link, only http, https, mailto and tel links are kept, and a table cell that starts with `=`, `+` or `@` (or `-` before a non-number) gets a backslash so it cannot run as a formula when you export the dataset to CSV or Excel. The words themselves are never removed.
- **Password-protected files** and legacy Office files (`.doc`, `.xls`, `.ppt`) are refused with a named error. Save them as DOCX or XLSX without a password first.
- **Tables in PDFs (best effort):** a Markdown table is written only when the columns can be trusted (cells line up under the same columns on every row). Otherwise the rows are kept as a fenced block of aligned text and the run says "table kept as aligned text". A table that continues on the next page, or whose left and right halves were read separately, is joined when the columns clearly match. Merged cells, cells with several wrapped lines and tables drawn as images do not convert well.
- **Hyphenated words (heuristic):** a hyphen at a line end is dropped only when the joined word looks like one word (it occurs elsewhere in the document, or neither half is a word of its own). It is kept when the next line starts with a capital or digit, when the compound appears hyphenated elsewhere, when the left part is a prefix or one or two letters (pre-, non-, co-), and when both halves are ordinary words (encoder-decoder). It can still be wrong for a rare word.
- **Footnotes (best effort):** small print at the bottom of a PDF page is moved to a `Footnotes:` list after that page's text. Small print that holds table rows, or runs longer than 12 lines, is left in place. Footnotes in two columns can run together.
- **Figures (best effort):** runs of short, scattered labels (axis ticks, diagram boxes) are left out and counted in `warnings`. Text inside images is not read.
- **Page breaks:** a paragraph cut by a page break is joined to the first lines of the next page and sits on the first page of the two.
- **Headings** in PDFs are detected from section numbers, keywords, small capitals, bold text and font size. A heading must start a line with a capital letter, digit or quote and is never a "Figure 1" or "Table 2" caption. A PDF with one font size, no bold and no numbering has no detected headings. **Not supported:** OCR of scans and images, text inside pictures, and reading forms or annotations.
- **DOCX:** images are skipped and counted in `warnings`. Headers, footers and text boxes are not included. **XLSX:** charts and images are skipped, hidden sheets are skipped, sheets are cut at 10,000 rows and 100 columns, and formulas show their stored values only.
- **HTML:** only the HTML the server sends is read. A page that builds its content with JavaScript comes back thin and gets a warning.
- **Public links only.** The Actor does not log in, use cookies or fetch addresses on private networks. Only ports 80 and 443 are used.
- A row can hold at most about 8.5 MB. For a very large document the chunks, then the end of the Markdown, are dropped and a warning says so.
- Each PDF page is converted on its own, so a paragraph that runs over a page break becomes two paragraphs.

### FAQ

**Which file types do I get?** PDF, DOCX, XLSX, HTML, CSV and plain text. The type comes from the file's content. A link that ends in `.pdf` but serves a login or error page returns an error row instead of a wrong conversion.

**Does it read scanned PDFs?** No. It reads the text layer only. Scans return `no_text_layer` and cost nothing.

**Can I get one file with all the text?** Each row has the whole document in `markdown`. Use the dataset export (JSON, CSV, Excel) from Apify for a file.

**What are the tokens?** An estimate of characters divided by 4. It is meant for sizing chunks, not for exact billing by a model provider.

**How do I keep page numbers?** Leave `includePageMarkers` on and read `pageStart` and `pageEnd` on each chunk.

**Why did a PDF come back with a warning about rotated text?** Text drawn sideways (watermarks, margin stamps) is skipped. The count is in `warnings`.

### Open-source credits

Licences were read from each package's `LICENSE` file in the installed version, and the whole production tree was scanned with `tools/licence-scan.mjs`.

| Package | Version | Licence | Used for |
|---|---|---|---|
| pdfjs-dist | 4.10.38 | Apache-2.0 | Reading text and positions from PDF |
| mammoth | 1.13.0 | BSD-2-Clause | DOCX to HTML |
| jszip | 3.10.1 | MIT (offered as MIT or GPL-3.0; used under MIT) | Opening DOCX and XLSX packages |
| cheerio | 1.0.0 | MIT | Parsing HTML |
| apify | 3.7.2 | Apache-2.0 | Actor runtime |

Counts are for the shipped Linux image (`npm install --omit=dev --omit=optional`, as in the Dockerfile): 215 production packages in all, 47 of them pulled in by the four conversion libraries. Of the 215: 154 MIT, 19 Apache-2.0, 18 BSD-2-Clause, 9 ISC, 5 BSD-3-Clause, and one or two each of (MIT OR CC0-1.0), 0BSD, BlueOak-1.0.0 (`sax`), (MIT AND Zlib) (`pako`), BSD (`duck`, BSD-2-Clause text), `jszip` (MIT or GPL-3.0, used under MIT) and CC-BY-4.0 (`caniuse-lite` browser data from the Apify SDK; attribution given here). Of the 47 under the conversion libraries: 27 MIT, 13 BSD-2-Clause, 2 ISC, 1 BSD-3-Clause, 1 BSD, 1 Apache-2.0, `jszip` and `pako`. Nine packages declare an MIT, ISC or Apache-2.0 licence in their metadata but ship no licence file: seven inside the Apify SDK tree, and `boolbase` (ISC) and `isarray` (MIT) under the conversion libraries. `map-stream` (Apify SDK tree) has no licence field but ships an MIT licence text; `pause-stream` declares MIT and Apache2.

# Actor input Schema

## `urls` (type: `array`):

Public links to PDF, DOCX, XLSX, HTML, CSV or text files (up to 200 per run, 25 MB each). The file type is detected from the file content, not from the link. Links to private networks, localhost and pages that need a login are refused.

## `chunking` (type: `string`):

How to split each document into retrieval chunks. 'by-heading' makes one chunk per section and keeps the heading path. 'by-page' makes one chunk per PDF page. 'none' returns the Markdown only.

## `maxChunkTokens` (type: `integer`):

Longer sections are split at paragraph boundaries so no chunk goes over this size. Tokens are approximate (characters divided by 4).

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

Only the first N pages of a PDF are converted. The row says so in its warnings when pages were left out. The price is per document, not per page.

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

Adds  before each PDF page in the Markdown. Chunks keep their page numbers either way.

## Actor input object example

```json
{
  "urls": [
    "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
    "https://raw.githubusercontent.com/apache/poi/trunk/test-data/document/SampleDoc.docx",
    "https://raw.githubusercontent.com/apache/poi/trunk/test-data/spreadsheet/SampleSS.xlsx"
  ],
  "chunking": "by-heading",
  "maxChunkTokens": 800,
  "maxPagesPerDocument": 100,
  "includePageMarkers": true
}
```

# Actor output Schema

## `documents` (type: `string`):

Table of documents: status, format, pages, title, word count and warnings. Open a row to read the Markdown and chunks.

## `summary` (type: `string`):

Counts of converted, no-text-layer, failed and skipped documents.

# 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 = {
    "urls": [
        "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
        "https://raw.githubusercontent.com/apache/poi/trunk/test-data/document/SampleDoc.docx",
        "https://raw.githubusercontent.com/apache/poi/trunk/test-data/spreadsheet/SampleSS.xlsx"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("leadharbor/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 = { "urls": [
        "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
        "https://raw.githubusercontent.com/apache/poi/trunk/test-data/document/SampleDoc.docx",
        "https://raw.githubusercontent.com/apache/poi/trunk/test-data/spreadsheet/SampleSS.xlsx",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("leadharbor/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 '{
  "urls": [
    "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
    "https://raw.githubusercontent.com/apache/poi/trunk/test-data/document/SampleDoc.docx",
    "https://raw.githubusercontent.com/apache/poi/trunk/test-data/spreadsheet/SampleSS.xlsx"
  ]
}' |
apify call leadharbor/document-to-markdown --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,leadharbor/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/UHAgni5t2qoQD1HwE/builds/dglm47nHJP3qnScWt/openapi.json
