# PDF Text Extractor - Citable Pages & RAG Chunks (`natstandridge/pdf-page-text-extractor`) Actor

Extract PDF text as original-numbered pages with document hashes, exact-offset RAG chunks and explicit coverage. Public HTTPS or base64 input. No OCR. Pay only for delivered nonempty pages.

- **URL**: https://apify.com/natstandridge/pdf-page-text-extractor.md
- **Developed by:** [Nathaniel Standridge](https://apify.com/natstandridge) (community)
- **Categories:** Developer tools
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$0.50 / 1,000 delivered nonempty pdf text pages

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

## PDF Text Extractor — Citable Pages & RAG Chunks

Turn text-based PDFs into **original-numbered pages, exact-offset chunks and explicit coverage** for document search, quotation tracking and RAG ingestion. Pay for a whole nonempty page only when it reaches the default dataset. Chunks and the coverage report are included.

This is a deterministic text-layer extractor, **not OCR, an AI summarizer, an embedding service, a table parser or a layout-preserving converter**. It does not determine whether a document is true, current or legally valid.

### Start with a working PDF

```json
{
  "urls": ["https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"],
  "maxPagesPerDocument": 10,
  "maxResults": 10,
  "chunkChars": 1200,
  "chunkOverlap": 100,
  "maxRunSeconds": 120
}
```

The checked public example has one page containing `Dummy PDF file`. A successful extraction costs **$0.0005** in Actor result charges. Its source may change or become unavailable; errors are reported rather than replaced with sample text. Set the run's maximum charge in Console or through the API as an additional guard.

For your own PDFs, provide public direct HTTPS URLs or caller-supplied bytes:

```json
{
  "pdfs": [{"name": "supplier-handbook.pdf", "base64": "YOUR_PDF_BYTES_AS_BASE64"}],
  "startPage": 1,
  "maxPagesPerDocument": 50,
  "maxResults": 100
}
```

Replace the base64 placeholder with an actual PDF. No data-URL prefix, password, cookie, token or login is accepted. Inline PDFs are useful when a document is not publicly hosted; you are responsible for permission to process it. The local helper below encodes a file without exposing it to another service:

```python
import base64
import json
from pathlib import Path

payload = Path("supplier-handbook.pdf").read_bytes()
actor_input = {"pdfs": [{"name": "supplier-handbook.pdf", "base64": base64.b64encode(payload).decode()}]}
Path("actor-input.json").write_text(json.dumps(actor_input))
```

### One complete workflow

1. Supply the PDFs from your recurring document intake batch. Run with bounded page and spending limits.
2. Read `OUTPUT` first. Check every document's selected range, skipped pages, unprocessed pages and `documentComplete` before treating a batch as complete.
3. Export default-dataset JSON. Upsert pages using `(documentSha256, pageNumber)` and retain `textSha256` to detect identical extracted content.
4. Send each `chunks[].text` to your own indexing or embedding system, retaining its `citation`, page number and source metadata. Chunk creation is included; no embedding provider is called.
5. When quoting a result, use the original physical page and source PDF. Validate important quotations visually, especially in tables or multi-column documents.

For a simple integration, save your exported dataset as `pages.json` and run this adapter:

```python
import json
from pathlib import Path

pages = json.loads(Path("pages.json").read_text())
with Path("chunks.jsonl").open("w") as output:
    for page in pages:
        for chunk in page["chunks"]:
            record = {"id": chunk["chunkId"], "text": chunk["text"],
                      "citation": chunk["citation"], "pageNumber": page["pageNumber"],
                      "documentSha256": page["documentSha256"], "sourceUrl": page["sourceUrl"]}
            output.write(json.dumps(record, ensure_ascii=False) + "\n")
```

There is no shared cross-run document cache or hidden subscription. A new run may extract and charge the same pages again; deduplication applies within a run and its recovery only.

### Output contract

Each paid dataset row is a complete, nonempty normalized text page:

| Field | Meaning |
|---|---|
| `documentSha256`, `documentBytes` | SHA-256 and byte length of the exact downloaded or supplied PDF, before parsing |
| `sourceId`, `sourceType`, `sourceUrl`, `finalUrl`, `sourceName` | Input association, public URL provenance or inline display name; inapplicable values are `null` |
| `pageNumber`, `pageCount` | One-based **physical** page and total physical pages; printed labels may differ |
| `text`, `textSha256`, `characterCount` | Full accepted normalized page text, its UTF-8 SHA-256 and Unicode code-point count |
| `chunks` | Deterministic within-page slices with `chunkId`, `startChar`, `endChar`, `text` and `citation` |
| `citation` | Source-byte identity and physical page, such as `sha256:...#page=1`; an identifier, not a hosted download link |
| `observationId` | Deterministic ID incorporating document bytes, page, normalized text and chunk settings |
| `qualityWarnings` | `short_text_layer` below 40 characters or `replacement_character_present`; heuristics, **not a correctness score** |
| `extractor`, `extractedAt` | Pinned extraction implementation and run-plan timestamp |

The following excerpt shows the exact text and offsets from the checked W3C example. The fields listed in the table are included in actual dataset rows; IDs and provenance are omitted here for readability:

```json
{
  "pageNumber": 1,
  "pageCount": 1,
  "text": "Dummy PDF file",
  "characterCount": 14,
  "chunks": [{"startChar": 0, "endChar": 14, "text": "Dummy PDF file"}]
}
```

Offsets are **zero-based Unicode code points**, with an exclusive end: in Python, `page["text"][startChar:endChar]` exactly equals the chunk text. They are not byte, token or JavaScript UTF-16 offsets. In JavaScript, slice `Array.from(page.text)` to use these offsets. Chunk boundaries are fixed character windows, not sentence or token boundaries. Overlap repeats text intentionally. Chunks never cross pages.

Normalization converts CRLF/CR to LF; removes C0 controls except tab/newline, DEL and surrogate code points; and strips surrounding whitespace. It does not apply Unicode normalization, merge paragraphs or reconstruct layout. Internal spaces and line breaks are otherwise retained from the extractor. Pages exceeding a safety limit are **skipped entirely**, never silently truncated.

### Free coverage report

`OUTPUT` includes the requested scope and one entry per retained input source. Each entry reports available source metadata, total pages, `selectedStart`, `selectedEnd`, `unselectedPageCount`, per-page status, empty/skipped/delivered counts, unprocessed selected pages, errors and any stop reason. Redirect provenance is recorded when a download succeeds.

- `complete: true` means all **configured page windows** were processed and every accepted nonempty page was delivered. It does not mean every PDF page or every visual element was extracted.
- `documentComplete: true` additionally requires that the selected window covers that entire physical PDF.
- A `startPage` beyond a document's last page is a successful empty window. The report retains the actual page count, zero selected pages and `documentComplete: false`; this does not imply the PDF itself has no text.
- Blank/image-only pages produce no paid rows. The status `empty` means no extractable text was found, not proof that the visual page is blank. A scanned PDF may complete with zero rows.
- Duplicate normalized URLs are removed before download. Identical document bytes are parsed once; later aliases point to `duplicateOf`. Repeated text on **different physical pages** is retained because those are distinct citations.
- Result, charge, time, parser and plan-size limits leave explicit partial coverage. A budget-limited run delivers complete pages only. Not-started sources can have unknown page counts (`null`).

If all attempted documents fail, the run fails after saving free diagnostics. A mix of useful pages and failures is partial. Malformed or unsupported inputs do not become paid error rows.

### Inputs and hard bounds

| Input or bound | Default | Maximum / rule |
|---|---:|---|
| `urls` plus `pdfs` | — | 1–10 submitted documents total; URLs first, then inline PDFs |
| Downloaded PDF | — | 10 MiB each; HTTPS on port 443; public DNS only |
| Inline PDFs | — | 4 MiB decoded **total**, strict base64 |
| `startPage` | 1 | 1–10,000 |
| `maxPagesPerDocument` | 50 | 1–100 physical pages in each window |
| `maxResults` | 100 | 1–300 delivered nonempty pages total |
| `chunkChars` | 1,200 | 200–4,000 Unicode code points |
| `chunkOverlap` | 100 | 0–500, and strictly less than half `chunkChars` |
| `maxRunSeconds` | 120 | 30–240; use a platform timeout of at least 300 seconds |
| Page text | — | 100,000 normalized code points; entire oversized page skipped |
| Parser | — | 256 MiB address space, 12 CPU seconds and 20 wall seconds per document |
| Decoded stream | — | 2 MiB, plus bounded filter, parsing-work and page-tree limits |
| Frozen result plan | — | 20 MiB including reserved metadata space |

Runtime limits deliberately favor safe, predictable batches over processing every unusual PDF. Strict parsing can reject PDFs that a desktop viewer repairs. A parser timeout or process failure can discard that document's still-undelivered text; it is not charged.

### Pricing

**$0.50 per 1,000 delivered nonempty text pages** ($0.0005 per page), no startup event. All chunks from a page are included. Blank, duplicate, failed, skipped, filtered-out-by-page-window and undelivered pages are uncharged. One page is one dataset item, not one chunk.

Examples: 20 delivered pages cost $0.01; 100 cost $0.05. These are Actor result charges, not a promise about your plan's other platform charges. Set a spending limit and inspect coverage after capped runs. A run can deliver less than requested when PDFs lack text layers or encounter safety limits.

Billing uses only Apify's native default-dataset event. The result plan is frozen before writes. An uncertain append is not retried; recovery checks the existing dataset and pending checkpoint. If that outcome cannot be verified, the run stops rather than risk duplicate pages or charges. A verified same-run restart reuses the frozen plan without refetching changed PDFs.

### Safety, privacy and limitations

Public downloads validate every DNS answer, pin a public address with TLS hostname verification, validate each redirect destination and check robots rules at each PDF origin. Download retries are bounded safe reads; exhausted rate limits pause collection. No credentials, cookies or proxy are forwarded. Private/local destinations and nonstandard ports are rejected.

Parsing runs in a separate resource-limited process without inherited runtime credentials. PDF JavaScript, launch actions, attachments and external resources are not executed or fetched. This is not a general-purpose security sandbox. Encrypted and copy-restricted PDFs are rejected **before attempting even an empty password**; there is no unlocking feature.

The **owned billable dataset client** disables automatic redirects and retries and restricts requests to the Apify API origin. This statement does not apply to upstream SDK lifecycle/key-value storage clients, whose pinned defaults remain unchanged. Public-PDF fetching is a separate manually validated path.

Your inline base64 is part of Apify's stored input; text and provenance are saved in the dataset and run records. Treat storage access and retention accordingly. Temporary downloaded PDFs are removed locally after processing; this does not delete the platform input or output. Avoid sensitive URL queries. No customer files are sent to an AI provider.

Image-only scans require a separate OCR workflow. Reading order, ligatures, columns, headers, tables and custom font encodings may be imperfect. A text layer can omit or misrepresent visible content. A short warning list is not a guarantee of high quality. No original-document download is hosted by this Actor.

Free alternatives such as pypdf and Poppler are good choices when you manage the execution environment. This Actor adds bounded Apify batching, citable page/chunk output, explicit partial coverage and delivered-page charging; it does not claim exclusive extraction technology.

### Local development validation

This section is for factory maintainers with the local source checkout, not a requirement for Store users. The `examples/` and `tools/` paths are repository developer artifacts, not downloadable Store attachments. The customer integration above is self-contained.

```sh
uv sync --frozen
uv run python -B -m unittest discover -s tests -v
npm ci --ignore-scripts --no-audit --no-fund
npm run test:schema
node tools/validate_apify_artifacts.mjs .
```

`tools/prepare_cases.py` creates private synthetic qualification inputs. `tools/local_smoke.py` retains source PDFs only under ignored private evidence and compares extraction with the independent Poppler `pdftotext` executable. Synthetic pages also have a known-text oracle. `tools/validate_cloud.py` checks actual dataset rows against schemas, frozen pages, hashes, chunks, coverage and independent source evidence. Unit tests exercise malformed/encrypted/expanding PDFs, deadlines, SSRF/robots/redirects, caps, duplicates and actual pinned-client ambiguous-write behavior.

Dependency: [pypdf](https://pypdf.readthedocs.io/en/stable/) under its BSD-3-Clause license. Its [text extraction guidance](https://pypdf.readthedocs.io/en/stable/user/extract-text.html) explains why text layers, complex layouts and decompressed streams need care.

# Actor input Schema

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

Public direct PDF URLs, without credentials, signed query parameters or fragments. Robots rules apply. Up to 10 documents total across urls and pdfs; 10 MiB per download. URLs are processed before inline PDFs.

## `pdfs` (type: `array`):

Optional inline PDFs you may process. Each entry has name and strict base64 (no data URL prefix). At most 4 MiB total decoded inline bytes; up to 10 documents including URLs. No password or decryption support.

## `startPage` (type: `integer`):

One-based physical PDF page number, not a printed label. Applies to every document. Beyond the end is a successful empty selected window, reported in coverage.

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

Number of physical pages in the selected window, including blank pages. Complete coverage refers to this window, not necessarily the entire PDF.

## `maxResults` (type: `integer`):

Overall whole-page result cap. Empty, duplicate, failed and skipped pages are uncharged. Later pages/documents may remain unprocessed when this cap is reached.

## `chunkChars` (type: `integer`):

Maximum Unicode code points per deterministic within-page chunk. Character offsets are zero-based with an exclusive end, not token, byte or UTF-16 offsets.

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

Repeated Unicode code points between adjacent chunks. Must be less than half chunkChars. Chunks never cross page boundaries.

## `maxRunSeconds` (type: `integer`):

Collection and delivery limit; reserves 25 seconds for result delivery. Each download has a 25-second hard limit and each PDF parser a 20-second hard limit. Platform timeout should be at least 300 seconds.

## Actor input object example

```json
{
  "urls": [
    "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
  ],
  "startPage": 1,
  "maxPagesPerDocument": 50,
  "maxResults": 100,
  "chunkChars": 1200,
  "chunkOverlap": 100,
  "maxRunSeconds": 120
}
```

# Actor output Schema

## `pages` (type: `string`):

No description

## `coverage` (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": [
        "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("natstandridge/pdf-page-text-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": ["https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"] }

# Run the Actor and wait for it to finish
run = client.actor("natstandridge/pdf-page-text-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": [
    "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
  ]
}' |
apify call natstandridge/pdf-page-text-extractor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,natstandridge/pdf-page-text-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/avpkbHjuCYhgKXZPf/builds/WXeCgH2i0PrcV1ysD/openapi.json
