# PDF Table Extractor: Cross-Page Tables to CSV & JSON (`aiqlabs/pdf-table-extractor`) Actor

Extract tables from PDFs and rejoin the ones a page break cut in half. Reads ruled and borderless tables, drops the header the file repeats on every page, names each column type, and returns one clean grid per table as JSON and CSV.

- **URL**: https://apify.com/aiqlabs/pdf-table-extractor.md
- **Developed by:** [Ai-Q Labs](https://apify.com/aiqlabs) (community)
- **Categories:** Developer tools, AI, Automation
- **Stats:** 3 total users, 2 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 Table Extractor: Cross-Page Tables to CSV & JSON

Extract tables from PDFs — and put back together the ones a page break cut in half.

Every other PDF table extractor hands you a table per page. A forty-page statistical release
becomes forty fragments, each carrying its own copy of the header, and you finish the job by
hand. This one recognises that a fragment continues overleaf, joins it to the one before,
and deletes the header the file repeated.

**Free to run.** No key, no account beyond Apify, no per-page charge.

***

### What it does

Give it links to PDF files. You get **one row per table**, with the grid as JSON and as CSV.

- **Rejoins tables split across page breaks** and drops the repeated header rows
- **Reads ruled tables from the lines the file draws**, including tables that rule only their
  columns and leave the rows to whitespace
- **Reads borderless tables** from column gaps that stay empty on every row
- **Finds the header**, including headers set two or three lines deep, and flattens
  "June" over "2025" into `June 2025`
- **Names each column's type** — number, currency, percent, date or text — after parsing
  `1,234`, `(1,234)` as negative, `1.234,56`, `98.6%`, `$12.50` and footnote markers
- **Reports what it is unsure about**: spanning cells, sparse grids, tables found from
  whitespace alone, and grids with no numeric column at all

### Why this one

The PDF table Actors already on Apify were read before this was built. Between them they
offer text, tables, metadata, accuracy scores and OCR. **Not one mentions what happens when
a table runs onto the next page** — and every long table does.

| | Others | This Actor |
|---|---|---|
| Table split across pages | Two tables, header twice | One table, header once |
| Which pages a table came from | — | `pages: [28, 29, 30, 31]` |
| The document's caption | — | `caption: "Table B-1. Employees on nonfarm payrolls…"` |
| Column types | — | `["text","number","number",…]` |
| Price | Pay per PDF or per page | **Free** |

### Measured, not asserted

Both of these documents mark their own continuation pages, which gives a ground truth the
run can be scored against. The test suite checks the extractor's answer against that list
rather than against a number nobody can verify.

**US Bureau of Labor Statistics, *The Employment Situation* (39 pages)**

- pages the document prints "Continued" on: **13, 29, 30, 31**
- pages absorbed by rejoining: **13, 29, 30, 31** — every one, and no others
- Table B-1 comes out as **one table of 247 rows across four pages**, with 12 repeated
  header rows removed
- Tables A-1, A-2 and A-3 have **identical ten-column layouts and byte-identical headers**.
  They stay three tables, because the caption says so.

**Federal Reserve H.4.1 (11 pages, mixed landscape and portrait)**

- three continuation pages hold a table; **all three are absorbed**
- Table 6 is rejoined even though it sits **8.6 pt further right** on its second page
- Table 1A, which shares table 1's five columns in the same places, is **not** swallowed

Unit tests: **44 passing**. Live tests against real PDFs: **36 passing**.

### Two details that decide whether this works at all

**A vertical rule is often not a vertical line.** In the BLS release every vertical rule in
every table is drawn as a *horizontal* stroke 0.6 pt long with a line width of 491 pt.
Stroking a segment paints a band of `lineWidth` around it, so a short fat stroke is a tall
thin bar. Read the segment and ignore the width and you find **zero** vertical rules on a
page full of them.

**Rules say where the columns are; text says where the rows are.** A typical BLS data page
draws nine vertical rules and four horizontal ones — the verticals separate every column,
the horizontals mark only the top, the header and the bottom. Forty data rows have no rule
between them. Waiting for a full grid finds nothing; assuming rows are ruled finds three
rows where there are forty. Which of the two applies is counted per table, not assumed.

### Input

| Field | Default | What it does |
|---|---|---|
| `pdfUrls` | — | Direct links to PDF files, one per line |
| `mergeAcrossPages` | `true` | Rejoin tables split by a page break |
| `useWhitespaceTables` | `true` | Also find tables that draw no lines |
| `onlyDataTables` | `false` | Keep only tables with a numeric, currency, percent or date column |
| `includeCsv` | `true` | Add a ready-to-save CSV string per table |
| `includeRecords` | `false` | Add one object per row, keyed by the detected header |
| `minRows` / `minColumns` | `2` / `2` | Floor on what counts as a table |
| `maxPages` | `200` | Pages read per PDF |
| `maxRowsPerTable` | `5000` | Rows kept per table |
| `maxFileMb` | `50` | Download stops here rather than taking the run down |
| `maxConcurrency` | `5` | PDFs at a time |

### Output

One row per table:

Trimmed from a real run over the BLS release:

```json
{
  "filename": "empsit.pdf",
  "caption": "Table B-1. Employees on nonfarm payrolls by industry sector and selected industry detail",
  "tableId": "empsit.pdf#28.1",
  "pages": [28, 29, 30, 31],
  "splitAcrossPages": true,
  "pageFragments": 4,
  "repeatedHeaderRowsDropped": 12,
  "detectedBy": "rules",
  "rowCount": 247,
  "columnCount": 10,
  "header": ["Industry", "June", "Apr.", "Not seasonally adjusted May p", "June p",
             "June", "Apr.", "Seasonally adjusted May p", "June p", "Change from:"],
  "columnTypes": ["text", "number", "number", "number", "number",
                  "number", "number", "number", "number", "number"],
  "rows": [["Total nonfarm . . .", "159,299", "158,713", "159,398", "159,830",
            "158,478", "158,798", "158,927", "158,984", "57"]],
  "csv": "Industry,June,Apr.,Not seasonally adjusted May p,…",
  "confidence": 0.93,
  "status": "warning",
  "issues": ["spanning_cells"],
  "notes": ["This table was split across pages 28, 29, 30, 31 and has been rejoined into one grid, dropping 12 repeated header row(s)."]
}
```

The key-value store gets a `SUMMARY` with the totals and a list of every table that was
rejoined, with the pages it came from.

#### Issue codes

| Code | Meaning |
|---|---|
| `spanning_cells` | A cell covers more than one column; its text is in the leftmost |
| `no_header_detected` | The columns are unnamed |
| `no_ruling_lines` | Found from whitespace alone — check the split before trusting it |
| `no_numeric_columns` | Every column reads as text; may be a form rather than data |
| `sparse_grid` | Under half the cells hold text |
| `rows_truncated` / `page_budget_reached` | A limit was reached; raise it for the rest |
| `some_pages_have_no_text` | Those pages are scans; any table on them is an image |
| `rotated_pages_skipped` | Ruling lines were not used on rotated pages |

### Limits, stated plainly

- **No OCR.** A scanned page has no text to read. It is reported, not guessed at.
- **Rotated pages** are read for text but not for rules, so a table there may be missed.
- **Row-spanning cells** are detected as column spans only; a cell that covers two *rows*
  lands in the upper one.
- **Headers deeper than four lines** keep their first four; the rest stay as body rows. The
  BLS table B-1 header is seven lines deep and shows this.
- **A tax form is a grid**, and it will be found as one. `onlyDataTables` is there for that.
- Fragments are only joined when their columns line up **and** the caption or the repeated
  header agrees. A document that does neither keeps its fragments apart.

***

Built by **Ai-Q Labs**. Other Actors: PDF Inspector, PDF to Text & Markdown, HTTP Status
Checker, Domain Availability Checker, Tech Stack Detector, Broken Link Checker, Bulk Domain
Checker, AI Crawler Checker.

# Actor input Schema

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

Direct links to PDF files, one per line. A link that lands on a login page or an HTML page is reported rather than silently skipped.

## `mergeAcrossPages` (type: `boolean`):

A table that runs onto the next page arrives as two tables, each with its own copy of the header. Turn this on and the fragments become one grid with the repeated header rows removed. Fragments are only joined when their columns line up and either the document's own caption says the table continues, or the header is repeated verbatim.

## `useWhitespaceTables` (type: `boolean`):

Look for tables that draw no lines, by finding column gaps that stay empty on every row. Turn this off if you only want tables the file explicitly ruled.

## `onlyDataTables` (type: `boolean`):

A tax form is a grid of ruled boxes and will be found as a table. Turn this on to keep only tables where at least one column reads as numbers, currency, percentages or dates.

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

Adds a ready-to-save CSV string of the whole table, header included, to each row.

## `includeRecords` (type: `boolean`):

Adds a `records` field: the body rows as objects whose keys are the detected column headers. Convenient, but it makes the output larger.

## `reportFilesWithoutTables` (type: `boolean`):

Push a row for every PDF that yielded no table, with the reason. Turn off for a dataset of tables only.

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

Grids with fewer rows than this are not reported. Borderless tables always need at least three.

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

Grids narrower than this are not reported.

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

Pages beyond this are not read. A table that continues past the limit is reported as ending there.

## `maxRowsPerTable` (type: `integer`):

Rows kept per table. A table longer than this is truncated and flagged.

## `maxCellChars` (type: `integer`):

Characters kept per cell. Longer cell text is cut here.

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

Downloading stops at this size and the file is reported as too large, so one huge PDF cannot take the run down.

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

How long to wait for one PDF to download before giving up on it.

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

How many PDFs to read at the same time.

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

Hard ceiling on how many URLs one run will read.

## Actor input object example

```json
{
  "pdfUrls": [
    "https://www.federalreserve.gov/releases/h41/current/h41.pdf"
  ],
  "mergeAcrossPages": true,
  "useWhitespaceTables": true,
  "onlyDataTables": false,
  "includeCsv": true,
  "includeRecords": false,
  "reportFilesWithoutTables": true,
  "minRows": 2,
  "minColumns": 2,
  "maxPages": 200,
  "maxRowsPerTable": 5000,
  "maxCellChars": 2000,
  "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.bls.gov/news.release/pdf/empsit.pdf"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("aiqlabs/pdf-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 = { "pdfUrls": ["https://www.bls.gov/news.release/pdf/empsit.pdf"] }

# Run the Actor and wait for it to finish
run = client.actor("aiqlabs/pdf-table-extractor").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.bls.gov/news.release/pdf/empsit.pdf"
  ]
}' |
apify call aiqlabs/pdf-table-extractor --silent --output-dataset

```

## MCP server setup

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

```

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/8ptlAAbheQERGA8rx/builds/gqIHQMRXgyJgbnHbl/openapi.json
