#!/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()