Extract JSON from a PDF text layer without OCR

2026-09-10 · AiNCEPTION

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:

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

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.

Try it: DocExtract on RapidAPI — free tier, no card required to start.

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