# PDF Inspector: Scanned PDF Detector & Metadata Audit (`aiqlabs/pdf-inspector`) Actor

Bulk PDF audit before OCR or text extraction: which pages are scanned and need OCR, which files are encrypted, what the metadata leaks (author, local paths), embedded JavaScript and attachments, PDF/A and PDF/UA conformance, unembedded fonts, and every link inside the document.

- **URL**: https://apify.com/aiqlabs/pdf-inspector.md
- **Developed by:** [Ai-Q Labs](https://apify.com/aiqlabs) (community)
- **Categories:** Developer tools, SEO tools, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per usage

This Actor is paid per platform usage. The Actor is free to use, and you only pay for the Apify platform usage, which gets cheaper the higher subscription plan you have.

Learn more: https://docs.apify.com/platform/actors/running/actors-in-store#pay-per-usage

## 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

## PDF Inspector: Scanned PDF Detector & Metadata Audit

Point it at a list of PDF URLs. It tells you, per file, whether text can be pulled out of it at all, what the file leaks about whoever made it, and whether anything inside it should stop you opening it.

This is the step *before* a PDF text extractor, not another one. A plain extractor hands back an empty string for a scanned page and gives you no way to tell that apart from a genuinely blank one. On a batch of a thousand documents that difference decides how much OCR you pay for.

**Free to run.** No API key, no proxy, no browser. Two HTTP requests per file at most: the download, and nothing else unless you turn link checking on.

***

### The question it answers that a text extractor cannot

For every page, the Actor separates four cases that all look identical in extractor output:

| Page kind | What it means | What to do |
|---|---|---|
| `text` | 200+ characters of extractable text | Extract it, you are done |
| `sparse` | Short but real text - a title page, a divider | Extract it |
| `image_with_stamp` | Almost no text next to images: a page number or Bates stamp burned onto a scan | Send to OCR |
| `image_only` | No text, images present | Send to OCR |
| `blank` | No text and no images. Genuinely empty | Skip it - OCR would cost money and return nothing |

Those roll up into one field per document, `textLayer`:

- `full` - every page can be read
- `partial` - **some pages can, some cannot.** This is the dangerous one: a plain extractor returns content, so nothing looks wrong, and you silently lose the scanned pages
- `none` - the whole document is a scan
- `empty` - every page inspected was confirmed blank

and one number, `pagesNeedingOcr`, with `ocrCountConfirmed` telling you whether that number was measured on every page or partly inferred because the probe budget ran out.

***

### What else comes back

**Can it even be opened**
Encrypted-with-a-password files, damaged files, and HTML error pages served with status 200 under a `.pdf` URL are separated from each other rather than all failing the same way.

**Permissions**
Which of the eight PDF permission bits are denied: printing, copying text, editing, filling forms, extracting for accessibility, assembling, high-quality printing. A file that opens fine can still be one that your extractor refuses to read.

**What the file discloses**
Author, subject, keywords, creator, producer, creation and modification dates, XMP creator tool and document ID. Plus a byte-level search for local filesystem paths - `C:\Users\...`, `/Users/...`, `\\fileserver\...`, `file:///...` - that ended up baked into the file. Those name a person or map your internal storage.

`revisions` counts incremental saves. A file saved incrementally three times still contains the two earlier versions of its content; anything "removed" in the last pass may still be in there.

**What it will run**
Document-level JavaScript, `OpenAction`, `Launch` actions (which ask the reader to execute an external file), `SubmitForm` actions (which post entered data to a URL), embedded file attachments with their filenames, and rich media. For inbound documents from outside your organisation, these are the fields worth a look.

Embedded XML matching a known e-invoice profile - Factur-X, ZUGFeRD, XRechnung, Order-X, UBL - is called out separately, because there the attachment is the point of the file.

**Conformance and accessibility**
Tagged or not (from the document's own `MarkInfo`, not a guess), document language, title, bookmark count, `PDF/A-1b`-style conformance and `PDF/UA-1` claims read from XMP.

**Fonts**
Font names, and whether font programs are embedded. The warning only fires when a font outside the standard 14 is referenced without shipping the program - a file that uses only Helvetica and Times and embeds nothing is correct and portable, and is not reported as a problem.

**Links inside the document**
Every URI link annotation, with its page number. Relative links are resolved against the location the PDF was downloaded from, exactly as a reader would. `file://` links and plain `http://` links are flagged. Turn `checkLinks` on and each one is requested, so you get the dead ones.

***

### Status, and why `ok` still means something

Every row carries `status`, one of `ok`, `warning`, `error`, plus `issues` (machine-readable codes), `issuesBySeverity`, and `explanations` - one plain sentence per issue, so nothing needs looking up.

The severity split is deliberate:

- **error** - the file could not be read, so no other field on that row can be trusted
- **warning** - something that will break a downstream job or leak information
- **notice** - true of most PDFs ever published: untagged, not linearised, producer string present, no document language

Notices are recorded, because if you are auditing for accessibility they are the whole point. **They never change `status`.** Untagged PDFs are the overwhelming majority of what exists; if that alone flipped every row to "warning", the twelve files that actually need work would be invisible.

`onlyIssues` uses the same rule, so it keeps the batch short instead of returning everything.

***

### Output

One dataset row per PDF, plus four ready-made views in the Apify UI: **Overview**, **OCR triage**, **Security and privacy**, **Accessibility and conformance**. Export as JSON, CSV, or Excel.

A `SUMMARY` record in the key-value store holds the run totals: the status split, the `textLayer` split, how many pages out of the batch need OCR and which files are worst, the security counts, and a breakdown of every issue code by how often it fired.

***

### Input

| Field | Default | Notes |
|---|---|---|
| `pdfUrls` | - | Direct links, one per line. Redirects followed |
| `extractText` | `false` | Also return the readable text |
| `checkLinks` | `false` | Request every link found inside the documents |
| `onlyIssues` | `false` | Drop rows whose status is `ok` |
| `maxPages` | `200` | Page-level inspection budget per file |
| `maxOcrProbePages` | `25` | Pages to read drawing instructions for |
| `maxFileMb` | `50` | Refused before the whole file is downloaded |
| `maxConcurrency` | `5` | Parsing holds a whole file in memory |

***

### Limits, stated plainly

- **Scanned-page detection is a measurement, not OCR.** It tells you which pages need OCR and roughly what that will cost you. It does not read them.
- **Password-protected files stay closed.** No password input, no cracking. They come back as `encrypted_password_required` and nothing else about them is claimed.
- **JavaScript, `Launch` and attachment detection is best-effort.** From PDF 1.5 onward, objects can live in compressed streams where a byte-level search cannot see them. Every positive is real; a negative means "not found", not "definitely absent". The parser's own findings are used first and the byte scan only adds to them.
- **`fontObjects` counts what is visible in the clear.** In a file that stores its font dictionaries in compressed object streams the count is 0 even though fonts exist. The unembedded-font warning depends on both signals, so it stays silent rather than firing wrongly.
- **Digital signatures are reported, not validated.** The Actor tells you a signature field exists. It does not tell you the signature is good.
- **Only the pages inside `maxPages` are classified.** `pageCount` is always the real total.

***

### Verification

`npm test` runs 22 unit tests covering page classification, the PDF date format, XMP in all three encodings real tools produce (single-quoted attributes, double-quoted attributes, child elements) under both the `xmpMM` and `xapMM` prefixes, the byte scan, link classification, and the severity rules.

`npm run test:live` runs 45 checks against real files on the real internet: a scanned six-page document, a password-protected file, a PDF/A-1b file written by Ghostscript, a PDF/UA-1 tagged government form with an XFA layer, a file with an embedded attachment, a file containing a relative link, an HTML page served as a PDF, a 404, a real 301 redirect, the size limit, the timeout, and live link checking against known-good and known-dead URLs.

# Actor input Schema

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

Direct links to PDF files, one per line. Redirects are followed and the URL that actually answered is reported back.

## `extractText` (type: `boolean`):

Return the readable text of each document alongside the audit. Off by default, because the audit is what tells you whether extracting text is even possible.

## `maxTextChars` (type: `integer`):

Upper bound on the extracted text kept per file. Only used when text extraction is on.

## `checkLinks` (type: `boolean`):

Request every link found in the document annotations and report which ones are dead. Adds one HTTP request per link, so it is off by default.

## `maxLinksToCheck` (type: `integer`):

Upper bound on link requests per file. Anything above this is still listed, just not checked.

## `onlyIssues` (type: `boolean`):

Keep rows whose status is warning or error and drop the clean ones. Notices such as "not tagged" do not count as attention, so this stays useful on real batches.

## `maxPages` (type: `integer`):

Page-level inspection stops after this many pages. The page count, metadata and security findings still cover the whole file.

## `maxOcrProbePages` (type: `integer`):

Deciding whether a page with no text is scanned or simply blank means reading its drawing instructions, which is the expensive part. Pages beyond this budget are reported as unprobed rather than guessed.

## `maxFileMb` (type: `integer`):

Files larger than this are refused before they are downloaded in full.

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

Give up on a single download after this many seconds.

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

How many PDFs to download and parse at the same time. Parsing holds the whole file in memory, so raising this on large documents raises memory use in step.

## `maxPdfs` (type: `integer`):

Safety stop for very long input lists.

## Actor input object example

```json
{
  "pdfUrls": [
    "https://www.irs.gov/pub/irs-pdf/f1040.pdf",
    "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
    "https://raw.githubusercontent.com/py-pdf/sample-files/main/007-imagemagick-images/imagemagick-images.pdf"
  ],
  "extractText": false,
  "maxTextChars": 20000,
  "checkLinks": false,
  "maxLinksToCheck": 50,
  "onlyIssues": false,
  "maxPages": 200,
  "maxOcrProbePages": 25,
  "maxFileMb": 50,
  "requestTimeoutSecs": 30,
  "maxConcurrency": 5,
  "maxPdfs": 5000
}
```

# Actor output Schema

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

No description

## `csv` (type: `string`):

No description

## `summary` (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 = {
    "pdfUrls": [
        "https://www.irs.gov/pub/irs-pdf/f1040.pdf",
        "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
        "https://raw.githubusercontent.com/py-pdf/sample-files/main/007-imagemagick-images/imagemagick-images.pdf"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("aiqlabs/pdf-inspector").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.irs.gov/pub/irs-pdf/f1040.pdf",
        "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
        "https://raw.githubusercontent.com/py-pdf/sample-files/main/007-imagemagick-images/imagemagick-images.pdf",
    ] }

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

# Fetch and print Actor results from the run's dataset (if there are any)
print("💾 Check your data here: https://console.apify.com/storage/datasets/" + run["defaultDatasetId"])
for item in client.dataset(run["defaultDatasetId"]).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.irs.gov/pub/irs-pdf/f1040.pdf",
    "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
    "https://raw.githubusercontent.com/py-pdf/sample-files/main/007-imagemagick-images/imagemagick-images.pdf"
  ]
}' |
apify call aiqlabs/pdf-inspector --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "command": "npx",
            "args": [
                "mcp-remote",
                "https://mcp.apify.com/?tools=aiqlabs/pdf-inspector",
                "--header",
                "Authorization: Bearer <YOUR_API_TOKEN>"
            ]
        }
    }
}

```

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/acts/lr8HyNEbtphrtkPVu/builds/efkLxohDfJ63KvS4y/openapi.json
