# Scanned PDF OCR to Text & Tables - Columns Preserved (`practical_ophthalmologist_iuq/scanned-pdf-ocr`) Actor

Read scanned PDFs with OCR and get clean per-page text plus the actual tables, as rows and columns. Empty cells stay null so nothing shifts, and cells the OCR could not read are flagged instead of being passed off as blank. 10 languages, no API key.

- **URL**: https://apify.com/practical\_ophthalmologist\_iuq/scanned-pdf-ocr.md
- **Developed by:** [Scrappeer](https://apify.com/practical_ophthalmologist_iuq) (community)
- **Categories:**
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $20.00 / 1,000 page or table returneds

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?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Scanned PDF OCR — text *and* tables, with the columns still attached

Read scanned or photographed PDFs with OCR and get two things back: clean text
for every page, and the actual tables that were on those pages, as rows and
columns you can put straight into a spreadsheet.

Built for scanned invoices, bank and brokerage statements, government filings,
old annual reports, tariff schedules, and any document that reached you as a
picture of a page rather than a file with text in it.

### The problem this solves

Every OCR tool will give you the words on a scanned page. Almost none will give
you the table, because the moment words are joined into lines the column
structure is gone.

Take a scanned page with this on it:

```
Item          Jan     Feb     Mar
Widgets        12             31
Gadgets                44     51
Doohickeys      7       9
```

A normal OCR pass returns:

```
Item Jan Feb Mar Widgets 12 31 Gadgets 44 51 Doohickeys 7 9
```

Is `31` a February figure or a March one? There is no way to tell any more. The
blank cells left no trace, so every value after the first gap has silently
shifted one column to the left.

This Actor keeps the coordinates of every word and rebuilds the grid from the
geometry, which is the only place that structure still exists:

```json
{
  "header": ["Item", "Jan", "Feb", "Mar"],
  "rows": [
    ["Widgets", "12", null, "31"],
    ["Gadgets", null, "44", "51"],
    ["Doohickeys", "7", "9", null]
  ]
}
```

### "Empty" and "unreadable" are not the same thing

This is the part other OCR tools get wrong, and it matters most on exactly the
documents people scan.

When OCR meets a smudged digit it often returns a garbage character with a
confidence of zero. Drop that word — as any sane confidence filter does — and
the cell it occupied becomes empty. Your table now says a fee was never charged,
when in truth the number was simply blurry.

So low-confidence marks are never silently discarded here. The cell is still
`null`, but its position is reported separately:

```json
{
  "rows": [["Doohickeys", null, "9", null]],
  "emptyCellCount": 1,
  "unreadableCellCount": 1,
  "unreadableCells": [[0, 1]]
}
```

`[0, 1]` means row 0, column 1 held something the OCR could not read. The other
`null` in that row is genuinely blank. Raising `dpi` or lowering
`minConfidence` usually recovers these.

#### Prose is not a table

Run a column finder over a page of paragraphs and it will cheerfully carve the
sentences into a dozen ragged columns. So a grid that comes out mostly empty is
discarded rather than returned: below `minFilledPercent` filled cells, the page
is reported as having no table instead of being handed a convincing-looking one
that means nothing. Lower the threshold for genuinely sparse forms.

### Tables that run over a page break

Financial statements and long reports split tables across pages constantly. A
reader has no trouble with it - the columns are in the same places and the rows
simply carry on - but two separate records are two things to reconcile by hand.

So a table whose columns line up with the one on the page before is joined onto
it, and the record carries a `pageSpan` saying where it came from:

```json
{ "pageNumber": 4, "pageSpan": [4, 5, 6], "rowCount": 71 }
```

A continuation page usually has no header of its own, so its first line is
treated as data. When the document repeats the header on every page instead,
the repeat is dropped rather than landing in the middle of your rows.

Set `joinAcrossPages` to false to keep one record per page.

### What you get

- **Per-page text** — everything OCR read, with a mean confidence score
- **Tables as real grids** — header row, data rows, empty cells as `null`
- **Unreadable cells flagged**, never disguised as empty
- **Markdown and CSV** in every table record — paste into a sheet or an LLM prompt
- **10 languages** — English, German, French, Spanish, Italian, Portuguese,
  Dutch, Korean, Japanese, Simplified Chinese. Combine them for mixed documents
- **Digital PDFs are not OCR'd** — if the file already has a text layer it is
  read directly, which is faster, exact, and cheaper for you
- **No proxy, no API key, no cloud OCR account.** Tesseract runs inside the Actor

### Input

```json
{
  "pdfUrls": ["https://example.com/scanned-statement.pdf"],
  "languages": ["eng"],
  "outputMode": "both",
  "dpi": 300,
  "minConfidence": 40,
  "minRows": 2,
  "minColumns": 2,
  "maxPagesPerPdf": 3
}
```

| Field | Default | Notes |
|---|---|---|
| `pdfUrls` | — | Required. Direct links to the PDFs. |
| `languages` | `["eng"]` | Tesseract codes, most likely first: `eng`, `deu`, `fra`, `spa`, `ita`, `por`, `nld`, `kor`, `jpn`, `chi_sim`. |
| `outputMode` | `both` | `both`, `tables` or `text`. |
| `dpi` | `300` | Higher is more accurate and slower. 400 for small print, 200 for clean large type. |
| `minConfidence` | `40` | 0–100. Below this a word is treated as unreadable rather than as text. |
| `joinAcrossPages` | `true` | Rejoin a table that a page break cut in half, and drop a header the document repeats on every page. |
| `minRows` / `minColumns` | `2` / `2` | Keep `minColumns` at 2+ so paragraphs are not mistaken for tables. |
| `minFilledPercent` | `35` | A real table is mostly full. Grids emptier than this are discarded as false detections. |
| `maxPagesPerPdf` | `3` | 0 reads every page. OCR is charged per page, so this stops at 3 by default rather than running up a bill on a long document - and it says so in the output whenever it had to stop early. |
| `maxFileSizeMb` | `50` | Larger files are skipped with an explanatory record. |
| `forceOcr` | `false` | Run OCR even when a text layer exists. |
| `includeMarkdown` / `includeCsv` | `true` | Extra formats on each table record. |

### Output

Three kinds of record, told apart by `recordType`.

**`table`** — one per detected table:

| Field | Description |
|---|---|
| `sourceUrl`, `fileName`, `pageNumber`, `pageCount` | Where it came from |
| `header` | Header row, or `null` if none was detected |
| `rows` | Data rows. Empty cells are `null`. |
| `rowCount`, `columnCount` | Shape |
| `emptyCellCount` | Cells that were genuinely blank |
| `unreadableCellCount`, `unreadableCells` | Cells that held unreadable marks, as `[row, column]` |
| `meanConfidence` | 0–100 across the cells that were read |
| `extractionMode` | `ocr` or `text-layer` |
| `markdown`, `csv` | Ready-to-paste versions |

**`pageText`** — one per page: `text`, `wordCount`, `meanConfidence`,
`extractionMode`.

**`notice`, `error`, `noResults`** — plain-language records explaining what
happened. A run that finds nothing tells you why instead of handing back an
empty dataset.

Export as Excel, CSV, JSON or XML from the Apify Console, or pull it through
the API.

### Typical uses

- **Scanned invoices and statements** — line items into a spreadsheet
- **Government and regulatory filings** — the paper-era back catalogue
- **Old annual reports** — financial tables from PDFs that predate text layers
- **Research archives** — result tables from scanned journal articles
- **RAG and LLM pipelines** — feed a model a real table instead of a scrambled line

### Limits, stated plainly

- **OCR is a guess.** Clean 300 DPI print reads at high confidence; faint
  photocopies, dot-matrix print and handwriting do not. Use `meanConfidence` and
  `unreadableCells` to decide how far to trust a result.
- Rotated and vertically-written tables are not supported yet.
- Cells merged across rows are reported in the first row they occupy.
- Joining is by column geometry, so two unrelated tables that happen to share a
  column layout on consecutive pages will be joined. Check `pageSpan` if that
  matters, or turn joining off.
- Password-protected files are skipped with an `error` record.
- Photographs of pages taken at an angle read poorly; scan flat where you can.

### Running long documents

OCR is memory-hungry: a 300 DPI page is tens of megabytes before Tesseract even
looks at it. This Actor is set to 2 GB, which reads a page in roughly fifteen
seconds. At 1 GB the same page takes minutes, because the container spends its
time swapping rather than reading - so if you fork this Actor, keep the memory
up rather than turning the DPI down.

### Support

Found a PDF that reads badly? Open an issue on the Actor's **Issues** tab with
the URL and the page number. That is the main way the detector improves.

***

### Run it

- **Actor on Apify Store** — [scanned-pdf-ocr](https://apify.com/practical_ophthalmologist_iuq/scanned-pdf-ocr)
- **Call it from Python** — [/api/python](https://apify.com/practical_ophthalmologist_iuq/scanned-pdf-ocr/api/python)
- **Call it from JavaScript** — [/api/javascript](https://apify.com/practical_ophthalmologist_iuq/scanned-pdf-ocr/api/javascript)

### Related Actors

If your PDF already has a text layer, you do not need OCR at all:

- [pdf-table-extractor](https://apify.com/practical_ophthalmologist_iuq/pdf-table-extractor) — tables out of born-digital PDFs, same treatment of empty cells ([Python](https://apify.com/practical_ophthalmologist_iuq/pdf-table-extractor/api/python) | [JavaScript](https://apify.com/practical_ophthalmologist_iuq/pdf-table-extractor/api/javascript))

Same approach applied to job boards — read the official public API, keep the
structure, no proxy and no API key:

- [career-page-job-monitor](https://apify.com/practical_ophthalmologist_iuq/career-page-job-monitor) — Greenhouse, Lever, Ashby, Workable, Recruitee and SmartRecruiters in one run
- [workday-jobs-scraper](https://apify.com/practical_ophthalmologist_iuq/workday-jobs-scraper) — Workday careers sites, paste any careers URL
- [greenhouse-jobs-scraper](https://apify.com/practical_ophthalmologist_iuq/greenhouse-jobs-scraper) · [lever-jobs-scraper](https://apify.com/practical_ophthalmologist_iuq/lever-jobs-scraper) · [ashby-jobs-scraper](https://apify.com/practical_ophthalmologist_iuq/ashby-jobs-scraper) · [workable-jobs-scraper](https://apify.com/practical_ophthalmologist_iuq/workable-jobs-scraper) · [recruitee-jobs-scraper](https://apify.com/practical_ophthalmologist_iuq/recruitee-jobs-scraper) · [smartrecruiters-jobs-scraper](https://apify.com/practical_ophthalmologist_iuq/smartrecruiters-jobs-scraper)

# Actor input Schema

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

Direct links to the PDFs you want read. Scanned or photographed pages are the point here; if a PDF already has a text layer the Actor reads it directly instead of running OCR. The example is a scanned 1960s government memo, so the first run shows real OCR.

## `languages` (type: `array`):

Tesseract language codes, most likely first. Installed: eng, deu, fra, spa, ita, por, nld, kor, jpn, chi\_sim.

## `outputMode` (type: `string`):

Tables only, page text only, or both.

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

Higher is more accurate and slower. 300 suits most scans; try 400 for small print, 200 for clean large type.

## `minConfidence` (type: `integer`):

0-100. Words OCR is less sure about than this are reported as unreadable rather than as text. Raise it for cleaner text, lower it if characters go missing.

## `minRows` (type: `integer`):

Ignore tables with fewer rows.

## `minColumns` (type: `integer`):

Ignore tables with fewer columns. Keep this at 2 or more so paragraphs are not mistaken for tables.

## `minFilledPercent` (type: `integer`):

A real table is mostly full. Grids emptier than this are treated as a false detection - usually a page of prose the column finder carved up - and are not returned. Lower it for sparse forms, raise it if prose is slipping through.

## `joinAcrossPages` (type: `boolean`):

A table that runs over a page break comes back as one table, with a header the document repeats on every page removed. The record's 'pageSpan' lists the pages it came from. Turn this off to keep one record per page.

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

How many pages to read per PDF. 0 means every page. OCR is charged per page and takes roughly 20 seconds per page, so this stops at 3 by default instead of running up a bill on a long document - and it tells you in the output whenever it had to stop early.

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

Larger PDFs are skipped with an explanatory record.

## `forceOcr` (type: `boolean`):

Off by default: a PDF that already has a text layer is read directly, which is faster, free of OCR errors and cheaper for you. Turn this on if the existing text layer is itself bad.

## `includeMarkdown` (type: `boolean`):

Adds a Markdown table to every table record.

## `includeCsv` (type: `boolean`):

Adds a CSV string to every table record.

## Actor input object example

```json
{
  "pdfUrls": [
    "https://www.archives.gov/files/research/jfk/releases/docid-32204484.pdf"
  ],
  "languages": [
    "eng"
  ],
  "outputMode": "both",
  "dpi": 300,
  "minConfidence": 40,
  "minRows": 2,
  "minColumns": 2,
  "minFilledPercent": 35,
  "joinAcrossPages": true,
  "maxPagesPerPdf": 3,
  "maxFileSizeMb": 50,
  "forceOcr": false,
  "includeMarkdown": true,
  "includeCsv": true
}
```

# Actor output Schema

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

Page text and tables, with empty cells kept as null and unreadable cells listed separately.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "pdfUrls": [
        "https://www.archives.gov/files/research/jfk/releases/docid-32204484.pdf"
    ],
    "languages": [
        "eng"
    ],
    "maxPagesPerPdf": 3
};

// Run the Actor and wait for it to finish
const run = await client.actor("practical_ophthalmologist_iuq/scanned-pdf-ocr").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "pdfUrls": ["https://www.archives.gov/files/research/jfk/releases/docid-32204484.pdf"],
    "languages": ["eng"],
    "maxPagesPerPdf": 3,
}

# Run the Actor and wait for it to finish
run = client.actor("practical_ophthalmologist_iuq/scanned-pdf-ocr").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "pdfUrls": [
    "https://www.archives.gov/files/research/jfk/releases/docid-32204484.pdf"
  ],
  "languages": [
    "eng"
  ],
  "maxPagesPerPdf": 3
}' |
apify call practical_ophthalmologist_iuq/scanned-pdf-ocr --silent --output-dataset

```

## MCP server setup

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

```

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/aviNaN4tXIOoOllKi/builds/UrAOaulEarEZjewgK/openapi.json
