"""OCR för ocr_read-steget (rpa/flow_engine.py). Läser text ur en inskannad PDF, ett fönster, ett skärmområde eller en bildfil. Windows-only: winocr (Windows.Media.Ocr) + pymupdf för PDF-rendering. Importeras bakom try/except så flow_engine kan importeras överallt – anropas ocr_read utan paketen kastas OcrUnavailable i stället för ImportError. """ from __future__ import annotations import re from pathlib import Path from . import config try: # Windows-only import winocr except Exception: # noqa: BLE001 winocr = None try: import pymupdf except Exception: # noqa: BLE001 pymupdf = None try: from PIL import Image, ImageGrab except Exception: # noqa: BLE001 Image = None ImageGrab = None DEFAULT_LANG = "sv-SE" # Om textlagret i en PDF ger minst så här många tecken hoppar vi OCR helt. _TEXT_LAYER_MIN = 40 class OcrUnavailable(RuntimeError): """winocr/pymupdf/PIL saknas eller OCR-språket är inte installerat i Windows.""" class OcrError(RuntimeError): """Filen/fönstret/området kunde inte OCR:as.""" def _need_winocr() -> None: if winocr is None: raise OcrUnavailable( "winocr kunde inte laddas – OCR fungerar bara på Windows. " "Installera med: pip install -r requirements.txt" ) def _need_pil() -> None: if Image is None: raise OcrUnavailable("Pillow kunde inte laddas – pip install -r requirements.txt") # -------------------------------------------------------------------------- # Låg nivå: en PIL-bild -> text + ordboxar # -------------------------------------------------------------------------- def image_to_text(pil_image, lang: str = DEFAULT_LANG) -> tuple[str, list[dict]]: """(text, words). words = [{text, x, y, w, h}] i bildpixlar.""" _need_winocr() try: result = winocr.recognize_pil_sync(pil_image, lang) except AssertionError as exc: # winocr assertar med exakt DISM-kommandot när språket saknas. raise OcrUnavailable( f"OCR-språket {lang!r} är inte installerat i Windows. Installera det " "(admin-terminal):\n " + str(exc) + "\neller via Inställningar → Tid " "och språk → Språk → lägg till språket → Språkalternativ → 'Optisk " "teckenläsning'." ) from exc except Exception as exc: # noqa: BLE001 raise OcrError(f"OCR misslyckades: {exc}") from exc lines = result.get("lines", []) or [] words: list[dict] = [] for line in lines: for w in line.get("words", []) or []: r = w.get("bounding_rect") or {} words.append({ "text": w.get("text", ""), "x": r.get("x", 0), "y": r.get("y", 0), "w": r.get("width", 0), "h": r.get("height", 0), }) # Bygg texten radvis (winocrs "text" slår ihop rader med mellanslag – sämre # för radbaserad extraktion i value_near). text = "\n".join(l.get("text", "") for l in lines) or (result.get("text") or "") return text, words # -------------------------------------------------------------------------- # Källor # -------------------------------------------------------------------------- def pdf_to_text(path: str | Path, lang: str = DEFAULT_LANG) -> str: """pypdf-textlager först; är det tomt renderas sidorna och OCR:as.""" p = Path(path) if not p.exists(): raise OcrError(f"PDF:en finns inte: {p}") layer = _pdf_text_layer(p) if len(layer.strip()) >= _TEXT_LAYER_MIN: return layer if pymupdf is None: raise OcrUnavailable( "pymupdf kunde inte laddas – behövs för att OCR:a inskannade PDF:er. " "pip install -r requirements.txt" ) _need_pil() parts: list[str] = [] doc = pymupdf.open(str(p)) try: for page in doc: pix = page.get_pixmap(dpi=300) mode = "RGBA" if pix.alpha else "RGB" img = Image.frombytes(mode, (pix.width, pix.height), pix.samples) parts.append(image_to_text(img.convert("RGB"), lang)[0]) finally: doc.close() return "\n\n".join(parts).strip() def _pdf_text_layer(p: Path) -> str: try: from pypdf import PdfReader reader = PdfReader(str(p)) return "\n".join((page.extract_text() or "") for page in reader.pages) except Exception: # noqa: BLE001 return "" def window_to_text(session, title: str | None, lang: str = DEFAULT_LANG) -> tuple[str, list[dict]]: """session = rpa.desktop.DesktopSession. Fångar fönstret och OCR:ar.""" _need_pil() win = session._window(title or None) # noqa: SLF001 – avsiktlig återanvändning img = win.capture_as_image() return image_to_text(img, lang) def region_to_text(bbox: tuple[int, int, int, int], lang: str = DEFAULT_LANG) -> tuple[str, list[dict]]: """bbox = (x, y, w, h) i skärmkoordinater.""" _need_pil() if ImageGrab is None: raise OcrUnavailable("PIL.ImageGrab saknas.") x, y, w, h = bbox img = ImageGrab.grab(bbox=(x, y, x + w, y + h), all_screens=True) return image_to_text(img, lang) def image_file_to_text(path: str | Path, lang: str = DEFAULT_LANG) -> tuple[str, list[dict]]: _need_pil() p = Path(path) if not p.is_absolute(): p = config.DESKTOP_IMAGE_DIR / p if not p.exists(): raise OcrError(f"Bildfilen finns inte: {p}") return image_to_text(Image.open(str(p)), lang) # -------------------------------------------------------------------------- # Extraktion ur OCR-text # -------------------------------------------------------------------------- def apply_pattern(text: str, pattern: str) -> str: """re.search mot texten. Har mönstret en namngiven grupp `(?P…)` returneras den, annars hela träffen. Ingen träff -> ''.""" m = re.search(pattern, text, re.IGNORECASE | re.MULTILINE) if not m: return "" if "value" in (m.groupdict() or {}): return m.group("value") or "" return m.group(0) def value_near(text: str, words: list[dict], label: str, mode: str = "same_line") -> str: """Värdet bredvid/under en etikett. same_line: resten av den rad som innehåller `label` (efter label + ev. ':'). below: närmaste rad UNDER etiketten, ungefär i samma kolumn (kräver ordboxar). """ lines = [ln for ln in text.splitlines()] if mode == "same_line": for ln in lines: if label.lower() in ln.lower(): idx = ln.lower().index(label.lower()) + len(label) rest = ln[idx:].strip(" :\t-") if rest: return rest return "" # below: hitta etikettordens y/x, ta närmaste ordrad under lbl_words = [w for w in words if label.lower() in w["text"].lower()] if not lbl_words and " " in label: first = label.split()[0].lower() lbl_words = [w for w in words if w["text"].lower() == first] if not lbl_words: return "" ly = min(w["y"] for w in lbl_words) lx = min(w["x"] for w in lbl_words) lh = max(w["h"] for w in lbl_words) below = [w for w in words if w["y"] > ly + lh - 5 and abs(w["x"] - lx) < 300] if not below: return "" row_y = min(w["y"] for w in below) row = [w for w in below if abs(w["y"] - row_y) < 15] row.sort(key=lambda w: w["x"]) return " ".join(w["text"] for w in row).strip()