Guide
Extracting tables from a PDF into JSON
A PDF table looks like a grid on the page and like a paragraph to most software. TableJSON sits in the middle. It reads the text layer, splits lines into cells, and returns JSON you can drop into a script or a spreadsheet. This guide is the plain version of that path: what you upload, what comes back, and how to tell a good extract from a messy one.
Take a one-page supplier list. In the PDF it looks like this, with the columns spaced apart:
| Vendor | Invoice | Amount |
|---|---|---|
| Northwind Paper | INV-1044 | 1,250.00 |
| Harbor Freight Co | INV-1045 | 860.40 |
After Extract, the interesting part of the JSON looks like this. Headers are lowercased and punctuation is turned into underscores. Amounts stay strings, commas included, so nothing is silently rounded.
{
"status": "success",
"filename": "vendors.pdf",
"total_pages": 1,
"tables_found": 1,
"metadata": { "extraction_mode": "text" },
"tables": [{
"table_id": 1,
"page_number": 1,
"page_end": 1,
"headers": ["vendor", "invoice", "amount"],
"total_rows": 2,
"rows": [{
"row_index": 1,
"data": {
"vendor": "Northwind Paper",
"invoice": "INV-1044",
"amount": "1,250.00"
},
"raw_cells": ["Northwind Paper", "INV-1044", "1,250.00"]
}, {
"row_index": 2,
"data": {
"vendor": "Harbor Freight Co",
"invoice": "INV-1045",
"amount": "860.40"
},
"raw_cells": ["Harbor Freight Co", "INV-1045", "860.40"]
}]
}]
}
Two fields matter when the keys look odd. data is the row as an object. raw_cells is the same row as an ordered list. If a header collapsed into column_3, the list still tells you which value was third. Keep both until you have checked a few rows by hand.
What the playground is doing
Open tablejson.com, drop the PDF on the left card, and press Extract. You do not need an account. The three counters show how long the read took, how many tables were found, and how many rows those tables hold. JSON is a tree you can fold. Table is the same rows, 50 at a time, which is there so a long statement does not lock the browser. Fullscreen is the wide view of that table. Download JSON if you are going to keep the result. The sample button loads sample.pdf from this site so you can see a successful run before you use a file you care about.
The same call from a terminal is a multipart POST. The field name has to be file. From another host you send X-API-Key. From the playground you do not, because the request is same-origin.
curl -X POST https://tablejson.com/v1/extract-tables \ -H "X-API-Key: YOUR_KEY" \ -F "file=@vendors.pdf"
You can also send a public URL instead of the bytes: {"file":"https://tablejson.com/sample-mini.pdf"} with Content-Type: application/json. That path is capped at 50 MB. An upload can be up to 500 MB. Private links, localhost, and link-local addresses are refused.
How a line becomes cells
TableJSON does not look at borders. A ruled line drawn as a vector is invisible to the extractor. What it sees is the text pdf-parse already pulled out, one line at a time. A line becomes a row when it splits into two or more pieces. The split tries, in order, a tab, a pipe character, two or more spaces, and finally commas. The first line that splits becomes the header. Following lines with the same number of cells, or one cell off, are body rows. A line that does not split ends the table. A later line with a very different cell count starts a new one.
That order is why a description like "Paper, A4, ream" usually survives. Real PDF text from a table almost always has wide gaps between columns, so the comma rule never runs. It is also why a CSV that someone printed to PDF with single spaces can collapse. If the only separator is a single space, the line is one cell.
Empty pieces are dropped. A tab-separated row 02 Apr<tab>Card<tab><tab>40.00 can lose the blank debit column and shift the balance left. When a row looks slid over by one, look at raw_cells and at the original line before you "fix" the header. The cleaning guide is the follow-up for that.
Pages, repeats, and the fallback table
page_number is the page where the header was seen. page_end is the last page that still belonged to that table. A statement that reprints Date, Description, Amount on every page usually becomes one table, because tables with the same headers are merged and the row indexes are rewritten in order. A totals block with a different shape stays its own table_id.
If the file has text but no line ever splits, you still get JSON. tables_found is 1, and the only table has headers line_number and extracted_content. Read that as "here is the text, unsplit." It is useful for debugging and useless as a ledger. Common causes: the PDF is a scan, the columns are single-spaced, or the "table" is a paragraph with bold lead-ins. Highlight a cell in Chrome. If you cannot, read text-layer PDFs and scans before you upload another file.
Decorative rules made of dashes or equals signs are skipped. So are a couple of sample-report banners the extractor was taught to ignore. A normal title above your table is not skipped. If that title is the first multi-cell line, it becomes the header and every real header is data. When row 1 looks like the title, promote row 2 yourself. Do not trust total_rows until you have glanced at the first and last row.
A small check before you automate
Pick three cells you can see on the PDF and find them in raw_cells. Check that the last row's amount is the last amount on the page, not a page number that got sucked into the column. Check total_pages against the PDF. If those three agree, the extract is good enough to script. If they do not, fix the file or clean the JSON. Looping the API over a folder of bad PDFs only multiplies the cleanup. The API guide assumes you have already done this check on one file.
Numbers stay strings on purpose. INV-1044 must not become a number, and 1,250.00 must not be guessed as twelve hundred fifty or as one point two five. Parse them in the step that knows the locale. JSON is the right container while the table still carries page numbers and more than one grid. If a colleague asked for a flat file, the JSON versus CSV guide shows a direct conversion that does not drop raw_cells until you are sure you do not need it.