# PDF Text Extractor: OCR, Markdown, Page Checks (`pradio/pdf-text-verifier`) Actor

Read your PDFs and get the text plus a verdict on every page — the file's own layer, OCR for scans at no per-page charge, or a row saying why it couldn't be read. A wrong layer is flagged, not passed off as clean. Full text or Markdown, page ranges, locked files, uploads. Failed files cost nothing.

- **URL**: https://apify.com/pradio/pdf-text-verifier.md
- **Developed by:** [Pradio Actors](https://apify.com/pradio) (community)
- **Categories:** Developer tools, AI
- **Features:** Limited permissions
- **Stats:** 1 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.93 / 1,000 document returneds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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: OCR, Markdown, Page Checks

### What does PDF Text Extractor do?

PDF Text Extractor reads your PDFs and gives you back one row per file: the text, plus a verdict on every page. Give it links, an upload, files sent inline or a saved table of links, then press Start. If a page is a scan, we read it with OCR — that's inside the document price. If a page's own text layer fails the check, we flag it instead of handing it to you as clean text.

**$3.25 per 1,000 documents, and a miss costs nothing.** The price covers the first 50 pages of a file; after that it's $0.10 per 1,000 pages. The optional page check adds $8 per 1,000 pages checked.

Read the text of a public PDF:

```json
{
  "urls": ["https://ntrs.nasa.gov/api/citations/20160001713/downloads/20160001713.pdf"]
}
```

In a 52-file run it read 45 of them, about 26 seconds each at the default memory. The other 7 came back as rows saying exactly why they failed.

### Who uses PDF Text Extractor

| Buyer | What you run it for |
|---|---|
| Search and AI pipeline builders | Checked text into indexes and embeddings |
| Compliance and legal teams | A check on every document before anyone reads it |
| Civic-data projects | Scanned or broken files out of public PDF batches |
| Document-ingest operators | Which batch files read clean and which need re-sourcing |

### Features

- **A verdict on every page it can judge.** The file's own layer, OCR, what made a page unreadable — or an honest `unverified` when the checks can't say.
- **OCR inside the price.** A page with no text layer is rendered and read inside the document's charge.
- **The page check.** Turn it on and we render every page and compare its text layer with what the page actually shows. If they disagree we flag it and use the rendered text.
- **Four ways to send files.** Links, an upload, base64 inline, or a saved dataset.
- **Markdown, chunks, cleanup.** Markdown on request, page-tagged chunks, repeated headers and footers dropped.
- **Tables, images, phrases.** Detected tables rebuilt, embedded images saved, up to 20 phrases screened.
- **Tuned OCR.** Language, resolution, rotation retries, layout, confidence floor, scan cleanup.
- **Repeat runs skip unchanged files.** A file already returned under the same options is skipped.
- **Caps you control.** How many files a run takes, how many pages each contributes, how many get OCR, how big a file can be, how long a download may take.

### What you can count on

- You only pay when a file is actually read: if it can't be answered you get a free row naming the file and why.
- A row is charged only after it lands in your dataset.
- If nothing comes back, you get one row saying so.
- Set a spending limit and the run stops cleanly; the last row carries the counts.
- Every run saves a summary of what it read, returned and charged.
- If the run itself breaks, it fails and says why in the log.
- If a file doesn't carry something a field asks for, that field comes back empty.

### Why this one

| | PDF Text Extractor | Most-used alternative |
|---|---|---|
| Rows on the same document | 1 | 1 |
| Price per 1,000 documents | $3.25 | $5.00 |
| Start charge per run | $0.00005 | $0.005 |
| A scanned page read by OCR | inside the document price | $0.015 a page |
| A verdict on every page | Yes | No |

Run on the govinfo.gov document in the example list, measured 2026-09-22. The per-page figure is the alternative's own pricing.

### What data does PDF Text Extractor return?

A real row the example link returned, cut to the fields checked first:

```json
{
  "url": "https://ntrs.nasa.gov/api/citations/20160001713/downloads/20160001713.pdf",
  "file_name": "20160001713.pdf",
  "row_type": "ROW",
  "status": "agree",
  "recommended_action": "Use the text as it is.",
  "page_count": 14,
  "pages_extracted": 14,
  "word_count": 895,
  "pdf_type": "text_based",
  "verdict_counts": {"agree": 14, "suspect_layer": 0, "wrong_layer": 0, "hidden_content": 0, "no_layer": 0, "unreadable": 0},
  "extra_pages": 0,
  "text_preview": "www.nasa.gov \nNational Aeronautics and Space Administration \nRelative Terrain Imaging ..."
}
```

On the 52-document run, 32 files read fully clean, 13 carried OCR-read pages, 16 a page that gave nothing.

Every field a row can carry:

| Field | What it holds |
|---|---|
| `url` | The link this row answers; null on an inline file or repeat |
| `file_name` | The link's last part, or the name you gave an inline file |
| `download_url` | The link the file was actually fetched from when it differs from the one given: a share link's download URL or a redirect's target; null on a direct link |
| `entry` | The input line the row answers; on repeat rows, where url is null |
| `status` | `agree` or the dominant page flag, `unverified` when its pages could not be judged; the failure word on a miss row |
| `recommended_action` | One plain sentence on what to do with the document; null on a miss |
| `miss_code` | The cause code on a miss row, each one explained in Miss codes below; null on a document row |
| `page_count` | Pages the PDF declares |
| `word_count` | Words in the delivered text |
| `pdf_type` | `text_based`, `scanned`, `mixed` or `unreadable` |
| `pages_extracted` | Pages that delivered text, by layer or OCR |
| `title` | The PDF's metadata title; null when it declares none |
| `created` | The PDF's creation date, ISO-8601; null when it carries none |
| `modified` | The PDF's last-modified date, ISO-8601; null when it carries none |
| `file_size_bytes` | The file's size in bytes |
| `document_hash` | SHA-256 of the bytes read; null when no file was read |
| `char_count` | Characters in the delivered text, page joins included |
| `text` | The whole document text, a blank line between pages; null when Markdown alone is wanted |
| `markdown` | The document as basic Markdown; when the format asks |
| `chunks` | Page-tagged text pieces for an index; under Chunk size |
| `page_range` | The range read, normalised; null on a whole document |
| `must_contain_results` | Each required phrase: found, its pages, the first matching line |
| `all_phrases_found` | True when every required phrase was found |
| `row_type` | `ROW`, `ITEM_STATUS`, `NO_TEXT_FOUND` or `STOPPED_EARLY` |
| `reason` | Status rows: why the entry was not answered, the run was empty, or it stopped early |
| `rowsFetched` | Status rows: rows fetched before de-duplication and the cap |
| `rowsReturned` | Status rows: document rows in the dataset, 0 on an empty result |
| `rowsRemaining` | Status rows: fetched rows the run did not return |
| `capped_entries` | Status rows: entries Maximum items held back, none read or billed |
| `dropped_entries` | Status rows: entries the keep filter dropped, on the kept-out row |
| `page_verdicts` | Per page: verdict — or null with `unverified` naming why the checks couldn't judge — which side delivered, the checks' scores, the text |
| `per_page_method` | Per page: embedded layer, OCR, or neither produced text |
| `layer_ocr_agreement` | Mean layer-versus-OCR agreement; under the page check |
| `wrong_layer_pages` | Pages whose render showed text the layer lacks; under the page check |
| `suspect_layer_pages` | Pages whose layer failed the statistics check |
| `ocr_pages` | Pages whose text came from OCR |
| `unreadable_pages` | Pages that gave no text from either side |
| `verified_pages` | Pages the render check verified; empty without the page check |
| `pages_truncated` | True when the page cap cut the read short |
| `low_confidence_pages` | OCR pages under your confidence floor |
| `ocr_capped_pages` | Pages still needing OCR after the per-document cap |
| `verify_inconclusive_pages` | Checked pages whose OCR read was too weak to judge the layer |
| `tables` | Rebuilt tables with cells and a rebuilt-or-uncertain mark; when Extract tables is on |
| `images` | Embedded images saved as PNGs: page, link, size; when Extract embedded images is on |
| `verdict_counts` | The tally of page verdicts across the document |
| `text_preview` | The first 200 characters of the delivered text |
| `metadata` | The PDF's document-info dictionary; null when it carries none |
| `processed_at` | When the row was produced, ISO-8601 |
| `processing_ms` | Milliseconds this entry's own read took; null on an entry never reached |
| `size_trimmed_fields` | What was shortened to fit one dataset row; null on almost every row |
| `text_cleanup` | What the clean-up pass removed; when Clean up the text is on |
| `extra_pages` | Extracted pages past the fiftieth; the count extra-page billing reads |

### How much does it cost?

**$3.25 per 1,000 documents.** Each file you get back bills once, $0.00325, which covers its first 50 pages. Past that it's $0.0001 a page. Turn the page check on and each checked page bills $0.008 — and only the pages the row lists as verified bill. Apify adds its own start charge per run, $0.00005 at the default 1024 MB.

| Documents given | Billed rows | You pay | If every file answers |
|---|---|---|---|
| 100 | about 74 | about $0.24 | $0.325 |
| 1,000 | about 742 | about $2.41 | $3.25 |
| 10,000 | about 7,424 | about $24.13 | $32.50 |

The billed share counts all 66 documents the test list names; on the 52-document run the 45 answered files carried 1,307 pages past the fiftieth, another $0.13.
On Apify's paid plans the document charge drops: Bronze $0.00275, Silver $0.002, Gold, Platinum and Diamond $0.00193. Failures, duplicates, skipped-unchanged files, capped entries and rows the keep filter drops are free.

### How do I use PDF Text Extractor?

1. Press Try for free.
2. Paste links into PDF links, or upload one PDF with the file picker.
3. Press Start; rows land in your dataset as JSON, CSV or Excel.

#### Worked examples

Use it to fill a spreadsheet with document text:

```json
{
  "urls": [
    "https://www.govinfo.gov/content/pkg/FR-2026-09-21/pdf/2026-19336.pdf",
    "https://ntrs.nasa.gov/api/citations/20240009519/downloads/NASA_shielding_SmallSat_collaboration_08062024.pdf"
  ]
}
```

Use it to turn a long report into Markdown for a RAG index:

```json
{
  "urls": ["https://ntrs.nasa.gov/api/citations/20160001713/downloads/20160001713.pdf"],
  "outputFormat": "both"
}
```

Use it to check a report's first pages against their render ($0.008 a verified page):

```json
{
  "urls": ["https://ntrs.nasa.gov/api/citations/20160001713/downloads/20160001713.pdf"],
  "pageRange": "1-3",
  "verifyPages": true
}
```

### Input

| Input | Type | Default | What it does |
|---|---|---|---|
| `urls` | array | 3 example links | PDF links, one per line; Drive and Dropbox shares resolve |
| `base64Pdfs`\* | array | none | Files inline: a bare base64 string, or `{name, data}` plus a password |
| `pdfFile` | file | none | One PDF picked in the Console, or its web URL over the API |
| `inputDatasetId`\* | string | none | A saved dataset's id; anyone with it can read it |
| `urlField`\* | string | `url` | The item column each link sits in |
| `outputFormat` | select | `text` | Whole-document columns: text, markdown or both |
| `keepRows` | select | `all` | Which results come back: all, answered, flagged or missed |
| `cleanText` | boolean | off | Drop repeated headers and footers, page-number lines, split hyphens |
| `extractTables` | boolean | off | Rebuild detected tables into rows of cells |
| `extractImages`\* | boolean | off | Save embedded images as PNGs in the run's store |
| `skipEmptyPages`\* | boolean | off | Leave proven-empty pages out of the page lists and counts |
| `pageRange` | string | all pages | Read only these pages: `1-5,8`, `10-`, `-3` for the last three |
| `mustContain`\* | array | none | Up to 20 phrases every document must carry |
| `pdfPassword` | string | none | Opens your protected PDFs; an entry's own password wins |
| `ocrLanguage` | select | `eng` | The language scans are read in; join codes with `+` |
| `verifyPages` | boolean | off | Render and OCR-check every page, $0.008 a page verified |
| `pdfTextMode` | select | `auto` | `text` never OCRs, `ocr` reads every page by OCR; `text` is refused under the page check |
| `autoRotate`\* | boolean | on | Retry a badly read scan turned 90, 180 and 270 degrees |
| `dpi`\* | integer | 144 | Render resolution for OCR, 72 to 400 |
| `preprocessMode` | select | `none` | A cleanup the render gets before OCR: grayscale, contrast, binarise |
| `minOcrConfidence`\* | integer | none | Flag OCR pages under this score, 0 to 100; the words stay, marked |
| `segmentationMode` | select | `auto` | The page layout OCR assumes; orientation choices detect rotation |
| `includeWords`\* | boolean | off | OCR pages list every word with confidence and position |
| `chunkSize`\* | integer | none | Split the text into chunks of about this size, 200 to 20,000 |
| `chunkOverlap`\* | integer | 200 | Characters shared between chunks, at most half the size |
| `maxItems`\* | integer | 100 | The most rows a run returns, billed and unbilled both counting |
| `maxPagesPerDocument`\* | integer | 300 | The most pages one document contributes; more marks it truncated |
| `ocrMaxPagesPerDocument`\* | integer | none | Cap OCR pages a document; an over-cap page is marked, never skipped |
| `maxConcurrency`\* | integer | 5 | Documents read at once, up to five; rows keep input order. A run that renders (verifyPages, OCR modes, extractImages) reads fewer at once, bound by the run's memory allocation |
| `lowMemory`\* | boolean | off | One document at a time, so the biggest files fit 1024 MB |
| `requestTimeoutSecs`\* | integer | 60 | Seconds one download may take, 5 to 600 |
| `maxDocumentSizeMb`\* | integer | 80 | A file over this answers a too-large row, 1 to 250 |
| `onlyNew`\* | boolean | off | Skip files an earlier run already returned unchanged under the same reading options |

\*Marked inputs are JSON editor or API only; the form hides them.

The flagged choice keeps documents marked suspect, wrong, hidden, unreadable or unverified; a clean scan read by OCR alone stays an answer.

**What the default checks cover, and what they don't.** Without Verify pages, no page is rendered. The checks read the layer's own statistics. Malformed glyphs mark it `suspect_layer`. They also read the paint order behind it: text drawn white, invisible, too small to see, off the page or under a picture is in the layer but not on the page a buyer sees. A run of it marks the page `hidden_content`. The limits: a scan keeps its text in an invisible layer under the page image. Faithful and garbled read the same to statistics, so those pages come back `unverified` rather than agreed. Pages with no layer at all are read by OCR. Where that read is too weak to tell whether the page held words, the page is `unverified`, not guessed.

**The page check.** With Verify pages on, we render every page and read the picture with OCR. If a born-digital text layer disagrees with what the page shows, we flag it and give you the OCR text instead. We can't check the text a scanner wrote into a file. A fresh OCR read is a second transcript, not a witness, so those pages come back `verify_inconclusive` with no verdict. The same goes for a page whose read was too weak to tell whether it held words. Every inconclusive page is listed in `verify_inconclusive_pages` and bills nothing. A verified page bills $0.008.

### Output

Everything you give it comes back named: a normal row when the file reads, or a row saying why it couldn't. That covers repeats, files it already gave you, bad links, entries past Maximum items or your spending limit, and rows your keep filter drops — each gets its own named row. Rows come back in the order you gave them.

| `row_type` | What it marks |
|---|---|
| `ROW` | A document row |
| `ITEM_STATUS` | An entry the run could not answer; the miss code and reason say why |
| `NO_TEXT_FOUND` | A run with nothing to return |
| `STOPPED_EARLY` | A charge or spending limit ended the run early; the count fields carry the numbers |

A file's status is `agree` when its pages pass the checks. One page of `hidden_content`, text the render never shows, marks the whole file at any share. An injected line is enough. Otherwise, if a fifth or more of its judged pages share one flag, that flag becomes the file's status: `suspect_layer` when the statistics check doesn't like a layer, `wrong_layer` when the page check disagrees, `no_layer` when only OCR could read the text, `unreadable` when the pages gave nothing. `unverified` means we read the file but couldn't judge its pages, so it is not a claim the text is good. Some carry the text a scanner wrote in. No check can certify it, the page check included. Others are reads too weak to tell. They want a clearer file or a higher `ocrDpi`. A page your OCR budget stopped wants a bigger one. What re-running does settle is a `suspect_layer` on a born-digital page: the page check can confirm or clear the flag against the render. When an entry fails, the status names the failure family and the miss code says why: `not_found`, `blocked`, `bad_url`, `fetch_failed`, `timeout`, `bad_input`, `failed`, `aborted`. `aborted` also covers an entry it deliberately didn't read: dropped by your keep filter, already returned unchanged, a repeat of an earlier entry, held back by the item cap, or never reached before your spending limit.

An empty run is an answer too: you get one row saying what you gave it and why nothing came back. When a filtered or stopped run ends, the status rows carry what was fetched, returned and left; a store record lists what a repeat run skipped.

#### Miss codes

Every miss row names its cause in one code, and every code below says what happened and what to do about it. None of these rows is billed. Where two cases answer with the same code, the row's `reason` says which one it was.

**The link or the file**

| Miss code | What it means |
|---|---|
| `bad_url` | The line is blank, or it is not an http(s) link. Give a full link to the file |
| `credentials_in_url` | The link carries a user name or password, so nothing was requested. Put a PDF password in `pdfPassword`, and give only files that open without a login |
| `sign_in_required` | The link answered with a sign-in page, so the file is not shared publicly. Share it publicly, or send the file itself in `pdfFile` or `base64Pdfs` |
| `http_<status>` | The host answered with that status instead of the file. `http_404` and `http_410` are gone or moved; `http_401`, `http_403` and `http_429` are a login, a refusal or a rate limit; another 4xx is the link's own fault; a 5xx is the host's |
| `not_pdf` | What came back is not a PDF. Check the link still points at the file |
| `corrupt_pdf` | The file starts like a PDF but cannot be read as one. Fetch it again, or take another copy |
| `password_required` | The PDF is protected and no password opened it. Set `pdfPassword`, or a password on the entry itself; if you did send one, the reason says the value was wrong |
| `extraction_not_permitted` | The file's own permissions forbid copying its text. Get the owner password, or a copy you are allowed to read |
| `too_large` | The file is over `maxDocumentSizeMb`, 80 MB by default. Raise the cap, or send a shorter document |
| `bad_page_range` | The page range is not a range, or it selects no page of that document. Give pages the document has, such as `1-5,8` |
| `bad_base64` | An inline entry is not readable base64. Send `{ "name": "...", "data": "<base64>" }` |
| `bad_input` | The run refused the input itself, such as two options that cannot hold together. The reason names the option and what to change |
| `read_error` | The PDF opened but could not be read. Fetch it again, or send another copy |

**The host or the network**

| Miss code | What it means |
|---|---|
| `dns_failure` | The host name does not resolve. Check the link's domain |
| `connection_refused` | The host refused the connection. Check the port, or whether the service is up |
| `host_unreachable` | The host could not be reached at all. Check the network path |
| `connection_reset` | The host closed the connection mid-download. Try again later |
| `timeout` | Nothing arrived for a minute. Raise `requestTimeoutSecs`, or try again |
| `tls_error` | The host's HTTPS certificate was rejected. Fetch it over http, or fix the certificate |
| `redirect_loop` | The link redirects to itself. Give the final link |
| `network_error` | The download failed for a reason we could not name. Try again |
| `host_stopped` | This host refused the run — a 403, a 429 we did not wait out, or an anti-bot challenge page — and we send it nothing more. The reason says whether your URL was never requested or was answered with a challenge. Come back later, or reach the file another way |

**What the run did with your entry**

| Miss code | What it means |
|---|---|
| `duplicate` | The same entry, or the same bytes under another name, is in this run twice. The first entry's row answers both |
| `already_returned` | Under `onlyNew`, an earlier run already returned this file unchanged under the same options. It is not read or billed again |
| `cap_reached` | `maxItems` held this entry back; `capped_entries` names every held entry. Raise the cap, or split the batch |
| `kept_out` | Your keep filter dropped every row, so nothing came back. `dropped_entries` names each entry and the reason says what became of it — raise the cap, widen the filter, or split the batch |
| `aborted` | The run stopped before this entry finished, on your spending limit or otherwise. It was not billed |

### What can you do with the data?

Feed a search index text where every page says how it was read — chunks name their pages, so an answer can cite the page it came from. Gate a batch by status and recommended action, or hand a flagged file to the page check when a flag looks wrong to you.

### Use PDF Text Extractor with AI agents

One line gives a compatible agent this Actor:

```bash
claude mcp add --transport http apify "https://mcp.apify.com?tools=Pradio/pdf-text-verifier"
```

### Personal data

It reads your files and stops there: the text comes back to you, we keep none of it after the run, and nothing is inferred from it. The only thing kept between runs is a fingerprint of each file it already gave you plus your options — a hash, not the content — in a store in your own account that you can delete. A document can carry personal data as written; you are its controller.

### Release notes

- 2026-10-10: the default checks now see the paint order, not just the text. A page hiding words the render never shows (white, invisible, tiny, off-page or under a picture) is `hidden_content` without Verify pages on, and one such page marks the document. A scan's invisible transcript layer, which statistics cannot tell faithful from garbled, no longer answers `agree`; it carries a null verdict flagged `unverified`, and a document the checks cannot judge answers `unverified` rather than claiming the text is clean. Blank pages now agree with the empty page they render. `suspect_layer` needs real corruption (malformed glyphs), so figure-heavy and acronym-dense pages stop flagging. Verify pages learned the same honesty. It can't judge a scan's own transcript either, so it marks those pages `verify_inconclusive`, bills nothing and still ships the transcript. It convicts `no_layer` only when the read is strong enough to prove the page held words. It calls `suspect_layer` only with the same corruption evidence the defaults need. A live download is no longer cut at one minute: it runs until a minute passes with nothing arriving, so a throttled host's 60 MB scan completes where it used to time out.
- 2026-10-09: a run stopped part-way through a document now lists the pages it never reached, marked `not_read`, instead of dropping them; one stopped before it read anything comes back as an `aborted` row.
- 2026-10-08: pick where a page's text comes from, the layer or OCR every page; a cleanup pass for faded scans; a spending-limit stop lands as named rows with counts; an inconclusive check bills nothing; skipped and filtered entries now read `aborted`, not a failure word.
- 2026-10-07: the page check is a visible option again, billed per page checked.
- 2026-10-06: new inputs for download timeout, file-size cap, page layout on scans, word-level OCR lists and embedded images.

### Limits

- **80 megabytes and sixty seconds a file, by default.** A bigger or slower file gets a row saying why. The size and timeout inputs raise both.
- **300 pages a file, by default.** A longer file is read up to the cap and marked truncated — raise the cap or give it a page range.
- **Public links or your own files.** A link behind a sign-in gets a row naming that; send the file itself instead.
- **A refusing host is left alone.** A 403 or a challenge page ends that host for the run; a 429 is waited out once. There's no proxy routing, on purpose.
- **Copy protection stays closed** without the file's owner password.
- **The default check reads the layer, not the render.** It measures each layer's statistics and whether its characters were painted where a reader can see them. Hidden text is flagged without a render, but a scan's transcript layer can't be told faithful from garbled and comes back `unverified`. Verify pages checks a born-digital layer against the rendered page. It can't check a scan's own transcript either.
- **The page check won't pair with text-only mode.** You get a row naming that refusal instead of a run half-keeping both.
- **Option fields stay empty until you set their switch.** Markdown, chunks, tables, phrase answers, images, word lists and the verify fields.
- **OCR is OCR.** A table needs the layer's layout, so an OCR-read page ships an empty tables list.

### Troubleshooting

**Fewer rows than entries?** A repeated link or the same bytes is a duplicate row; an unchanged repeat is an already-returned row.

**A failure row where a document should be?** The miss code names the cause — a missing password, an over-size file, a refusing host — and the reason says what to fix.

**A clean file flagged suspect?** Slide decks and dense jargon carry the shapes the statistics check flags. Re-run it with the page check on.

**Pages marked inconclusive under the page check?** The OCR read was too weak to judge the layer: raise the resolution or fix the language and re-run.

### FAQ

**Can I use integrations?**
Yes. Schedule it on Apify and pick rows up through Make, Zapier or webhooks when a run finishes.

**Can I use the Apify API?**
Yes. Call the run-sync endpoint with the JSON above.

**Can I use it through an MCP server?**
Yes. The line under Use with AI agents adds the Actor to a compatible agent.

**Is it legal to extract text from PDFs?**
The Actor reads the files you give it and stops there. The rights in the text are yours or your licensor's; the right to read each file, and what you do with the rows, is on you.

**Can it read scanned PDFs?**
Yes. A page with no text layer is read by OCR inside the document price, and your row names which pages those were.

**How much do 1,000 PDFs cost?**
$3.25 if every entry answers and fits in 50 pages, about $2.41 at the measured share.

**How do I export the results?**
Rows land in your dataset as JSON, CSV or Excel, or over the API; images as PNGs in the run's store.

**What are the limits?**
100 entries a run, 300 pages a document, 80 MB and a minute of silence on the download. A host that keeps sending is given the time. Each is an input you can raise.

**How accurate is the text?**
A born-digital layer is the file's own text. A scanned page comes back at OCR quality in the language you picked. The page check settles a born-digital file you're unsure about. We can't check the text a scanner wrote into a file, so those pages come back `unverified`.

**Where do I get support?**
Open an issue on this Actor's Issues tab. We answer within two days.

### Feedback

A document that reads wrong or a field you need: open an issue on the Issues tab. If the Actor saved you time, a review on the Store helps other buyers find it.

### See also

[SEC EDGAR Filings Lookup Scraper](https://apify.com/Pradio/sec-edgar-filings) · [Broken Link Checker](https://apify.com/Pradio/broken-link) · [Companies House Company Profile Scraper](https://apify.com/Pradio/companies-house) · [Impressum Scraper](https://apify.com/Pradio/imprint-contact)

### Not affiliated

PDF Text Extractor is an independent tool, not affiliated with, endorsed by or sponsored by any document host or standards body, including the example documents' hosts, the PDF format's steward and the Tesseract OCR project. It reads the files you point it at.

# Actor input Schema

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

the PDF links, one per line — a comma or semicolon splits a pasted line. Drive and Dropbox share links work too. If the same file comes up twice we mark it a duplicate instead of reading it again; a page that only lists more links isn't followed

## `base64Pdfs` (type: `array`):

for calls that carry the file itself rather than a link: a bare base64 string, or a JSON list of {"name", "data"} objects — an optional "password" key opens a locked file. There's a size cap on what you can send; a file sent twice is marked duplicate, not read again

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

pick the file in the Console's file picker, or pass its web URL through the API — a path on your machine isn't a URL. The same file twice is marked duplicate, not read again

## `inputDatasetId` (type: `string`):

the id of a saved Apify dataset — the letters and digits under its name in Storage — shared so anyone who has it can read it; it needs the id itself, not a link. Each item is read for the link in the url column, or whichever column urlField names; a duplicate is marked, not read again

## `urlField` (type: `string`):

leave it as url unless your saved table names its link column something else; a row that is itself just a link is read as is

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

what each row's whole-document columns carry: the text (default), Markdown with the layout's headings and lists, or both; per-page results carry the same text either way

## `keepRows` (type: `string`):

which results a run keeps: everything, only files that read, only flagged ones (suspect or unreadable), or only failures. A failure comes back as a free row naming it, and an entry your filter drops still shows up named — never billed

## `cleanText` (type: `boolean`):

drop the headers and footers repeated on 30% or more of pages, page-number lines, space runs and split hyphens; the row lists what it removed, and per-page text stays as read

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

rebuild the tables each page's layout carries — every table comes back with its page, rows of cells and a note on whether the rebuild is certain. A page read by OCR has no layout to work from; your text and page results stay as they are

## `extractImages` (type: `boolean`):

save each embedded image to the run's key-value store as a PNG, with its page, size and link listed in the row's images field; at most 200 a document

## `skipEmptyPages` (type: `boolean`):

leave a page carrying nothing — no text layer, no OCR words — out of the page results and the counts. A page the OCR cap stopped stays listed: never read isn't blank

## `pageRange` (type: `string`):

which pages to read in each file: 1-5 or 2-5,8, 10- to the end, -3 for the last three; empty reads everything. A typo names the entry in the results and bills nothing

## `mustContain` (type: `array`):

up to 20 phrases every file must carry, matched as written ignoring case; each row says found, which pages and the first matching line. More than 20, or an entry that isn't text, is refused

## `pdfPassword` (type: `string`):

the password that opens your protected PDFs — an entry's own password wins. A file that needs one and gets none comes back named and free

## `ocrLanguage` (type: `string`):

the language your scanned pages are written in, English by default; join codes with + (eng+deu) for a document in two. A code the engine doesn't ship is refused before anything reads; OCR runs where a page has no text layer, and on every page when the page check is on

## `verifyPages` (type: `boolean`):

render every page and re-read the picture by OCR, flagging a born-digital text layer that disagrees. A scan's own transcript cannot be judged this way either: the fresh OCR is a second transcript, not a witness. It comes back verify_inconclusive, like a page whose read was too weak to judge. Inconclusive pages keep their text and bill nothing. Billed $0.008 per page the check verifies

## `pdfTextMode` (type: `string`):

auto reads the embedded layer and OCRs a page that has no usable one; text never runs OCR, so a layerless page answers no_layer with nothing; ocr reads every page by OCR and ships that text whatever the layer holds. With the page check on, text is refused

## `autoRotate` (type: `boolean`):

retry a badly read OCR page turned 90, 180 and 270 degrees and keep the best read, for sideways scans; turned pages are marked in the page results

## `dpi` (type: `integer`):

the resolution pages are rendered at for OCR, 72 to 400; empty applies 144 — the setting the stated rates were measured at

## `preprocessMode` (type: `string`):

a cleanup the rendered page gets before OCR reads it: grayscale drops the colour, contrast stretches light and dark, binarise makes every pixel black or white. For faded or noisy scans; none leaves the page as rendered

## `minOcrConfidence` (type: `integer`):

leave empty to flag nothing; 0 to 100 flags an OCR-read page whose confidence falls under it. The words stay, marked in the page results and the low-confidence list

## `segmentationMode` (type: `string`):

leave Automatic unless a scan reads wrong: tells OCR whether the page is one column, a block, a line, a word or scattered text; the orientation choices also detect rotation

## `includeWords` (type: `boolean`):

each OCR-read page lists every word it found with its confidence and position, for checking a scan word by word; a very large row may shorten the list and says so

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

split each document's text into chunks of about this size, each naming its pages; cuts fall at a paragraph, line or space. Empty for none; 200 to 20000

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

characters repeated from a chunk's end into the next so a sentence cut at an edge stays whole; only used with Chunk size, at most half of it

## `maxItems` (type: `integer`):

the most rows one run returns, free rows counting too. Entries past it are named on a free cap_reached row — or on the run's last row when the cap is 1

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

the most pages one file contributes; a longer one is read to here and marked pages_truncated. The cap also bounds a long file's extra-page charges

## `ocrMaxPagesPerDocument` (type: `integer`):

the most pages of one file OCR may read, never more than Maximum pages per document; leave empty for no limit. A page past the cap is marked, never skipped

## `maxConcurrency` (type: `integer`):

how many files read at once, up to 5 (empty means 5). Rows keep your order and OCR still reads a page at a time; a run that renders (the page check, an OCR mode, image extraction) reads fewer at once, held by the memory the run was given — on a small allocation use Low-memory mode

## `lowMemory` (type: `boolean`):

read one file at a time so the buffers only ever hold one: slower, but the biggest allowed files fit 1024 MB. Ignores Documents read at once

## `requestTimeoutSecs` (type: `integer`):

seconds one file's download may take before its row answers timeout, 5 to 600; empty keeps 60. A host asking for a short wait is waited out once

## `maxDocumentSizeMb` (type: `integer`):

a file over this many megabytes isn't fetched — you get a free too_large row naming its size; empty keeps 80

## `onlyNew` (type: `boolean`):

for lists you run again: skip a file an earlier run already returned when the file and your options match. Skipped entries are free and each named on its row; matching is by the file's fingerprint only

## Actor input object example

```json
{
  "urls": [
    "https://www.govinfo.gov/content/pkg/FR-2026-09-21/pdf/2026-19336.pdf",
    "https://ntrs.nasa.gov/api/citations/20240009519/downloads/NASA_shielding_SmallSat_collaboration_08062024.pdf",
    "https://ntrs.nasa.gov/api/citations/20160001713/downloads/20160001713.pdf"
  ],
  "urlField": "url",
  "outputFormat": "text",
  "keepRows": "all",
  "cleanText": false,
  "extractTables": false,
  "extractImages": false,
  "skipEmptyPages": false,
  "ocrLanguage": "eng",
  "verifyPages": false,
  "pdfTextMode": "auto",
  "autoRotate": true,
  "preprocessMode": "none",
  "segmentationMode": "auto",
  "includeWords": false,
  "chunkOverlap": 200,
  "maxItems": 100,
  "maxPagesPerDocument": 300,
  "maxConcurrency": 5,
  "lowMemory": false,
  "onlyNew": false
}
```

# Actor output Schema

## `rows` (type: `string`):

The verified text rows for this run: one data row per document, plus uncharged status rows for unreadable inputs.

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

Counts for this run: fetched, pushed, charged, duplicates dropped, stopped early.

# 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.govinfo.gov/content/pkg/FR-2026-09-21/pdf/2026-19336.pdf",
        "https://ntrs.nasa.gov/api/citations/20240009519/downloads/NASA_shielding_SmallSat_collaboration_08062024.pdf",
        "https://ntrs.nasa.gov/api/citations/20160001713/downloads/20160001713.pdf"
    ],
    "keepRows": "all",
    "ocrLanguage": "eng"
};

// Run the Actor and wait for it to finish
const run = await client.actor("pradio/pdf-text-verifier").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.govinfo.gov/content/pkg/FR-2026-09-21/pdf/2026-19336.pdf",
        "https://ntrs.nasa.gov/api/citations/20240009519/downloads/NASA_shielding_SmallSat_collaboration_08062024.pdf",
        "https://ntrs.nasa.gov/api/citations/20160001713/downloads/20160001713.pdf",
    ],
    "keepRows": "all",
    "ocrLanguage": "eng",
}

# Run the Actor and wait for it to finish
run = client.actor("pradio/pdf-text-verifier").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.govinfo.gov/content/pkg/FR-2026-09-21/pdf/2026-19336.pdf",
    "https://ntrs.nasa.gov/api/citations/20240009519/downloads/NASA_shielding_SmallSat_collaboration_08062024.pdf",
    "https://ntrs.nasa.gov/api/citations/20160001713/downloads/20160001713.pdf"
  ],
  "keepRows": "all",
  "ocrLanguage": "eng"
}' |
apify call pradio/pdf-text-verifier --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,pradio/pdf-text-verifier"
        }
    }
}
```

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/c6C4ryQymHrXQU7XY/builds/1J59vdqnft5igqczu/openapi.json
