"""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