From 936ebc2f56e0b25a406d866889a906ec6368170f Mon Sep 17 00:00:00 2001 From: Stefan Lohmaier Date: Fri, 19 Jun 2026 08:46:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(pdf):=20gemeinsames=20pdfutil=20=E2=80=94?= =?UTF-8?q?=20Scan-PDFs=20als=20Bild=20+=20OCR=20(Files=20&=20Mail)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scan-/bildbasierte PDFs werden jetzt von Files-MCP (read_file) UND Mail-MCP (read_attachment) ueber das gemeinsame Modul pdfutil.py verarbeitet: Seiten via PyMuPDF als PNG (150dpi, max 20) + OCR-Text (tesseract deu+eng). Verschluesselte/ kaputte PDFs bleiben graceful. Deps: pymupdf, pytesseract (+ system tesseract-ocr). 76 Tests gruen. --- files/server.py | 34 ++------------------ mail/server.py | 22 ++----------- pdfutil.py | 70 ++++++++++++++++++++++++++++++++++++++++++ requirements-extra.txt | 1 + tests/MCPTEST.md | 7 +++-- 5 files changed, 80 insertions(+), 54 deletions(-) create mode 100644 pdfutil.py diff --git a/files/server.py b/files/server.py index 2b252b4..7c08ee8 100644 --- a/files/server.py +++ b/files/server.py @@ -13,6 +13,7 @@ from starlette.routing import Mount sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) from common import get_current_user, OAUTH_ROUTES, BearerAuthMiddleware +from pdfutil import pdf_to_content from common import load_config as _lc _cfg = _lc() @@ -116,38 +117,7 @@ def read_file( if any(t in ct for t in TEXT_HINTS): return [TextContent(type="text", text=r.text[:100000])] if ct == "application/pdf" or path.lower().endswith(".pdf"): - try: - import pdfplumber - with pdfplumber.open(io.BytesIO(r.content)) as pdf: - pages = [] - for i, page in enumerate(pdf.pages, 1): - t = page.extract_text() or "" - if t.strip(): - pages.append(f"--- Seite {i} ---\n{t}") - text = "\n\n".join(pages) - if text.strip(): - return [TextContent(type="text", text=f"[PDF: {path}]\n\n{text[:200000]}")] - # Kein extrahierbarer Text -> bildbasiert/gescannt: Seiten als Bilder - # rendern, damit das LLM sie per Vision lesen kann (statt nutzloser Rohbytes). - try: - import fitz # PyMuPDF - doc = fitz.open(stream=r.content, filetype="pdf") - n = doc.page_count - MAX_PAGES = 20 - out = [TextContent(type="text", text=f"[PDF '{path}' ist bildbasiert/gescannt ({n} Seite(n)) — als Bilder gerendert:]")] - for i in range(min(n, MAX_PAGES)): - pix = doc[i].get_pixmap(dpi=150) - out.append(ImageContent(type="image", data=base64.b64encode(pix.tobytes("png")).decode(), mimeType="image/png")) - if n > MAX_PAGES: - out.append(TextContent(type="text", text=f"[... {n - MAX_PAGES} weitere Seiten ausgelassen (Limit {MAX_PAGES}).]")) - return out - except Exception as e: - return [ - TextContent(type="text", text=f"[PDF '{path}' ist bildbasiert; Rendern fehlgeschlagen ({e}). Rohdaten folgen.]"), - EmbeddedResource(type="resource", resource=BlobResourceContents(uri=f"file://{path}", blob=base64.b64encode(r.content).decode(), mimeType=ct)), - ] - except Exception as e: - return [TextContent(type="text", text=f"PDF '{path}' konnte nicht gelesen werden: {e}")] + return pdf_to_content(r.content, path, ct) try: text = r.content.decode("utf-8") return [TextContent(type="text", text=text[:100000])] diff --git a/mail/server.py b/mail/server.py index 661caf5..e164ea8 100644 --- a/mail/server.py +++ b/mail/server.py @@ -23,6 +23,7 @@ from starlette.routing import Mount sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) from common import get_current_user, OAUTH_ROUTES, BearerAuthMiddleware +from pdfutil import pdf_to_content from common import load_config as _lc _cfg = _lc() @@ -302,26 +303,7 @@ def read_attachment( if mime.startswith("text/"): return [TextContent(type="text", text=payload.decode("utf-8", errors="replace")[:100000])] if mime == "application/pdf" or att["filename"].lower().endswith(".pdf"): - try: - import pdfplumber - with pdfplumber.open(io.BytesIO(payload)) as pdf: - pages = [] - for i, page in enumerate(pdf.pages, 1): - t = page.extract_text() or "" - if t.strip(): - pages.append(f"--- Seite {i} ---\n{t}") - text = "\n\n".join(pages) - if text.strip(): - return [TextContent(type="text", text=f"[PDF: {att['filename']}]\n\n{text[:200000]}")] - # Kein extrahierbarer Text -> vermutlich Scan/Bild-PDF: als Blob zurueck - return [ - TextContent(type="text", text=f"[PDF '{att['filename']}' enthaelt keinen extrahierbaren Text (vermutlich Scan). Rohdaten folgen.]"), - EmbeddedResource(type="resource", resource=BlobResourceContents( - uri=f"mail://attachment/{att['filename']}", - blob=base64.b64encode(payload).decode(), mimeType=mime)), - ] - except Exception as e: - return [TextContent(type="text", text=f"PDF '{att['filename']}' konnte nicht gelesen werden: {e}")] + return pdf_to_content(payload, att["filename"], mime, uri=f"mail://attachment/{att['filename']}") return [EmbeddedResource(type="resource", resource=BlobResourceContents( uri=f"mail://attachment/{att['filename']}", blob=base64.b64encode(payload).decode(), mimeType=mime))] diff --git a/pdfutil.py b/pdfutil.py new file mode 100644 index 0000000..352f6c2 --- /dev/null +++ b/pdfutil.py @@ -0,0 +1,70 @@ +"""Gemeinsame PDF-Verarbeitung fuer Files- und Mail-MCP. + +Text-PDFs -> extrahierter Text. Bildbasierte/gescannte PDFs -> Seiten als PNG +gerendert (PyMuPDF) + OCR-Text (tesseract), damit das LLM sie per Vision liest +UND durchsuchbaren Text bekommt. Verschluesselte/kaputte PDFs -> graceful. +""" +import base64 +import io + +from mcp.types import TextContent, ImageContent, EmbeddedResource, BlobResourceContents + +DPI = 150 +MAX_PAGES = 20 + + +def _ocr(png_bytes): + try: + import pytesseract + from PIL import Image + return pytesseract.image_to_string(Image.open(io.BytesIO(png_bytes)), lang="deu+eng").strip() + except Exception: + return "" + + +def pdf_to_content(content, label, ct="application/pdf", uri=None): + uri = uri or f"file://{label}" + + # 1. Echten Text extrahieren + try: + import pdfplumber + with pdfplumber.open(io.BytesIO(content)) as pdf: + pages = [] + for i, page in enumerate(pdf.pages, 1): + t = page.extract_text() or "" + if t.strip(): + pages.append(f"--- Seite {i} ---\n{t}") + text = "\n\n".join(pages) + if text.strip(): + return [TextContent(type="text", text=f"[PDF: {label}]\n\n{text[:200000]}")] + except Exception: + pass # weiter zum Rendern (z.B. verschluesselt -> faellt unten in except) + + # 2. Bildbasiert/gescannt -> Seiten rendern + OCR + try: + import fitz # PyMuPDF + doc = fitz.open(stream=content, filetype="pdf") + n = doc.page_count + images, ocr_pages = [], [] + for i in range(min(n, MAX_PAGES)): + png = doc[i].get_pixmap(dpi=DPI).tobytes("png") + images.append(ImageContent(type="image", data=base64.b64encode(png).decode(), mimeType="image/png")) + t = _ocr(png) + if t: + ocr_pages.append(f"--- Seite {i + 1} (OCR) ---\n{t}") + header = f"[PDF '{label}' ist bildbasiert/gescannt ({n} Seite(n)) — als Bilder gerendert" + header += " (+ OCR-Text)" if ocr_pages else "" + header += ":]" + out = [TextContent(type="text", text=header)] + if ocr_pages: + out.append(TextContent(type="text", text="\n\n".join(ocr_pages)[:200000])) + out += images + if n > MAX_PAGES: + out.append(TextContent(type="text", text=f"[... {n - MAX_PAGES} weitere Seiten ausgelassen (Limit {MAX_PAGES}).]")) + return out + except Exception as e: + return [ + TextContent(type="text", text=f"[PDF '{label}' konnte nicht verarbeitet werden ({e}). Rohdaten folgen.]"), + EmbeddedResource(type="resource", resource=BlobResourceContents( + uri=uri, blob=base64.b64encode(content).decode(), mimeType=ct)), + ] diff --git a/requirements-extra.txt b/requirements-extra.txt index d32295c..496219d 100644 --- a/requirements-extra.txt +++ b/requirements-extra.txt @@ -5,4 +5,5 @@ openpyxl==3.1.5 pdfplumber==0.11.9 pillow==12.2.0 PyMuPDF==1.27.2.3 +pytesseract==0.3.13 python-docx==1.2.0 diff --git a/tests/MCPTEST.md b/tests/MCPTEST.md index 62f3c03..7e5bf36 100644 --- a/tests/MCPTEST.md +++ b/tests/MCPTEST.md @@ -100,5 +100,8 @@ Frueher gab `read_file` bei Scan-PDFs (kein extrahierbarer Text) nur Rohbytes (`EmbeddedResource`) zurueck — claude.ai konnte den Inhalt nicht lesen. Jetzt werden solche PDFs mit **PyMuPDF** seitenweise als **PNG-Bilder** (150 dpi, max 20 Seiten) gerendert und als `ImageContent` zurueckgegeben -> das LLM liest sie per Vision. -Produktiv-Feature (gilt fuer alle User). Test: `TestFileTypes` scanned.pdf -> `image`. -Runtime-Dep: `pymupdf` (siehe `requirements-extra.txt`). +Zusaetzlich **OCR** (tesseract deu+eng) -> durchsuchbarer Text neben den Bildern. +Gemeinsames Modul `pdfutil.py` wird von Files-MCP (`read_file`) UND Mail-MCP +(`read_attachment`) genutzt -> Scan-PDF-Mailanhaenge werden genauso gerendert. +Produktiv-Feature (alle User). Test: `TestFileTypes` scanned.pdf -> `image`. +Runtime-Deps: `pymupdf`, `pytesseract` + System `tesseract-ocr`/`-deu` (s. `requirements-extra.txt`).