"""OCR text extraction from PDF documents. Rasterizes each page via PyMuPDF (fitz) and runs the same OCR engine pipeline as ocr.py (tesseract / PaddleOCR PP-OCRv5 / PaddleOCR-VL). Contract: argv[1] = pdf path, argv[2] = options JSON {"quality","language","pages"}. Output: {"success": true, "engine": ..., "pages": n, "text": ...} to stdout. Errors: {"error": ...} to stdout, exit 1. Engine functions are imported from sibling ocr.py (the dispatcher adds the scripts directory to sys.path, and `import ocr` loads ocr.py as a module without triggering its __main__ guard). """ import sys import json import os import tempfile MAX_PAGES = 50 def emit_progress(percent, stage): """Emit structured progress to stderr for bridge.ts to capture.""" print(json.dumps({"progress": percent, "stage": stage}), file=sys.stderr, flush=True) def parse_page_spec(spec, total_pages): """Parse a page spec like 'all', '1-3,5', '2,4-6' into a sorted list of 0-based indices. Returns (pages, error) where error is a string if the spec is invalid. """ if spec.strip().lower() == "all": return list(range(total_pages)), None pages = set() parts = spec.split(",") for part in parts: part = part.strip() if not part: continue if "-" in part: bounds = part.split("-", 1) try: start = int(bounds[0].strip()) end = int(bounds[1].strip()) except ValueError: return None, f"Invalid page range: {part}" if start < 1 or end < 1: return None, f"Invalid page range: {part} (pages start at 1)" if start > end: return None, f"Invalid page range: {part} (start > end)" if end > total_pages: return None, f"Invalid page range: {part} (document has {total_pages} pages)" for i in range(start, end + 1): pages.add(i - 1) # 0-based else: try: num = int(part) except ValueError: return None, f"Invalid page number: {part}" if num < 1: return None, f"Invalid page number: {num} (pages start at 1)" if num > total_pages: return None, f"Invalid page number: {num} (document has {total_pages} pages)" pages.add(num - 1) # 0-based if not pages: return None, "No pages specified" return sorted(pages), None def main(): input_path = sys.argv[1] settings = json.loads(sys.argv[2]) if len(sys.argv) > 2 else {} quality = settings.get("quality", "balanced") language = settings.get("language", "auto") pages_spec = settings.get("pages", "all") try: emit_progress(2, "Opening PDF") # Lazy import: fitz (PyMuPDF) is in the base venv but keep it lazy # so py_compile succeeds without it. import fitz doc = fitz.open(input_path) total_pages = len(doc) if total_pages == 0: print(json.dumps({"error": "PDF has no pages"})) sys.exit(1) # Parse page specification page_indices, parse_error = parse_page_spec(pages_spec, total_pages) if parse_error: print(json.dumps({"error": parse_error})) sys.exit(1) if len(page_indices) > MAX_PAGES: print(json.dumps({"error": f"Too many pages for OCR (max {MAX_PAGES})"})) sys.exit(1) emit_progress(5, "Preparing") # Import engine functions from sibling ocr.py. # The dispatcher's scripts directory is on sys.path, so this import # loads ocr.py as a module (its __main__ guard prevents re-execution). import ocr as ocr_module # Language auto-detection on the first page's raster was_auto = language == "auto" # Create a temp directory for scratch PNGs next to the input scratch_dir = tempfile.mkdtemp( prefix="ocr_pdf_", dir=os.path.dirname(input_path) or tempfile.gettempdir(), ) page_texts = [] engine_used = quality num_pages = len(page_indices) for i, page_idx in enumerate(page_indices): page_num = page_idx + 1 # 1-based for display pct = 5 + int((i / num_pages) * 85) emit_progress(pct, f"Page {page_num}/{total_pages}") # Rasterize page to PNG at 200 DPI page = doc[page_idx] pix = page.get_pixmap(dpi=200) png_path = os.path.join(scratch_dir, f"page_{page_idx}.png") pix.save(png_path) # Auto-detect language on the first page only current_lang = language if was_auto and i == 0: current_lang = ocr_module.auto_detect_language(png_path) language = current_lang # reuse for remaining pages elif was_auto: current_lang = language # already detected # Always route PDF OCR through tesseract. PaddleOCR segfaults # on arm64 CPU when processing rasterised PDF pages (SIGSEGV in # the doc-orientation / structural-analysis stage). Tesseract is # reliable for page-level images and is already installed in the # container with multi-language packs. The image-OCR tool still # offers PaddleOCR tiers for single images where it is stable. if quality not in ("fast", "balanced", "best"): print(json.dumps({"error": f"Unknown quality: {quality}"})) sys.exit(1) text = ocr_module.run_tesseract(png_path, current_lang, is_auto=was_auto) engine_used = "tesseract" page_texts.append((page_num, text)) # Clean up scratch PNG immediately try: os.remove(png_path) except OSError: pass # Clean up scratch directory try: os.rmdir(scratch_dir) except OSError: pass doc.close() # Join page texts with page headers parts = [] for page_num, text in page_texts: parts.append(f"\n\n--- Page {page_num} ---\n\n{text}") # Strip leading newlines from the first page full_text = "".join(parts).strip() emit_progress(95, "Done") print(json.dumps({ "success": True, "engine": engine_used, "pages": num_pages, "text": full_text, })) except Exception as e: print(json.dumps({"error": str(e)})) sys.exit(1) if __name__ == "__main__": main()