# PDF OCR Reliability Checker for RAG (`filnith/document-provenance-ocr`) Actor

Extract text from native and scanned PDFs for RAG pipelines. Every line keeps its page and bounding box, while every page receives a reliability verdict and actionable warnings. Mixed PDFs are handled page by page, so incomplete extraction does not look like success.

- **URL**: https://apify.com/filnith/document-provenance-ocr.md
- **Developed by:** [Jeremy Laidi](https://apify.com/filnith) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1,400.00 / 1,000 document processeds

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?

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

## Document → text with page, position and a reliability verdict

Three things for every document:

1. **the text**, page by page;
2. **provenance** — the page and bounding box of every line, in PDF points;
3. **a reliability verdict per page**, with the thresholds published so you can
   check the call instead of trusting it.

Native text layer and OCR are decided **per page, not per document** — a report
with a clean first page and scanned annexes is common, and treating it as one
thing loses either the annexes or the accuracy.

### What you get back

```json
{
  "url": "...",
  "ok": true,
  "pagesTotal": 9, "pagesProcessed": 2, "pagesOcr": 2,
  "language": "fra",
  "usablePagesRatio": 1.0,
  "medianLexicalValidity": 0.772,
  "warnings": [],
  "pages": [{
    "page": 1, "source": "ocr",
    "reliability": { "verdict": "good", "words": 312,
                     "wordShapeScore": 1.0, "lexicalValidity": 0.772,
                     "lexiconChecked": true, "ocrConfidence": 93.1 },
    "lines": [{ "text": "PROCES VERBAL", "bbox": [230.6, 22.1, 347.8, 33.8] }]
  }],
  "markdown": "<!-- page 1 | ocr | good -->\n..."
}
```

`verdict` is one of `good`, `fair`, `poor`, `empty`, `ocr_failed`.

**`ocr_failed` is not `empty`.** A blank page and a page the engine could not
read are different facts, and conflating them is how a document silently loses
half its content. When recognition fails, the verdict says so and carries the
underlying error.

### If you are an agent deciding whether to call this

**Call it when** you have a PDF whose content you will quote, cite or act on, and
being wrong is expensive — contracts, public records, invoices, reports — and
especially when you do not know whether the file is a clean export or a scan.

**Do not call it when** you only need rough text from a known-good digital PDF
and cost matters more than certainty. Cheaper extractors do that job and do it
well. Do not call it for HTML pages: this reads PDFs.

**What you get to decide with:**

- `usablePagesRatio` — how much of this document you can rely on. 1.0 means every
  page passed.
- `warnings` — names the pages that did not, in plain language.
- `pages[].reliability.verdict` — per page: `good`, `fair`, `poor`, `empty`,
  `ocr_failed`. **Treat `ocr_failed` as "this page exists and could not be
  read", never as a blank page.**
- `thresholds` — the exact rule behind every verdict, returned with the response
  so you can apply your own standard instead of ours.

**Cost:** $2.00 per document, plus $0.05 per scanned page actually recognised.
A document that fails is not charged.

**Escalation rule that works:** if `usablePagesRatio` is below 1, do not
summarise the document as if it were complete. Name the missing pages to the
human, or fetch a better copy.

### Why this exists — measured on 12 real documents

A corpus of 12 French municipal records, 157 pages, all public:

| | |
|---|---|
| Pages with no text layer at all | **32 of 157 — 20 %** |
| Documents that come back **completely empty** without OCR | **5 of 12** |
| Documents partly scanned | 1 |

That last one is the case that costs you. It runs to 52 pages, 17 of them with
no text layer. A text-layer-only extraction returns **7,842 characters** from
it: no error, no warning, apparently a success — with a third of the document
silently missing.

This actor reads those pages, and says per page how far to trust what it read.
The corpus and the measurement are reproducible: the documents are public
records, the test is "does this page carry a text layer".

### Where this sits, honestly

Confidence scores and bounding boxes are **not** new. Dedicated document-AI
platforms — Reducto, LlamaParse, Nutrient, Unstructured, Docling, and the
hyperscaler services — have offered them for years, and if you are choosing a
document platform you should look at those first. This is not a category
challenger and does not pretend to be.

What it is: that behaviour **inside Apify**, at a flat per-document price, with
one property those services do not all give you — **a failed recognition is
never reported as an empty page**, and the thresholds behind the verdict are
returned with every result so you can check the call instead of trusting it.

An agent cannot tell good extraction from bad. It will summarise, cite and act
on either. The verdict gives it a decision it can make: retry, escalate to a
human, or trust the page.

Verdicts are computed from three independent signals — the engine's own
confidence, the share of tokens shaped like real words, and validity against a
dictionary for the detected language. None is sufficient alone: confidence is
flattering on regular noise, word shape misses wrong digits, a dictionary misses
proper nouns. **If no dictionary exists for the detected language, the verdict
is capped at `fair`** — the best grade is not awarded on a check that was not
performed.

### Measured, not claimed

On a controlled pair (the same document with a text layer, then scanned), 10
lines out of 10 matched, with a provenance gap of **0.6 pt horizontally and
3.8 pt vertically** — under 1.5 mm. The vertical offset is systematic: a text
layer box includes ascenders and descenders, an OCR box hugs the ink.

On 6 real French municipal records (11 pages, 6 native, 5 scanned): OCR
confidence 87.3–94.3, lexical validity 0.708–0.795 against 0.681–0.823 for the
native pages of the same corpus. **OCR performs at the level of the document's
own text layer** — it is not a fallback.

Thresholds are returned with every result. They were set before measurement and
recalibrated once, downward, for a stated reason: the native text layer of real
administrative documents scores 0.68–0.82, so demanding 0.80 of OCR would demand
more than the document contains. The ceiling is the text layer, not 1.0.

### What this does not do

`lexicalValidity` checks that words exist, not that they are the right ones. A
misread figure stays invisible to it: `12000` read as `72000` is perfectly
lexical. **For any number you intend to rely on, the verdict is not a
substitute for a human check.** Real errors seen in output: `Eeletons` for
`Égletons`, `3°ME` for `3ÈME`.

Languages shipped: French, English, German, Spanish, Italian, Dutch.

By default the fetch honours `robots.txt`. `ignoreRobotsTxt` exists for
documents you own or are authorised to fetch — the choice, and its
consequences, are yours.

### Pricing

| Event | Price |
|---|---|
| `document` — one per document successfully read | $2.00 |
| `ocr-page` — one per scanned page actually recognised | $0.05 |

A failed document is never charged. A page whose recognition failed is not
charged as an OCR page. A ten-page scan costs $2.50; a native PDF of any length
costs $2.00.

# Actor input Schema

## `documentUrls` (type: `array`):

Direct URLs to the documents to read (PDF). One charge per document.

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

0 = all pages. Lower it to cap cost and runtime on long documents.

## `ocrLanguages` (type: `string`):

Tesseract language codes, joined by +. Only used on pages that have no text layer.

## `includeLines` (type: `boolean`):

Return every line with its page and bounding box in PDF points. Turn off for markdown only.

## `ignoreRobotsTxt` (type: `boolean`):

By default the fetch honours robots.txt. Enable only for documents you own or are authorised to fetch.

## Actor input object example

```json
{
  "documentUrls": [
    "https://example.org/report.pdf"
  ],
  "maxPagesPerDocument": 0,
  "ocrLanguages": "fra+eng",
  "includeLines": true,
  "ignoreRobotsTxt": false
}
```

# Actor output Schema

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

One entry per URL given in documentUrls. Key fields: ok, pagesTotal, pagesProcessed, pagesOcr, language, usablePagesRatio, medianLexicalValidity, warnings, thresholds, markdown, and pages\[] with per-page reliability {verdict, wordShapeScore, lexicalValidity, ocrConfidence} and lines\[] with {text, bbox}. verdict is one of good, fair, poor, empty, ocr\_failed — ocr\_failed is a recognition failure, not a blank page.

# 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 = {
    "documentUrls": [
        "https://example.org/report.pdf"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("filnith/document-provenance-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 = { "documentUrls": ["https://example.org/report.pdf"] }

# Run the Actor and wait for it to finish
run = client.actor("filnith/document-provenance-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 '{
  "documentUrls": [
    "https://example.org/report.pdf"
  ]
}' |
apify call filnith/document-provenance-ocr --silent --output-dataset

```

## MCP server setup

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