# Scanned PDF & Image OCR - Tables to JSON (`automa-flow/scanned-document-ocr-table-extractor`) Actor

OCR scanned PDFs, images and photos of documents into text, Markdown and cell-level JSON tables. Per-page status, English, Latin-script, Japanese and Cyrillic models, no external OCR account. Failed and blank pages are not charged.

- **URL**: https://apify.com/automa-flow/scanned-document-ocr-table-extractor.md
- **Developed by:** [Vadim Bezrukov](https://apify.com/automa-flow) (community)
- **Categories:** Automation, Developer tools, Integrations
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $4.00 / 1,000 page with texts

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

## Scanned PDF & Image OCR - Tables to JSON

Turn scanned PDFs, phone photos and images of documents into readable text, Markdown and **tables as JSON, cell by cell**. Send up to 200 files per run as public URLs, from another Actor's Dataset, as base64 or as one upload. Built for n8n, Make, AI agents and back-office pipelines that receive scans and need structured data, without an external OCR account or API key.

Every document comes back as one Dataset row with an explicit status, per-page status and confidence, and every table as headers plus rows of cells. The Console prefill runs three bundled sample scans (an invoice, an employee roster and a receipt photo), so the first run shows real tables without any file of your own.

Latest notes are in the [changelog](https://apify.com/automa-flow/scanned-document-ocr-table-extractor/changelog).

### What a scan becomes

The bundled `sample:invoice-scan` is an image-only PDF: no text layer, only pixels. Its line items come back as a table:

```json
{
  "status": "SUCCESS",
  "filename": "invoice-scan.pdf",
  "tables": [
    {
      "table_id": "42dc2a009179-p1-t1",
      "page": 1,
      "headers": ["Qty", "Item", "Unit price", "Amount"],
      "rows": [
        ["2", "LED module", "234.97", "469.94"],
        ["4", "Copper wire 2mm", "133.13", "532.52"],
        ["28", "Mounting plate", "233.97", "6,551.16"],
        ["", "Subtotal", "", "13,539.99"],
        ["", "VAT 20%", "", "2,708.00"],
        ["", "Total due", "", "16,247.99"]
      ],
      "row_count": 10,
      "column_count": 4
    }
  ]
}
```

(Rows shortened here; the run returns all ten.) The same row also carries the full reading-order `text` and a `markdown` version with the table in place.

A one-page scan with text costs $0.005, plus $0.002 for each table it holds. A typical batch of 50 two-page scans with 40 tables costs about **$0.54**: $0.001 per document, $0.004 per page with text and $0.002 per table, plus $0.01 to start at the default 2 GB. Failed, blank and unprocessed pages are free.

### Why use it

Automation platforms rarely have an OCR step you can just install, and the usual workarounds are a Docker sidecar with Tesseract, a separate cloud OCR account with its own key, or a vision LLM. Store OCR tools mostly return text lines: the table is gone.

This Actor keeps the table. On our 20-document synthetic benchmark of image-only scans (536 table cells, English, German, French, Spanish, Polish, Russian and Japanese, skew, noise and a 120 dpi scan) it recovered **535 of 536 cells** with a mean character error rate of 0.003. That is a synthetic benchmark, not a guarantee for your documents: see Limits.

- **Tables from scans to JSON**: headers and rows of cells, plus Markdown and the model's HTML.
- **PDF, PNG, JPEG, TIFF and WebP** in one run; multi-page TIFF frames are pages.
- **Per-page status**: a failed or blank page never hides behind a successful document, and is never charged.
- **Chains after other Actors**: read document links from a crawler's, a downloader's or PDF Text Extractor's Dataset.
- **No external account**: the OCR, layout and table models run inside the Actor.

### Supported input

| Input | Use it when |
| --- | --- |
| `urls` | Files are on public http or https URLs, or you want a bundled sample (`sample:invoice-scan`, `sample:employee-roster-scan`, `sample:receipt-photo`). |
| `datasetId` | Another Actor's run stored the document links in a Dataset column. |
| `base64Files` | An agent, a webhook or your backend has the bytes: up to 20 files of 10 MB. A `data:...;base64,` prefix is accepted. |
| `fileUpload` | You upload one file in the Console. |

One run can mix `urls`, `base64Files` and `fileUpload`, up to 200 documents. When the platform fills `urls` with the samples and your request also has `base64Files` or `fileUpload`, the samples are skipped (`RUN_SUMMARY.droppedDefaultSamples`).

The file type comes from the file's bytes, not its name or `Content-Type`. An HTML login page served with HTTP 200 is `NOT_A_DOCUMENT`, never an OCR of a login page. Private hosts, localhost, cloud metadata addresses and ports other than 80 and 443 are rejected.

| Option | Default | Meaning |
| --- | --- | --- |
| `ocrLanguage` | `en` | `en`, `latin` (German, French, Spanish, Polish and other Latin-script languages) and `japan` share one multilingual model; `cyrillic` selects a Cyrillic model. |
| `extractTables` | `true` | Detect tables and return them cell by cell. |
| `includePages` | `true` | Include each page's text in `pages`. |
| `renderDpi` | `200` | Resolution for PDF pages, 150 to 300. |
| `adaptiveDpi` | `true` | Render low-resolution scans (under 150 dpi) and very small text again at 300 dpi. |
| `maxPagesPerDocument` | `100` | Pages after the limit are skipped and free; the document is `PARTIAL`. Up to 500. |
| `maxFileSizeMb` | `50` | Larger files end `TOO_LARGE` without a download. Up to 100. |
| `maxConcurrency` | `2` | Upper bound for parallel documents; fewer are used when the run's CPU share is smaller. |

### Quick start

Run the prefill, or send your own scan:

```json
{
  "urls": ["https://example.com/inbox/scan-0417.pdf"],
  "ocrLanguage": "latin",
  "extractTables": true
}
```

Open the **Tables** view for one row per table, **Documents** for one row per file, **Pages** for page status and confidence.

### Chain after a crawler, a downloader or PDF Text Extractor

PDF Text Extractor answers a scanned PDF with `NO_TEXT_LAYER`; a crawler or the Parallel Bulk File Downloader ends with rows that hold file links. Pass that Dataset here:

```json
{
  "datasetId": "YOUR_DATASET_ID",
  "urlField": "source_url",
  "extractTables": true
}
```

`urlField` is the column with the link (default `url`; dot paths such as `document.url` work). PDF Text Extractor stores `source_url` with query values redacted, so signed links (S3, CDN) must come from the original list or another column. A run reads up to 200 rows from `datasetOffset`; `RUN_SUMMARY.nextDatasetOffset` says where the next run starts. Rows without a link are skipped and free. Each result keeps `source_dataset_id` and `source_item_index`, so it joins back to its source row.

To run automatically after another Actor, add an Actor-to-Actor integration on it with `"datasetId": "{{resource.defaultDatasetId}}"` in this Actor's input. The integration's `payload` is accepted and not read.

### n8n, Make and backend

One HTTP request runs the Actor and returns the rows when it finishes (keep batches small enough to finish within the request timeout):

```bash
curl -X POST "https://api.apify.com/v2/acts/automa-flow~scanned-document-ocr-table-extractor/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"urls": ["https://example.com/inbox/scan-0417.pdf"], "extractTables": true}'
```

In n8n use an **HTTP Request** node with that URL and body (or the Apify node), then map `tables[0].rows` to a Google Sheet and route rows whose `status` is not `SUCCESS` to a review queue. For bytes from an email attachment, send `base64Files: [{"filename": "scan.pdf", "base64": "..."}]` instead of `urls`. A schedule or webhook that sends each new batch is the natural repeat: there is no state between runs.

### Sample output

Full rows for the three samples are in [examples/sample_output.json](examples/sample_output.json). Main fields:

| Field | Meaning |
| --- | --- |
| `status`, `failure_reason`, `failure_detail` | The document outcome, see Statuses. |
| `text`, `markdown` | Reading-order text (pages separated by form feeds) and Markdown with tables in place. |
| `tables[]` | `headers`, `rows`, `row_count`, `column_count`, `cells_filled`, `bbox`, `detection_confidence`, `markdown`, `html`. |
| `pages[]` | Per page: `status`, `dpi`, `line_count`, `mean_ocr_confidence`, `tables_count`, `text`. |
| `source_id`, `sha256`, `fingerprint` | SHA-256 of the bytes, and a hash of the text and cells that is equal for equal content (rows without content hash their status and reason). |
| `billing` | What the row was charged for: `document_billed`, `pages_billed`, `tables_billed`. |

### Statuses

| Document status | Meaning | Charged |
| --- | --- | --- |
| `SUCCESS` | Every processed page returned text or was blank. | yes |
| `PARTIAL` | Text plus failed pages, the page limit or the run time limit. | for the pages with text |
| `NO_TEXT` | The file rendered but held no text (blank scans). | no |
| `FAILED` | Nothing usable. `failure_reason` says why: `DOWNLOAD_FAILED`, `NOT_A_DOCUMENT`, `EMPTY_FILE`, `TOO_LARGE`, `PASSWORD_PROTECTED`, `INVALID_SOURCE`, `RENDER_FAILED`, `OCR_FAILED`, `TIMEOUT`. | no |
| `NOT_PROCESSED` | Stopped by the run time limit (`NOT_PROCESSED_TIME_LIMIT`) or your maximum charge (`CHARGE_LIMIT_REACHED`). | no |

A damaged or truncated PDF or image is `NOT_A_DOCUMENT`, and an image over 80 megapixels is `TOO_LARGE`. A run with only buyer-side failures (a 404, an HTML page, an encrypted PDF, a truncated file) still ends `SUCCEEDED`, because every row says what to fix. The run fails only for invalid input, a damaged model file, an OCR engine that crashes three times, an engine failure on every document it accepted, or storage that cannot be written. `RUN_SUMMARY` in the run's Key-Value Store has the outcome, counts per status and reason, pages and tables charged, seconds per page and the inputs a follow-up run should retry.

### Pricing

Pay per event, platform usage included:

| Event | Price |
| --- | --- |
| Actor start | $0.005 per GB of memory (default 2 GB: $0.01) |
| Document processed (`SUCCESS` or `PARTIAL`) | $0.001 |
| Page with text | $0.004 |
| Table extracted (two or more columns, one or more data rows) | $0.002 |

The prefill costs about $0.03. Retries, failed downloads, blank and failed pages, skipped pages and unprocessed documents are never charged. Set **Maximum cost per run** to cap a batch: a document is delivered only when its whole price fits, and the rest end `NOT_PROCESSED`.

### Limits

- The accuracy figures come from our 20-document synthetic benchmark of clean generated scans. Real faxes, crumpled paper and phone photos at an angle have not been benchmarked yet.
- `cyrillic` was tested on one Russian document only. Other scripts (Chinese, Korean, Arabic, Devanagari) are not offered yet.
- No handwriting, no form fields or key-value extraction, no office formats (DOCX, XLSX), no password-protected PDFs.
- Very low-resolution scans can lose table structure even though the text is read; adaptive DPI helps with scans down to about 120 dpi.
- Pages scanned sideways or upside down are not rotated before OCR; rotate them first (photos with an EXIF orientation are turned upright).
- An image over 80 megapixels ends `TOO_LARGE`. A page is read at up to 40 megapixels (20 below 2 GB of memory); a larger page is scaled down to that size.
- A PDF that already has a text layer is OCRed like a scan; for digital PDFs, PDF Text Extractor is cheaper.
- A page takes about 5 seconds at the default 2 GB (median on our staging basket; about 10 seconds at 1 GB). Up to 500 pages per document and 200 documents per run.

### Privacy and security

Process only documents you have the right to process. Files are held in memory and a temporary folder that is deleted after each document; the original files are not stored in the Dataset or Key-Value Store. Logs and `RUN_SUMMARY` hold counts, statuses and timings, never document text, base64 payloads or URL query strings, and stored URLs have their query values redacted. Apify keeps the run input (including base64) as part of the run, which is platform behaviour.

If a run fails or looks wrong, open an Issue on this Actor and include the run URL. Apify shares that run with us automatically, so there is no need to paste your documents or credentials.

### API and MCP

Call the Actor from any HTTP client, the Apify API clients, n8n, Make or Zapier with your own Apify token. Agents can find it through Apify MCP search; running it uses your Apify account, and `maxTotalChargeUsd` bounds the bill of each call. The run's `RUN_SUMMARY` record gives an agent the outcome, counts and the inputs to retry without reading every row.

Direct MCP endpoint:

```text
https://mcp.apify.com?tools=automa-flow/scanned-document-ocr-table-extractor
```

### Technical details

- OCR: [RapidOCR](https://github.com/RapidAI/RapidOCR) 3.10 with PP-OCRv6 detection and multilingual recognition, PP-OCRv5 for Cyrillic (Apache-2.0, PaddleOCR models). Tables: RapidLayout table detection and RapidTable SLANet-plus (Apache-2.0). PDF rendering: pypdfium2 (Apache-2.0 / BSD-3-Clause), images: Pillow (MIT-CMU), inference: ONNX Runtime (MIT), OpenCV headless (Apache-2.0), NumPy (BSD-3-Clause).
- Models are downloaded when the image is built and verified by SHA-256 at every start; a run never downloads a model. `RUN_SUMMARY.modelChecksums` lists them.
- OCR runs in a worker process with a 60-second limit per page; a page that overruns is stopped, its worker replaced, and the batch continues. Downloads make at most three requests per URL within 90 seconds: network errors and HTTP 408, 429, 502, 503 and 504 are retried, other errors are final.
- Default memory 2 GB (minimum 1 GB). Same bytes and settings give the same text, tables and `fingerprint`.

# Changelog

This Actor's version history is a separate document: https://apify.com/automa-flow/scanned-document-ocr-table-extractor/changelog.md

# Actor input Schema

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

Public http:// or https:// links to PDF, PNG, JPEG, TIFF or WebP files, up to 200. The prefill uses the bundled samples sample:invoice-scan, sample:employee-roster-scan and sample:receipt-photo. Ignored when Source Dataset is set.

## `datasetId` (type: `string`):

The Dataset of a finished crawler, downloader or PDF Text Extractor run whose rows hold document links. Takes priority over Document URLs. Reads up to 200 rows per run. Cannot be combined with base64 files or an upload.

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

The column that holds the document link, for example url, fileUrl or document.url. Rows without a value are skipped and not charged.

## `datasetOffset` (type: `integer`):

Zero-based row to start from. For a Dataset over 200 rows, run again with nextDatasetOffset from RUN_SUMMARY.

## `base64Files` (type: `array`):

Files as standard base64 or as data URLs, for agents and scripts that send bytes. Each item needs a filename and the base64 payload. At most 20 files of up to 10 MB each; larger files should be URLs.

## `fileUpload` (type: `string`):

One PDF or image file uploaded in Console. Omit it when you are not uploading.

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

Script of the documents. en, latin (German, French, Spanish, Polish and other Latin-script languages) and japan share one multilingual model; cyrillic uses a Cyrillic model and is tested on one Russian sample only.

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

Detect tables and return them cell by cell. Turn it off for text only; no table-extracted events are then charged.

## `includePages` (type: `boolean`):

Add each page's text to the pages array. Page status and confidence are always returned.

## `renderDpi` (type: `integer`):

Resolution for rendering PDF pages before OCR. 200 suits most scans. Images are read at their own resolution.

## `adaptiveDpi` (type: `boolean`):

Render low-resolution scans (under 150 dpi) and pages with very small text again at 300 dpi.

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

Pages after this limit are not processed or charged; the document is then PARTIAL with PAGE_CAP_REACHED.

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

Larger files are not downloaded and end TOO_LARGE, free of charge.

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

Upper bound for documents processed at the same time. The Actor uses fewer when the run's memory or CPU share is smaller (OCR is CPU-bound).

## `payload` (type: `object`):

Added by an Apify Actor-to-Actor integration. It is accepted so the run can start and is not read. Set datasetId to {{resource.defaultDatasetId}} in the integration input.

## Actor input object example

```json
{
  "urls": [
    "sample:invoice-scan",
    "sample:employee-roster-scan",
    "sample:receipt-photo"
  ],
  "urlField": "url",
  "datasetOffset": 0,
  "ocrLanguage": "en",
  "extractTables": true,
  "includePages": true,
  "renderDpi": 200,
  "adaptiveDpi": true,
  "maxPagesPerDocument": 100,
  "maxFileSizeMb": 50,
  "maxConcurrency": 2
}
```

# Actor output Schema

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

No description

## `tables` (type: `string`):

No description

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

No description

## `runSummary` (type: `string`):

No description

# 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": [
        "sample:invoice-scan",
        "sample:employee-roster-scan",
        "sample:receipt-photo"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("automa-flow/scanned-document-ocr-table-extractor").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": [
        "sample:invoice-scan",
        "sample:employee-roster-scan",
        "sample:receipt-photo",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("automa-flow/scanned-document-ocr-table-extractor").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": [
    "sample:invoice-scan",
    "sample:employee-roster-scan",
    "sample:receipt-photo"
  ]
}' |
apify call automa-flow/scanned-document-ocr-table-extractor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,automa-flow/scanned-document-ocr-table-extractor"
        }
    }
}
```

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/v6qLfupdAhSqmkgly/builds/zsRLIep6Lckfzzhfr/openapi.json
