DEV Community

Cover image for Document OCR API in Python: Receipts, Forms and Scanned PDFs
AI Engine
AI Engine

Posted on Originally published at ai-engine.net

Document OCR API in Python: Receipts, Forms and Scanned PDFs

To turn receipts, forms and scanned PDFs into structured data, OCR text alone is not enough: you need to know which price belongs to which item and which date belongs to which field. This tutorial does it in Python with a document OCR API. We tested every script on three real documents: a 1994 Tesco receipt, a 2020 Polish supermarket receipt, and two typewritten 1913 receipts scanned by the U.S. National Archives.

Want to run it on your own receipts? Get a key for the OCR Wizard API.


What the API returns

You need Python with requests and a RapidAPI key in the RAPIDAPI_KEY environment variable.

import os

import requests

response = requests.post(
    "https://ocr-wizard.p.rapidapi.com/ocr",
    headers={
        "x-rapidapi-host": "ocr-wizard.p.rapidapi.com",
        "x-rapidapi-key": os.environ["RAPIDAPI_KEY"],
    },
    files={"image": open("receipt.jpg", "rb")},
    timeout=60,
)
result = response.json()["body"]
print(result["detectedLanguage"])
print(result["fullText"][:120])
print(result["annotations"][0])
Enter fullscreen mode Exit fullscreen mode

Real output on the Tesco receipt:

en
TESCO
EVERY LITTLE HELPS
6 JAM DOUGHNTS
REDUCED PRICE
6 JAM DOUGHNTS
REDUCED PRICE
0.55
MLD WHT CHDR
1.45
RICH TEA
0.39
{'text': 'TESCO', 'boundingPoly': [{'x': 159, 'y': 375}, {'x': 580, 'y': 375}, {'x': 580, 'y': 419}, {'x': 159, 'y': 419}]}
Enter fullscreen mode Exit fullscreen mode
  • fullText: all the text, block by block.
  • detectedLanguage: the language code.
  • annotations: every word with its boundingPoly, four corner points in pixels.

In fullText, the item MLD WHT CHDR and its price 1.45 are on separate lines, because the receipt prints them in two columns. The word boxes fix that.


Rebuild the rows from the word boxes

Words on the same printed line have close vertical centers. The script groups words whose centers are less than 60% of a word height apart, sorts each row from left to right, then parse_receipt reads the price at the end of each row and stops at the total. It handles both 1.45 and 1,99 decimals.

import os
import re
import sys

import requests

HEADERS = {
    "x-rapidapi-host": "ocr-wizard.p.rapidapi.com",
    "x-rapidapi-key": os.environ["RAPIDAPI_KEY"],
}


def ocr_image(path):
    with open(path, "rb") as f:
        response = requests.post(
            "https://ocr-wizard.p.rapidapi.com/ocr",
            headers=HEADERS,
            files={"image": f},
            timeout=60,
        )
    response.raise_for_status()
    return response.json()["body"]


def box(word):
    xs = [p["x"] for p in word["boundingPoly"]]
    ys = [p["y"] for p in word["boundingPoly"]]
    return min(xs), min(ys), max(xs), max(ys)


def group_rows(annotations):
    rows = []
    for word in sorted(annotations, key=lambda w: box(w)[1] + box(w)[3]):
        x0, y0, x1, y1 = box(word)
        middle = (y0 + y1) / 2
        if rows and abs(middle - rows[-1]["middle"]) < (y1 - y0) * 0.6:
            rows[-1]["words"].append(word)
        else:
            rows.append({"middle": middle, "words": [word]})
    lines = []
    for row in rows:
        words = sorted(row["words"], key=lambda w: box(w)[0])
        line = words[0]["text"]
        for previous, word in zip(words, words[1:]):
            gap = box(word)[0] - box(previous)[2]
            height = box(word)[3] - box(word)[1]
            line += (" " if gap > height * 0.25 else "") + word["text"]
        lines.append(line)
    return lines


PRICE = re.compile(r"(-?\d+[.,]\d{2})[A-Z]?$")
QUANTITY = re.compile(r"(\s[A-Z])?\s\d+\s?x\s?\d+[.,]\d{2}$")
END_OF_ITEMS = ("TOTAL", "SUMA", "SPRZEDAZ")
TOTAL_LINES = ("TOTAL", "SUMA PLN")


def parse_receipt(lines):
    items, total, in_items, previous = [], None, True, ""
    for line in lines:
        match = PRICE.search(line)
        label = line[: match.start()].strip() if match else line
        if label.upper().startswith(END_OF_ITEMS):
            in_items = False
        if match and label.upper().startswith(TOTAL_LINES):
            total = float(match.group(1).replace(",", "."))
        elif match and in_items:
            if label.upper() == "REDUCED PRICE":
                label = previous + " (reduced)"
            items.append((QUANTITY.sub("", label), float(match.group(1).replace(",", "."))))
        previous = line
    return items, total


if __name__ == "__main__":
    result = ocr_image(sys.argv[1])
    lines = group_rows(result["annotations"])
    items, total = parse_receipt(lines)
    print("Language:", result["detectedLanguage"])
    for label, amount in items:
        print(f"{amount:>7.2f}  {label}")
    line_sum = round(sum(amount for _, amount in items), 2)
    print(f"Total: {total}  Sum of lines: {line_sum}")
    if total is None or abs(line_sum - total) > 0.01:
        print("Check failed: send this receipt to manual review")
Enter fullscreen mode Exit fullscreen mode

Output on the Polish receipt:

Language: pl
   1.99  ChlebPszen-Zyt550g
   3.29  Ser ź.Świat. 150g
   4.99  Polędw Łos 100g
  -2.00  Rabat
Total: 8.27  Sum of lines: 8.27
Enter fullscreen mode Exit fullscreen mode

The three products, the discount and the total are all there, and the line sum matches the printed total.

Check the total before you trust a receipt

Output on the Tesco receipt:

Language: en
   0.55  6 JAM DOUGHNTS (reduced)
   1.45  MLD WHT CHDR
   0.39  RICH TEA
   0.44  C/CHP COOKIE
   0.50  4 TEACAKES (reduced)
   0.85  18 BROWN ROLLS (reduced)
   0.55  TUNA STEAKS
   0.64  L/GRAIN RICE
   0.79  ORANGE NET
Total: 6.71  Sum of lines: 6.16
Check failed: send this receipt to manual review
Enter fullscreen mode Exit fullscreen mode

This thermal receipt is from 1994, and the price of the first box of doughnuts has faded to a faint smudge. The line sum is 0.55 short, so the check sends the receipt to a person instead of booking a wrong amount. It costs one line of code and catches damaged paper, folded corners and cut-off photos.


See what the API read

Draw the word boxes to debug a document (reuses ocr_image and box, needs Pillow):

from PIL import Image, ImageDraw


def draw_boxes(image_path, annotations, output_path):
    image = Image.open(image_path).convert("RGB")
    draw = ImageDraw.Draw(image)
    for word in annotations:
        draw.rectangle(box(word), outline=(22, 163, 74), width=3)
    image.save(output_path)


result = ocr_image("receipt.jpg")
draw_boxes("receipt.jpg", result["annotations"], "receipt_boxes.jpg")
Enter fullscreen mode Exit fullscreen mode

A faded 1994 Tesco receipt with a green box around each word the OCR API returned


Scanned PDFs: extract fields page by page

The /ocr-pdf endpoint takes pdf_file and an optional first_page and last_page (both default to 1). We built a two-page PDF from two 1913 receipts and pulled the date, payer and amount from each page:

import os
import re

import requests

HEADERS = {
    "x-rapidapi-host": "ocr-wizard.p.rapidapi.com",
    "x-rapidapi-key": os.environ["RAPIDAPI_KEY"],
}


def ocr_pdf(path, first_page=1, last_page=1):
    with open(path, "rb") as f:
        response = requests.post(
            "https://ocr-wizard.p.rapidapi.com/ocr-pdf",
            headers=HEADERS,
            files={"pdf_file": f},
            data={"first_page": first_page, "last_page": last_page},
            timeout=120,
        )
    response.raise_for_status()
    return response.json()["body"]["pages"]


DATE = re.compile(r"(?:January|February|March|April|May|June|July|August|"
                  r"September|October|November|December) \d{1,2}(?:st|nd|rd|th)?,? \d{4}")
AMOUNT = re.compile(r"\d+\.\d{2}")
PAYER = re.compile(r"of ([A-Z][\w.]* (?:[A-Z][\w.]* ?)+),")

pages = ocr_pdf("scans.pdf", first_page=1, last_page=2)
for number, page in enumerate(pages, start=1):
    text = page["fullText"]
    date = DATE.search(text)
    payer = PAYER.search(text)
    amounts = [float(a) for a in AMOUNT.findall(text)]
    print({
        "page": number,
        "date": date.group(0) if date else None,
        "received_from": payer.group(1) if payer else None,
        "amount": max(amounts) if amounts else None,
    })
Enter fullscreen mode Exit fullscreen mode

Real output, in about 2 seconds for both pages:

{'page': 1, 'date': 'August 23, 1913', 'received_from': 'Dewey C. Bailey', 'amount': 393.75}
{'page': 2, 'date': 'August 7th 1913', 'received_from': 'Dewey C. Bailey', 'amount': 5.0}
Enter fullscreen mode Exit fullscreen mode

Regex or LLM for field extraction?

  • Regular expressions: best when the layout repeats (same stores, your own forms). Free, fast, deterministic.
  • An LLM after the OCR: best when every document looks different (supplier invoices, contracts). Send the rebuilt rows, not the raw fullText, so the model sees each item next to its price.

Production tips

  • Send the full resolution: small text needs pixels, so do not shrink photos before upload.
  • Store the raw JSON: rerun an improved parser on old documents without new requests.
  • Control the page range: send long PDFs in ranges and merge the results.
  • Validate every number: compare sums with totals, dates with a plausible range, and route failures to review.

Full tutorial with images: Document OCR API in Python on ai-engine.net.


Sources

Try it on your own documents: grab a key from the OCR Wizard API page and run receipt.py on a receipt.

Top comments (0)