# Government PDF Table Extractor – PDF to JSON (`lwsdjfls/govt-pdf-table-extractor`) Actor

PDF table extractor for government reports and annual accounts: extract tables from PDF to JSON with headers, typed records, page and position, confidence and sum checks. Joins multi-page tables, handles merged headers and Dutch number formats; Claude can read scanned pages, validated in code.

- **URL**: https://apify.com/lwsdjfls/govt-pdf-table-extractor.md
- **Developed by:** [Leyten Data](https://apify.com/lwsdjfls) (community)
- **Categories:** Developer tools, AI
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $10.00 / 1,000 table extracteds

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

### What does Government PDF Table Extractor do?

**PDF table extractor** for government reports, annual accounts and statistics: give it PDF URLs (budgets, annual reports, key-figure publications, price lists) and get every table back as **clean JSON rows** with the column headers, typed records, the page and position it came from, a confidence score and the checks that were run on it. Tables that continue over a page break come back as one table. A deterministic pass reads text-layer PDFs in under a second per document; an optional **LLM pass (Claude)** reads scanned or messy pages, and everything the model returns is validated in code before you see it.

#### Why tables from PDFs are hard, and what this Actor does about it

| Problem | What happens here |
| --- | --- |
| Column headers spread over two or three lines, with a group label ("Jaarrekening", "Begroting") centred above several year columns | Header rows are joined into one name per column and group labels are spread over the columns they cover: `Jaarrekening 2020`, `Begroting 2025**` |
| Borderless tables aligned by whitespace, and ruled tables with thin lines | Three detectors run on every page (whitespace-aligned, ruled lines, and a second ruled-line reader); the best reading per table region is kept |
| A table continues on the next page, with or without the header printed again | Continuations with the same columns are joined into one table with `pages: [14, 15]` and `merged: true`; the repeated header is dropped |
| Row labels that wrap onto two lines, and words cut in two at a column boundary | Re-joined |
| Section headings inside the table ("Mobiliteit in Nederland") | Kept as rows, listed in `sectionRows`, excluded from column typing and sums |
| Dutch and English number formats (`4.585` vs `4,585.00`), negatives in parentheses, footnote marks on years | The format is decided per column, never per cell; `records` carry real numbers |
| Totals | Every row labelled Totaal/Total/Som is checked against the rows above or below it, at the precision the table prints; a mismatch is reported in `checks`, never corrected |
| Text boxes and chart legends that look like two-column tables | Dropped (prose filter, chart captions) |
| Scanned PDFs, faint scans, exotic layouts | `llm-fallback` sends only those pages to Claude; `llm` sends every page. The model transcribes, the code validates: column counts, numbers, sums, expected columns |

Low confidence means a **marking** (`lowConfidence: true`, the reasons in `checks` and `warnings`), never a guess filled in for you.

#### The two modes

**fast** (default): deterministic only. Camelot 2.0 (stream and lattice) and pdfplumber read the text layer; no model, no API cost, about a second per document. Pages without a text layer (scans) are reported in the document record and yield no tables.

**llm-fallback**: the deterministic pass first; then Claude Haiku 4.5 (or Sonnet 5) reads the pages that have no text layer and the pages whose tables all came out with low confidence, from a rendered image of the page. Its answer goes through the same normalisation and checks as the deterministic result.

**llm**: Claude reads every page. For documents where layout beats the deterministic detectors (multi-level headers with merged cells, forms, brochures).

The model call logs its tokens and cost per page (`LLM page 14 of report.pdf: Claude Haiku 4.5, 1,472 in / 812 out tokens, $0.0055 API cost, 1 table(s)`) and the run summary totals them. The LLM events are priced to cover that cost.

#### How the choice of libraries was made

The deterministic engine is [Camelot 2.0](https://pypi.org/project/camelot-py/) (MIT, pdfium backend, no system dependencies) for whitespace-aligned and ruled tables, with [pdfplumber](https://github.com/jsvine/pdfplumber) (MIT) as a second reader of ruled lines. On Dutch government reports Camelot's stream mode was the only open-source detector that returned financial tables as correct grids including the row-label column; PyMuPDF's table finder is fast but AGPL-licensed, and the transformer models (Docling's TableFormer, Marker) score higher on benchmarks but need gigabytes of models and seconds per page on CPU, which does not fit a per-page price. Those hard pages are what the LLM mode is for.

#### Confidence and checks

`confidence` starts from the detector's own accuracy and whitespace measures (or the model's stated confidence) and is lowered for: no header row detected, rows with a different number of cells, columns that mix numbers and text, totals that do not add up, columns without a header name, and a column count that differs from **Expected columns**. Below 0.7 the table is `lowConfidence`. The `checks` array says exactly what was tested and what was found, for instance:

```text
sum · ok: false · Totaal in column "Jaarrekening 2023": the 6 rows above add up to 40,54, the table prints 40,56
```

A failed sum check means the rows and the total as printed do not agree: either a cell was misread, or the table lists only some of the components. The Actor tells you; it does not decide for you.

### Features

- **Extract tables from PDF to JSON**: headers, rows, typed records, caption and footnotes per table
- **Multi-line and grouped headers** joined into one name per column
- **Multi-page tables** merged into one, with the repeated header dropped
- **Dutch and English number formats** parsed per column into real numbers
- **Sum checks on totals and subtotals**, reported, never silently corrected
- **Confidence score and explicit checks** per table; low confidence is a marking, not a guess
- **Page, position and optional cell bounding boxes** for every table
- **Three detectors** (Camelot stream and lattice, pdfplumber lines) with the best reading kept per region
- **Optional Claude pass** for scanned or messy pages, validated in code, with cost logged per page
- Runs through the Apify API, on a schedule, or from Make, Zapier and n8n

### Use cases

#### Data and RAG engineers: tables that survive chunking

Running text goes through chunkers fine; tables do not. Extract them once, store `records` next to the document with `page` and `caption`, and let the retriever return the table as structured data (or a Markdown rendering of `headers` + `rows`) instead of a scrambled paragraph.

```js
// npm install apify-client
import { ApifyClient } from 'apify-client';

const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client.actor('lwsdjfls/govt-pdf-table-extractor').call({
    pdfUrls: ['https://open.overheid.nl/documenten/0ceeb3e8-e2b6-4237-9772-f75087e320ed/file'],
    mode: 'fast',
});
const { items } = await client.dataset(run.defaultDatasetId).listItems({ clean: true });
for (const { payload } of items) {
    if (payload.kind !== 'table') continue;
    if (payload.lowConfidence) console.warn(`${payload.fileName} p.${payload.page}: check ${payload.checks.filter((c) => !c.ok).map((c) => c.detail).join('; ')}`);
    await store.saveTable({ document: payload.documentId, page: payload.page, caption: payload.caption, headers: payload.headers, records: payload.records });
}
```

#### Policy analysts: government and statistical data

Budgets, annual reports and key-figure publications on open.overheid.nl, rijksfinancien.nl and CBS come as PDFs with the numbers in tables. The example below is Table 1 of the *Integraal Overzicht Financiën Gemeenten 2025* (Ministry of the Interior, [open.overheid.nl](https://open.overheid.nl/documenten/0ceeb3e8-e2b6-4237-9772-f75087e320ed/file)): the key financial figures of all municipalities 2020–2024, delivered as numbers. The same document's Table 2 (income per source, with the group headers Jaarrekening and Begroting spread over the year columns) comes out with every subtotal verified and one finding: the printed grand total for 2023 is 0,10 off the sum of its parts.

#### Finance and procurement teams

Price lists, statements and supplier reports: set **Expected columns** to the columns your import needs; tables that match get those names as record keys, tables that do not are flagged in `checks` and marked low confidence so nobody imports the wrong grid.

#### Journalists and researchers

Point it at a folder of reports (URLs), take the `tables` view, and filter on `caption`.

### How to use it

1. Paste the document links into **PDF URLs**, or post the files themselves as base64 in **PDF files** if they are not public. Narrow to the pages that matter with **Pages**, written like `14-17` or `1-5,8`. Pages you do not ask for are never read and never charged.
2. Leave **Mode** on `fast` first. That is the deterministic reader: it costs the least and, for any PDF with a real text layer, it is also the most accurate. Only if pages come back empty or with low confidence should you move to `llm-fallback`, which sends exactly those pages to Claude and leaves the rest alone. Reserve `llm` for documents that are scans from end to end.
3. Read the `tables` view for the extracted tables and `documents` for what happened per file. Every table carries its page, its position, the headers, the confidence and the sum checks, so you can tell a clean extraction from one worth a human glance instead of trusting all of them equally.

Set **Expected columns** when you process the same report every quarter. Tables that match the count get your column names as record keys, and any table that does not is flagged rather than quietly renamed. **Join tables that continue on the next page** keeps a table that runs over a page break as one table with all its rows.

Nothing is silently accepted. Whatever the model reads on a scanned page is validated in code against the page before it is delivered, and a file that cannot be fetched or is larger than **Maximum file size** comes back as a failed document record that costs nothing, rather than disappearing from your results.

```json
{
  "pdfUrls": ["https://open.overheid.nl/documenten/dpc-cee2e3debdd8cd3e16f84a876ee24537e3e034c0/pdf"],
  "pages": "14-17",
  "mode": "fast"
}
```

### Input

| Field | What it does | Example |
| --- | --- | --- |
| `pdfUrls` | Direct http(s) links to PDF files, one per line, each up to 2,048 characters; links to key-value store records work too. A link listed twice is read once. | `["https://open.overheid.nl/documenten/dpc-cee2e3debdd8cd3e16f84a876ee24537e3e034c0/pdf"]` |
| `pdfFiles` | For API callers: base64 files, up to 100, within Apify's 9 MB input limit. | `[{ "fileName": "report.pdf", "data": "<base64>" }]` |
| `pages` | Page numbers and ranges to read, for every document, such as `1-5, 8` (up to 1,000 characters); empty means all pages. | `"14-17"` |
| `mode` | `fast` (deterministic only), `llm-fallback` (Claude for scans and low-confidence pages) or `llm` (Claude reads every page). | `"fast"` |
| `llmModel` | `haiku` (Claude Haiku 4.5, cheaper) or `sonnet` (Claude Sonnet 5, for dense or hard layouts). | `"haiku"` |
| `flavor` | `auto`, `stream` (whitespace only) or `lattice` (ruled lines only). | `"auto"` |
| `expectedColumns` | The column names you expect, in order; matching tables get them as record keys, others are flagged. | `["Post", "2023", "2024"]` |
| `minRows` | Tables with fewer body rows are ignored. | `2` |
| `minColumns` | Tables with fewer columns are ignored. | `2` |
| `mergeAcrossPages` | Join a table that continues on the next page. | `true` |
| `includeCells` | Add every cell with its page and bounding box to each table. | `false` |
| `maxPagesPerDocument` | Pages beyond this are not read or charged. | `200` |
| `maxFileMb` | Larger files are reported as failed and cost nothing; at most 100. | `50` |
| `maxLlmPagesPerRun` | Caps the pages sent to Claude in one run. | `200` |
| `proxyConfiguration` | Only for downloading PDFs from sites that block data-centre addresses. | `{ "useApifyProxy": false }` |

```json
{
  "pdfUrls": ["https://open.overheid.nl/documenten/dpc-cee2e3debdd8cd3e16f84a876ee24537e3e034c0/pdf"],
  "pages": "14-17",
  "mode": "llm-fallback",
  "llmModel": "haiku",
  "mergeAcrossPages": true
}
```

For files you hold yourself, pass them base64-encoded in `pdfFiles`, or upload them to a key-value store and give the record URL in `pdfUrls`.

#### Supported input and limits

- PDF files with a text layer (any producer); scanned or image-only PDFs in the LLM modes. Password-protected PDFs are not supported.
- Up to 500 URLs or 100 base64 files per run, 50 MB per file by default (100 MB at most), 200 pages per document by default.
- The same file twice (a URL listed twice, two URLs that serve identical bytes, or an upload of a file you also linked) is read and charged once; the repeat gets a document record with status `duplicate`.
- After 20 documents in one run that cost nothing (not downloadable, not a PDF or too large, a repeat of a file already read, a PDF that cannot be opened, tables or a worker answer too large for the run's memory, or no page to process), the remaining documents are not tried; they are reported as failed, free of charge. The same happens once the run's spending limit is reached.
- In the LLM modes the model's answer is capped at 4,096 output tokens per page. A page with more table text than that can come back incomplete, and the log says so.
- A run stops sending pages to Claude when its API cost climbs far above what its LLM pages are charged (a safeguard against pages that cost more to read than their price); the remaining pages are delivered from the deterministic pass.
- Tables drawn as images inside a text-layer PDF are not read in fast mode (the page has text, so it is not treated as a scan); use `llm-fallback` with a low-confidence outcome or `llm`.
- Rotated pages and tables set sideways are read as they are; the detectors do not rotate.
- Nested tables and tables with cells that span rows *and* columns in irregular ways come back as a flat grid with the spanning text in the first cell of the span.
- Sum checks look for totals labelled Totaal, Total, Subtotaal, Som, Gesamt or Somme. A table lists at most 100 sum checks and 100 warnings; the rest are counted in a closing entry.
- A table too large for one dataset item (about 8 MB of JSON, such as a table of thousands of rows joined across hundreds of pages) is delivered as consecutive items with the same caption, headers, footnotes and checks, each with a `part` field; it is charged as one table. A table lists at most 100 footnotes of 20,000 characters together; the rest are counted in a closing entry.
- A table that cannot be delivered even in parts, which only a crafted file produces (a single row larger than an item, or headers and other repeated fields taking more than half of one), is left out as a whole, and the pages it is on are still charged. The document record counts it in `tablesNotDelivered` and names its page and size in `error`.
- A run holds its tables in memory until it delivers them: up to 32 MB of table JSON per GB of memory (128 MB with the 4 GB maximum). A document whose tables do not fit in what is left is reported as failed and not charged (LLM pages already read excepted), and its `error` says when no run with this memory can hold it; once the run holds half of that and a document no longer fits, the remaining documents are not tried. In the LLM modes the tables on the pages the model does not read are measured first: a document they already make too large sends no page to Claude. The PDF worker's answer for one document is limited the same way (up to about 21 MB per GB, several hundred pages of a typical report). Give the run more memory, or read long documents in page ranges.
- File names from the server (or from `pdfFiles`) are cleaned of control characters and cut to 200 characters.

### Output

One record per table:

- `caption`, `headers`, `rows` (cell text, `null` for empty), `records` (one object per row keyed by header, numbers parsed), `footnotes`
- `columnTypes` (`number`, `text`, `mixed`, `empty`), `numberFormat` (`nl` or `en`), `sectionRows`
- `page`, `pages` (every page the table covers), `bbox` in PDF points with a top-left origin, `pageSize`, optional `cells` with the page and a bounding box per cell (for a table joined across pages, the cells of every page)
- `part`: `null`, or for a table delivered in parts its `index`, `count`, `firstRow` and `tableRowCount`; `rows`, `records`, `sectionRows`, `cells` and `rowCount` then hold that part's rows, and the `tables` view shows `part` beside each row
- `method`: `camelot-stream`, `camelot-lattice`, `pdfplumber-lines`, `llm-haiku` or `llm-sonnet`
- `confidence` (0–1), `lowConfidence`, `checks` (`column-count`, `numeric-consistency`, `header`, `sum`, `expected-columns`), `warnings`, `merged`, `textLayer`
- `llm`: model, tokens and API cost when the model read the page
- `documentId`, `documentUrl`, `fileName`, `tableIndex`

Plus one `document` record per file, with `status` `processed`, `no-tables`, `failed` or `duplicate`: pages processed, pages without a text layer, pages read by the LLM, tables found, tables not delivered (`tablesNotDelivered`), low-confidence tables, the LLM API cost, and the error when the file could not be fetched, opened or held in the run's memory (a failed document costs nothing, except LLM pages the model had already read, which its record lists).

Table 1 of the Integraal Overzicht Financiën Gemeenten 2025 (page 3): caption separated, year headers with footnote marks kept, Dutch thousands separators parsed into numbers, and a row of counts with percentages in parentheses reported in `warnings` rather than silently parsed.

```json
{
  "envelopeVersion": 1,
  "id": "caced5b180e76c8d:t1",
  "scope": "doc:caced5b180e76c8d",
  "changeType": "added",
  "sourceUrl": "https://open.overheid.nl/documenten/0ceeb3e8-e2b6-4237-9772-f75087e320ed/file",
  "scrapedAt": "2026-09-15T20:00:00.000Z",
  "firstSeenAt": "2026-09-15T20:00:00.000Z",
  "hash": "71667740daeff13f87b94c1e0a9ea9b9f72cd895a4ee696c1266f8454a907c5f",
  "previousHash": null,
  "changes": [],
  "summary": "Added: Integraal Overzicht Financien Gemeenten 2025.pdf p.3 table 1 (Tabel 1. Kengetallen financiële positie): 11×6",
  "diffText": null,
  "payload": {
    "kind": "table",
    "documentUrl": "https://open.overheid.nl/documenten/0ceeb3e8-e2b6-4237-9772-f75087e320ed/file",
    "fileName": "Integraal Overzicht Financien Gemeenten 2025.pdf",
    "documentId": "caced5b180e76c8d",
    "page": 3,
    "pages": [
      3
    ],
    "tableIndex": 1,
    "caption": "Tabel 1. Kengetallen financiële positie",
    "footnotes": [],
    "headers": [
      "label",
      "2020",
      "2021",
      "2022",
      "2023*",
      "2024**"
    ],
    "rows": [
      [
        "Exploitatiesaldo (€ mln.)",
        "4.585",
        "1.761",
        "3.745",
        "1.644",
        "1.976"
      ],
      [
        "Eigen vermogen (€ mln.)",
        "34.081",
        "35.875",
        "39.651",
        "41.322",
        "43.346"
      ],
      [
        "Materiële en immateriële vaste activa (€ mln.)",
        "66.274",
        "68.605",
        "70.900",
        "74.103",
        "78.423"
      ],
      [
        "Solvabiliteit (%)",
        "37%",
        "38%",
        "40%",
        "40%",
        "39%"
      ],
      [
        "Netto schuldquote, gecorrigeerd voor doorgeleende gelden (%)",
        "48%",
        "49%",
        "42%",
        "41%",
        "41%"
      ],
      [
        "Exploitatieruimte (%)",
        "7%",
        "3%",
        "5%",
        "2%",
        "2%"
      ],
      [
        "Aantal gemeenten met 3 jaar op rij negatief resultaat",
        "69 (20%)",
        "30 (9%)",
        "5 (1%)",
        "9 (3%)",
        "10 (3%)"
      ],
      [
        "Grondexploitatie (afgezet tegen totale baten)",
        "7%",
        "6%",
        "5%",
        "5%",
        "5%"
      ],
      [
        "EMU saldo (€ mln.)",
        "-931",
        "426",
        "2.184",
        "-1.223",
        "-2.942"
      ],
      [
        "Stijging OZB-tarief, gecorrigeerd voor waardeontwikkeling (%)",
        "4,9%",
        "4,7%",
        "3,6%",
        "6%",
        "5,9%"
      ],
      [
        "Netto investeringsquote (%)",
        "1,0%",
        "1,0%",
        "0,8%",
        "1,4%",
        "1,9%"
      ]
    ],
    "records": [
      {
        "2020": 4585,
        "2021": 1761,
        "2022": 3745,
        "label": "Exploitatiesaldo (€ mln.)",
        "2023*": 1644,
        "2024**": 1976
      },
      {
        "2020": 34081,
        "2021": 35875,
        "2022": 39651,
        "label": "Eigen vermogen (€ mln.)",
        "2023*": 41322,
        "2024**": 43346
      },
      {
        "2020": 66274,
        "2021": 68605,
        "2022": 70900,
        "label": "Materiële en immateriële vaste activa (€ mln.)",
        "2023*": 74103,
        "2024**": 78423
      },
      {
        "2020": 37,
        "2021": 38,
        "2022": 40,
        "label": "Solvabiliteit (%)",
        "2023*": 40,
        "2024**": 39
      },
      {
        "2020": 48,
        "2021": 49,
        "2022": 42,
        "label": "Netto schuldquote, gecorrigeerd voor doorgeleende gelden (%)",
        "2023*": 41,
        "2024**": 41
      },
      {
        "2020": 7,
        "2021": 3,
        "2022": 5,
        "label": "Exploitatieruimte (%)",
        "2023*": 2,
        "2024**": 2
      },
      {
        "2020": "69 (20%)",
        "2021": "30 (9%)",
        "2022": "5 (1%)",
        "label": "Aantal gemeenten met 3 jaar op rij negatief resultaat",
        "2023*": "9 (3%)",
        "2024**": "10 (3%)"
      },
      {
        "2020": 7,
        "2021": 6,
        "2022": 5,
        "label": "Grondexploitatie (afgezet tegen totale baten)",
        "2023*": 5,
        "2024**": 5
      },
      {
        "2020": -931,
        "2021": 426,
        "2022": 2184,
        "label": "EMU saldo (€ mln.)",
        "2023*": -1223,
        "2024**": -2942
      },
      {
        "2020": 4.9,
        "2021": 4.7,
        "2022": 3.6,
        "label": "Stijging OZB-tarief, gecorrigeerd voor waardeontwikkeling (%)",
        "2023*": 6,
        "2024**": 5.9
      },
      {
        "2020": 1,
        "2021": 1,
        "2022": 0.8,
        "label": "Netto investeringsquote (%)",
        "2023*": 1.4,
        "2024**": 1.9
      }
    ],
    "columnTypes": [
      "text",
      "number",
      "number",
      "number",
      "number",
      "number"
    ],
    "numberFormat": "nl",
    "rowCount": 11,
    "columnCount": 6,
    "sectionRows": [],
    "bbox": [
      65.78,
      90.41,
      545.54,
      310.51
    ],
    "pageSize": {
      "width": 595.25,
      "height": 842
    },
    "method": "camelot-stream",
    "confidence": 0.89,
    "lowConfidence": false,
    "checks": [
      {
        "kind": "column-count",
        "ok": true,
        "detail": "every row has 6 cells"
      },
      {
        "kind": "numeric-consistency",
        "ok": true,
        "detail": "5 numeric column(s), all cells parse"
      },
      {
        "kind": "header",
        "ok": true,
        "detail": "header: label | 2020 | 2021 | 2022 | 2023* | 2024**"
      }
    ],
    "warnings": [
      "Column \"2020\" is numeric but 1 cell(s) are not numbers.",
      "Column \"2021\" is numeric but 1 cell(s) are not numbers.",
      "Column \"2022\" is numeric but 1 cell(s) are not numbers.",
      "Column \"2023*\" is numeric but 1 cell(s) are not numbers.",
      "Column \"2024**\" is numeric but 1 cell(s) are not numbers."
    ],
    "merged": false,
    "textLayer": true,
    "cells": null,
    "llm": null,
    "part": null
  }
}
```

### Pricing

| Event | Name | What it means | Price |
| --- | --- | --- | --- |
| `apify-actor-start` | Actor start | Charged by Apify when a run starts: once for a run of up to 1 GB of memory, and once more for every extra GB. It covers the first five seconds of compute. | $0.00005 per run up to 1 GB of memory ($0.05 per 1,000) |
| `page-processed` | Page processed | One PDF page read and searched for tables in the deterministic pass. Pages skipped by the page range or the per-document cap are free; a document that cannot be downloaded or opened costs nothing. | $0.002 per page ($2.00 per 1,000) |
| `table-extracted` **(primary)** | Table extracted | One table delivered as rows, records and metadata (page, position, headers, column types, confidence, checks). A table that continues over several pages counts once. | $0.01 per table ($10.00 per 1,000) |
| `llm-page-processed` | LLM page (Claude Haiku 4.5) | One page read by Claude Haiku 4.5 from its image, for scanned pages or tables the deterministic pass could not read with confidence. Covers the API cost; tables found this way are charged as tables too. | $0.03 per page ($30.00 per 1,000) |
| `llm-page-processed-premium` | LLM page (Claude Sonnet 5) | One page read by Claude Sonnet 5, for dense or hard layouts where Haiku is not enough. Covers the API cost; tables found this way are charged as tables too. | $0.06 per page ($60.00 per 1,000) |

A 60-page report with 20 tables in fast mode costs $0.32. The same report in `llm` mode with Haiku costs $2.12 (60 LLM pages); with `llm-fallback` you pay the LLM price only for the pages that needed it. Documents that cannot be fetched, opened or held in the run's memory are not charged, except LLM pages the model had already read, and neither are pages skipped by the page range or the per-document cap. The deterministic pass on a page that the LLM later re-reads is not charged twice: the page price is charged once per page.

An LLM page is charged as soon as the model has answered, because that is when its API cost is incurred: it stays charged if the run is aborted or fails before the tables are delivered. A model call that fails is not charged. Page and table events are charged when the results are delivered. If the platform restarts a run while its pages are being read (a migration), or you resurrect an aborted run, the pages read before the restart are read and charged again, and **Maximum LLM pages per run** counts each attempt on its own.

### FAQ

#### Why is a table split in two, or two tables joined?

Joining follows the column count and the header: a continuation with the same columns and the same header (or no header, starting at the top of the next page) is joined. Switch **Join tables that continue on the next page** off to get every page's table separately.

#### The header is `label, column_2, column_3`. Why?

No header row was recognised (the first row already held amounts). The `header` check says so and the confidence is lowered; give **Expected columns** to name them.

#### Can I get CSV or Excel instead of JSON?

The dataset can be exported as CSV or Excel from the Apify Console or API; `records` flattens well. For one CSV per table, render `headers` and `rows`.

#### Why does the LLM mode cost more?

Every page the model reads is an API call to Anthropic (about $0.01 per page with Haiku 4.5, $0.02 with Sonnet 5, September 2026). The LLM events in the pricing table cover that cost; the deterministic mode has no such cost and is therefore much cheaper. The log states per page what the call cost.

#### Is PDF table extraction with this Actor GDPR-compliant, and what happens to my documents?

Documents are downloaded into the run's temporary storage and deleted as soon as each one is processed (page images for the LLM after each batch of pages); nothing is kept between runs. In the LLM modes, page images go to the Anthropic API under the Actor owner's account (Anthropic does not train on API data); in fast mode nothing leaves the Apify platform. Results live only in your own run's dataset. If your documents contain personal data, you remain the controller under the GDPR; the example documents are public publications on open.overheid.nl, and the Actor is not affiliated with their publishers.

### Related Actors

- [Regulatory Page Change Monitor: know when a report or publication page changes](https://apify.com/lwsdjfls/regulatory-page-change-monitor), then extract the new tables.
- [Crawlability & AI Crawler Access Auditor: whether a source allows automated fetching](https://apify.com/lwsdjfls/crawlability-ai-access-auditor) and has reserved text and data mining rights.

### Support

A PDF whose tables come out wrong? Open an issue on the Actor page with the URL and the page number; layouts that fail are how the detectors get better.

# Changelog

This Actor's version history is a separate document: https://apify.com/lwsdjfls/govt-pdf-table-extractor/changelog.md

# Actor input Schema

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

Direct http(s) links to PDF files (one per line, each up to 2,048 characters). Links to key-value store records work too. The same link listed twice is read once.

## `pdfFiles` (type: `array`):

For API callers: \[{ "fileName": "report.pdf", "data": "<base64>" }]. Up to 100 files; the total input must stay under Apify's 9 MB input limit, so use URLs for large files.

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

Page numbers and ranges to read, for every document: 1-5,8 or 14-17 (up to 1,000 characters). Empty means all pages (up to the per-document cap).

## `mode` (type: `string`):

fast: deterministic only (text-layer PDFs, cheapest). llm-fallback: deterministic, plus Claude for pages without a text layer (scans) and pages whose tables came out with low confidence. llm: Claude reads every page.

## `llmModel` (type: `string`):

Claude Haiku 4.5 reads most pages well and is the cheaper event; Claude Sonnet 5 for dense or hard layouts.

## `flavor` (type: `string`):

auto tries whitespace-aligned and ruled-line detection and keeps the best reading per table. stream: whitespace only (reports, statistics). lattice: ruled lines only (forms, invoices).

## `expectedColumns` (type: `array`):

Optional. The column names you expect, in order. Tables with that many columns get these names as record keys; others are flagged in checks.

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

Tables with fewer body rows are ignored (layout boxes, legends).

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

Tables with fewer columns are ignored.

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

A table with the same columns that continues on the next page (with or without a repeated header) is delivered once, with all its rows and pages.

## `includeCells` (type: `boolean`):

Adds every cell with its page and bounding box (PDF points, origin top-left) to each table. Makes records larger.

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

Pages beyond this are not read (or charged).

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

Larger files are reported as failed and cost nothing. At most 100 MB.

## `maxLlmPagesPerRun` (type: `integer`):

Caps the pages sent to Claude in one run; the rest is delivered from the deterministic pass.

## `proxyConfiguration` (type: `object`):

Only for downloading PDFs from sites that block data-centre addresses.

## Actor input object example

```json
{
  "pdfUrls": [
    "https://open.overheid.nl/documenten/dpc-cee2e3debdd8cd3e16f84a876ee24537e3e034c0/pdf"
  ],
  "pdfFiles": [],
  "pages": "14-17",
  "mode": "fast",
  "llmModel": "haiku",
  "flavor": "auto",
  "expectedColumns": [],
  "minRows": 2,
  "minColumns": 2,
  "mergeAcrossPages": true,
  "includeCells": false,
  "maxPagesPerDocument": 200,
  "maxFileMb": 50,
  "maxLlmPagesPerRun": 200,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

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

Every table with its headers, rows as records, page, confidence and checks; a table larger than one dataset item comes in parts (see part).

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

Per document: pages processed, scanned pages, LLM pages, tables found, errors and LLM API cost.

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

Counts, failures and the cost of the run.

# 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://open.overheid.nl/documenten/dpc-cee2e3debdd8cd3e16f84a876ee24537e3e034c0/pdf"
    ],
    "pages": "14-17"
};

// Run the Actor and wait for it to finish
const run = await client.actor("lwsdjfls/govt-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://open.overheid.nl/documenten/dpc-cee2e3debdd8cd3e16f84a876ee24537e3e034c0/pdf"],
    "pages": "14-17",
}

# Run the Actor and wait for it to finish
run = client.actor("lwsdjfls/govt-pdf-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 '{
  "pdfUrls": [
    "https://open.overheid.nl/documenten/dpc-cee2e3debdd8cd3e16f84a876ee24537e3e034c0/pdf"
  ],
  "pages": "14-17"
}' |
apify call lwsdjfls/govt-pdf-table-extractor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,lwsdjfls/govt-pdf-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/ip6AuqF2s2x6yh0l4/builds/9hxFahbRaeXY0c0Wp/openapi.json
