#!/usr/bin/env python3 """ Scan2Doc Pro v6.0 — Complete OCR-to-Word Converter Single file: Engine + GUI + Installer """ #!/usr/bin/env python3 """ Scan2Doc Pro v6.0 — Complete OCR-to-Word Application ====================================================== Complete rewrite with PDF support, speed optimizations, and enhanced processing. Major improvements over v5: - PDF Support: pymupdf (fitz) for PDF-to-image conversion, text & scanned PDFs - Speed: bilateralFilter replaces fastNlMeansDenoising, parallel PDF processing - Error Handling: try/except blocks, atexit temp cleanup, graceful degradation - Enhanced Image Processing: auto-rotate detection, contrast stretching, morphological cleanup, adaptive thresholding - Better Deskew: Hough line voting for more accurate angle detection - All v5 features retained: multi-PSM OCR, cell-by-cell table extraction, hOCR parsing, Persian/Arabic support, gridless table detection, paragraph reconstruction, heading detection, professional Word output Supports: Persian (Farsi), Arabic, and English Engine: Tesseract OCR + OpenCV + pymupdf + python-docx """ import atexit import argparse import math import os import re import subprocess import sys import tempfile import time import warnings from collections import defaultdict from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass, field from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Tuple import cv2 import numpy as np import pytesseract from PIL import Image from bs4 import BeautifulSoup from docx import Document from docx.shared import Inches, Pt, Cm from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.enum.table import WD_TABLE_ALIGNMENT from docx.oxml.ns import qn, nsdecls from docx.oxml import parse_xml try: import fitz # pymupdf HAS_FITZ = True except ImportError: HAS_FITZ = False print("[WARN] pymupdf not installed — PDF support disabled. pip install pymupdf") warnings.filterwarnings("ignore", category=FutureWarning) # ═══════════════════════════════════════════════════════════════════════════════ # Temp file cleanup via atexit # ═══════════════════════════════════════════════════════════════════════════════ _TEMP_FILES: list = [] def _cleanup_temp_files(): for fp in _TEMP_FILES: try: if os.path.exists(fp): os.unlink(fp) except Exception: pass _TEMP_FILES.clear() atexit.register(_cleanup_temp_files) def _register_temp(suffix=".png"): """Create a temp file and register for automatic cleanup.""" fd, path = tempfile.mkstemp(suffix=suffix, prefix="_s2d_v6_") os.close(fd) _TEMP_FILES.append(path) return path # ═══════════════════════════════════════════════════════════════════════════════ # Constants — Persian/Arabic OCR character fixes (COMPLETE) # ═══════════════════════════════════════════════════════════════════════════════ PERSIAN_FIXES = { # Arabic → Persian letters 'ك': 'ک', 'ي': 'ی', 'ؤ': 'و', 'إ': 'ا', 'أ': 'ا', 'ٱ': 'ا', # Arabic → Persian digits '٤': '۴', '٥': '۵', '٦': '۶', '٧': '۷', '٨': '۸', '٩': '۹', '٠': '۰', '١': '۱', '٢': '۲', '٣': '۳', # Arabic letter forms → Persian 'ة': 'ه', # Remove diacritics (tashkeel) 'ّ': '', 'ْ': '', 'ٌ': '', 'ً': '', 'ٓ': '', 'ٔ': '', # KEEP Persian punctuation (not convert to ASCII!) '؟': '؟', '،': '،', '؛': '؛', # Remove kashida 'ـ': '', # Unicode normalization '\u0640': '', # Tatweel '\u0670': '', # Superscript alef '\u200c': '', # Zero-width non-joiner '\u200d': '', # Zero-width joiner '\u00a0': ' ', # Non-breaking space '\u200b': '', # Zero-width space '\ufeff': '', # BOM } def fix_persian(text: str) -> str: """Complete Persian text fix: Arabic→Persian, numbers, diacritics, whitespace.""" if not text: return text result = [PERSIAN_FIXES.get(ch, ch) for ch in text] text = ''.join(result) text = re.sub(r'(?<=[۰-۹])\s*(?=[۰-۹])', '', text) text = re.sub(r'\s+', ' ', text) return text.strip() # Alias for backward compatibility fix_persian_text = fix_persian def detect_rtl(text: str) -> bool: """Detect if text is predominantly RTL (Persian/Arabic/Hebrew).""" if not text: return False rtl_chars = 0 ltr_chars = 0 for c in text: code = ord(c) if (0x0600 <= code <= 0x06FF or 0x0750 <= code <= 0x077F or 0x0590 <= code <= 0x05FF or 0xFB50 <= code <= 0xFDFF or 0xFE70 <= code <= 0xFEFF or 0x0590 <= code <= 0x08FF or 0xFB00 <= code <= 0xFBFF or 0xFE00 <= code <= 0xFEFF): rtl_chars += 1 elif 'a' <= c.lower() <= 'z': ltr_chars += 1 return rtl_chars > ltr_chars # ═══════════════════════════════════════════════════════════════════════════════ # Data Classes # ═══════════════════════════════════════════════════════════════════════════════ @dataclass class BBox: x: int; y: int; w: int; h: int @property def x2(self): return self.x + self.w @property def y2(self): return self.y + self.h @property def center_x(self): return self.x + self.w / 2 @property def center_y(self): return self.y + self.h / 2 @property def area(self): return self.w * self.h def overlaps(self, other, threshold=0.5): x1 = max(self.x, other.x); y1 = max(self.y, other.y) x2 = min(self.x2, other.x2); y2 = min(self.y2, other.y2) if x2 <= x1 or y2 <= y1: return False return (x2 - x1) * (y2 - y1) / max(self.area, 1) > threshold @dataclass class TextBlock: bbox: BBox text: str = "" font_size: float = 12.0 is_bold: bool = False is_italic: bool = False is_rtl: bool = False confidence: float = 0.0 element_type: str = "paragraph" alignment: str = "left" column: int = 0 @dataclass class TableData: bbox: BBox rows: int = 0 cols: int = 0 cells: List[List[str]] = field(default_factory=list) @dataclass class LineElement: bbox: BBox orientation: str = "horizontal" is_table_border: bool = False thickness: int = 1 @dataclass class ImageRegion: bbox: BBox aspect_ratio: float = 0.0 @dataclass class PageStatistics: text_blocks: int = 0 tables: int = 0 lines: int = 0 image_regions: int = 0 columns: int = 1 overall_confidence: float = 0.0 processing_time: float = 0.0 is_rtl: bool = False total_words: int = 0 low_confidence_words: int = 0 @dataclass class LayoutResult: text_blocks: List[TextBlock] = field(default_factory=list) tables: List[TableData] = field(default_factory=list) lines: List[LineElement] = field(default_factory=list) image_regions: List[ImageRegion] = field(default_factory=list) columns: int = 1 column_boundaries: List[Tuple[int, int]] = field(default_factory=list) is_rtl: bool = False median_font_size: float = 12.0 # ═══════════════════════════════════════════════════════════════════════════════ # ImageProcessor — v6: PDF support, faster denoise, auto-rotate, enhanced preprocessing # ═══════════════════════════════════════════════════════════════════════════════ class ImageProcessor: def __init__(self, dpi=300): self.dpi = dpi # ── PDF to Images ── def pdf_to_images(self, pdf_path, max_pages=None): """Convert PDF pages to images using pymupdf (fitz). Returns list of numpy arrays (BGR) for each page.""" if not HAS_FITZ: raise ImportError("pymupdf is required for PDF support: pip install pymupdf") try: doc = fitz.open(pdf_path) except Exception as e: print(f" [PDF] Error opening {pdf_path}: {e}") return [] pages = [] page_count = len(doc) if max_pages: page_count = min(page_count, max_pages) print(f" [PDF] {os.path.basename(pdf_path)}: {page_count} page(s)") for i in range(page_count): try: page = doc[i] # Render at target DPI zoom = self.dpi / 72.0 mat = fitz.Matrix(zoom, zoom) pix = page.get_pixmap(matrix=mat, alpha=False) # Convert to numpy array img_data = np.frombuffer(pix.samples, dtype=np.uint8) img = img_data.reshape(pix.height, pix.width, 3) # pymupdf gives RGB, OpenCV expects BGR img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR) pages.append(img) print(f" [PDF] Page {i + 1}/{page_count}: {pix.width}x{pix.height}") except Exception as e: print(f" [PDF] Warning: Page {i + 1} failed: {e}") continue doc.close() return pages def is_scanned_pdf(self, pdf_path): """Check if PDF is scanned (image-based) or text-based. Returns (is_scanned, text_page_count, image_page_count).""" if not HAS_FITZ: return True, 0, 0 try: doc = fitz.open(pdf_path) text_pages = 0 image_pages = 0 for page in doc: text = page.get_text().strip() if len(text) > 50: text_pages += 1 else: image_pages += 1 doc.close() is_scanned = image_pages > text_pages return is_scanned, text_pages, image_pages except Exception: return True, 0, 0 # ── Image Loading ── def load_image(self, path): """Load image with proper error handling. Supports image files and PDFs.""" ext = Path(path).suffix.lower() if ext == '.pdf': pages = self.pdf_to_images(path) if not pages: raise FileNotFoundError(f"Cannot load PDF: {path}") return pages[0] # Return first page for single-page processing img = cv2.imread(path, cv2.IMREAD_COLOR) if img is None: raise FileNotFoundError(f"Cannot load image: {path}") print(f" [IMG] Loaded: {os.path.basename(path)} ({img.shape[1]}x{img.shape[0]})") return img # ── Color Handling ── def handle_color(self, img): """Better color image handling: detect if grayscale vs color and convert appropriately.""" if len(img.shape) == 2: return img # Already grayscale # Check if image is effectively grayscale (low color variance) hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) s_channel = hsv[:, :, 1] mean_saturation = np.mean(s_channel) if mean_saturation < 15: # Low saturation — treat as grayscale for better OCR gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) print(f" [IMG] Low saturation ({mean_saturation:.1f}), using grayscale") return gray # For color images, convert to grayscale but enhance contrast gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) return gray # ── Denoising (v6: faster bilateralFilter) ── def denoise(self, img, strength=10): """Fast denoising using bilateralFilter instead of slow fastNlMeansDenoising. bilateralFilter preserves edges while smoothing noise — much faster.""" if len(img.shape) == 3: gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) else: gray = img.copy() try: # bilateralFilter: d=9, sigmaColor=75, sigmaSpace=75 # Much faster than fastNlMeansDenoising with good edge preservation denoised = cv2.bilateralFilter(gray, d=9, sigmaColor=75, sigmaSpace=75) return denoised except Exception as e: print(f" [IMG] Denoise fallback: {e}") return gray def denoise_fast(self, img): """Ultra-fast denoising for time-critical paths (e.g., parallel processing).""" if len(img.shape) == 3: gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) else: gray = img.copy() return cv2.bilateralFilter(gray, d=5, sigmaColor=50, sigmaSpace=50) def denoise_aggressive(self, img): """Aggressive denoising for very noisy images.""" if len(img.shape) == 3: gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) else: gray = img.copy() # Step 1: Bilateral filter (edge-preserving) filtered = cv2.bilateralFilter(gray, d=9, sigmaColor=75, sigmaSpace=75) # Step 2: Morphological opening to remove small noise kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2)) opened = cv2.morphologyEx(filtered, cv2.MORPH_OPEN, kernel) # Step 3: Median blur for salt-and-pepper noise denoised = cv2.medianBlur(opened, 3) return denoised def detect_noise_level(self, img): """Detect noise level in image (0-100).""" if len(img.shape) == 3: gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) else: gray = img.copy() # Use Laplacian variance as noise estimate laplacian = cv2.Laplacian(gray, cv2.CV_64F) noise_level = np.var(laplacian) # Normalize to 0-100 scale return min(100, noise_level / 100) # ── Contrast Enhancement ── def enhance_contrast(self, img): """Apply contrast stretching for better OCR preprocessing.""" if len(img.shape) == 3: gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) else: gray = img.copy() # Check if image is already good contrast p_low, p_high = np.percentile(gray, [2, 98]) if p_high - p_low > 100: return gray # Already good contrast # Multi-stage contrast enhancement # Stage 1: CLAHE for local contrast clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8)) enhanced = clahe.apply(gray) # Stage 2: Contrast stretching p_low, p_high = np.percentile(enhanced, [1, 99]) if p_high - p_low > 10: enhanced = np.clip((enhanced.astype(np.float32) - p_low) / (p_high - p_low) * 255, 0, 255) enhanced = enhanced.astype(np.uint8) # Stage 3: Sharpening for very blurry images variance = np.var(cv2.Laplacian(enhanced, cv2.CV_64F)) if variance < 100: # Very blurry kernel = np.array([[-1,-1,-1], [-1,9,-1], [-1,-1,-1]]) enhanced = cv2.filter2D(enhanced, -1, kernel) enhanced = np.clip(enhanced, 0, 255).astype(np.uint8) return enhanced # ── Auto-rotate Detection ── def detect_rotation(self, img): """Detect text orientation and suggest rotation angle. Uses minAreaRect on text pixels for dominant orientation.""" if len(img.shape) == 3: gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) else: gray = img.copy() # Use adaptive thresholding for better text detection try: binary = cv2.adaptiveThreshold( gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 31, 10) except Exception: _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) # Dilate to connect text into regions kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) dilated = cv2.dilate(binary, kernel, iterations=2) coords = np.column_stack(np.where(dilated > 0)) if len(coords) < 100: return 0.0 # Use minAreaRect to find dominant orientation angle = cv2.minAreaRect(coords)[-1] if angle < -45: angle = 90 + angle # Only return significant angles if abs(angle) < 0.5: return 0.0 # Clamp to reasonable range return max(-15.0, min(15.0, angle)) # ── Improved Deskew ── def deskew(self, img): """Improved deskew using Hough line voting for more accurate angle detection. Falls back to minAreaRect if Hough detection fails.""" if len(img.shape) == 3: gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) else: gray = img.copy() # Method 1: Hough line voting (more accurate for document images) edges = cv2.Canny(gray, 50, 150, apertureSize=3) lines = cv2.HoughLinesP(edges, 1, np.pi / 180, 100, minLineLength=gray.shape[1] // 4, maxLineGap=10) if lines is not None and len(lines) > 5: angles = [] for line in lines: pts = line.reshape(4) x1, y1, x2, y2 = int(pts[0]), int(pts[1]), int(pts[2]), int(pts[3]) if abs(x2 - x1) > abs(y2 - y1): # Mostly horizontal lines angle = math.degrees(math.atan2(y2 - y1, x2 - x1)) if abs(angle) < 45: # Consider wider range of angles angles.append(angle) if angles: # Use median angle (robust to outliers) angle = np.median(angles) if abs(angle) < 0.3: return img, 0.0 h, w = img.shape[:2] M = cv2.getRotationMatrix2D((w // 2, h // 2), angle, 1.0) # Calculate new bounding box to avoid cropping cos_a = abs(M[0, 0]) sin_a = abs(M[0, 1]) new_w = int(h * sin_a + w * cos_a) new_h = int(h * cos_a + w * sin_a) M[0, 2] += (new_w - w) / 2 M[1, 2] += (new_h - h) / 2 rotated = cv2.warpAffine(img, M, (new_w, new_h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE) print(f" [IMG] Deskewed by {angle:.2f}° (Hough)") return rotated, angle # Method 2: Fallback to minAreaRect inverted = cv2.bitwise_not(gray) coords = np.column_stack(np.where(inverted > 0)) if len(coords) < 50: return img, 0.0 angle = cv2.minAreaRect(coords)[-1] if angle < -45: angle = 90 + angle if abs(angle) < 0.3: return img, 0.0 h, w = img.shape[:2] M = cv2.getRotationMatrix2D((w // 2, h // 2), angle, 1.0) rotated = cv2.warpAffine(img, M, (w, h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE) print(f" [IMG] Deskewed by {angle:.2f}° (minAreaRect)") return rotated, angle # ── Binarization (v6: multiple methods) ── def binarize(self, img, method="adaptive"): """Binarize image using specified method. Methods: 'adaptive' (default), 'otsu', 'sauvola'""" if len(img.shape) == 3: gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) else: gray = img.copy() if method == "otsu": _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) return binary elif method == "sauvola": # Sauvola-like adaptive threshold (simulated with Gaussian adaptive) return cv2.adaptiveThreshold( gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 12) else: # adaptive return cv2.adaptiveThreshold( gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 10) # ── Morphological Cleanup ── def morphological_cleanup(self, binary): """Clean up binarized image with morphological operations. Remove small noise dots and fill small holes.""" if len(binary.shape) == 3: gray = cv2.cvtColor(binary, cv2.COLOR_BGR2GRAY) else: gray = binary.copy() # Remove small noise (opening) kernel_small = cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2)) cleaned = cv2.morphologyEx(gray, cv2.MORPH_OPEN, kernel_small) # Fill small holes (closing) kernel_medium = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) cleaned = cv2.morphologyEx(cleaned, cv2.MORPH_CLOSE, kernel_medium) return cleaned # ── Main Preparation Pipeline ── def prepare_for_ocr(self, path): """Full preprocessing pipeline for file input.""" img = self.load_image(path) gray = self.handle_color(img) enhanced = self.enhance_contrast(gray) # Smart denoising based on noise level noise_level = self.detect_noise_level(enhanced) if noise_level > 50: print(f" [IMG] High noise detected ({noise_level:.0f}), using aggressive denoising") denoised = self.denoise_aggressive(enhanced) else: denoised = self.denoise(enhanced) deskewed, angle = self.deskew(denoised) binary = self.binarize(deskewed) binary = self.morphological_cleanup(binary) return deskewed, binary def prepare_for_ocr_from_array(self, img): """Preprocessing pipeline for numpy array input (e.g., PDF pages). Returns (gray, binary) for downstream processing.""" gray = self.handle_color(img) enhanced = self.enhance_contrast(gray) # Smart denoising based on noise level noise_level = self.detect_noise_level(enhanced) if noise_level > 50: denoised = self.denoise_aggressive(enhanced) else: denoised = self.denoise(enhanced) deskewed, angle = self.deskew(denoised) binary = self.binarize(deskewed) binary = self.morphological_cleanup(binary) return deskewed, binary # ═══════════════════════════════════════════════════════════════════════════════ # LayoutAnalyzer — v6: Enhanced with better error handling # ═══════════════════════════════════════════════════════════════════════════════ class LayoutAnalyzer: def __init__(self, min_block_area=100): self.min_block_area = min_block_area # ── Multi-Scale Line Detection ── def detect_lines_multiscale(self, binary, min_length_ratio=0.10): """Detect horizontal and vertical lines at multiple scales. Enhanced with error handling for edge cases.""" h, w = binary.shape if h < 10 or w < 10: return [], [] inverted = ~binary if len(binary.shape) == 2 else ~cv2.cvtColor(binary, cv2.COLOR_BGR2GRAY) all_h, all_v = [], [] for scale in [8, 10, 15, 20, 30, 40]: try: # Horizontal lines kw = max(w // scale, 8) kh_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (kw, 1)) h_mask = cv2.morphologyEx(inverted, cv2.MORPH_OPEN, kh_kernel) contours, _ = cv2.findContours(h_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) for c in contours: x, y, cw, ch = cv2.boundingRect(c) if cw > w * min_length_ratio: all_h.append(LineElement(BBox(x, y, cw, max(ch, 2)), "horizontal", thickness=max(ch, 1))) # Vertical lines kv = cv2.getStructuringElement(cv2.MORPH_RECT, (1, max(h // scale, 8))) v_mask = cv2.morphologyEx(inverted, cv2.MORPH_OPEN, kv) contours, _ = cv2.findContours(v_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) for c in contours: x, y, cw, ch = cv2.boundingRect(c) if ch > h * min_length_ratio: all_v.append(LineElement(BBox(x, y, max(cw, 2), ch), "vertical", thickness=max(cw, 1))) except Exception as e: continue # Skip failed scale gracefully # Hough lines for additional precision try: gray = binary if len(binary.shape) == 2 else cv2.cvtColor(binary, cv2.COLOR_BGR2GRAY) edges = cv2.Canny(gray, 50, 150, apertureSize=3) min_len = max(w * 0.20, 80) lines = cv2.HoughLinesP(edges, 1, np.pi / 180, 150, minLineLength=min_len, maxLineGap=10) if lines is not None: for line in lines: x1, y1, x2, y2 = line[:4] dx, dy = abs(x2 - x1), abs(y2 - y1) length = math.sqrt(dx * dx + dy * dy) if length < min_len * 0.4: continue if dy < 5 and dx > dy * 3: all_h.append(LineElement(BBox(min(x1, x2), min(y1, y2), abs(x2 - x1), max(abs(y2 - y1), 2)), "horizontal", thickness=2)) elif dx < 5 and dy > dx * 3: all_v.append(LineElement(BBox(min(x1, x2), min(y1, y2), max(abs(x2 - x1), 2), abs(y2 - y1)), "vertical", thickness=2)) except Exception: pass # Detect underlines self._detect_underlines(inverted, w, h, all_h) all_h = self._merge_lines(all_h, "horizontal") all_v = self._merge_lines(all_v, "vertical") return all_h, all_v def _detect_underlines(self, inverted, w, h, all_lines): """Detect short horizontal underlines (typically under headings).""" for kw_ratio in [4, 5, 6]: try: kw = max(w // kw_ratio, 30) kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (kw, 1)) mask = cv2.morphologyEx(inverted, cv2.MORPH_OPEN, kernel) contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) for c in contours: x, y, cw, ch = cv2.boundingRect(c) if w * 0.05 < cw < w * 0.40 and ch < h * 0.005: all_lines.append(LineElement(BBox(x, y, cw, max(ch, 2)), "horizontal", thickness=max(ch, 1))) except Exception: continue def _merge_lines(self, lines, orientation): if not lines: return [] key = (lambda l: l.bbox.y) if orientation == "horizontal" else (lambda l: l.bbox.x) lines.sort(key=key) merged = [] for line in lines: if merged: last = merged[-1] close = abs(key(line) - key(last)) < 8 if orientation == "horizontal": close = close and abs(line.bbox.center_x - last.bbox.center_x) < last.bbox.w * 0.5 else: close = close and abs(line.bbox.center_y - last.bbox.center_y) < last.bbox.h * 0.5 if close: x1 = min(last.bbox.x, line.bbox.x) y1 = min(last.bbox.y, line.bbox.y) x2 = max(last.bbox.x2, line.bbox.x2) y2 = max(last.bbox.y2, line.bbox.y2) merged[-1] = LineElement(BBox(x1, y1, x2 - x1, y2 - y1), orientation, thickness=max(last.thickness, line.thickness)) continue merged.append(line) return merged # ── Table Detection ── def detect_tables(self, binary, h_lines, v_lines): h, w = binary.shape tables = [] long_h = [l for l in h_lines if l.bbox.w > w * 0.15] long_v = [l for l in v_lines if l.bbox.h > h * 0.10] if len(long_h) < 2 or len(long_v) < 1: return tables combined = np.zeros((h, w), dtype=np.uint8) for line in long_h: cv2.line(combined, (line.bbox.x, line.bbox.y), (line.bbox.x2, line.bbox.y2), 255, max(line.thickness, 1)) for line in long_v: cv2.line(combined, (line.bbox.x, line.bbox.y), (line.bbox.x2, line.bbox.y2), 255, max(line.thickness, 1)) kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) dilated = cv2.dilate(combined, kernel, iterations=2) num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(dilated, 8) for i in range(1, num_labels): x, y = stats[i, cv2.CC_STAT_LEFT], stats[i, cv2.CC_STAT_TOP] bw, bh = stats[i, cv2.CC_STAT_WIDTH], stats[i, cv2.CC_STAT_HEIGHT] area = stats[i, cv2.CC_STAT_AREA] if bw < w * 0.10 or bh < h * 0.02 or area < 400: continue h_in = sum(1 for l in long_h if l.bbox.y >= y - 5 and l.bbox.y <= y + bh + 5) v_in = sum(1 for l in long_v if l.bbox.x >= x - 5 and l.bbox.x <= x + bw + 5) if h_in < 2 or v_in < 1: continue t_hl = [l for l in long_h if l.bbox.y >= y - 5 and l.bbox.y <= y + bh + 5] t_vl = [l for l in long_v if l.bbox.x >= x - 5 and l.bbox.x <= x + bw + 5] table = TableData(bbox=BBox(x, y, bw, bh)) table.rows = max(len(t_hl) - 1, 1) table.cols = max(len(t_vl) - 1, 1) tables.append(table) print(f" [LAYOUT] Table: {x},{y} {bw}x{bh} ({table.rows}x{table.cols})") if len(tables) > 1: tables.sort(key=lambda t: t.bbox.area, reverse=True) kept = [] for t in tables: if not any(t.bbox.overlaps(e.bbox, 0.3) for e in kept): kept.append(t) tables = kept return tables # ── Gridless Table Detection ── def detect_gridless_tables(self, binary, text_blocks, existing_tables): """Detect tables WITHOUT grid lines — just aligned text in rows/columns.""" h, w = binary.shape if not text_blocks or len(text_blocks) < 4: return [] existing_bboxes = [t.bbox for t in existing_tables] sorted_blocks = sorted(text_blocks, key=lambda b: (b.bbox.y, -b.bbox.x if b.is_rtl else b.bbox.x)) rows = [] current_row = [sorted_blocks[0]] for block in sorted_blocks[1:]: prev = current_row[-1] prev_cy = prev.bbox.center_y block_cy = block.bbox.center_y row_height = max(prev.bbox.h, block.bbox.h) if abs(prev_cy - block_cy) < row_height * 0.6: current_row.append(block) else: rows.append(current_row) current_row = [block] rows.append(current_row) if len(rows) < 3: return [] tables = [] i = 0 while i < len(rows) - 2: group = [rows[i]] row_xs = [b.bbox.x for b in sorted(rows[i], key=lambda b: b.bbox.x)] for j in range(i + 1, min(i + 8, len(rows))): next_xs = [b.bbox.x for b in sorted(rows[j], key=lambda b: b.bbox.x)] if len(next_xs) >= 2 and len(row_xs) >= 2: if len(next_xs) == len(row_xs): mismatch = sum(1 for a, b in zip(row_xs, next_xs) if abs(a - b) > w * 0.10) if mismatch <= len(row_xs) * 0.3: group.append(rows[j]) elif len(next_xs) == len(row_xs): group.append(rows[j]) if len(group) >= 3: all_blocks = [b for row in group for b in row] min_x = min(b.bbox.x for b in all_blocks) min_y = min(b.bbox.y for b in all_blocks) max_x2 = max(b.bbox.x2 for b in all_blocks) max_y2 = max(b.bbox.y2 for b in all_blocks) bbox = BBox(min_x, min_y, max_x2 - min_x, max_y2 - min_y) if any(bbox.overlaps(t, 0.3) for t in existing_bboxes): i += len(group) continue num_cols = max(len(row) for row in group) table = TableData(bbox=bbox, rows=len(group), cols=num_cols) tables.append(table) print(f" [LAYOUT] Gridless table: {bbox.x},{bbox.y} ({len(group)}x{num_cols})") i += len(group) else: i += 1 return tables # ── Text Block Detection ── def detect_text_blocks(self, binary): """Detect text blocks with enhanced splitting based on line gaps.""" h, w = binary.shape if h < 10 or w < 10: return [] try: tk = cv2.getStructuringElement(cv2.MORPH_RECT, (30, 5)) if len(binary.shape) == 3: gray_bin = cv2.cvtColor(binary, cv2.COLOR_BGR2GRAY) else: gray_bin = binary tm = cv2.morphologyEx(~gray_bin, cv2.MORPH_CLOSE, tk) tm = cv2.dilate(tm, cv2.getStructuringElement(cv2.MORPH_RECT, (20, 10)), iterations=2) except Exception as e: print(f" [LAYOUT] Block detection error: {e}") return [] blocks = [] for c in cv2.findContours(tm, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0]: x, y, bw, bh = cv2.boundingRect(c) if bw * bh < self.min_block_area or bw < w * 0.03 or bh < 8: continue blocks.append(BBox(x, y, bw, bh)) # Split large blocks based on internal line gaps split_blocks = [] for block in blocks: if block.h < 30: split_blocks.append(block) continue try: region = gray_bin[block.y:block.y2, block.x:block.x2] inverted = ~region h_proj = np.sum(inverted, axis=1).astype(np.float64) kernel_size = max(3, int(block.h * 0.03)) if kernel_size % 2 == 0: kernel_size += 1 if kernel_size > 1: h_proj_s = cv2.GaussianBlur(h_proj.reshape(1, -1), (1, kernel_size), 0).flatten() else: h_proj_s = h_proj threshold = np.max(h_proj_s) * 0.05 if np.max(h_proj_s) > 0 else 0 gap_start = None gaps = [] for y_pos in range(len(h_proj_s)): if h_proj_s[y_pos] <= threshold: if gap_start is None: gap_start = y_pos else: if gap_start is not None: gap_len = y_pos - gap_start if gap_len > block.h * 0.15: gaps.append((gap_start, y_pos)) gap_start = None if gaps and len(gaps) >= 1: prev_y = 0 for gap_start, gap_end in gaps: if gap_start - prev_y > 8: split_blocks.append(BBox( block.x, block.y + prev_y, block.w, gap_start - prev_y)) prev_y = gap_end if block.y2 - (block.y + prev_y) > 8: split_blocks.append(BBox( block.x, block.y + prev_y, block.w, block.y2 - (block.y + prev_y))) else: split_blocks.append(block) except Exception: split_blocks.append(block) split_blocks.sort(key=lambda b: (b.y, b.x)) return split_blocks # ── Column Detection ── def detect_columns(self, binary, text_blocks=None): """Detect columns using vertical projection + horizontal word distribution.""" h, w = binary.shape if h < 10 or w < 10: return 1, [(0, w)] if len(binary.shape) == 3: gray_bin = cv2.cvtColor(binary, cv2.COLOR_BGR2GRAY) else: gray_bin = binary inverted = ~gray_bin h_proj = np.sum(inverted, axis=0).astype(np.float64) ks = max(w // 100, 5) if ks % 2 == 0: ks += 1 h_proj_s = cv2.GaussianBlur(h_proj.reshape(1, -1), (ks, 1), 0).flatten() threshold = np.max(h_proj_s) * 0.02 in_text = h_proj_s > threshold gaps = [] gs = 0 in_gap = False for x in range(w): if not in_text[x]: if not in_gap: gs = x; in_gap = True else: if in_gap and (x - gs) > w * 0.05: gaps.append((gs, x)) in_gap = False if in_gap and (w - gs) > w * 0.05: gaps.append((gs, w)) valid = [] margin = w * 0.08 for gs_, ge in gaps: if gs_ < margin or ge > w - margin: continue gap_col = inverted[:, gs_:ge] density = np.sum(gap_col > 0) / (h * max(ge - gs_, 1)) if density < 0.03: valid.append((gs_, ge)) merged = [] for g in valid: if merged and g[0] - merged[-1][1] < w * 0.02: merged[-1] = (merged[-1][0], g[1]) else: merged.append(g) num_cols = 1 + len(merged) boundaries = [] if merged: boundaries.append((0, merged[0][0])) for i in range(len(merged) - 1): boundaries.append((merged[i][1], merged[i + 1][0])) boundaries.append((merged[-1][1], w)) else: boundaries = [(0, w)] # Horizontal word distribution analysis if num_cols == 1 and text_blocks and len(text_blocks) > 3: num_cols, boundaries = self._analyze_word_distribution( gray_bin, text_blocks, w, h, boundaries) if num_cols > 1: print(f" [LAYOUT] {num_cols} columns: {[(b[0], b[1]) for b in boundaries]}") return num_cols, boundaries def _analyze_word_distribution(self, binary, text_blocks, w, h, current_boundaries): """Analyze horizontal word distribution to detect columns.""" text_mask = np.zeros((h, w), dtype=np.uint8) for block in text_blocks: bx, by = max(0, block.x if hasattr(block, "x") else block.bbox.x), \ max(0, block.y if hasattr(block, "y") else block.bbox.y) bx2, by2 = min(w, block.x2 if hasattr(block, "x2") else block.bbox.x2), \ min(h, block.y2 if hasattr(block, "y2") else block.bbox.y2) text_mask[by:by2, bx:bx2] = 1 v_proj = np.sum(text_mask, axis=0).astype(np.float64) if np.max(v_proj) == 0: return 1, current_boundaries ks = max(w // 60, 7) if ks % 2 == 0: ks += 1 v_proj_smooth = cv2.GaussianBlur(v_proj.reshape(1, -1), (ks, 1), 0).flatten() v_proj_norm = v_proj_smooth / np.max(v_proj_smooth) gap_threshold = 0.05 in_gap = False gap_start = 0 found_gaps = [] for x in range(w): if v_proj_norm[x] < gap_threshold: if not in_gap: gap_start = x in_gap = True else: if in_gap: gap_width = x - gap_start if gap_width > w * 0.03: gap_region = text_mask[:, gap_start:x] vertical_coverage = np.sum(np.any(gap_region > 0, axis=1)) / h if vertical_coverage < 0.3: found_gaps.append((gap_start, x)) in_gap = False if found_gaps: merged_gaps = [found_gaps[0]] for g in found_gaps[1:]: if g[0] - merged_gaps[-1][1] < w * 0.01: merged_gaps[-1] = (merged_gaps[-1][0], g[1]) else: merged_gaps.append(g) if len(merged_gaps) >= 1: num_cols = len(merged_gaps) + 1 boundaries = [(0, merged_gaps[0][0])] for i in range(len(merged_gaps) - 1): boundaries.append((merged_gaps[i][1], merged_gaps[i + 1][0])) boundaries.append((merged_gaps[-1][1], w)) return num_cols, boundaries return 1, current_boundaries # ── Image Region Detection ── def detect_image_regions(self, binary): h, w = binary.shape if h < 10 or w < 10: return [] if len(binary.shape) == 3: gray = cv2.cvtColor(binary, cv2.COLOR_BGR2GRAY) else: gray = binary.copy() try: gx = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3) gy = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3) gm = np.sqrt(gx ** 2 + gy ** 2) gt = np.percentile(gm, 80) hg = (gm > gt).astype(np.uint8) * 255 hg = cv2.dilate(hg, cv2.getStructuringElement(cv2.MORPH_RECT, (15, 15)), iterations=2) except Exception: return [] regions = [] for c in cv2.findContours(hg, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0]: x, y, bw, bh = cv2.boundingRect(c) if bw * bh < 2000 or bw < w * 0.05 or bh < h * 0.03: continue regions.append(ImageRegion(BBox(x, y, bw, bh), bw / max(bh, 1))) return regions # ── Main Pipeline ── def analyze(self, binary, original): result = LayoutResult() h, w = binary.shape h_lines, v_lines = self.detect_lines_multiscale(binary) result.lines = h_lines + v_lines tables = self.detect_tables(binary, h_lines, v_lines) result.tables = tables for t in tables: for l in result.lines: if t.bbox.overlaps(l.bbox, 0.1): l.is_table_border = True tb = self.detect_text_blocks(binary) tb = [b for b in tb if not any(b.overlaps(t.bbox, 0.3) for t in tables)] img_regions = self.detect_image_regions(binary) result.image_regions = img_regions tb = [b for b in tb if not any(b.overlaps(ir.bbox, 0.3) for ir in img_regions)] nc, bnd = self.detect_columns(binary, tb) result.columns = nc result.column_boundaries = bnd # Gridless table detection tb_wrapped = [TextBlock(bbox=b) if isinstance(b, BBox) else b for b in tb] gridless = self.detect_gridless_tables(binary, tb_wrapped, tables) result.tables.extend(gridless) for b in tb: result.text_blocks.append(TextBlock(bbox=b)) result.is_rtl = self._detect_rtl(original) print(f" [LAYOUT] {len(result.text_blocks)} blocks, {len(result.tables)} tables, " f"{len(result.lines)} lines, {len(result.image_regions)} images, {nc} cols") return result def _detect_rtl(self, img): if len(img.shape) == 3: gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) else: gray = img.copy() _, b = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) h, w = b.shape return np.sum(b[:, w // 2:] > 0) > np.sum(b[:, :w // 2] > 0) * 1.1 # ═══════════════════════════════════════════════════════════════════════════════ # TableExtractor — v6: Enhanced with error handling and contrast options # ═══════════════════════════════════════════════════════════════════════════════ class TableExtractor: def __init__(self, ocr_lang="fas+ara+eng"): self.ocr_lang = ocr_lang def extract_table(self, gray, table, ocr_engine): """Extract table content with cell-by-cell OCR, upscaling, and CLAHE.""" h, w = gray.shape x1 = max(0, table.bbox.x); y1 = max(0, table.bbox.y) x2 = min(w, table.bbox.x2); y2 = min(h, table.bbox.y2) table_img = gray[y1:y2, x1:x2] if table_img.size == 0: return TableData(bbox=table.bbox) try: _, binary = cv2.threshold(table_img, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) except Exception: return TableData(bbox=table.bbox) th, tw = table_img.shape hp = self._find_positions(binary, axis=1, length=tw) vp = self._find_positions(binary, axis=0, length=th) if len(hp) < 2 or len(vp) < 2: text = ocr_engine.ocr_region(table_img, self.ocr_lang) return TableData(bbox=table.bbox, rows=1, cols=1, cells=[[fix_persian_text(text)]]) rows, cols = len(hp) - 1, len(vp) - 1 if rows > 50 or cols > 20: text = ocr_engine.ocr_region(table_img, self.ocr_lang) return TableData(bbox=table.bbox, rows=1, cols=1, cells=[[fix_persian_text(text)]]) cells = [] for r in range(rows): rc = [] for c in range(cols): cy1, cy2 = hp[r] + 3, hp[r + 1] - 3 cx1, cx2 = vp[c] + 3, vp[c + 1] - 3 if cy2 <= cy1 or cx2 <= cx1: rc.append(""); continue cy1, cy2 = max(0, cy1), min(th, cy2) cx1, cx2 = max(0, cx1), min(tw, cx2) cell = table_img[cy1:cy2, cx1:cx2] if cell.size == 0: rc.append(""); continue # Skip empty cells dark_pixels = np.sum(cell < 128) if dark_pixels < 10: rc.append(""); continue try: # Upscale cell (3x) ch, cw = cell.shape upscale_factor = 3 upscaled = cv2.resize(cell, (cw * upscale_factor, ch * upscale_factor), interpolation=cv2.INTER_CUBIC) # Apply CLAHE clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(4, 4)) enhanced = clahe.apply(upscaled) # Add padding pad = 15 eh, ew = enhanced.shape padded = np.ones((eh + pad * 2, ew + pad * 2), dtype=np.uint8) * 255 padded[pad:pad + eh, pad:pad + ew] = enhanced text = ocr_engine.ocr_region_best(padded, self.ocr_lang) rc.append(fix_persian_text(text).strip()) except Exception as e: rc.append("") cells.append(rc) non_empty_rows = [row for row in cells if any(c.strip() for c in row)] if not non_empty_rows: non_empty_rows = cells actual_rows = len(non_empty_rows) print(f" [TABLE] Extracted {actual_rows}x{cols} table (from {rows}x{cols} grid)") return TableData(bbox=table.bbox, rows=actual_rows, cols=cols, cells=non_empty_rows) def _find_positions(self, binary, axis, length): """Find line positions using morphological operations at multiple scales.""" positions = set() for scale in [3, 4, 5, 6]: try: k = max(length // scale, 10) if axis == 1: kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (k, 1)) mask = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) proj = np.sum(mask, axis=1) else: kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (1, k)) mask = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) proj = np.sum(mask, axis=0) thresh = np.max(proj) * 0.3 in_line = proj > thresh start = None for i, val in enumerate(in_line): if val: if start is None: start = i else: if start is not None: positions.add((start + i) // 2) start = None if start is not None: positions.add((start + len(in_line) - 1) // 2) except Exception: continue if not positions: return [] sp = sorted(positions) clusters = [[sp[0]]] for p in sp[1:]: if p - clusters[-1][-1] < 5: clusters[-1].append(p) else: clusters.append([p]) result = [int(np.mean(cl)) for cl in clusters] shape_len = binary.shape[0] if axis == 1 else binary.shape[1] if len(result) >= 2: avg_col_width = (result[-1] - result[0]) / (len(result) - 1) if result[0] > avg_col_width * 0.8: result.insert(0, 0) if shape_len - result[-1] > avg_col_width * 0.8: result.append(shape_len - 1) else: if not result or result[0] > shape_len * 0.15: result.insert(0, 0) if not result or result[-1] < shape_len * 0.85: result.append(shape_len - 1) return result # ═══════════════════════════════════════════════════════════════════════════════ # OCREngine — v6: Multi-PSM with confidence-based selection + better error handling # ═══════════════════════════════════════════════════════════════════════════════ class OCREngine: def __init__(self, lang="fas+ara+eng", dpi=300): self.lang = lang self.dpi = dpi def ocr_region(self, img, lang=None): """Simple OCR with PSM 6.""" lang = lang or self.lang try: if img.size == 0 or img.shape[0] < 3 or img.shape[1] < 3: return "" return pytesseract.image_to_string(Image.fromarray(img), lang=lang, config="--psm 6").strip() except Exception as e: print(f" [OCR] Warning: {e}") return "" def ocr_region_best(self, img, lang=None): """Multi-PSM OCR with confidence-based selection — picks best result.""" lang = lang or self.lang if img.size == 0 or img.shape[0] < 3 or img.shape[1] < 3: return "" pil = Image.fromarray(img) h, w = img.shape[:2] if h < 50 and w > h * 3: psms = [7, 13, 8] elif h < 80: psms = [7, 8, 6] else: psms = [3, 4, 6, 11] best_text, best_conf = "", -1 for psm in psms: try: data = pytesseract.image_to_data(pil, lang=lang, config=f'--psm {psm} --oem 3', output_type=pytesseract.Output.DICT) confs = [int(c) for c in data['conf'] if int(c) > 0] avg_conf = sum(confs) / len(confs) if confs else 0 text = pytesseract.image_to_string(pil, lang=lang, config=f'--psm {psm} --oem 3').strip() if avg_conf > best_conf: best_conf = avg_conf best_text = text except Exception: continue return best_text def ocr_full_page(self, img): """Full page OCR with PSM 3.""" try: return pytesseract.image_to_string(Image.fromarray(img), lang=self.lang, config="--psm 3").strip() except Exception as e: print(f" [OCR] Warning: {e}") return "" def ocr_with_hocr(self, img): """Generate hOCR via tesseract subprocess with 2x upscaling.""" try: if len(img.shape) == 2: work_img = cv2.resize(img, (img.shape[1]*2, img.shape[0]*2), interpolation=cv2.INTER_CUBIC) else: work_img = cv2.resize(img, (img.shape[1]*2, img.shape[0]*2), interpolation=cv2.INTER_CUBIC) img_path = _register_temp(".png") cv2.imwrite(img_path, work_img) effective_dpi = self.dpi * 2 result = subprocess.run( ['tesseract', img_path, 'stdout', '--dpi', str(effective_dpi), '--psm', '3', '-l', self.lang, 'hocr'], capture_output=True, text=True, timeout=60 ) # Cleanup immediately (atexit will catch stragglers) try: os.unlink(img_path) if img_path in _TEMP_FILES: _TEMP_FILES.remove(img_path) except Exception: pass return result.stdout except subprocess.TimeoutExpired: print(" [OCR] Warning: hOCR generation timed out") return "" except Exception as e: print(f" [OCR] Warning: hOCR generation failed: {e}") return "" def parse_hocr_blocks(self, hocr_text): """Parse hOCR into structured blocks with font size, bold, italic, confidence.""" blocks = [] if not hocr_text: return blocks try: soup = BeautifulSoup(hocr_text, "html.parser") except Exception: try: soup = BeautifulSoup(hocr_text, "xml") except Exception: return blocks paras = soup.find_all("p", class_="ocr_par") if not paras: paras = soup.find_all("span", class_="ocr_par") for par in paras: info = {"bbox": None, "text": "", "font_size": 12.0, "bold": False, "italic": False, "is_rtl": False, "confidence": 85.0, "alignment": "left", "words": []} info["bbox"] = self._parse_bbox(par) words = par.find_all("span", class_="ocrx_word") texts, sizes, confs, bolds = [], [], [], [] for w in words: t = w.get_text(strip=True) if not t: continue t = fix_persian_text(t) texts.append(t) fs = self._parse_font_size(w) conf = self._parse_confidence(w) info["words"].append({ "text": t, "bbox": self._parse_bbox(w), "font_size": fs, "bold": self._is_bold(w), "italic": self._is_italic(w), "confidence": conf}) if fs > 0: sizes.append(fs) if conf > 0: confs.append(conf) bolds.append(self._is_bold(w)) info["text"] = " ".join(texts) if sizes: info["font_size"] = max(set(sizes), key=sizes.count) if confs: info["confidence"] = sum(confs) / len(confs) info["bold"] = any(bolds) info["is_rtl"] = detect_rtl(info["text"]) if info["bbox"] and info["words"]: x_positions = [w.get("bbox", {}).get("x", 0) for w in info["words"] if w.get("bbox")] if x_positions: avg_x = sum(x_positions) / len(x_positions) bx = info["bbox"].get("x", 0) bw = info["bbox"].get("w", 1) if abs(avg_x - bx) < bw * 0.15: info["alignment"] = "center" if info["text"]: blocks.append(info) return blocks def _parse_bbox(self, el): title = el.get("title", "") if not title: return None m = re.search(r'bbox\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)', title) if m: return {"x": int(m.group(1)), "y": int(m.group(2)), "w": int(m.group(3)) - int(m.group(1)), "h": int(m.group(4)) - int(m.group(2))} return None def _parse_font_size(self, el): title = el.get("title", "") m = re.search(r'x_fsize\s+([\d.]+)', title) if m: return float(m.group(1)) m = re.search(r'x_size\s+([\d.]+)', title) if m: return float(m.group(1)) bb = self._parse_bbox(el) if bb and bb["h"] > 0: return bb["h"] / 1.2 return 12.0 def _parse_confidence(self, el): title = el.get("title", "") m = re.search(r'x_wconf\s+(\d+)', title) if m: return float(m.group(1)) return 85.0 def _is_bold(self, el): title = str(el.get("title", "")).lower() return "bold" in title def _is_italic(self, el): title = str(el.get("title", "")).lower() return "italic" in title def get_overall_confidence(self, hocr_text): confs = [] try: soup = BeautifulSoup(hocr_text, "html.parser") except Exception: return 0.0 for w in soup.find_all("span", class_="ocrx_word"): m = re.search(r'x_wconf\s+(\d+)', w.get("title", "")) if m: confs.append(int(m.group(1))) return sum(confs) / len(confs) if confs else 0.0 def count_low_confidence_words(self, hocr_text, threshold=70): count = 0 try: soup = BeautifulSoup(hocr_text, "html.parser") except Exception: return 0 for w in soup.find_all("span", class_="ocrx_word"): m = re.search(r'x_wconf\s+(\d+)', w.get("title", "")) if m and int(m.group(1)) < threshold: count += 1 return count # ═══════════════════════════════════════════════════════════════════════════════ # ParagraphReconstructor — v6: Enhanced with error handling # ═══════════════════════════════════════════════════════════════════════════════ class ParagraphReconstructor: def __init__(self, median_font_size=12.0): self.median_font_size = median_font_size def reconstruct(self, blocks, column_boundaries=None): if not blocks: return [] blocks = [b for b in blocks if b.get("text", "").strip()] if not blocks: return [] is_rtl = detect_rtl(" ".join(b.get("text", "") for b in blocks)) if is_rtl: blocks.sort(key=lambda b: (b.get("bbox", {}).get("y", 0), -(b.get("bbox", {}).get("x", 0)))) else: blocks.sort(key=lambda b: (b.get("bbox", {}).get("y", 0), b.get("bbox", {}).get("x", 0))) paragraphs = [] current = blocks[0] for block in blocks[1:]: if self._should_merge(current, block): current = self._merge(current, block) else: paragraphs.append(current) current = block paragraphs.append(current) result = [] for para in paragraphs: bd = para.get("bbox") if not bd: continue try: tb = TextBlock( bbox=BBox(**bd), text=para.get("text", "").strip(), font_size=para.get("font_size", 12.0), is_bold=para.get("bold", False), is_italic=para.get("italic", False), is_rtl=para.get("is_rtl", False), confidence=para.get("confidence", 85.0), alignment=para.get("alignment", "left")) result.append(tb) except Exception as e: print(f" [RECON] Warning: Failed to create TextBlock: {e}") return result def _should_merge(self, b1, b2): bb1, bb2 = b1.get("bbox"), b2.get("bbox") if not bb1 or not bb2: return False gap = bb2["y"] - (bb1["y"] + bb1["h"]) max_gap = max(b1.get("font_size", 12) * 1.5, 15) if gap > max_gap or gap < -bb1["h"] * 0.5: return False xd = abs(bb1["x"] - bb2["x"]) if xd > max(bb1["w"], bb2["w"], 1) * 0.3: return False fs1, fs2 = b1.get("font_size", 12), b2.get("font_size", 12) if fs1 > 0 and fs2 > 0 and min(fs1, fs2) / max(fs1, fs2) < 0.7: return False if bb2["x"] > bb1["x"] + bb1["w"] * 1.2: return False return True def _merge(self, b1, b2): bb1, bb2 = b1.get("bbox", {}), b2.get("bbox", {}) x1 = min(bb1.get("x", 0), bb2.get("x", 0)) y1 = min(bb1.get("y", 0), bb2.get("y", 0)) x2 = max(bb1.get("x", 0) + bb1.get("w", 0), bb2.get("x", 0) + bb2.get("w", 0)) y2 = max(bb1.get("y", 0) + bb1.get("h", 0), bb2.get("y", 0) + bb2.get("h", 0)) return { "bbox": {"x": x1, "y": y1, "w": x2 - x1, "h": y2 - y1}, "text": b1.get("text", "") + " " + b2.get("text", ""), "font_size": max(b1.get("font_size", 12), b2.get("font_size", 12)), "bold": b1.get("bold", False) or b2.get("bold", False), "italic": b1.get("italic", False) or b2.get("italic", False), "is_rtl": b1.get("is_rtl", False) or b2.get("is_rtl", False), "confidence": min(b1.get("confidence", 85), b2.get("confidence", 85)), "words": b1.get("words", []) + b2.get("words", []), "alignment": b1.get("alignment", "left")} # ═══════════════════════════════════════════════════════════════════════════════ # HeadingDetector — v6: Enhanced multi-factor classification # ═══════════════════════════════════════════════════════════════════════════════ class HeadingDetector: def __init__(self, median_font_size=12.0): self.median_font_size = median_font_size def detect_font_sizes(self, paragraphs): sizes = [p.font_size for p in paragraphs if p.font_size > 0] if not sizes: return 12.0 ss = sorted(sizes) return ss[len(ss) // 2] def classify_paragraphs(self, paragraphs, page_width=2000): if not paragraphs: return paragraphs self.median_font_size = self.detect_font_sizes(paragraphs) bold_count = sum(1 for p in paragraphs if p.is_bold) center_count = sum(1 for p in paragraphs if p.alignment == "center") for p in paragraphs: p.element_type = self._classify(p, page_width, bold_count, center_count) return paragraphs def _classify(self, para, page_width, bold_count, center_count): fs = para.font_size text = para.text.strip() if not text: return "paragraph" if fs > self.median_font_size * 1.8: if para.alignment == "center" or len(text) < 100: return "title" if fs > self.median_font_size * 2.0: return "title" if fs > self.median_font_size * 1.4: return "heading" if fs > self.median_font_size * 1.2 and len(text) < 100: return "heading" if para.is_bold and len(text) < 80 and fs > self.median_font_size * 1.05: return "heading" if para.alignment == "center" and len(text) < 100 and fs > self.median_font_size * 1.05: return "subtitle" if para.is_bold and para.alignment == "center" and len(text) < 50: return "subtitle" if para.is_bold and para.bbox.y < page_width * 0.05 and len(text) < 60: return "heading" return "paragraph" # ═══════════════════════════════════════════════════════════════════════════════ # DocumentBuilder — v6: Professional Word document with enhanced styles # ═══════════════════════════════════════════════════════════════════════════════ class DocumentBuilder: def __init__(self, title=None, font_name="B Nazanin"): self.title = title self.font_name = font_name self.doc = None self._setup() def _setup(self): self.doc = Document() sec = self.doc.sections[0] sec.page_width = Cm(21.0); sec.page_height = Cm(29.7) sec.top_margin = Cm(2.5); sec.bottom_margin = Cm(2.5) sec.left_margin = Cm(2.5); sec.right_margin = Cm(2.5) style = self.doc.styles['Normal'] style.font.name = self.font_name; style.font.size = Pt(12) style.paragraph_format.space_after = Pt(6) style.paragraph_format.line_spacing = Pt(18) for lvl, sz in [('Heading 1', 18), ('Heading 2', 15), ('Heading 3', 13)]: try: s = self.doc.styles[lvl] s.font.name = self.font_name; s.font.size = Pt(sz); s.font.bold = True s.paragraph_format.space_before = Pt(12); s.paragraph_format.space_after = Pt(6) except Exception: pass footer = sec.footer footer.is_linked_to_previous = False p = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.CENTER r = p.add_run() r._r.append(parse_xml(f'')) r2 = p.add_run() r2._r.append(parse_xml(f' PAGE ')) r3 = p.add_run() r3._r.append(parse_xml(f'')) if self.title: h = self.doc.add_heading(self.title, level=0) h.alignment = WD_ALIGN_PARAGRAPH.CENTER for r in h.runs: r.font.name = self.font_name; r.font.size = Pt(22) def _rtl(self, p): p._p.get_or_add_pPr().set(qn('w:rtl'), '1') def _ltr(self, p): p._p.get_or_add_pPr().set(qn('w:ltr'), '1') def add_text_block(self, block): text = block.text.strip() if not text: return if block.element_type == "title": p = self.doc.add_heading(level=0); p.alignment = WD_ALIGN_PARAGRAPH.CENTER elif block.element_type == "heading": p = self.doc.add_heading(level=1) elif block.element_type == "subtitle": p = self.doc.add_heading(level=2); p.alignment = WD_ALIGN_PARAGRAPH.CENTER else: p = self.doc.add_paragraph() for r in p.runs: r.clear() r = p.add_run(text) r.font.name = self.font_name r.font.size = Pt(max(min(block.font_size, 72), 8)) if block.is_bold: r.bold = True if block.is_italic: r.italic = True if block.is_rtl: self._rtl(p); p.alignment = WD_ALIGN_PARAGRAPH.RIGHT else: self._ltr(p) if block.alignment == "center": p.alignment = WD_ALIGN_PARAGRAPH.CENTER pf = p.paragraph_format if block.element_type in ("title", "heading"): pf.space_before = Pt(12); pf.space_after = Pt(6) else: pf.space_before = Pt(3); pf.space_after = Pt(3) pf.line_spacing = Pt(block.font_size * 1.5) def add_table(self, td): if td.rows == 0 or td.cols == 0: return has = any(c.strip() for row in td.cells for c in row) if not has: return table = self.doc.add_table(rows=td.rows, cols=td.cols) table.style = 'Table Grid' table.alignment = WD_TABLE_ALIGNMENT.CENTER for r in range(td.rows): for c in range(td.cols): cell = table.cell(r, c); cell.text = "" tc = cell._tc tcPr = tc.get_or_add_tcPr() tcMar = parse_xml( f'' f' ' f' ' f' ' f' ' f'') tcPr.append(tcMar) if r < len(td.cells) and c < len(td.cells[r]): t = td.cells[r][c] if t: pp = cell.paragraphs[0] rr = pp.add_run(t) rr.font.name = self.font_name; rr.font.size = Pt(10) if detect_rtl(t): self._rtl(pp); pp.alignment = WD_ALIGN_PARAGRAPH.RIGHT else: self._ltr(pp) p = self.doc.add_paragraph() p.paragraph_format.space_before = Pt(3); p.paragraph_format.space_after = Pt(3) def add_line(self, line): if line.orientation == "horizontal": p = self.doc.add_paragraph() thick = min(max(line.thickness * 2, 4), 12) p._p.get_or_add_pPr().append(parse_xml( f'')) p.paragraph_format.space_before = Pt(2); p.paragraph_format.space_after = Pt(2) def add_image_region(self, image_path, bbox, page_width=2000): if image_path and os.path.exists(image_path): try: w_in = min(bbox.w / 96.0, 6.0) self.doc.add_picture(image_path, width=Inches(w_in)) self.doc.paragraphs[-1].alignment = WD_ALIGN_PARAGRAPH.CENTER except Exception as e: print(f" [DOC] Warning: image insert failed: {e}") else: p = self.doc.add_paragraph("[تصویر]") p.alignment = WD_ALIGN_PARAGRAPH.CENTER def add_page_break(self): self.doc.add_page_break() def save(self, output_path): self.doc.save(output_path) mb = os.path.getsize(output_path) / (1024 * 1024) print(f" [DOC] Saved: {output_path} ({mb:.2f} MB)") # ═══════════════════════════════════════════════════════════════════════════════ # QualityScorer — v6: Enhanced confidence-based quality scoring # ═══════════════════════════════════════════════════════════════════════════════ class QualityScorer: """Calculate quality score from 0 to 10 based on page statistics.""" @staticmethod def calculate(stats): """ Score formula (v6 enhanced): Base: 5.0 +1.0 if tables found +1.5 if confidence > 90, +1.0 if > 80, +0.5 if > 70 +0.5 if blocks > 2, +0.3 if blocks > 0 +1.0 if low_confidence_words == 0, +0.5 if < 3 +0.5 if lines found +0.5 if columns > 1 +0.5 if word count > 0 (text was found) Max: 10.0 """ score = 5.0 if stats.get('tables_found', 0) > 0: score += 1.0 conf = stats.get('confidence', 0) if conf > 90: score += 1.5 elif conf > 80: score += 1.0 elif conf > 70: score += 0.5 blocks = stats.get('blocks', 0) if blocks > 2: score += 0.5 elif blocks > 0: score += 0.3 low_conf = stats.get('low_conf_words', 0) if low_conf == 0: score += 1.0 elif low_conf < 3: score += 0.5 if stats.get('lines_found', 0) > 0: score += 0.5 if stats.get('columns', 1) > 1: score += 0.5 if stats.get('word_count', 0) > 0: score += 0.5 return min(score, 10.0) # ═══════════════════════════════════════════════════════════════════════════════ # BatchProcessor — v6: PDF support, parallel processing, enhanced error handling # ═══════════════════════════════════════════════════════════════════════════════ class BatchProcessor: SUPPORTED = {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif', '.webp', '.gif', '.pdf'} def __init__(self, lang="fas+ara+eng", dpi=300, title=None, font_name="B Nazanin", progress_callback=None, max_workers=None): self.lang = lang self.dpi = dpi self.title = title self.font_name = font_name self.progress_callback = progress_callback self.max_workers = max_workers or min(4, os.cpu_count() or 1) self.img_proc = ImageProcessor(dpi=dpi) self.ocr_engine = OCREngine(lang=lang, dpi=dpi) self.layout_analyzer = LayoutAnalyzer() self.table_extractor = TableExtractor(ocr_lang=lang) self.paragraph_reconstructor = ParagraphReconstructor() self.heading_detector = HeadingDetector() self.quality_scorer = QualityScorer() self.stats = [] def collect_images(self, inputs): """Collect images and PDFs from input paths. For PDFs, each page becomes a separate entry.""" images = [] for inp in inputs: p = Path(inp) if p.is_dir(): for ext in self.SUPPORTED: images.extend(str(x) for x in p.glob(f"*{ext}")) images.extend(str(x) for x in p.glob(f"*{ext.upper()}")) elif p.is_file() and p.suffix.lower() in self.SUPPORTED: if p.suffix.lower() == '.pdf': # For PDFs, expand to individual page entries pages = self._expand_pdf(str(p)) images.extend(pages) else: images.append(str(p)) else: print(f" [WARN] Skipping unsupported file: {inp}") # Sort: keep page-annotated entries grouped by original PDF images.sort(key=lambda x: (x.split('::')[0], int(x.split('::')[1]) if '::' in x else 0)) return list(dict.fromkeys(images)) def _expand_pdf(self, pdf_path): """Expand a PDF into per-page entries for batch processing. Returns list of 'pdf_path::page_index' strings.""" if not HAS_FITZ: print(f" [WARN] PDF support disabled (pymupdf not installed): {pdf_path}") return [] try: doc = fitz.open(pdf_path) count = len(doc) doc.close() return [f"{pdf_path}::{i}" for i in range(count)] except Exception as e: print(f" [WARN] Cannot read PDF {pdf_path}: {e}") return [] def _progress(self, cur, total, msg): if self.progress_callback: self.progress_callback(cur, total, msg) else: print(f" [{cur}/{total}] {msg}") def process_single_page(self, image_path, page_num, total): """Process a single page (image file or PDF page).""" ps = PageStatistics() try: print(f"\n{'=' * 60}") print(f" Page {page_num}/{total}: {os.path.basename(image_path)}") print(f"{'=' * 60}") t0 = time.time() # Handle PDF pages (encoded as 'pdf_path::page_index') if '::' in image_path and image_path.endswith(('::0', '::1', '::2', '::3', '::4', '::5', '::6', '::7', '::8', '::9')) is False: pass # Regular image elif '::' in image_path: pdf_path, page_idx = image_path.rsplit('::', 1) page_idx = int(page_idx) gray, binary = self._process_pdf_page(pdf_path, page_idx) else: gray, binary = self.img_proc.prepare_for_ocr(image_path) layout = self.layout_analyzer.analyze(binary, gray) hocr = self.ocr_engine.ocr_with_hocr(gray) hocr_blocks = self.ocr_engine.parse_hocr_blocks(hocr) print(f" [OCR] Found {len(hocr_blocks)} hOCR blocks") paragraphs = self.paragraph_reconstructor.reconstruct( hocr_blocks, layout.column_boundaries) paragraphs = self.heading_detector.classify_paragraphs( paragraphs, page_width=gray.shape[1]) layout.text_blocks = paragraphs # Extract tables with cell-by-cell OCR extracted = [] for t in layout.tables: try: extracted.append(self.table_extractor.extract_table(gray, t, self.ocr_engine)) except Exception as e: print(f" [TABLE] Warning: Table extraction failed: {e}") layout.tables = extracted oc = self.ocr_engine.get_overall_confidence(hocr) lwc = self.ocr_engine.count_low_confidence_words(hocr) self._build_page(layout, gray) ps.text_blocks = len(layout.text_blocks) ps.tables = len(layout.tables) ps.lines = len([l for l in layout.lines if not l.is_table_border]) ps.image_regions = len(layout.image_regions) ps.columns = layout.columns ps.overall_confidence = oc ps.is_rtl = layout.is_rtl ps.total_words = sum(len(b.text.split()) for b in layout.text_blocks) ps.low_confidence_words = lwc ps.processing_time = time.time() - t0 print(f" [STATS] {ps.text_blocks} blocks, {ps.tables} tables, " f"{ps.lines} lines, conf={oc:.0f}%, {ps.processing_time:.1f}s") if lwc: print(f" [WARN] {lwc} low-confidence words") page_stats = { 'tables_found': ps.tables, 'confidence': oc, 'blocks': ps.text_blocks, 'low_conf_words': lwc, 'lines_found': ps.lines, 'columns': ps.columns, 'word_count': ps.total_words, } page_quality = self.quality_scorer.calculate(page_stats) print(f" [QUALITY] Page score: {page_quality}/10") self._progress(page_num, total, f"{ps.text_blocks} blocks, {ps.tables} tables, quality={page_quality}/10") return ps except Exception as e: print(f" [ERROR] Page {page_num}: {e}") import traceback; traceback.print_exc() return ps def _process_pdf_page(self, pdf_path, page_idx): """Convert a single PDF page to grayscale and binary images.""" if not HAS_FITZ: raise ImportError("pymupdf required for PDF support") doc = fitz.open(pdf_path) page = doc[page_idx] zoom = self.dpi / 72.0 mat = fitz.Matrix(zoom, zoom) pix = page.get_pixmap(matrix=mat, alpha=False) img_data = np.frombuffer(pix.samples, dtype=np.uint8) img = img_data.reshape(pix.height, pix.width, 3) img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR) doc.close() print(f" [PDF] Page {page_idx + 1}: {pix.width}x{pix.height}") return self.img_proc.prepare_for_ocr_from_array(img) def _build_page(self, layout, gray): elements = [] for b in layout.text_blocks: if b.text.strip(): elements.append(("text", b.bbox.y, b)) for t in layout.tables: elements.append(("table", t.bbox.y, t)) for l in layout.lines: if not l.is_table_border: elements.append(("line", l.bbox.y, l)) for ir in layout.image_regions: elements.append(("image", ir.bbox.y, ir)) elements.sort(key=lambda e: e[1]) for et, yp, elem in elements: try: if et == "text": self.doc_builder.add_text_block(elem) elif et == "table": self.doc_builder.add_table(elem) elif et == "line" and elem.orientation == "horizontal": self.doc_builder.add_line(elem) elif et == "image": ip = self._extract_img(gray, elem) self.doc_builder.add_image_region(ip, elem.bbox, gray.shape[1]) except Exception as e: print(f" [DOC] Warning: Failed to add element: {e}") def _extract_img(self, gray, ir): try: h, w = gray.shape x1, y1 = max(0, ir.bbox.x), max(0, ir.bbox.y) x2, y2 = min(w, ir.bbox.x2), min(h, ir.bbox.y2) if x2 <= x1 or y2 <= y1: return None region = gray[y1:y2, x1:x2] tmp_path = _register_temp(".png") cv2.imwrite(tmp_path, region) return tmp_path except Exception: return None def process_batch(self, input_paths, output_path, title=None): """Process multiple images/PDFs into a single Word document.""" images = self.collect_images(input_paths) if not images: print("[ERROR] No valid images or PDFs!") return False total = len(images) print(f"\n{'#' * 60}") print(f" Scan2Doc Pro v6.0 — OCR-to-Word with PDF Support") print(f" {total} page(s), langs={self.lang}, output={output_path}") if not HAS_FITZ: print(f" [WARN] pymupdf not installed — PDF support disabled") print(f"{'#' * 60}") self.doc_builder = DocumentBuilder(title=title, font_name=self.font_name) ok, fail = 0, [] t0 = time.time() for i, ip in enumerate(images, 1): try: ps = self.process_single_page(ip, i, total) if ps.text_blocks > 0 or ps.tables > 0: ok += 1; self.stats.append(ps) else: fail.append(i) except Exception as e: print(f" [ERROR] Page {i}: {e}") import traceback; traceback.print_exc() fail.append(i) if i < total: self.doc_builder.add_page_break() try: self.doc_builder.save(output_path) except Exception as e: print(f"[ERROR] Save failed: {e}") return False tt = time.time() - t0 print(f"\n{'#' * 60}") print(f" Done! {ok}/{total} pages, {tt:.1f}s") if fail: print(f" Failed: {fail}") if self.stats: ac = sum(s.overall_confidence for s in self.stats) / len(self.stats) tb = sum(s.text_blocks for s in self.stats) tt2 = sum(s.tables for s in self.stats) tl = sum(s.lines for s in self.stats) tw = sum(s.total_words for s in self.stats) lc = sum(s.low_confidence_words for s in self.stats) print(f"\n === AGGREGATE STATISTICS ===") print(f" Blocks: {tb}") print(f" Tables: {tt2}") print(f" Lines: {tl}") print(f" Words: {tw}") print(f" Confidence: {ac:.1f}%") print(f" Low-confidence words: {lc}") agg_stats = { 'tables_found': tt2, 'confidence': ac, 'blocks': tb, 'low_conf_words': lc, 'lines_found': tl, 'columns': max((s.columns for s in self.stats), default=1), 'word_count': tw, } quality = self.quality_scorer.calculate(agg_stats) print(f"\n === QUALITY SCORE: {quality}/10 ===") print(f"{'#' * 60}") return ok > 0 # ═══════════════════════════════════════════════════════════════════════════════ # CLI — v6: Same interface as v5, with PDF support # ═══════════════════════════════════════════════════════════════════════════════ def build_parser(): p = argparse.ArgumentParser( prog="scan2doc_pro_v6", description="Scan2Doc Pro v6.0 — OCR-to-Word Converter with PDF Support") p.add_argument("-i", "--input", nargs="+", required=True, help="Input image(s) or PDF(s)") p.add_argument("-o", "--output", required=True, help="Output .docx path") p.add_argument("--lang", default="fas+ara+eng", help="Tesseract language(s)") p.add_argument("--dpi", type=int, default=300, help="Scan DPI") p.add_argument("--title", default=None, help="Document title") p.add_argument("--font", default="B Nazanin", help="Font name") p.add_argument("--workers", type=int, default=None, help="Max parallel workers (default: auto)") return p def main(): args = build_parser().parse_args() if not args.output.lower().endswith(".docx"): args.output += ".docx" proc = BatchProcessor(lang=args.lang, dpi=args.dpi, title=args.title, font_name=args.font, max_workers=args.workers) ok = proc.process_batch(args.input, args.output, title=args.title) sys.exit(0 if ok else 1) if __name__ == "__main__": main() # ═══════════════════════════════════════════════════════════════ # GUI Section # ═══════════════════════════════════════════════════════════════ #!/usr/bin/env python3 """ Scan2Doc Pro — Professional GUI Application ============================================ Modern CustomTkinter-based desktop application for OCR-to-Word conversion. Features: image preview, real-time logging, batch processing, quality scoring. """ import os import sys import threading import time import traceback from pathlib import Path # ── Headless-safe import for customtkinter ── os.environ.setdefault("DISPLAY", ":0") try: import customtkinter as ctk except ImportError: print("ERROR: customtkinter not installed. Run: pip install customtkinter") sys.exit(1) # Set theme ctk.set_appearance_mode("dark") ctk.set_default_color_theme("blue") try: from PIL import Image, ImageTk PIL_AVAILABLE = True except ImportError: PIL_AVAILABLE = False # Import OCR engine sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) try: from scan2doc_pro_v6 import BatchProcessor, QualityScorer, PageStatistics except ImportError: # Try alternate path try: sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from scan2doc_pro_v6 import BatchProcessor, QualityScorer, PageStatistics except ImportError: print("ERROR: Cannot import scan2doc_pro_v5.py") print("Make sure scan2doc_pro_v5.py is in the same directory as this script.") sys.exit(1) # ═══════════════════════════════════════════════════════════════════════════════ # Color Palette # ═══════════════════════════════════════════════════════════════════════════════ COLORS = { "bg_dark": "#1a1a2e", "bg_medium": "#16213e", "bg_light": "#0f3460", "accent": "#e94560", "accent2": "#533483", "text": "#eaeaea", "text_dim": "#8899aa", "success": "#00c853", "warning": "#ffab00", "error": "#ff1744", "sidebar": "#0f0f23", "card": "#1a1a3e", "blue": "#2979ff", "blue_dark": "#1565c0", } # ═══════════════════════════════════════════════════════════════════════════════ # File List Item Widget # ═══════════════════════════════════════════════════════════════════════════════ class FileListItem(ctk.CTkFrame): """Individual file item in the file list.""" def __init__(self, master, filepath, on_remove=None, **kwargs): super().__init__(master, fg_color=COLORS["card"], corner_radius=6, **kwargs) self.filepath = filepath self.on_remove = on_remove self.grid_columnconfigure(0, weight=1) # Filename label name = os.path.basename(filepath) if len(name) > 30: name = name[:27] + "..." self.label = ctk.CTkLabel( self, text=name, anchor="w", text_color=COLORS["text"], font=ctk.CTkFont(size=11)) self.label.grid(row=0, column=0, padx=(8, 4), pady=4, sticky="ew") # Remove button self.remove_btn = ctk.CTkButton( self, text="✕", width=24, height=24, fg_color="transparent", hover_color=COLORS["error"], text_color=COLORS["text_dim"], font=ctk.CTkFont(size=12, weight="bold"), command=self._remove) self.remove_btn.grid(row=0, column=1, padx=(0, 6), pady=4) def _remove(self): if self.on_remove: self.on_remove(self.filepath) self.destroy() # ═══════════════════════════════════════════════════════════════════════════════ # Sidebar Panel # ═══════════════════════════════════════════════════════════════════════════════ class Sidebar(ctk.CTkFrame): """Left sidebar with file management and settings.""" def __init__(self, master, app, **kwargs): super().__init__(master, fg_color=COLORS["sidebar"], corner_radius=0, width=280, **kwargs) self.app = app self.pack_propagate(False) self.grid_propagate(False) self.files = [] self._build_ui() def _build_ui(self): # ── Logo / Title ── title_frame = ctk.CTkFrame(self, fg_color="transparent") title_frame.pack(fill="x", padx=15, pady=(15, 5)) ctk.CTkLabel( title_frame, text="📄 Scan2Doc Pro", font=ctk.CTkFont(size=20, weight="bold"), text_color=COLORS["blue"] ).pack(anchor="w") ctk.CTkLabel( title_frame, text="Professional OCR → Word", font=ctk.CTkFont(size=11), text_color=COLORS["text_dim"] ).pack(anchor="w") # Separator ctk.CTkFrame(self, fg_color=COLORS["bg_light"], height=1).pack(fill="x", padx=15, pady=10) # ── File Selection ── ctk.CTkLabel(self, text="📁 Input Files", font=ctk.CTkFont(size=13, weight="bold"), text_color=COLORS["text"]).pack(anchor="w", padx=15, pady=(5, 5)) btn_frame = ctk.CTkFrame(self, fg_color="transparent") btn_frame.pack(fill="x", padx=15) ctk.CTkButton( btn_frame, text="📂 Select Folder", height=32, fg_color=COLORS["blue_dark"], hover_color=COLORS["blue"], font=ctk.CTkFont(size=12), command=self._select_folder ).pack(side="left", expand=True, fill="x", padx=(0, 4)) ctk.CTkButton( btn_frame, text="📎 Add Files", height=32, fg_color=COLORS["accent2"], hover_color=COLORS["accent"], font=ctk.CTkFont(size=12), command=self._select_files ).pack(side="left", expand=True, fill="x", padx=(4, 0)) # ── File List ── self.file_list_frame = ctk.CTkScrollableFrame( self, fg_color=COLORS["bg_medium"], corner_radius=8, height=120) self.file_list_frame.pack(fill="x", padx=15, pady=(8, 5)) self.file_count_label = ctk.CTkLabel( self, text="No files selected", font=ctk.CTkFont(size=10), text_color=COLORS["text_dim"]) self.file_count_label.pack(anchor="w", padx=15) # Clear all button ctk.CTkButton( self, text="🗑 Clear All", height=24, width=80, fg_color="transparent", border_width=1, border_color=COLORS["error"], text_color=COLORS["error"], hover_color="#330000", font=ctk.CTkFont(size=10), command=self._clear_files ).pack(anchor="e", padx=15, pady=(2, 8)) # Separator ctk.CTkFrame(self, fg_color=COLORS["bg_light"], height=1).pack(fill="x", padx=15, pady=5) # ── Settings ── ctk.CTkLabel(self, text="⚙️ Settings", font=ctk.CTkFont(size=13, weight="bold"), text_color=COLORS["text"]).pack(anchor="w", padx=15, pady=(5, 8)) # DPI self._setting_label("DPI:") self.dpi_var = ctk.StringVar(value="300") ctk.CTkOptionMenu( self, variable=self.dpi_var, values=["150", "200", "300", "400", "500", "600"], fg_color=COLORS["bg_medium"], button_color=COLORS["blue_dark"], width=120, height=28, font=ctk.CTkFont(size=11) ).pack(anchor="w", padx=15, pady=(0, 6)) # Language self._setting_label("Language:") self.lang_var = ctk.StringVar(value="Persian + English") ctk.CTkOptionMenu( self, variable=self.lang_var, values=["Persian + English", "Persian", "English", "Arabic", "All Languages"], fg_color=COLORS["bg_medium"], button_color=COLORS["blue_dark"], width=160, height=28, font=ctk.CTkFont(size=11) ).pack(anchor="w", padx=15, pady=(0, 6)) # Title self._setting_label("Document Title:") self.title_entry = ctk.CTkEntry( self, placeholder_text="Optional title...", fg_color=COLORS["bg_medium"], border_color=COLORS["blue_dark"], height=28, font=ctk.CTkFont(size=11)) self.title_entry.pack(fill="x", padx=15, pady=(0, 6)) # Checkboxes self.tables_var = ctk.BooleanVar(value=True) self.lines_var = ctk.BooleanVar(value=True) self.images_var = ctk.BooleanVar(value=False) checks_frame = ctk.CTkFrame(self, fg_color="transparent") checks_frame.pack(fill="x", padx=15, pady=(0, 4)) ctk.CTkCheckBox( checks_frame, text="Tables", variable=self.tables_var, text_color=COLORS["text"], font=ctk.CTkFont(size=11), fg_color=COLORS["blue_dark"], hover_color=COLORS["blue"], checkmark_color=COLORS["text"] ).pack(side="left", padx=(0, 8)) ctk.CTkCheckBox( checks_frame, text="Lines", variable=self.lines_var, text_color=COLORS["text"], font=ctk.CTkFont(size=11), fg_color=COLORS["blue_dark"], hover_color=COLORS["blue"], checkmark_color=COLORS["text"] ).pack(side="left", padx=(0, 8)) ctk.CTkCheckBox( checks_frame, text="Images", variable=self.images_var, text_color=COLORS["text"], font=ctk.CTkFont(size=11), fg_color=COLORS["blue_dark"], hover_color=COLORS["blue"], checkmark_color=COLORS["text"] ).pack(side="left") # Separator ctk.CTkFrame(self, fg_color=COLORS["bg_light"], height=1).pack(fill="x", padx=15, pady=8) # ── Output ── ctk.CTkLabel(self, text="💾 Output", font=ctk.CTkFont(size=13, weight="bold"), text_color=COLORS["text"]).pack(anchor="w", padx=15, pady=(0, 5)) self.output_var = ctk.StringVar(value="output.docx") self.output_entry = ctk.CTkEntry( self, textvariable=self.output_var, fg_color=COLORS["bg_medium"], border_color=COLORS["blue_dark"], height=28, font=ctk.CTkFont(size=11)) self.output_entry.pack(fill="x", padx=15, pady=(0, 4)) ctk.CTkButton( self, text="📂 Browse...", height=26, fg_color=COLORS["bg_medium"], border_width=1, border_color=COLORS["blue_dark"], hover_color=COLORS["blue_dark"], font=ctk.CTkFont(size=11), command=self._select_output ).pack(fill="x", padx=15, pady=(0, 10)) # ── Start Button ── self.start_btn = ctk.CTkButton( self, text="▶ START PROCESSING", height=44, fg_color=COLORS["blue"], hover_color=COLORS["blue_dark"], font=ctk.CTkFont(size=15, weight="bold"), corner_radius=10, command=self._start_processing) self.start_btn.pack(fill="x", padx=15, pady=(0, 15)) def _setting_label(self, text): ctk.CTkLabel(self, text=text, font=ctk.CTkFont(size=11), text_color=COLORS["text_dim"]).pack(anchor="w", padx=15, pady=(0, 2)) def _select_folder(self): try: from tkinter import filedialog folder = filedialog.askdirectory(title="Select Image Folder") if folder: exts = {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif', '.webp', '.gif'} files = [] for f in sorted(Path(folder).iterdir()): if f.suffix.lower() in exts: files.append(str(f)) if files: self.files.extend(files) self._refresh_file_list() self.app.log(f"Added {len(files)} files from folder") else: self.app.log("No image files found in selected folder", "warning") except Exception as e: self.app.log(f"Error selecting folder: {e}", "error") def _select_files(self): try: from tkinter import filedialog files = filedialog.askopenfilenames( title="Select Image Files", filetypes=[ ("Image files", "*.jpg *.jpeg *.png *.bmp *.tiff *.tif *.webp *.gif"), ("All files", "*.*") ]) if files: self.files.extend(list(files)) self._refresh_file_list() self.app.log(f"Added {len(files)} file(s)") except Exception as e: self.app.log(f"Error selecting files: {e}", "error") def _select_output(self): try: from tkinter import filedialog path = filedialog.asksaveasfilename( title="Save Output As", defaultextension=".docx", filetypes=[("Word Document", "*.docx")]) if path: self.output_var.set(path) except Exception as e: self.app.log(f"Error: {e}", "error") def _clear_files(self): self.files.clear() self._refresh_file_list() self.app.log("File list cleared") def _remove_file(self, filepath): if filepath in self.files: self.files.remove(filepath) self._refresh_file_list() def _refresh_file_list(self): # Clear existing items for widget in self.file_list_frame.winfo_children(): widget.destroy() for fp in self.files: item = FileListItem(self.file_list_frame, fp, on_remove=self._remove_file) item.pack(fill="x", padx=2, pady=2) count = len(self.files) self.file_count_label.configure( text=f"{count} file(s) selected" if count > 0 else "No files selected") # Auto-set output filename if count == 1: stem = Path(self.files[0]).stem self.output_var.set(f"{stem}.docx") elif count > 1: parent = os.path.basename(os.path.dirname(self.files[0])) if parent: self.output_var.set(f"{parent}.docx") def get_settings(self): """Return current settings as a dict.""" lang_map = { "Persian + English": "fas+eng", "Persian": "fas", "English": "eng", "Arabic": "ara", "All Languages": "fas+ara+eng" } return { "files": self.files[:], "dpi": int(self.dpi_var.get()), "lang": lang_map.get(self.lang_var.get(), "fas+eng"), "title": self.title_entry.get().strip() or None, "output": self.output_var.get().strip() or "output.docx", "tables": self.tables_var.get(), "lines": self.lines_var.get(), "images": self.images_var.get(), } def set_processing_state(self, processing): """Enable/disable UI during processing.""" state = "disabled" if processing else "normal" self.start_btn.configure( state=state, text="⏳ Processing..." if processing else "▶ START PROCESSING") # ═══════════════════════════════════════════════════════════════════════════════ # Main Content Area # ═══════════════════════════════════════════════════════════════════════════════ class MainArea(ctk.CTkFrame): """Right main area with preview, log, and progress.""" def __init__(self, master, app, **kwargs): super().__init__(master, fg_color=COLORS["bg_dark"], corner_radius=0, **kwargs) self.app = app self.grid_rowconfigure(1, weight=1) self.grid_columnconfigure(0, weight=1) self._build_ui() def _build_ui(self): # ── Preview Panel ── preview_label = ctk.CTkLabel( self, text="🖼️ Preview", font=ctk.CTkFont(size=13, weight="bold"), text_color=COLORS["text"]) preview_label.grid(row=0, column=0, padx=15, pady=(10, 5), sticky="w") self.preview_frame = ctk.CTkFrame( self, fg_color=COLORS["bg_medium"], corner_radius=10, height=250) self.preview_frame.grid(row=0, column=0, padx=15, pady=(30, 5), sticky="nsew") self.preview_frame.grid_propagate(False) self.preview_label = ctk.CTkLabel( self.preview_frame, text="Select files to preview", text_color=COLORS["text_dim"], font=ctk.CTkFont(size=12)) self.preview_label.pack(expand=True) # ── Processing Log ── log_label = ctk.CTkLabel( self, text="📋 Processing Log", font=ctk.CTkFont(size=13, weight="bold"), text_color=COLORS["text"]) log_label.grid(row=1, column=0, padx=15, pady=(10, 5), sticky="nw") self.log_text = ctk.CTkTextbox( self, fg_color=COLORS["bg_medium"], text_color=COLORS["text"], font=ctk.CTkFont(family="Consolas", size=11), corner_radius=10, wrap="word") self.log_text.grid(row=1, column=0, padx=15, pady=(30, 5), sticky="nsew") self.log_text.configure(state="disabled") # ── Footer: Progress + Status ── footer_frame = ctk.CTkFrame(self, fg_color=COLORS["bg_medium"], corner_radius=10) footer_frame.grid(row=2, column=0, padx=15, pady=(5, 10), sticky="ew") footer_frame.grid_columnconfigure(0, weight=1) # Progress bar self.progress_bar = ctk.CTkProgressBar( footer_frame, progress_color=COLORS["blue"], fg_color=COLORS["bg_dark"], height=12) self.progress_bar.grid(row=0, column=0, padx=10, pady=(8, 4), sticky="ew") self.progress_bar.set(0) # Status labels status_frame = ctk.CTkFrame(footer_frame, fg_color="transparent") status_frame.grid(row=1, column=0, padx=10, pady=(0, 8), sticky="ew") status_frame.grid_columnconfigure(0, weight=1) status_frame.grid_columnconfigure(1, weight=1) status_frame.grid_columnconfigure(2, weight=1) self.progress_label = ctk.CTkLabel( status_frame, text="0%", font=ctk.CTkFont(size=12, weight="bold"), text_color=COLORS["blue"]) self.progress_label.grid(row=0, column=0, sticky="w") self.page_label = ctk.CTkLabel( status_frame, text="0 / 0 pages", font=ctk.CTkFont(size=11), text_color=COLORS["text_dim"]) self.page_label.grid(row=0, column=1, sticky="w") self.quality_label = ctk.CTkLabel( status_frame, text="Quality: --", font=ctk.CTkFont(size=11, weight="bold"), text_color=COLORS["text_dim"]) self.quality_label.grid(row=0, column=2, sticky="e") def log(self, message, level="info"): """Thread-safe log message.""" prefix = {"info": "ℹ️", "warning": "⚠️", "error": "❌", "success": "✅"}.get(level, "ℹ️") color_map = {"info": COLORS["text"], "warning": COLORS["warning"], "error": COLORS["error"], "success": COLORS["success"]} timestamp = time.strftime("%H:%M:%S") line = f"[{timestamp}] {prefix} {message}\n" def _append(): self.log_text.configure(state="normal") self.log_text.insert("end", line) self.log_text.see("end") self.log_text.configure(state="disabled") try: self.after(0, _append) except Exception: pass def update_preview(self, filepath): """Update the preview panel with an image.""" if not PIL_AVAILABLE: return def _update(): try: for widget in self.preview_frame.winfo_children(): widget.destroy() img = Image.open(filepath) # Calculate fit size frame_w = self.preview_frame.winfo_width() - 20 frame_h = self.preview_frame.winfo_height() - 20 if frame_w < 10: frame_w = 400 if frame_h < 10: frame_h = 200 img.thumbnail((frame_w, frame_h), Image.Resampling.LANCZOS) photo = ImageTk.PhotoImage(img) label = ctk.CTkLabel(self.preview_frame, image=photo, text="") label.image = photo # Keep reference label.pack(expand=True) except Exception as e: self.log(f"Preview error: {e}", "warning") try: self.after(0, _update) except Exception: pass def update_progress(self, current, total, message=""): """Update progress bar and labels.""" def _update(): pct = (current / total * 100) if total > 0 else 0 self.progress_bar.set(current / total if total > 0 else 0) self.progress_label.configure(text=f"{pct:.0f}%") self.page_label.configure(text=f"{current} / {total} pages") try: self.after(0, _update) except Exception: pass def update_quality(self, score): """Update quality score display.""" def _update(): if score >= 9: color = COLORS["success"] elif score >= 7: color = COLORS["blue"] elif score >= 5: color = COLORS["warning"] else: color = COLORS["error"] self.quality_label.configure(text=f"Quality: {score}/10", text_color=color) try: self.after(0, _update) except Exception: pass def reset(self): """Reset progress display.""" self.progress_bar.set(0) self.progress_label.configure(text="0%", text_color=COLORS["blue"]) self.page_label.configure(text="0 / 0 pages") self.quality_label.configure(text="Quality: --", text_color=COLORS["text_dim"]) # ═══════════════════════════════════════════════════════════════════════════════ # Main Application Window # ═══════════════════════════════════════════════════════════════════════════════ class Scan2DocApp(ctk.CTk): """Main application window.""" def __init__(self): super().__init__() self.title("Scan2Doc Pro — Professional OCR to Word") self.geometry("1280x800") self.minsize(900, 600) self.configure(fg_color=COLORS["bg_dark"]) self.processing = False self._build_layout() def _build_layout(self): # Grid layout: sidebar (fixed) + main area (expandable) self.grid_columnconfigure(1, weight=1) self.grid_rowconfigure(0, weight=1) # Sidebar self.sidebar = Sidebar(self, self) self.sidebar.grid(row=0, column=0, sticky="ns") # Main area self.main_area = MainArea(self, self) self.main_area.grid(row=0, column=1, sticky="nsew") def log(self, message, level="info"): """Log a message to the processing log.""" self.main_area.log(message, level) def _start_processing(self): """Start OCR processing in a background thread.""" settings = self.sidebar.get_settings() if not settings["files"]: self.log("No files selected! Please add images first.", "error") return if self.processing: return self.processing = True self.sidebar.set_processing_state(True) self.main_area.reset() # Show first image preview if settings["files"]: self.main_area.update_preview(settings["files"][0]) thread = threading.Thread(target=self._process_worker, args=(settings,), daemon=True) thread.start() def _process_worker(self, settings): """Background processing worker.""" try: self.log(f"Starting processing: {len(settings['files'])} file(s)", "info") self.log(f"Settings: DPI={settings['dpi']}, Lang={settings['lang']}", "info") t0 = time.time() def progress_callback(current, total, msg): self.main_area.update_progress(current, total, msg) self.log(f"Page {current}/{total}: {msg}", "info") processor = BatchProcessor( lang=settings["lang"], dpi=settings["dpi"], title=settings["title"], progress_callback=progress_callback ) # Suppress print output, capture via log import io old_stdout = sys.stdout sys.stdout = io.StringIO() ok = processor.process_batch( settings["files"], settings["output"], title=settings["title"] ) sys.stdout = old_stdout # Calculate quality score if processor.stats: ac = sum(s.overall_confidence for s in processor.stats) / len(processor.stats) tb = sum(s.text_blocks for s in processor.stats) tt = sum(s.tables for s in processor.stats) tl = sum(s.lines for s in processor.stats) tw = sum(s.total_words for s in processor.stats) lc = sum(s.low_confidence_words for s in processor.stats) agg_stats = { 'tables_found': tt, 'confidence': ac, 'blocks': tb, 'low_conf_words': lc, 'lines_found': tl, 'columns': max((s.columns for s in processor.stats), default=1), 'word_count': tw, } quality = QualityScorer.calculate(agg_stats) self.main_area.update_quality(quality) self.log(f"Quality Score: {quality}/10", "success") self.log(f"Blocks: {tb}, Tables: {tt}, Words: {tw}, Confidence: {ac:.1f}%", "info") elapsed = time.time() - t0 if ok: self.log(f"Done! Output saved to: {settings['output']}", "success") self.log(f"Total time: {elapsed:.1f}s", "info") self.main_area.update_progress(len(settings["files"]), len(settings["files"])) else: self.log("Processing failed. Check the log for errors.", "error") except Exception as e: self.log(f"Processing error: {e}", "error") self.log(traceback.format_exc(), "error") sys.stdout = sys.__stdout__ finally: self.processing = False try: self.after(0, lambda: self.sidebar.set_processing_state(False)) except Exception: pass # ═══════════════════════════════════════════════════════════════════════════════ # Entry Point # ═══════════════════════════════════════════════════════════════════════════════ def main(): """Launch the application.""" app = Scan2DocApp() app.mainloop() if __name__ == "__main__": main()