PDF to Excel with a neural network: extract tables without copy-paste

A working pipeline: pull a table out of a PDF, ask the model for JSON, write xlsx and reconcile the totals so a silent error does not slip through.

4 min · · автоматизация · development

If you have dozens of invoices, statements or price lists in PDF, copying them into a spreadsheet by hand eats an evening. A neural network can do PDF to Excel in a few lines of code, but only if you do not trust it blindly: a model can skip a row or mix up a decimal comma. Below is a pipeline where the result is checked with arithmetic, plus a cost estimate per page.

Two kinds of PDF and why it matters

First, find out what is inside the file.

  • A text layer exists. You can select the text with the mouse. It can be extracted in code and sent to the model as a plain string. This is cheaper and more accurate.
  • It is a scan or a photo. You cannot select text. The page has to be rendered to an image and sent to a model that accepts images as input.

A quick check in code: if extract_text() returns an empty string or garbage, the page is most likely an image.

Step 1. Get the text or the image

pdfplumber works for the text layer and PyMuPDF for rendering pages:

bash
pip install pdfplumber pymupdf openpyxl openai
bash
import base64
import pdfplumber
import fitz  # PyMuPDF


def page_text(path: str, page_no: int) -> str:
    with pdfplumber.open(path) as pdf:
        return pdf.pages[page_no].extract_text() or ""


def page_png_b64(path: str, page_no: int, dpi: int = 150) -> str:
    doc = fitz.open(path)
    pix = doc[page_no].get_pixmap(dpi=dpi)
    return base64.b64encode(pix.tobytes("png")).decode("ascii")

150 dpi is usually enough for printed text. Raise it for small fonts, but remember that a bigger image means more input tokens.

Step 2. Ask the model for JSON

The main rule: do not ask to "make a table", ask for a strictly defined structure. Then the result is easy to verify in code.

bash
import json
from openai import OpenAI

client = OpenAI(base_url="https://api.zukko.pro/v1", api_key="fetch_your_key")

PROMPT = """Extract the line-item table from the page. Return JSON only, no commentary:
{"rows": [{"name": "...", "qty": 0, "price": 0.0, "total": 0.0}],
 "document_total": 0.0}
Write numbers as numbers with a dot, without spaces or currency signs.
If a value is not in the document, use null. Do not invent anything."""


FENCE = "`" * 3  # markdown fence, in case the model adds one

The text path uses deepseek-v4.1-flash: it has no image input, but it is the cheapest model in our table. Scans need a model with image input, for example claude-sonnet-5-5 or gpt-6.1-sol. Request formats are covered in the chat completions docs.

Step 3. Check the sums before writing the file

This is the most important step. The model may be wrong by a single digit while the table looks convincing.

bash
from decimal import Decimal


def validate(data: dict) -> list[str]:
    problems = []
    rows = data["rows"]
    for i, row in enumerate(rows):
        if None in (row["qty"], row["price"], row["total"]):
            problems.append(f"row {i}: empty value")
            continue
        calc = Decimal(str(row["qty"])) * Decimal(str(row["price"]))
        if abs(calc - Decimal(str(row["total"]))) > Decimal("0.01"):
            problems.append(f"row {i}: {calc} != {row['total']}")
    s = sum(Decimal(str(r["total"])) for r in rows if r["total"] is not None)
    doc_total = data.get("document_total")
    if doc_total is not None and abs(s - Decimal(str(doc_total))) > Decimal("0.01"):
        problems.append(f"sum of rows {s} does not match total {doc_total}")
    return problems

Two checks catch most errors: quantity times price equals the row total, and the sum of rows equals the document total. Send pages with warnings for another pass (for example to a stronger model) or to manual review.

Step 4. Write xlsx with openpyxl

bash
from openpyxl import Workbook


def write_xlsx(pages: list[dict], out_path: str) -> None:
    wb = Workbook()
    ws = wb.active
    ws.append(["Name", "Qty", "Price", "Total"])
    for data in pages:
        for r in data["rows"]:
            ws.append([r["name"], r["qty"], r["price"], r["total"]])
    last = ws.max_row
    ws.append(["Total", None, None, f"=SUM(D2:D{last})"])
    wb.save(out_path)

The closing SUM formula is a second independent control: compare its result with the total in the PDF by eye.

What it costs

Prices as of 18 October 2026, dollars per 1M tokens. Formula: input × input price + output × output price.

A text page on deepseek-v4.1-flash (0.013 / 0.038). Assume 1500 input tokens and 800 output tokens:

1500 × 0.013 / 1,000,000 + 800 × 0.038 / 1,000,000 = 0.0000195 + 0.0000304 ≈ $0.00005

For 100 pages that is about $0.005.

An image page on claude-sonnet-5-5 (0.4 / 2). How many tokens an image takes depends on its size, so assume 1600 input tokens and 800 output tokens:

1600 × 0.4 / 1,000,000 + 800 × 2 / 1,000,000 = 0.00064 + 0.0016 = $0.00224

For 100 pages that is about $0.22. Read the real token count for an image from the usage field of the response and measure on three to five of your own pages before running a batch. Current prices: GET https://api.zukko.pro/v1/models or the "Models" page in the dashboard.

Practical tips

  • Do not send personal data to the model unless the task needs it: templates often contain names and bank details.
  • Cut multi-page tables by page and merge them in code instead of asking the model to process 80 pages at once.
  • Describe complex multi-level headers and merged cells in the prompt in words: which columns exist and in what order.
  • Other document types are covered in the posts on PDF processing via vision and on extracting data from documents.

Create a key in the dashboard; the code above works with it unchanged. A basic Python example is in the docs.

FAQ

Can I do this without code?

Yes, for one file: upload the pages to the browser chat and ask for a table. For regular work, a script with sum checks is more reliable.

What about poor-quality scans?

Raise the dpi, crop the margins and retry. If the model answers null, that is more honest than an invented value: check those rows by hand.

Which model is more accurate?

We do not publish our own quality measurements. Compare two or three models on ten of your own pages and count which one produces fewer mismatches in the sum check.

How many pages can I process at once?

Pages are independent, so they can be sent in parallel. Limits depend on the key limits and the model provider; handle a 429 error with a retry and a pause.

zukko

Pay 25×
less

for Claude Code & ChatGPT

One fetch_* key and base URL api.zukko.pro — for Cursor, Claude Code, Codex, and any SDK.

Get API keyDocumentation

api.zukko.pro · fetch_*

PDF to Excel with AI: Python code and total checks · Zukko