PDF to Markdown for RAG & LLMs: OCR, DOCX, Excel
Pricing
from $2.00 / 1,000 documents
PDF to Markdown for RAG & LLMs: OCR, DOCX, Excel
Convert PDF (native or scanned), DOCX, XLSX, PPTX, HTML and images, by URL or upload, to Markdown, JSON or RAG chunks with page numbers and headings. Tesseract OCR with per-page confidence. $2 per 1,000 documents + $0.0003/page; failures are free. 50 files per run by default. MCP tool for AI agents.
Pricing
from $2.00 / 1,000 documents
Rating
0.0
(0)
Developer
Rigel
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
17 hours ago
Last modified
Categories
Share
What is Document to Markdown for RAG?
PDF to Markdown, DOCX to Markdown and OCR in one Actor. Send PDFs (native or scanned), Word, Excel, PowerPoint, HTML or images by URL. You get back clean Markdown, structured JSON, or RAG chunks that carry page numbers and the heading path for citations.
- Every page records how it was read (
text-layerorocr) and, for scans, Tesseract's confidence. - Tables become Markdown tables. A ZIP of CSV files per document is optional.
- Failed, empty, duplicate and unsupported files are free. There is no start fee.
- The file never leaves the run: there is no third-party LLM or OCR API.
- It works from the API, Python, LangChain, LlamaIndex, n8n, Make, and as an MCP tool for AI agents (Claude, Cursor, any MCP client).
How much does it cost?
Pay per event. $2.00 per 1,000 documents, plus the pages actually read:
| Event | Price | When |
|---|---|---|
| Document | $0.002 | A file that produced at least one billed page |
| Text page | $0.0003 | A PDF page read from the text layer, one spreadsheet sheet, one slide, or 3,000 characters of Word, HTML, Markdown or plain text |
| OCR page | $0.004 | A PDF page or image frame read with Tesseract. Replaces the text-page event for that page |
| Free | $0 | Download failure, unsupported type, encrypted file, empty result, duplicate in the same run, pages skipped by pages |
Real examples:
| Input | Charged | Cost |
|---|---|---|
| IRS Publication 509 (PDF, 14 text pages) | 1 document + 14 pages | $0.0062 |
| NIST CSF 2.0, pages 1–8, as RAG chunks | 1 document + 8 pages | $0.0044 |
| Word file under 3,000 characters | 1 document + 1 page | $0.0023 |
| Excel workbook, 2 sheets | 1 document + 2 pages | $0.0026 |
| One scanned page | 1 document + 1 OCR page | $0.006 |
| 20-page text PDF / 20-page scan | $0.008 / $0.082 | |
| 1,000 ten-page text PDFs | 1,000 documents + 10,000 pages | $5.00 |
Set a spending cap on the run (maxTotalChargeUsd, minimum $0.05). When the next event would go over it, the run stops cleanly and the row is partial with the flag truncated-budget. Events that were not delivered are not charged.
How to use it
- Paste one or more direct file URLs into Document URLs, or select files from your computer with Upload files. A path such as
C:\report.pdfis not a URL and is rejected. - Pick an Output format:
markdown(one row per document),json(adds per-page Markdown and a plaintextfield),text, orchunks(one row per RAG chunk). - Run. Download the dataset as JSON, CSV or Excel, or read
OUTPUT.mdin the key-value store when you converted a single document.
Ready-made examples: PDF chunks for RAG · PDF to Markdown for LLMs · Scanned PDF OCR · Image OCR · DOCX to Markdown · Excel tables · PPTX with notes · Batch PDF URLs
Output
One dataset row per document:
{"source": "https://www.irs.gov/pub/irs-pdf/p509.pdf","fileName": "p509.pdf","mimeType": "application/pdf","status": "ok","pageCount": 14,"pagesProcessed": 14,"metadata": { "title": "2026 Publication 509", "language": "en" },"markdown": "<!-- page: 1 -->\n\n## Publication 509\n\n# Contents\n\n…","pages": [ { "page": 1, "method": "text-layer", "ocrConfidence": null, "chars": 2446 } ],"tables": [ { "page": 2, "rows": 5, "cols": 2, "markdown": "| IF you're... | THEN you may need... |\n| --- | --- | …", "csvKey": null } ],"flags": [],"charged": { "document": 1, "page": 14, "ocr-page": 0 }}
With output: "chunks", you get one row per chunk instead:
{"fileName": "NIST.CSWP.29.pdf","status": "ok","chunkIndex": 3,"text": "…","pageStart": 2,"pageEnd": 4,"headingPath": ["The NIST Cybersecurity Framework (CSF) 2.0", "Abstract", "Acknowledgments"],"chars": 1264,"charged": false}
The document is charged once. Only chunk 0 carries charged, metadata, pages, tables and pageCount; later chunks have "charged": false.
<!-- page: N --> marks where each source page starts (turn this off with includePageMarkers: false). The key-value store also holds RUN_SUMMARY.json (counts, error codes, amount charged), one doc-*.md file per document, OUTPUT.md when exactly one document was converted, and tables-*.zip when tableCsv is on. Markdown longer than 400,000 characters is cut in the row, and the full text is stored under markdownKey.
A file that fails still gets a row with status: "failed", an errorCode and charged: false. If every file fails, the run fails and nothing is charged.
errorCode | Meaning |
|---|---|
DOWNLOAD | HTTP error, DNS failure or the 60-second download timeout |
FILE_ACCESS_DENIED | The key-value record refused the run. Pass the signed recordPublicUrl (?signature=), or use Upload files in the Console |
SSRF | Private, loopback or link-local address (also checked after redirects) |
UNSUPPORTED | File type not handled, including legacy .doc and .ppt |
ENCRYPTED | Password-protected PDF |
EMPTY | No text found |
CORRUPT | The file could not be parsed |
CONTENT_MISMATCH | An HTML page saved under a .pdf or .docx name |
TOO_LARGE / ZIP_BOMB | Over maxFileMb, or an Office file that unpacks to more than 200 MB |
TIMEOUT | The conversion did not finish within timeoutSecs |
OCR_FAILED | Tesseract could not read the page |
PAGE_RANGE | pages is invalid or selects no page of this document |
LIMIT | The spending cap was reached before this document |
INPUT / INTERNAL | Invalid source item / unexpected error |
Input
| Field | Default | Role |
|---|---|---|
sources | — | {"url": "https://…"} items. A requestsFromUrl link list is accepted. |
files | — | One or more uploaded files. In the Console, use Upload files. From the API, pass each record's signed recordPublicUrl. The record key becomes fileName. |
datasetId | — | Optional dataset picker (read access). Each item is one source: a URL string or {"url": …} |
keyValueStoreId | — | Optional key-value store picker (read access). datasetId items {"kvKey": "report.pdf"} are read from this store |
output | markdown | markdown, json, text or chunks |
ocr | auto | auto OCRs a PDF page only when it has no text layer and holds a large image. always OCRs every PDF page, never uses the text layer only. Images are always OCR'd. |
ocrLanguages | eng, fra | Also deu, spa, ita, por, nld |
maxOcrPages | 40 | OCR pages billed per document. Further scanned pages are skipped, free, and flagged truncated-ocr |
ocrDpi | 200 | 120–300 |
pages | all | Page range for PDF, PowerPoint or TIFF, e.g. 1-5,8 |
tables, tableCsv | true, false | Markdown tables; optional ZIP of CSV files per document |
excelOutput | markdown | XLSX/XLS only. rows writes one JSON item per data row (sheet, rowIndex, values) instead of the Markdown row; both returns both. A sheet is still billed as one text page |
includePageMarkers, includeNotes | true, true | <!-- page: N --> markers; PowerPoint speaker notes |
chunkSize, chunkOverlap | 1500, 150 | Chunk budget in characters, overlap included. Long blocks are cut on a blank line, a sentence, then a word. Long tables are cut between rows and repeat their header row |
maxPagesPerDocument | 500 | Beyond this, pages are skipped and flagged truncated-pages |
maxRowsPerSheet | 20000 | Truncated sheets are flagged truncated-rows |
maxFileMb, maxDocuments | 100, 50 | Up to 100 MB per file. Default 50 documents, maximum 1,000. A run stops at 3600 seconds |
timeoutSecs | 300 | Per document. Pages already read are kept and charged; the rest are not |
headers | — | HTTP headers for your own files (e.g. Authorization). Never logged, and dropped if a redirect changes host |
Accepted: PDF, DOCX, XLSX, XLS, CSV, PPTX, HTML, TXT, Markdown, PNG, JPEG, TIFF, WebP, GIF, BMP.
Call it from the API
curl -X POST "https://api.apify.com/v2/acts/rigel~doc-to-markdown/run-sync-get-dataset-items?maxTotalChargeUsd=0.10" \-H "Authorization: Bearer $APIFY_TOKEN" \-H "Content-Type: application/json" \-d '{"sources": [{"url": "https://www.irs.gov/pub/irs-pdf/p509.pdf"}], "output": "markdown"}'
run-sync-get-dataset-items waits for the run and returns HTTP 408 after 300 seconds. For large batches, start the run asynchronously (/runs) and read the dataset when it finishes, or use a webhook.
Upload a file
In the Console, open the input and use Upload files. Select one or more files. The Console stores each file in your key-value store and fills files with the signed record URL. The original file name is the record key, and that name is fileName in the dataset. Document URLs stays a list of {"url": "https://…"} and is unchanged. Billing is the same as for a document URL.
The Actor runs with limited permissions, so it cannot open your store with its own client. It GETs the URL you pass, including ?signature=, and does not follow a redirect or add a token. A signed record URL answers 200 without a token. If that GET returns 401 or 403 and the URL has no signature, the run then tries its own store only.
From the API, store the file, then pass recordPublicUrl. Listing keys returns that field on every record:
GET https://api.apify.com/v2/key-value-stores/{storeId}/keys
The bytes themselves are:
GET https://api.apify.com/v2/key-value-stores/{storeId}/records/{recordKey}?signature=…
from decimal import Decimalfrom apify_client import ApifyClientclient = ApifyClient("<APIFY_TOKEN>")store = client.key_value_store("<STORE_ID>")with open("report.pdf", "rb") as handle:store.set_record("report.pdf", handle.read(), content_type="application/pdf")signed = store.get_record_public_url("report.pdf")# https://api.apify.com/v2/key-value-stores/<STORE_ID>/records/report.pdf?signature=…client.actor("rigel/doc-to-markdown").call(run_input={"files": [signed]},max_total_charge_usd=Decimal("0.10"),)
curl -X PUT "https://api.apify.com/v2/key-value-stores/$STORE_ID/records/report.pdf" \-H "Authorization: Bearer $APIFY_TOKEN" \-H "Content-Type: application/pdf" \--data-binary @report.pdfSIGNED=$(python - <<'PY'from apify_client import ApifyClientimport osstore = ApifyClient(os.environ["APIFY_TOKEN"]).key_value_store(os.environ["STORE_ID"])print(store.get_record_public_url("report.pdf"))PY)curl -X POST "https://api.apify.com/v2/acts/rigel~doc-to-markdown/runs?maxTotalChargeUsd=0.10" \-H "Authorization: Bearer $APIFY_TOKEN" \-H "Content-Type: application/json" \-d "{\"files\":[\"$SIGNED\"]}"
Only https://api.apify.com/v2/key-value-stores/<store>/records/<file> is accepted on that host. Any other Apify API path is refused. The signature is not written to the dataset. Any other host goes through the same SSRF checks as sources.
Python, LangChain and LlamaIndex
from decimal import Decimalfrom apify_client import ApifyClientfrom langchain_core.documents import Documentclient = ApifyClient("<APIFY_TOKEN>")run = client.actor("rigel/doc-to-markdown").call(run_input={"sources": [{"url": "https://nvlpubs.nist.gov/nistpubs/CSWP/NIST.CSWP.29.pdf"}],"output": "chunks","pages": "1-8","includePageMarkers": False,},max_total_charge_usd=Decimal("0.10"),)documents = [Document(page_content=row["text"],metadata={"source": row["source"],"page_start": row["pageStart"],"page_end": row["pageEnd"],"heading_path": " > ".join(row.get("headingPath") or []),},)for row in client.dataset(run.default_dataset_id).iterate_items()if row.get("status") != "failed"]
For LlamaIndex, build TextNode(text=row["text"], metadata={...}) from the same rows.
n8n and Make
- n8n: an HTTP Request node
POST https://api.apify.com/v2/acts/rigel~doc-to-markdown/run-sync-get-dataset-itemswith Header AuthAuthorization: Bearer <token>and the input as JSON body. Or use the Apify node, Run an Actor and get dataset. Branch onstatusanderrorCode. - Make: Apify › Run an Actor (Run synchronously: Yes) → Get Dataset Items → map
markdown.
Use it from AI agents (MCP)
Add the Apify MCP server with only this tool:
{ "mcpServers": { "apify-docs": { "url": "https://mcp.apify.com?tools=rigel/doc-to-markdown" } } }
or claude mcp add --transport http apify-docs "https://mcp.apify.com?tools=rigel/doc-to-markdown". Sign in with OAuth, or add the header Authorization: Bearer <APIFY_TOKEN>.
The agent gets the tool rigel--doc-to-markdown, plus get-dataset-items and get-key-value-store-record to read the results. The tool returns run status and storage ids, not the text. Tell your agent to:
- call
rigel--doc-to-markdownwithoutput: "chunks"for long files, ormarkdownfor short ones; - read rows with
get-dataset-itemsandfields: "pageStart,pageEnd,headingPath,text", or readOUTPUT.mdfor a single short document; - cite
pageStart–pageEnd.
This Actor is pay-per-event and runs with limited permissions, so agents can also use it through Apify's agentic payments.
Related Actors
- Website Screenshot API, Full Page & URL/HTML to PDF: turn web pages into PDFs first, then convert those PDFs to Markdown chunks here.
- Factur-X Validator & Generator: when a PDF is a Factur-X / ZUGFeRD e-invoice (rows flagged
factur-x-detected), validate it or extract its XML to JSON.
FAQ
How much does it cost to convert 1,000 PDFs? $2.00 for the documents, plus $0.30 per 1,000 text pages. For 1,000 ten-page text PDFs that is $5.00. Scanned pages cost $0.004 each.
Do I pay for files that fail? No. Download errors, encrypted or unsupported files, empty results and duplicates are free, and a run where every file fails costs nothing.
Can it read scanned PDFs and photos?
Yes. Pages without a text layer are OCR'd with Tesseract in English, French, German, Spanish, Italian, Portuguese or Dutch. Each page reports ocrConfidence. Clean scans score above 0.9 in our tests; handwriting does not work well.
Are my documents sent to OpenAI or another API? No. Conversion and OCR run inside your Apify run. Files are stored only in your own dataset and key-value store. Logs hold counters and error codes, never file contents, names or URLs.
How do I keep page numbers for citations?
Use output: "chunks": each chunk has pageStart, pageEnd and headingPath. In markdown mode, <!-- page: N --> markers show where each page starts.
What does a "page" mean for Word or HTML? Word, HTML and text have no fixed pages, so they are billed and cited in 3,000-character slices. A spreadsheet sheet or a slide counts as one page.
Can I convert files that are not on a public URL?
Use Upload files in the Console, or pass a signed recordPublicUrl. headers (for example Authorization) still work for your own file hosts. Private and local network addresses are refused, and this Actor never sends your token to a URL.
How large can a batch be?
The default is 50 documents, and the maximum is 1,000, at 100 MB per file. The run itself stops after 3600 seconds. Documents already finished are kept and charged; documents not started are not. Raise maxDocuments only when each file is small, and run with /runs instead of run-sync.
Why is my .doc or .ppt refused?
Legacy binary Office formats are not supported. Save the file as .docx or .pptx.
Does it parse invoices?
No. When a Factur-X / ZUGFeRD invoice is detected, the row is flagged factur-x-detected; use Factur-X Toolkit to validate or extract it.
Zapier: my Zap times out. Zapier's synchronous "Run Actor" action stops waiting after 30 seconds. For batches, run the Actor asynchronously and start the next step from Apify's "Finished Actor run" trigger, then read the results with the "Fetch dataset items" search.
Limits
- Layout comes from pdfplumber (text positions, font sizes, tables). Headings are inferred from font size and from numbered section titles (
2. Introduction,3.1 Profiles), so on some PDFs the heading path is approximate. Multi-column magazines and sparse banner tables can come out imperfect. - OCR is local Tesseract, not a commercial engine. Confidence is the mean word confidence, not a calibrated probability.
- Animated GIF and WebP: the first frame only. Encrypted PDFs are refused (a PDF with only an owner password opens).
- Default memory is 1024 MB. An 80-page text PDF peaked at 159 MB in testing. Raise it toward 4096 MB only for long OCR batches.
- After a platform migration, the run resumes from the
STATErecord and does not bill a document twice.
Open-source components
Built on pdfplumber and pdfminer.six (MIT), pypdfium2/PDFium (Apache-2.0 and BSD-3-Clause), pypdf (BSD-3-Clause), python-docx, openpyxl and python-pptx (MIT), BeautifulSoup and markdownify (MIT), Pillow (MIT-CMU), and Tesseract OCR (Apache-2.0). None is AGPL or GPL.
Feedback
Found a document that converts badly? Open an issue on the Actor's Issues tab with a public link to the file. That's the fastest way to get it fixed.