Every small business has the box. Supplier invoices, tax letters, signed contracts, warranty cards, bank letters, the lease. When you need one document, you dig through the box. When you scan things “to be organised”, you end up with a folder of files called Scan_20260914_0001.pdf that nobody can search.

Two pieces of free software fix most of this:

  • OCRmyPDF adds an invisible, searchable text layer to scanned PDFs. It is open source (MPL-2.0), runs offline and uses the Tesseract OCR engine, which reads Indonesian and English among more than 100 languages.
  • A small local LLM through Ollama reads that text and works out what the document is: who sent it, the date, the type and the amount. Then the script renames and files it.

In this guide you’ll build a Python script that watches an inbox/ folder and, for every scan:

  1. runs OCR so the PDF becomes searchable (Ctrl+F works, Spotlight finds it)
  2. asks a local model for date, sender, document type and amount, as JSON
  3. checks those answers against the OCR text so made-up values get caught
  4. renames the file to something like 2026-09-03_pln_utility-bill.pdf and moves it to archive/2026/
  5. adds a row to index.csv so you can sort and filter everything in a spreadsheet

Nothing leaves your computer. There’s no API key and nothing to pay per page. Build time is about 45 minutes, most of it downloads.

What you need

ItemCostNotes
Computer with 8 GB+ RAM$0 (you have one)Windows (via WSL), macOS or Linux
OCRmyPDF + Tesseract$0Open source
Ollama$0ollama.com, runs models locally
qwen2.5:3b model$0About 1.9 GB download
Python 3.10+ and requests$0
A way to scan$0 to ~$500Phone app to start; a sheet-fed scanner if you have hundreds of pages

On scanning: for a few dozen documents your phone is fine. On iPhone, the Notes and Files apps have a built-in “Scan Documents” option. On Android, the Google Drive app has a Scan button. Both export PDFs. For a real backlog (a few hundred pages or more), a duplex sheet-fed scanner saves hours. The Ricoh (formerly Fujitsu) ScanSnap iX1600 is rated at 40 pages per minute, and Brother’s ADS series costs less. Expect to pay roughly $250 to $500 for one; check local prices before you buy. Flatbed printer-scanners work too, but you place each page by hand.

Scan at 300 DPI in greyscale or colour. Tesseract does best at about 300 DPI. Going higher mostly makes files bigger.

Step 1: Install OCRmyPDF and the Indonesian language pack

# Ubuntu / Debian / WSL
sudo apt update
sudo apt install -y ocrmypdf tesseract-ocr-ind tesseract-ocr-eng unpaper poppler-utils

# macOS (Homebrew)
brew install ocrmypdf tesseract-lang unpaper poppler

On Windows, the simplest route is WSL (wsl --install in an admin PowerShell, then the Ubuntu commands above). OCRmyPDF’s docs also describe a native Windows install, but it takes more setup.

Check that the languages are there:

tesseract --list-langs     # should include eng, ind and osd
ocrmypdf --version

Distro packages are sometimes a few versions behind. If you want the latest OCRmyPDF, pip install ocrmypdf inside a virtual environment works, as long as Tesseract and Ghostscript are installed from your package manager.

Step 2: Try OCR by hand on one file

Put one scan in a test folder and run:

ocrmypdf -l ind+eng --rotate-pages --deskew --clean \
  --skip-text --sidecar test.txt \
  Scan_0001.pdf test_ocr.pdf

What the flags do:

  • -l ind+eng: OCR with Indonesian and English. Mixed documents are normal here (an Indonesian invoice full of English product names), so use both.
  • --rotate-pages: fixes pages scanned upside down or sideways.
  • --deskew: straightens slightly crooked pages. This noticeably improves accuracy on phone scans.
  • --clean: uses unpaper to remove speckles before OCR. It only affects what Tesseract sees; the visible page stays as scanned.
  • --skip-text: if a page already has text (a PDF that was emailed to you, not scanned), leave it alone instead of failing. Those pages are left out of the sidecar file, so the script uses pdftotext (from poppler) to read their text instead.
  • --sidecar test.txt: also writes the recognised text to a plain text file. The script feeds this to the LLM.

Open test_ocr.pdf and press Ctrl+F. If you can find a word from the page, it worked. Open test.txt and look at the quality. Clean printed text usually comes out nearly perfect. Faded thermal receipts and handwriting don’t (see the Reality check).

To time it on your machine:

time ocrmypdf -l ind+eng --rotate-pages --deskew --skip-text Scan_0001.pdf /tmp/out.pdf

Tesseract usually takes a few seconds per page on a normal laptop CPU, and OCRmyPDF processes pages in parallel across your cores. Time a 10-page document and multiply. That tells you how long a backlog will take far better than any number I could give you.

Step 3: Install Ollama and a small model

Install from ollama.com (there’s an installer for macOS and Windows). On Linux:

curl -fsSL https://ollama.com/install.sh | sh
ollama pull qwen2.5:3b

qwen2.5:3b is a 3-billion-parameter model, about 1.9 GB on disk, and it runs on a CPU with 8 GB of RAM. It reads Indonesian reasonably well and handles this narrow task (pull four fields out of a page of text) fine. If your computer has 16 GB+ RAM or a GPU, qwen2.5:7b (about 4.7 GB) makes fewer mistakes. You change one line in the script.

Quick test:

ollama run qwen2.5:3b "Who issued this document? 'PT PLN (Persero) Tagihan Listrik September 2026'"

Then set up the project:

mkdir paperbox && cd paperbox
python3 -m venv .venv && source .venv/bin/activate
pip install requests
mkdir inbox archive needs-review

Step 4: The script

Save this as paperbox.py:

import csv, json, re, shutil, subprocess, sys, tempfile
from datetime import date, datetime
from pathlib import Path

import requests

MODEL = "qwen2.5:3b"
OLLAMA_URL = "http://localhost:11434/api/chat"
LANGS = "ind+eng"
INBOX, ARCHIVE, REVIEW = Path("inbox"), Path("archive"), Path("needs-review")
INDEX = Path("index.csv")
IMAGE_EXT = {".jpg", ".jpeg", ".png", ".tif", ".tiff"}
MAX_CHARS = 4000   # the first page or two is enough to identify a document

DOC_TYPES = ["invoice", "receipt", "contract", "tax", "bank", "letter",
             "utility-bill", "warranty", "id-document", "other"]

SCHEMA = {
    "type": "object",
    "properties": {
        "date": {"type": "string", "description": "YYYY-MM-DD or empty"},
        "sender": {"type": "string"},
        "doc_type": {"type": "string", "enum": DOC_TYPES},
        "amount": {"type": "string", "description": "digits only, or empty"},
        "summary": {"type": "string"},
    },
    "required": ["date", "sender", "doc_type", "amount", "summary"],
}

PROMPT = """You file scanned business documents. Read the OCR text below
and return JSON with:
- date: the document date (issue date, not due date) as YYYY-MM-DD, or "" if unclear
- sender: the company or person who issued it, short (e.g. "PLN", "Tokopedia", "Kantor Pajak")
- doc_type: one of {types}
- amount: the total amount as digits only (no dots, no currency), or "" if none
- summary: one short sentence describing the document
Only use information that is in the text. If unsure, use "".

OCR TEXT:
{text}"""


def ocr(src: Path, out_pdf: Path, sidecar: Path) -> None:
    if src.suffix.lower() in IMAGE_EXT:
        # Single images need a DPI; phone photos often lack it
        cmd = ["ocrmypdf", "--image-dpi", "300"]
    else:
        cmd = ["ocrmypdf"]
    cmd += ["-l", LANGS, "--rotate-pages", "--deskew", "--skip-text",
            "--optimize", "1", "--sidecar", str(sidecar), str(src), str(out_pdf)]
    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode != 0:
        lines = result.stderr.strip().splitlines()
        raise RuntimeError(lines[-1] if lines else f"ocrmypdf exit {result.returncode}")


def ask_llm(text: str) -> dict:
    resp = requests.post(OLLAMA_URL, timeout=300, json={
        "model": MODEL,
        "messages": [{"role": "user", "content": PROMPT.format(
            types=", ".join(DOC_TYPES), text=text[:MAX_CHARS])}],
        "format": SCHEMA,
        "stream": False,
        "options": {"temperature": 0},
    })
    resp.raise_for_status()
    return json.loads(resp.json()["message"]["content"])


def check(info: dict, text: str) -> list[str]:
    """Catch values the model invented. Returns a list of problems."""
    problems = []
    digits_only = re.sub(r"\D", "", text)
    try:
        d = datetime.strptime(info["date"], "%Y-%m-%d").date()
        if d > date.today() or d.year < 1990:
            problems.append(f"implausible date {info['date']}")
            info["date"] = ""
        elif str(d.year) not in text:
            problems.append(f"year {d.year} not in text")
    except ValueError:
        info["date"] = ""
    if info["amount"] and info["amount"] not in digits_only:
        problems.append(f"amount {info['amount']} not in text")
        info["amount"] = ""
    if not info["sender"]:
        problems.append("no sender")
    return problems


def slug(s: str, limit: int = 40) -> str:
    s = re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")
    return s[:limit].rstrip("-") or "unknown"


def unique_path(folder: Path, stem: str) -> Path:
    path, n = folder / f"{stem}.pdf", 2
    while path.exists():
        path, n = folder / f"{stem}-{n}.pdf", n + 1
    return path


def process(src: Path) -> None:
    print(f"- {src.name}")
    with tempfile.TemporaryDirectory() as tmp:
        out_pdf, sidecar = Path(tmp) / "out.pdf", Path(tmp) / "out.txt"
        ocr(src, out_pdf, sidecar)
        text = sidecar.read_text(encoding="utf-8", errors="ignore")
        if len(text.strip()) < 30:
            # --skip-text leaves pages that already had text out of the
            # sidecar, so read born-digital PDFs with pdftotext instead
            subprocess.run(["pdftotext", "-layout", str(out_pdf), str(sidecar)],
                           check=True)
            text = sidecar.read_text(encoding="utf-8", errors="ignore")
        if len(text.strip()) < 30:
            info = {"date": "", "sender": "", "doc_type": "other",
                    "amount": "", "summary": ""}
            problems = ["almost no text found"]
        else:
            info = ask_llm(text)
            problems = check(info, text)

        stem = "_".join([info["date"] or "undated", slug(info["sender"]),
                         slug(info["doc_type"])])
        folder = REVIEW if problems else ARCHIVE / (info["date"][:4] or "undated")
        folder.mkdir(parents=True, exist_ok=True)
        dest = unique_path(folder, stem)
        shutil.move(out_pdf, dest)
        shutil.move(sidecar, dest.with_suffix(".txt"))

    new_index = not INDEX.exists()
    with INDEX.open("a", newline="", encoding="utf-8") as f:
        w = csv.writer(f)
        if new_index:
            w.writerow(["file", "date", "sender", "type", "amount",
                        "summary", "problems", "original"])
        w.writerow([dest, info["date"], info["sender"], info["doc_type"],
                    info["amount"], info["summary"], "; ".join(problems), src.name])
    src.unlink()   # original is only removed after the archived copy exists
    print(f"  -> {dest}" + (f"  (CHECK: {'; '.join(problems)})" if problems else ""))


def main() -> None:
    files = sorted(p for p in INBOX.iterdir()
                   if p.suffix.lower() in IMAGE_EXT | {".pdf"})
    if not files:
        sys.exit("inbox/ is empty")
    for src in files:
        try:
            process(src)
        except Exception as e:   # one bad file shouldn't stop the batch
            print(f"  FAILED: {e}")


if __name__ == "__main__":
    main()

A few design choices worth knowing about:

  • The format schema makes Ollama return valid JSON with exactly these fields. Ollama has supported structured outputs since version 0.5. Without it, small models wrap the JSON in chatty text and the parser breaks.
  • doc_type is an enum. The model must pick from your list, so you don’t get “Invoice”, “invoice”, “faktur” and “tagihan” as four different types. Edit DOC_TYPES to match your paperwork.
  • check() is the important part. Small models sometimes invent a plausible amount or take the date from the wrong place. The script only accepts an amount if those exact digits appear in the OCR text, rejects future dates, and checks that the year actually appears on the page. Anything suspicious goes to needs-review/ instead of the archive.
  • Originals are deleted only after the new file is safely written. Still, keep your original scans somewhere else until you trust the setup.

Step 5: Run it

Copy some scans into inbox/ and run the script. Ollama must be running: the desktop app starts it automatically, and on Linux the installer sets it up as a service.

python paperbox.py

Output looks like this:

- Scan_20260914_0001.pdf
  -> archive/2026/2026-09-03_pln_utility-bill.pdf
- Scan_20260914_0002.pdf
  -> needs-review/undated_cv-sumber-makmur_invoice.pdf  (CHECK: amount 1250000 not in text)
- IMG_4471.jpg
  -> archive/2025/2025-11-20_samsung_warranty.pdf

Open index.csv in Excel or Google Sheets and you have a sortable register of every document: filter by sender, find everything from 2025, see what’s waiting in review.

Spend two minutes on needs-review/ after each batch. Fix the filename by hand and move the file to the right year folder. In practice it’s mostly faded receipts and documents with several amounts on them.

Step 6: Search everything

Every archived PDF now has a text layer and a .txt copy next to it, so you can search in a few ways:

# every document that mentions part of an NPWP number
grep -ril "01.234.567" archive/

# faster on big archives: ripgrep (sudo apt install ripgrep)
rg -il "garansi" archive/ --glob "*.txt"

On macOS, Spotlight indexes the PDF text layer, so typing a supplier name into Spotlight finds the scan. If you sync archive/ to Google Drive, Drive search also finds text inside PDFs.

To run it automatically, schedule it with cron (Linux, macOS or WSL):

crontab -e
# every 15 minutes, process anything new in inbox/
*/15 * * * * cd /home/you/paperbox && flock -n .lock .venv/bin/python paperbox.py >> log.txt 2>&1

flock -n skips a run if the previous one is still going, so a large batch doesn’t get processed twice. On macOS, flock isn’t installed by default (brew install flock), or just leave it out if your batches are small. Also make sure the scanner has finished writing a file before the next run picks it up. Scanning to a different folder and moving finished files into inbox/ is the safe way.

Point your scanner’s “scan to folder” setting, or a folder your phone syncs to, at inbox/, and filing happens without you.

Honest cost breakdown

CostAmount
Software (OCRmyPDF, Tesseract, Ollama, Qwen 2.5)$0
Per page processed$0, just electricity
Your setup time45–60 minutes
Phone scanning$0, but slow: roughly 10–20 seconds per page including handling
Sheet-fed scanner (optional)roughly $250–$500 one-time

Time per document: OCR plus one LLM call. On a CPU-only laptop, the qwen2.5:3b call usually takes anywhere from a few seconds to a few tens of seconds per document, depending on the CPU and how much text it reads. Run a batch of 20 documents and time it. A 500-document backlog can run overnight while you sleep.

Could you use a cloud model instead? Yes, and a big cloud model would be faster and more accurate on messy pages. But you’d be sending contracts, ID cards and bank letters to a third party, and in Indonesia processing personal data is covered by the Personal Data Protection Law (UU No. 27 Tahun 2022). For an archive like this, local is the sensible default. Use a cloud API only for documents with no personal data, and check the provider’s data-use terms and current pricing first.

Turning this into income

Small offices, clinics, law firms, notaries and family businesses often have years of paper they’d love to have searchable but will never get round to scanning. “We scan your archive, make it searchable and hand you a filed, indexed folder” is a service you can sell with this exact setup, a sheet-fed scanner and a laptop.

How to price it honestly:

  1. Time a test batch. Take 100 real pages from a prospect (with permission) and time everything: removing staples and clips, sorting, scanning, reviewing needs-review/. Prep and review usually take far longer than the scanning itself.
  2. Quote per page or per box, from your own numbers. If 100 pages took you 25 minutes of hands-on time and you want Rp100,000 per hour, your floor is about Rp420 per page before scanner wear, transport and profit. Add those, then compare with what local photocopy and scanning shops charge per page in your area. They are your real competition.
  3. Sell the outcome, not the OCR. Clients pay for “find any contract in 5 seconds” and an index spreadsheet, not for the word “OCR”.

Two rules if you do this. First, put the data handling in writing: where files are stored, that processing is local, and when you delete your copies. Second, don’t assume scanning means shredding. Some documents (originals with wet signatures, certain tax records) may need to be kept on paper, so the client’s accountant or lawyer should decide what can be thrown away.

Nobody can promise you a number of clients. What this setup does give you is near-zero cost per page, so whatever you charge is almost all payment for your time.

Reality check

What doesn’t work well:

  • Handwriting. Tesseract is built for printed text. Handwritten notes and filled-in forms come out as garbage. The PDF is still archived; the handwritten parts just won’t be searchable.
  • Faded thermal receipts. The shiny receipt paper that fades within months OCRs badly once it has faded. Scan those first, or photograph them in good light.
  • Bad phone photos. Shadows, angled shots and curled pages hurt accuracy more than anything else. Use the phone’s document-scan mode (it flattens and crops), not the regular camera.
  • Multi-document scans. If you feed 30 different invoices into the scanner as one PDF, you get one file named after the first invoice. Scan one document per file, or use your scanner software’s option to split on blank separator pages.
  • 3B models make mistakes. Expect some wrong senders and doc types, especially on pages with several company names (a delivery note that mentions the seller, the courier and you). The checks catch invented amounts and dates, but not a real name taken from the wrong part of the page. Skim the index weekly, or switch to qwen2.5:7b if your hardware allows.

Common mistakes:

  • Using --force-ocr on PDFs that already have text. It turns the pages into images and throws away the original text layer, which makes files bigger and often less accurate. Use --skip-text (as the script does), or --redo-ocr if an old OCR layer is bad.
  • Forgetting the ind language pack. With English only, most Indonesian words still come out, but accuracy on words like “pembayaran” and “tagihan” drops. Check tesseract --list-langs.
  • Deleting originals on day one. Run the script on copies for the first few weeks and spot-check the archive. Only then let it process your only copies.
  • Trusting the amount column for accounting. It’s a search aid, not bookkeeping. Discounts, tax lines and several subtotals confuse small models. For real expense tracking, use a dedicated receipt workflow and check every number.

Wrap-up

Paper is one of the few office problems where AI now gives a boring, reliable win. OCRmyPDF makes the scans searchable, and a 2 GB model on your own laptop names and files them. The whole stack is free, runs offline, and the script is about 150 lines. Start with the ten documents you’ve had to dig for most often this year. If those come out right, empty the box.