pdf table extraction api
PDF Table Extraction API
One endpoint. POST a PDF, get complete Markdown back — simple tables as GFM, and complex table structure preserved as sanitized HTML when GFM would be lossy.
$ curl -X POST https://pdftomarkdown.dev/v1/convert \
-H "Authorization: Bearer demo_public_key" \
-H "Content-Type: application/json" \
-d '{"input":{"pdf_url":"https://pdftomarkdown.dev/samples/invoice.pdf"}}'{
"complete": true,
"markdown": "# CONTOSO LTD.\n\n# INVOICE\n\nINVOICE: INV-100\nDATE: 11/15/2019\nDUE DATE: 12/15/2019\n\n| SALESPERSON | P.O. NUMBER | REQUISITIONER | SHIPPED VIA | F.O.B. POINT | TERMS |\n| --- | --- | --- | --- | --- | --- |\n| | PO-3333 | | | | |\n\n| QUANTITY | DESCRIPTION | UNIT PRICE | TOTAL |\n| --- | --- | --- | --- |\n| 1 | Test for 23 fields | $100.00 | $100.00 |\n\nTOTAL DUE: $610.00\n\n> Processed by pdfToMarkdown.dev",
"pages": 1,
"request_id": "req_example_invoice"
}Server-side PDF processing. Review privacy, security, and data retention.
Tables are where every other PDF tool falls apart
Text extraction from PDFs is a mostly solved problem. Tables are not. The moment your document has a multi-level header, merged cells, or a column of right-aligned numbers with a subtotal row, rule-based tools start guessing — and guessing wrong.
pdfToMarkdown uses a vision-language model that looks at the rendered page, not the raw character stream. It sees the grid lines, the alignment, the header hierarchy. It reconstructs the table the way you would if you were reading the page yourself.
What garbled table extraction looks like
Here is what a typical text extraction library produces from a financial statement table:
Revenue 2023 2022 Change
Product revenue 142,300 118,500 20.1%
Service revenue 38,700 35,200 9.9%
Total revenue 181,000 153,700 17.8%
Cost of revenue 94,100 82,600
Gross profit 86,900 71,100
Gross margin 48.0% 46.3%
No cell boundaries. No alignment. Headers and data rows are indistinguishable. Good luck feeding that into a downstream parser.
Here is the same table from pdfToMarkdown:
| | 2023 | 2022 | Change |
|---|---|---|---|
| **Product revenue** | $142,300 | $118,500 | 20.1% |
| **Service revenue** | $38,700 | $35,200 | 9.9% |
| **Total revenue** | **$181,000** | **$153,700** | **17.8%** |
| Cost of revenue | $94,100 | $82,600 | |
| **Gross profit** | **$86,900** | **$71,100** | |
| Gross margin | 48.0% | 46.3% | |
Pipe-delimited markdown. Every cell in the right column. Subtotal rows distinguished with bold. Ready for LLM extraction, pandas, or direct rendering.
The tables that break rule-based tools
Multi-header tables
Financial reports and spec sheets commonly use two or three levels of column headers — “Q1 / Revenue / Actual vs Budget”. Libraries like tabula and camelot flatten these into a single header row or silently drop the top level. pdfToMarkdown preserves the hierarchy.
Merged cells and spanning rows
A product comparison table where a category label spans three rows? pdfplumber gives you the label once and empty strings for the next two rows, or worse, shifts every subsequent cell. pdfToMarkdown preserves that span as sanitized HTML inside the Markdown instead of flattening or inventing cells.
Tables without visible gridlines
Many professional documents use whitespace-only alignment with no drawn borders. Rule-based tools rely on detecting lines or character alignment thresholds. When columns are close together or font sizes vary, detection fails. A vision-language model does not depend on detecting lines — it reads the visual layout directly.
Tables that span multiple pages
When a table continues across a page break, most tools treat each page as an independent extraction. You get two separate fragments with the header repeated (or not). pdfToMarkdown keeps the table content coherent in the Markdown output, using GFM only where the resulting table is representable without loss.
Complex table examples
Product spec sheet
| Parameter | Unit | Model A | Model B | Model C |
|---|---|---|---|---|
| Operating voltage | V | 3.3–5.0 | 1.8–3.3 | 3.3 |
| Current draw (active) | mA | 12 | 8 | 22 |
| Current draw (sleep) | µA | 15 | 3 | 120 |
| Temperature range | °C | -40 to +85 | -40 to +125 | 0 to +70 |
| Interface | — | SPI, I²C | SPI | UART, SPI |
| Package | — | QFN-24 | WLCSP-16 | SOIC-8 |
Financial balance sheet
| Assets (in thousands) | Dec 31, 2023 | Dec 31, 2022 |
|---|---|---|
| **Current assets** | | |
| Cash and equivalents | $45,200 | $38,100 |
| Accounts receivable, net | $22,800 | $19,400 |
| Inventory | $8,300 | $7,100 |
| **Total current assets** | **$76,300** | **$64,600** |
| **Non-current assets** | | |
| Property and equipment, net | $31,400 | $28,900 |
| Goodwill | $12,600 | $12,600 |
| **Total assets** | **$120,300** | **$106,100** |
Why camelot, tabula, and pdfplumber struggle
These are good libraries. They work well on simple, well-formed tables with visible gridlines and a single header row. But they share the same fundamental limitation: they work from the raw PDF character stream and try to infer table structure from character positions and line objects.
This breaks when:
- The PDF was generated from a scan — there are no character positions, only an image. Tabula and camelot cannot process scanned PDFs at all without a separate OCR step.
- Column alignment is ambiguous — when two columns have similar x-coordinates, heuristic-based splitting produces wrong cell assignments.
- The table uses visual formatting instead of lines — alternating row colors, bold headers, indentation for sub-rows. None of these are “lines” in the PDF spec.
- Headers span multiple rows —
camelotflattens them.tabulasometimes drops them entirely.
pdfToMarkdown sidesteps all of this. The vision-language model processes the rendered page image. It does not parse PDF operators or guess at column boundaries. It reads the table.
Extract tables from any PDF in one API call
curl -X POST https://pdftomarkdown.dev/v1/convert \
-H "Authorization: Bearer demo_public_key" \
-H "Content-Type: application/json" \
-d '{"input":{"pdf_url":"https://pdftomarkdown.dev/samples/invoice.pdf"}}'
Or from Python:
import base64, requests
pdf_b64 = base64.b64encode(open("financial-report.pdf", "rb").read()).decode()
response = requests.post(
"https://pdftomarkdown.dev/v1/convert",
headers={"Authorization": "Bearer demo_public_key"},
json={"input": {"pdf_base64": pdf_b64}},
)
result = response.json()
# Print simple rectangular tables emitted as GFM pipe rows
for line in result["markdown"].split("\n"):
if line.startswith("|"):
print(line)
The response contains the full document as Markdown. Simple rectangular tables become escaped GFM pipe tables that render in GitHub, Notion, and Obsidian. Complex tables with row or column spans, nested tables, or nested block content remain sanitized raw HTML inside the Markdown so their structure is not discarded.
Feeding extracted tables into a data pipeline
For simple rectangular tables, the GFM pipe format is easy to parse programmatically. Complex tables use the documented sanitized HTML fallback and should be handled with an HTML parser instead:
import base64
import pandas as pd
import requests
# BEGIN SYNCED GFM TABLE PARSER
from html import unescape
def split_gfm_row(line):
row = line.strip()
if not (row.startswith("|") and row.endswith("|")):
raise ValueError("Expected a GFM table row with outer pipes")
cells, cell = [], []
escaped = False
for char in row[1:-1]:
if escaped:
if char in ("\\", "|", "*", "_", "`"):
cell.append(char)
else:
cell.extend(("\\", char))
escaped = False
elif char == "\\":
escaped = True
elif char == "|":
cells.append(unescape("".join(cell).strip()))
cell = []
else:
cell.append(char)
if escaped:
cell.append("\\")
cells.append(unescape("".join(cell).strip()))
return cells
def parse_gfm_candidate(lines):
if len(lines) < 2:
return None
rows = [split_gfm_row(line) for line in lines]
width = len(rows[0])
if width == 0 or len(rows[1]) != width:
return None
if any(cell != "---" for cell in rows[1]):
return None
if any(len(row) != width for row in rows[2:]):
return None
return rows
def iter_table_candidates(markdown):
current = []
for line in markdown.splitlines():
row = line.strip()
if row.startswith("|") and row.endswith("|"):
current.append(row)
continue
if current:
candidate = parse_gfm_candidate(current)
if candidate is not None:
yield "gfm", candidate
current = []
if "<table" in row.casefold():
yield "html", markdown
if current: # flush a table at end of output
candidate = parse_gfm_candidate(current)
if candidate is not None:
yield "gfm", candidate
def extract_gfm_tables(markdown):
return [value for kind, value in iter_table_candidates(markdown) if kind == "gfm"]
def first_table_branch(markdown):
return next(iter_table_candidates(markdown), ("none", None))
# END SYNCED GFM TABLE PARSER
pdf_b64 = base64.b64encode(open("quarterly-report.pdf", "rb").read()).decode()
response = requests.post(
"https://pdftomarkdown.dev/v1/convert",
headers={"Authorization": "Bearer YOUR_API_KEY"},
json={"input": {"pdf_base64": pdf_b64}},
)
result = response.json()
table_kind, table_rows = first_table_branch(result["markdown"])
if table_kind == "html":
raise ValueError("Complex sanitized HTML table found. Pass the <table> branch to a non-rendering HTML parser; do not render it as trusted HTML.")
if table_kind == "none":
raise ValueError("No GFM or HTML table found in the response")
header = table_rows[0]
data = table_rows[2:]
df = pd.DataFrame(data, columns=header)
The parser selects the first valid table candidate in document order. It accepts escaped GFM blocks only when they have a header, the canonical --- separator row, and a consistent column count. Structural splitting happens before canonical escapes are removed and completed cells are HTML-decoded, so pipes and backslashes stay inside the correct semantic cell; markup-looking cell text remains unrendered data. For the complex-table html branch, pass the sanitized <table> output to a real non-rendering HTML parser and inspect its nodes and span attributes. Do not render API output as trusted HTML or assign it directly to innerHTML.
Related pages
- PDF Parsing API — general-purpose PDF to markdown conversion
- Invoice Data Extraction API — extract line items and totals from invoices
- API documentation — full endpoint reference, response schema, and error codes
Pricing
Both tiers are free. No credit card required.
Hacker
Free, no signup
- Public demo key — copy & paste
- Only page 1 is processed
- 3 requests/min per IP
- Watermark in output
Try it with a table-heavy PDF
Free tier — no account needed. It converts page 1 only and adds a watermark. Upgrade to developer to remove the watermark and unlock full multi-page PDFs.