Files
certain/backend/app/services/ocr.py
T
GuidoandClaude Opus 4.8 7461fddb7a feat: Phase 5 – PDF/Bild-Beleg-Import (lokal, Tesseract-OCR)
Backend:
- services/ocr: PDF-Text via PyMuPDF, OCR (Tesseract) für Scans/Bilder,
  heuristischer Parser (Datum + Betrag je Zeile, CH/DE/US-Beträge).
- routers/documents: /documents/preview (Upload -> Text -> Kandidaten ->
  gemeinsame Prüf-/Buchungslogik des CSV-Imports). csv_import refaktoriert:
  detect_rows() von CSV und Beleg gemeinsam genutzt.
- requirements: PyMuPDF, pytesseract, pillow; install.sh installiert tesseract-ocr
  (+ deu). pytest: Heuristik-Parser und PDF-Vorschau.

Frontend:
- Import-Seite: CSV/PDF-Umschalter; PDF-Modus mit Beleg-Auslesen, extrahiertem
  Text zur Kontrolle und Best-Effort-Hinweis; gleiche Prüfansicht + Buchen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 20:25:32 +02:00

128 lines
4.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""PDF-/Bild-Belege lokal auslesen (kein Cloud-Dienst).
- Digitale PDFs: Text direkt via PyMuPDF.
- Gescannte PDFs / Bilder: OCR via Tesseract (pytesseract).
Danach ein heuristischer Parser, der Zeilen mit Datum + Betrag findet. Das ist
bewusst Best-Effort – das Ergebnis landet in derselben Prüfansicht wie der
CSV-Import und wird dort korrigiert.
"""
import io
import re
from datetime import datetime
from decimal import Decimal, InvalidOperation
try: # PyMuPDF (Text + Rendern)
import fitz # type: ignore
except Exception: # noqa: BLE001
fitz = None
try: # OCR
import pytesseract # type: ignore
from PIL import Image # type: ignore
except Exception: # noqa: BLE001
pytesseract = None
Image = None
class ExtractionError(RuntimeError):
pass
def _image_to_text(data: bytes) -> str:
if pytesseract is None or Image is None:
raise ExtractionError("OCR nicht verfügbar (pytesseract/Pillow/Tesseract fehlen).")
img = Image.open(io.BytesIO(data))
return pytesseract.image_to_string(img, lang="deu+eng")
def _pdf_to_text(data: bytes) -> str:
if fitz is None:
raise ExtractionError("PDF-Verarbeitung nicht verfügbar (PyMuPDF fehlt).")
parts: list[str] = []
with fitz.open(stream=data, filetype="pdf") as doc:
for page in doc:
txt = page.get_text()
if txt and txt.strip():
parts.append(txt)
elif pytesseract is not None and Image is not None:
pix = page.get_pixmap(dpi=200)
img = Image.open(io.BytesIO(pix.tobytes("png")))
parts.append(pytesseract.image_to_string(img, lang="deu+eng"))
return "\n".join(parts)
def extract_text(data: bytes, filename: str, content_type: str) -> str:
name = (filename or "").lower()
if name.endswith(".pdf") or content_type == "application/pdf":
return _pdf_to_text(data)
return _image_to_text(data)
# --- Heuristischer Zeilen-Parser -------------------------------------------
DATE_RE = re.compile(r"\b(\d{4}-\d{2}-\d{2}|\d{1,2}[./]\d{1,2}[./]\d{2,4})\b")
# Betrag: entweder mit Tausender-Gruppen (1'234.56 / 1.234,56) oder schlicht (3000.00).
AMOUNT_RE = re.compile(r"[-+]?(?:\d{1,3}(?:['.,\s]\d{3})+|\d+)[.,]\d{2}(?!\d)")
_DATE_FORMATS = ["%Y-%m-%d", "%d.%m.%Y", "%d.%m.%y", "%d/%m/%Y", "%d/%m/%y"]
def _norm_date(token: str):
for fmt in _DATE_FORMATS:
try:
return datetime.strptime(token, fmt).date()
except ValueError:
continue
return None
def _parse_amount_auto(s: str) -> int | None:
"""Betrag mit automatisch erkanntem Dezimaltrenner (CH/DE/US) -> Cent."""
s = s.strip()
neg = s.startswith("-") or (s.startswith("(") and s.endswith(")"))
m = re.search(r"[.,](\d{2})$", s)
if not m:
return None
int_digits = re.sub(r"\D", "", s[: m.start()])
frac = m.group(1)
try:
val = Decimal(f"{int_digits or '0'}.{frac}")
except InvalidOperation:
return None
cents = int(val * 100)
return -cents if neg else cents
def parse_transactions(text: str) -> list[dict]:
"""Findet Zeilen mit Datum + Betrag und macht daraus Kandidaten-Buchungen."""
rows: list[dict] = []
for line in text.splitlines():
line = line.strip()
if len(line) < 6:
continue
dm = DATE_RE.search(line)
if not dm:
continue
d = _norm_date(dm.group(0))
if d is None:
continue
rest = f"{line[: dm.start()]} {line[dm.end():]}"
amounts = AMOUNT_RE.findall(rest)
if not amounts:
continue
amount = _parse_amount_auto(amounts[0])
if amount is None or amount == 0:
continue
desc = rest
for a in amounts:
desc = desc.replace(a, " ")
desc = re.sub(r"\s+", " ", desc).strip(" -–|;,.")
rows.append({
"date": d.isoformat(),
"amount_cents": amount,
"payee": desc[:120] or None,
"purpose": None,
"counterparty_iban": None,
})
return rows