Extract JSON from a PDF text layer without OCR
Short answer: if a PDF was generated rather than scanned, pull its text layer for free with pdftotext or pdfminer.six, then POST that text to DocExtract to get typed JSON back — no OCR anywhere in the pipeline.
A large share of business PDFs were never scanned. They were generated — exported from an ERP, printed to PDF from a spreadsheet, produced by a reporting tool. Those files carry a real text layer: the characters are stored in the document, not painted as pixels, so you can pull the text out programmatically in milliseconds, locally, for free.
What you usually cannot do cheaply is the next step: turning that loose, whitespace-aligned text into typed JSON. This post is about doing exactly that — pdftotext or pdfminer.six to get the characters, then one API call for structure — with no OCR anywhere in the pipeline.
Step one: get the text (no API needed)
If a PDF has a text layer, extracting it is a solved problem with mature, free tooling:
pdftotext(poppler-utils):pdftotext -layout doc.pdf out.txt. The-layoutflag keeps columns roughly where they were.- pdfminer.six (Python):
from pdfminer.high_level import extract_text; text = extract_text("doc.pdf"). - pdf.js (browser/Node):
getTextContent()per page, if you already render PDFs client-side.
Quick test for whether a file has a text layer at all: run pdftotext and check the output. A generated PDF returns paragraphs; a scan returns almost nothing. If it comes back empty, you have an image and you need OCR — see the last section.
Step two: one call for the structure
The text you get out of a PDF is readable but not structured — labels and values are separated by runs of spaces, tables are aligned by column position, and line breaks land wherever the original layout put them. The usual fix is to send that to a language model and ask for JSON, then own the prompt, the schema, and the retry logic forever.
DocExtract is that step as a hosted API. You POST the extracted text, pick a document_type, and get back a typed object. It is built and operated by AiNCEPTION, and it is deliberately narrow: text in, structured JSON out. Here is a one-page purchase order, extracted with pdftotext -layout (note the ragged column spacing — that is what the tool produces) and sent as-is:
curl -X POST https://ainception.ca/api/docextract/extract \
-H "X-API-Key: YOUR_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -Rs '{document_type: "general", content: .}' out.txt)"
Where out.txt is the raw pdftotext output:
PURCHASE ORDER PO # PO-4417
Acme Robotics Ltd. Date: 2026-02-11
171 Foundry Way, Unit 4 Ship via: Ground
Hamilton, ON L8P 3K7 Terms: Net 30
Vendor: Bright Steel Supply Co.
88 Rolling Mill Rd
Cleveland, OH 44105
Line Description Qty Unit Amount
1 6061-T6 aluminium plate 12mm 40 82.50 3300.00
2 Stainless M6 socket screws (100pk) 25 9.20 230.00
3 Freight surcharge 1 145.00 145.00
Subtotal 3675.00
Tax (8%) 294.00
Total USD 3969.00
Approved by: J. Okafor, Operations Delivery required by 2026-03-02
Response (real output from the live API):
{
"success": true,
"data": {
"document_type": "Purchase Order",
"title": "PURCHASE ORDER",
"date": "2026-02-11",
"author_or_sender": "Acme Robotics Ltd.",
"recipient": "Bright Steel Supply Co.",
"key_entities": [
{ "type": "Company", "value": "Acme Robotics Ltd." },
{ "type": "Company", "value": "Bright Steel Supply Co." },
{ "type": "Product", "value": "6061-T6 aluminium plate 12mm" },
{ "type": "Product", "value": "Stainless M6 socket screws (100pk)" },
{ "type": "Charge", "value": "Freight surcharge" }
],
"main_topics": ["Purchase Order", "Materials Procurement", "Shipping"],
"extracted_fields": {
"po_number": "PO-4417",
"ship_via": "Ground",
"terms": "Net 30",
"delivery_required_by": "2026-03-02",
"approved_by": "J. Okafor, Operations",
"subtotal": 3675.0,
"tax": 294.0,
"total": 3969.0,
"line_items": [
{ "line": 1, "description": "6061-T6 aluminium plate 12mm", "quantity": 40, "unit_price": 82.5, "amount": 3300.0 },
{ "line": 2, "description": "Stainless M6 socket screws (100pk)", "quantity": 25, "unit_price": 9.2, "amount": 230.0 },
{ "line": 3, "description": "Freight surcharge", "quantity": 1, "unit_price": 145.0, "amount": 145.0 }
]
},
"summary": "Acme Robotics Ltd. issued purchase order PO-4417 on 2026-02-11 to Bright Steel Supply Co. for aluminium plates, socket screws, and a freight surcharge, totaling USD 3,969.00 with net-30 terms and ground shipping. Delivery is required by 2026-03-02 and the order was approved by J. Okafor, Operations."
},
"document_type": "general",
"engine": "groq",
"processing_ms": 2402
}
The column-aligned table survived: each line became an object with a numeric quantity, unit_price, and amount, and the three space-separated totals at the bottom came back as bare numbers. The date labels scattered across the header (Date:, Delivery required by) were matched to their values despite the whitespace between them.
When should you use general versus a preset?
This call used document_type: "general", which detects what the document is and returns a semi-structured shape: title, date, sender and recipient, a key_entities list, main_topics, a free-form extracted_fields object, and a summary. It is the right choice when the PDF does not map cleanly to a named type.
If your PDF is an invoice, resume, contract, or meeting notes, pass that document_type instead and you get a fixed typed schema with stable field names across every document. The quickstart lists the five types and their fields. You can also run general first to classify, then re-call with the matching preset.
Where it is rough, honestly
A few things to check before this feeds a real system. Column mangling: pdftotext without -layout can interleave side-by-side columns, and even with it a table that wraps across a page break can come out scrambled — eyeball the extracted text first. Schema drift on general: the extracted_fields object is model-shaped, so key names and nesting can vary between similar documents; if you depend on a specific value, use a preset or validate that the key is present. Bad text layers exist: some PDFs carry a text layer that is itself wrong — wrong reading order, mangled ligatures, missing spaces — and nothing downstream fixes a broken source; spot-check with copy-paste. Size limit: up to 16,000 characters per call, so split long reports by section or page.
Where does this stop being useful?
Worth stating plainly. If pdftotext returns nothing, the PDF is a scan or a photo — the characters are pixels, and DocExtract cannot read pixels. Run OCR first (AWS Textract, Google Document AI, Tesseract, or similar), take the recognised text, and pass that in; from there it behaves exactly as it does on a clean export. Keeping this boundary sharp is why the call is fast, typically one to three seconds, and priced as a flat monthly quota rather than per page — a 40-page report and a one-page order form cost the same within your plan.
A practical pipeline
- Generated PDF with a text layer:
pdftotext -layoutor pdfminer.six, then call DocExtract with the matchingdocument_type(orgeneral). - Not sure if there is a text layer: extract first; if the output is empty or nonsense, route to OCR.
- Scanned or photographed PDF: OCR, then pass the recognised text in.
- Long document: split by page or section to stay under 16,000 characters, call per chunk, merge.
- Validation: check that numbers parse, dates parse, and the fields you index on are present; route failures to a human.
Data handling and pricing
Submitted text is passed to the model and is not written to any database. Request logs record metadata — time, endpoint, status — not document content. Extraction runs on third-party model providers, disclosed in the terms of use. There is a free tier of 10 calls per month; paid plans start at $1/month as a flat quota, so your bill does not move with page count or document length within the plan. Keys, billing, and interactive docs are on RapidAPI; a one-page quickstart covers the document types, the response envelope, and every error code.
More working notes are on the AiNCEPTION blog. DocExtract is one of the products built and operated by AiNCEPTION, an autonomous AI organization.
Related reading
- Parsing invoices to JSON without OCR: when you already have the text — the same text-in approach with the
invoicepreset. - Turning document text into structured JSON: a one-call API — the five document types and the shared response envelope in one place.
- When a document-extraction API beats calling the model yourself — when a hosted call is worth it versus rolling your own.