Decide what one record means

Choose a document family before writing the schema. An order confirmation, invoice, and application form may all have dates and names, but those fields mean different things. For this example, one visible order becomes one object in an orders array. The document_id comes from the printed order number; it is not a number invented by the model.

Decide what the JSON will drive. An archive can hold a record with a missing tax value for review. A payment or account-access workflow needs that value resolved before acting on it. If the page has no printed order number, assign a separate file or intake ID for tracking; leave the document's own ID missing rather than asking the model to invent one.

PDF you haveFirst checkNext step
Selectable text, stable layoutExtract text and inspect its reading orderUse the schema and source comparison shown below
Scan or image-only pagesCheck the OCR text against the page imageCorrect or queue unreadable fields before extraction
Tables across pages or mixed layoutsCheck record boundaries and repeated headers page by pageSplit by document type or add page references to each record

For a fixed layout with clearly labeled fields, start with ordinary text extraction or a template-based parser. AI helps most when labels move, prose needs interpretation, or layouts vary within a document family. A model still needs a clear source: if text extraction loses columns or reading order, repair that input or use page images with an approved vision-capable tool before trusting the JSON.

Inspect the PDF before asking for JSON

Run Poppler's pdftotext on a copy of the PDF. The -layout option tries to preserve physical spacing; it does not guarantee that columns or table rows will read correctly. Open the text file and look for missing pages, repeated headers inside records, misplaced amounts, and characters that changed during extraction.

pdftotext -layout synthetic-order-records.pdf - > source-text.txt

The downloadable example contains five printed order IDs, ORDER-1001 through ORDER-1005. The last order says its date was not supplied; another shows Tax: not shown; one approval code says pending. ORDER-1004 explicitly prints three monetary zeros. Check that these details survive in source-text.txt. If the text is blank or scrambled, fix the PDF reading step before trying to make valid JSON from it.

Define missing values in the schema

The schema makes every property present in every order object, while allowing null for three values the PDF may omit. A required property with a null value is different from a property the extractor forgot to return. 0 remains a number, not a synonym for missing. The JSON Schema null reference explains this distinction.

JSON Schema for one order-document family
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["orders"],
  "additionalProperties": false,
  "properties": {
    "orders": {
      "type": "array",
      "items": {
        "type": "object",
        "required": ["document_id", "supplier", "order_date",
          "currency", "subtotal", "tax", "total", "approval_code"],
        "additionalProperties": false,
        "properties": {
          "document_id": {"type": "string"},
          "supplier": {"type": "string"},
          "order_date": {"type": ["string", "null"]},
          "currency": {"type": "string"},
          "subtotal": {"type": "number"},
          "tax": {"type": ["number", "null"]},
          "total": {"type": "number"},
          "approval_code": {"type": ["string", "null"]}
        }
      }
    }
  }
}

The extraction instruction treats pending as no finalized approval code. That is a rule for this document family, not a universal meaning for every PDF. Write such rules beside your schema. If a blank box, “not applicable,” and “not captured” need different downstream treatment, give them distinct review states instead of forcing all three into null.

Make the schema fit the source rather than flattening every PDF into these eight fields. For an invoice with several line items, use one invoice object containing an items array; each item should keep its description, quantity, unit price, and printed line amount together. For a form with checkboxes, define the allowed answers and decide whether an unmarked box means “no” or “unknown” from the form's instructions. For a document packet containing invoices and credit notes, classify the pages first and use separate schemas or a clearly tagged document type. Otherwise a negative credit can be mistaken for a negative invoice total.

Set rules for dates, decimal separators, currencies, and missing fields before running a batch. JSON Schema can require a field and restrict its type, but it cannot determine whether a value was read from the right row. Keep the printed text or page reference available for that later check.

Run the worked extraction

Place the source PDF, schema, prompt, manually authored expected JSON, and checker in one working directory. The first command below creates text for inspection. The grouped command then sends that exact text with the prompt to Codex through standard input. Run codex exec --help if your installed CLI uses different options; --skip-git-repo-check allows this example to run in an ordinary download directory.

pdftotext -layout synthetic-order-records.pdf - > source-text.txt

{ cat pdf-order-extraction-prompt.txt; printf '\nSOURCE TEXT:\n'; cat source-text.txt; } |
  codex exec -s read-only --ephemeral --skip-git-repo-check \
    --output-schema pdf-order-extraction-schema.json \
    --output-last-message pdf-order-extraction-observed.json -

python3 structured-extraction-check.py \
  pdf-order-extraction-expected.json \
  pdf-order-extraction-observed.json \
  --schema pdf-order-extraction-schema.json --order-rules

The prompt restricts extraction to the supplied text, permits only explicit date normalization, and forbids calculating missing values. The expected file was checked against the PDF separately from the model output. On 29 September 2026, the text-input run produced five records with zero differences from that expected file. The checker returned valid: true and review_required: true. It flagged the missing tax and unfinished approval code in ORDER-1002, plus the missing date in ORDER-1005. A zero-difference comparison therefore does not mean every record is ready for import. You can inspect the retained output without rerunning a model.

You can use the same method with another tool that accepts pasted text and instructions: supply the extracted text, the schema, and the document-family rules; ask for JSON only; save the response as a file and validate it with a JSON Schema validator. If the tool cannot enforce a schema during generation, validation after the response is essential, and a failed response must be corrected or rerun before review. For a vision tool, provide the original pages and ask it to identify uncertain fields and their page numbers in a separate review note, or add those properties to a new schema. This article's retained result comes from the Codex text-input command above, so treat another tool's output as a fresh extraction to check.

Check the JSON and the original pages separately

This schema checks property names and JSON types. By itself, it would accept an empty orders array, an empty ID, or a date string such as banana. The optional --order-rules pass rejects those cases, checks duplicate IDs and the shape of currency codes, and marks missing values for review. It cannot tell whether a code is a real currency, an amount belongs to the correct row, or the result includes every source record.

The downloadable checker compares monetary values exactly; a rounded or nearly equal amount still differs. Its expected file is a human-authored reference for this small example. Making an entire answer file by hand for every PDF would defeat a larger extraction workflow. For a batch, establish the expected document and record counts independently, reject duplicate IDs and failed schema or business-rule checks, and route missing or ambiguous fields to a review queue. Compare high-impact values with their original page before acting on them. Review a documented sample of lower-risk records and expand that review when errors cluster by supplier, layout, or scan quality. Record what was checked; a schema pass alone is not an accuracy score.

If you need the schema itself to reject an empty list or restrict a string, add constraints such as minItems, minLength, or pattern and use a validator that actually implements them. JSON Schema's format may be an annotation rather than an enforced date check, depending on the validator and settings. The supplied checker supports the displayed schema's keywords plus its separate order rules, not every JSON Schema feature.

When the PDF is scanned or the text is unreliable

For an image-only PDF, one option is OCRmyPDF: run ocrmypdf input.pdf searchable.pdf, then extract text from searchable.pdf. For a file mixing pages with and without text, its documentation describes --mode skip. Inspect a few pages before processing the whole batch; a searchable PDF can still have wrong characters or a broken reading order.

We checked the OCR failure path with the downloadable order PDF. pdftoppm rendered its page as a 200 DPI PNG; Tesseract then read the image. You can reproduce the input step with these commands:

pdftoppm -f 1 -l 1 -r 200 -png -singlefile \
  synthetic-order-records.pdf orders-page
tesseract orders-page.png orders-ocr

The page visibly prints ORDER-1004's approval code as AP-00, but the OCR text said AP-0Q0. We supplied that OCR text to Codex under the same schema, changing the prompt's source description from pdftotext to Tesseract. Its JSON kept AP-0Q0. The schema and order rules passed; comparison with the separately reviewed PDF reference found one value difference at $.orders[3].approval_code. The right action is to check the page and correct or queue that field, not to accept the schema pass as proof of accuracy.

Compare the page image with the OCR text, especially 0 versus O in IDs, decimal separators, negative signs, and amounts near a table border. If only some fields are unreadable, keep the legible fields and queue the uncertain ones with page references. If the whole page is poor, try a better scan or a page-image extraction route and review its result against the image. An amount derived from a subtotal equation may be plausible, but it is not a transcription of an unreadable digit.

When a table crosses pages

A page break can split one row into two apparent records, and a repeated column heading can be mistaken for data. Extract a suspect page separately, for example with pdftotext -f 2 -l 2 -layout input.pdf -, and compare it with the page image. In a continued line-item table, keep the second page's items inside the same parent invoice when its invoice number and section heading show continuity. Check that every output object has a source ID or a clearly marked missing ID, that each printed ID occurs once, and that each row's values belong to the same document. An intake ID can track a record without pretending it was printed on the page.

uniqueItems checks whole array items, so it may miss two objects with the same order ID but different values. Check IDs separately, as --order-rules does here. In a batch, also keep the original filename or file ID and a revision marker. Two pages with the same ID could be a duplicate scan, an amended document, or a genuine collision; a reviewer needs the pages to decide which record to keep.

When numbers, dates, or currencies are ambiguous

What the source showsSafe treatmentReview trigger
0.00 printed for subtotal, tax, and totalKeep three numeric zerosStop if OCR dropped a decimal point or the zero belongs to another row
Tax: not shown or Approval code: pendingUse null under this fixture's rulesConfirm the phrase on the page and whether downstream work permits a missing value
15 August 2026Normalize to 2026-08-15Stop on a numeric date whose day/month order is unclear
1,234.56 or 1.234,56Use the document's stated locale and currency before convertingStop if the separator convention cannot be established from the source
A negative amount on a credit notePreserve the sign and classify the document correctlyDo not silently turn it into a positive invoice amount

The first three rows appear in the supplied order PDF. The separator and credit-note rows show how to handle other documents. If a printed total disagrees with its components, preserve what the page says and queue the record. Discounts, shipping, and credits may explain the difference, but changing the total during extraction would hide the discrepancy.

Handle a batch without losing its exceptions

Start with a small set that includes each layout and document type you expect, plus a poor scan and a document with missing fields. Decide whether to split files by type, page range, or supplier before extraction. Use a stable file ID, keep the original PDF, and save the extracted text or page images used as input. Send only one coherent document or page group per request when the model could confuse boundaries in a mixed packet.

For each result, record the source file, page range, schema version, validation outcome, and review status. Count input documents and visible records against output objects. Check IDs for duplicates across files, not just within one JSON array. Apply business checks that fit the document family: allowed currency codes, date ranges, whether line-item arithmetic agrees with printed totals, and whether required signatures or approvals are present. Keep arithmetic disagreement as a flag, not a license to overwrite the printed amount.

If you see thisCheck nextAction
Valid JSON but fewer records than the pages showPage ranges, repeated headers, and merged rowsRe-extract the missing pages and compare record IDs
Schema failure or an unexpected fieldWhether the document type changed or the model added an explanationFix the instruction or route the new type to its own schema
One supplier has many correctionsIts layout, scan quality, and field labelsReview that supplier's batch more closely before import
A value cannot be located on the pageOCR text, page image, and row associationLeave the value unresolved rather than accept a plausible guess

Keep a review record before import

For fields that can affect money, access, or legal notice, store the page number and a short source excerpt beside the candidate value. This review row shows how to track the printed ORDER-1002; it is a suggested review record, separate from the extracted JSON:

{
  "document_id": "ORDER-1002",
  "field": "tax",
  "candidate_value": null,
  "source_page": 1,
  "source_excerpt": "Tax: not shown",
  "review_reason": "source_missing",
  "status": "needs_human_review"
}

Queue a record when a business-critical field is missing, a scanned value is uncertain, totals disagree, an ID repeats, or a value cannot be found on the page. A reviewer should compare the candidate with the original page, record any correction and its reason, then approve or reject the import. A model-generated confidence score is a hint for routing review, not proof that a value matches the source. If the PDF contains personal or confidential information, follow the site's data handling guide and your organization's approved tool policy before sending it to an external model.

The downloadable run covers one digitally generated page with five orders supplied as text. The additional OCR run used an image rendered from that same clean page; it exposes one real OCR-to-JSON error but does not measure performance on a physical scan. Continued tables, mixed-document packets, and large batches remain procedures to test on your own files. Handwriting and very poor scans may require manual transcription. Import only records that meet the checks and review requirements you set for their use.