"""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 base64 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)") VALUE_DATE_RE = re.compile(r"value\s*date\s*[:\s]\s*(\d{1,2}[./]\d{1,2}[./]\d{2,4}|\d{4}-\d{2}-\d{2})", re.I) IBAN_RE = re.compile(r"IBAN[:\s]+([A-Z]{2}[A-Z0-9]{8,32})", re.I) _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 _line_amount(line: str) -> int | None: """Betrag, falls die ZEILE im Wesentlichen nur ein Betrag ist (evtl. mit Währungssymbol/-code), z. B. „-97,00€" oder „1'234.56 CHF". Sonst None.""" s = re.sub(r"\s*(€|EUR|CHF|Fr\.?|\$|USD)\s*$", "", line.strip(), flags=re.I).strip() if AMOUNT_RE.fullmatch(s): return _parse_amount_auto(s) return None def _line_date(line: str): """Datum, falls die ZEILE nur aus einem Datum besteht (Buchungsdatum).""" s = line.strip() if DATE_RE.fullmatch(s): return _norm_date(s) return None def _filter_noise(raw_lines: list[str]) -> list[str]: """Entfernt wiederkehrende Kopf-/Fußzeilen von Kontoauszügen (Name/Adresse/ eigene IBAN im Seitenkopf, „Issued on" + Ausstellungsdatum, Seitenzahl, Spaltenüberschriften).""" lines = [ln for ln in raw_lines if ln] if not lines: return [] # Halter-Kopf: die Zeilen bis vor „Issued on" (Name, Adresse, eigene IBAN). holder: set[str] = set() for ln in lines[:6]: if ln.lower().startswith("issued on"): break holder.add(ln) heads = {"description", "booking date", "amount", "account activity"} out: list[str] = [] skip_date = False for ln in lines: low = ln.lower() if skip_date: skip_date = False if _line_date(ln): # das Ausstellungsdatum direkt nach „Issued on" continue if ln in holder or low in heads: continue if low.startswith("issued on"): skip_date = True continue if re.fullmatch(r"\d+\s*/\s*\d+", ln): # Seitenzahl „1 / 34" continue out.append(ln) return out def _make_candidate(block: list[str], amount: int) -> dict: """Baut aus den angesammelten Beschreibungszeilen + Betrag eine Kandidaten- Buchung. Buchungsdatum = letzte reine Datumszeile (sonst Value-/Erst-Datum).""" d = None used: set[int] = set() for j in range(len(block) - 1, -1, -1): dd = _line_date(block[j]) if dd: d, _ = dd, used.add(j) break if d is None: for j, l in enumerate(block): vm = VALUE_DATE_RE.search(l) if vm: d, _ = _norm_date(vm.group(1)), used.add(j) break if d is None: for j, l in enumerate(block): gm = DATE_RE.search(l) if gm: d, _ = _norm_date(gm.group(1)), used.add(j) break iban = None parts: list[str] = [] for j, l in enumerate(block): if j in used or VALUE_DATE_RE.search(l): continue m = IBAN_RE.search(l) if m and iban is None: iban = re.sub(r"[^A-Za-z0-9]", "", m.group(1)).upper() continue parts.append(l) payee = parts[0] if parts else None purpose = " · ".join(parts[1:]) if len(parts) > 1 else None return { "date": d.isoformat() if d else None, "amount_cents": amount, "payee": payee[:120] if payee else None, "purpose": purpose[:200] if purpose else None, "counterparty_iban": iban, } def _parse_blocks(text: str) -> list[dict]: """Blockweise: jede Zeile, die im Wesentlichen NUR ein Betrag ist, schließt eine Buchung ab; die Zeilen davor sind Beschreibung/Datum/IBAN. Passt zu mehrzeiligen Auszügen (N26 & Co.), bei denen Datum und Betrag eigene Zeilen sind.""" lines = _filter_noise([ln.strip() for ln in text.splitlines()]) rows: list[dict] = [] block: list[str] = [] for ln in lines: amt = _line_amount(ln) if amt is None or amt == 0: block.append(ln) continue rows.append(_make_candidate(block, amt)) block = [] return rows def _parse_lines(text: str) -> list[dict]: """Fallback: Datum UND Betrag in derselben Zeile (einfache Tabellen).""" 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 def parse_transactions(text: str) -> list[dict]: """Kandidaten-Buchungen aus dem Beleg-Text. Erst blockbasiert (mehrzeilig), sonst der einfache Ein-Zeilen-Fallback.""" rows = _parse_blocks(text) valid = [r for r in rows if r.get("date") and r.get("amount_cents")] if valid: return rows return _parse_lines(text) # --- Seiten als Bild (zur Kontrolle in der Prüfansicht) -------------------- def render_pages(data: bytes, filename: str, content_type: str, max_pages: int = 6, dpi: int = 110) -> list[str]: """Rendert die Beleg-Seiten als PNG-Data-URLs (PDF via PyMuPDF, ohne poppler). Bilder werden unverändert als Data-URL zurückgegeben. Best-Effort.""" name = (filename or "").lower() out: list[str] = [] is_pdf = name.endswith(".pdf") or content_type == "application/pdf" try: if is_pdf: if fitz is None: return [] with fitz.open(stream=data, filetype="pdf") as doc: for k, page in enumerate(doc): if k >= max_pages: break pix = page.get_pixmap(dpi=dpi) b64 = base64.b64encode(pix.tobytes("png")).decode("ascii") out.append(f"data:image/png;base64,{b64}") else: mime = content_type or "image/png" b64 = base64.b64encode(data).decode("ascii") out.append(f"data:{mime};base64,{b64}") except Exception: # noqa: BLE001 – Vorschau ist optional return out return out