# backend/utils.py import os import time import pytesseract from PIL import Image from pdf2image import convert_from_path import shutil import asyncio import uuid import subprocess import base64 import re from models import get_prompt from reportlab.lib.pagesizes import A4 from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.enums import TA_JUSTIFY, TA_CENTER # Se agrega BeautifulSoup para procesar HTML from bs4 import BeautifulSoup UPLOAD_FOLDER = "uploads" OUTPUT_FOLDER = "outputs" def encode_image(file_path): with open(file_path, "rb") as f: return base64.b64encode(f.read()).decode("utf-8") def run_tesseract(file_path): """ Si el archivo es un PDF, se realiza OCR página a página añadiendo al principio de cada una la cabecera con el formato [Page 0001], [Page 0002], etc. Para otros formatos se realiza OCR normal. """ extracted_text = "" if file_path.lower().endswith(".pdf"): try: pages = convert_from_path(file_path) for i, page in enumerate(pages, start=1): page_text = pytesseract.image_to_string(page, lang='eng') extracted_text += f"[Page {i:04d}]\n{page_text}\n\n" except Exception as e: extracted_text = f"❌ Error processing PDF: {str(e)}" else: image = Image.open(file_path) extracted_text = pytesseract.image_to_string(image, lang='eng') return extracted_text def call_api_correction(api, model, text, prompt_key="ocr_correction"): prompt = get_prompt(prompt_key) + text try: import google.generativeai as genai except ImportError: raise ImportError("Please install 'google-generativeai' to use Gemini.") try: loop = asyncio.get_event_loop() except RuntimeError: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) # Get the API key api_key = os.environ.get("GEMINI_API_KEY") if not api_key: return "❌ GEMINI_API_KEY not found in environment variables" # Configure API genai.configure(api_key=api_key) # Create model instance text_model = genai.GenerativeModel(model) # Generate content response = text_model.generate_content(prompt) # Get text response return response.text def call_api_ocr(api, model, file_path, prompt_key="ocr"): """ OCR processing using Gemini's vision capabilities. Implements image optimization and retries with exponential backoff for handling timeouts. """ try: import sys import traceback import mimetypes import base64 import time from PIL import Image import google.generativeai as genai except ImportError: raise ImportError("Please install 'google-generativeai' to use Gemini.") try: loop = asyncio.get_event_loop() except RuntimeError: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) MAX_RETRIES = 3 RETRY_DELAY = 2 # Initial delay in seconds, will increase with each retry try: # Debug information print(f"DEBUG OCR: File path: {file_path}") print(f"DEBUG OCR: File exists: {os.path.exists(file_path)}") print(f"DEBUG OCR: File size: {os.path.getsize(file_path) if os.path.exists(file_path) else 'N/A'}") # Get the API key api_key = os.environ.get("GEMINI_API_KEY") if not api_key: error_msg = "GEMINI_API_KEY not found in environment variables" print(f"DEBUG OCR ERROR: {error_msg}") return f"❌ {error_msg}" # Configure API print(f"DEBUG OCR: Configuring API with key: {api_key[:4]}...{api_key[-4:] if len(api_key) > 8 else ''}") genai.configure(api_key=api_key) # Open and optimize the image try: # Open the image img = Image.open(file_path) # Check image size - resize if too large (max 1600px on any side) max_size = 1600 original_size = img.size if img.width > max_size or img.height > max_size: # Calculate new size preserving aspect ratio if img.width > img.height: new_width = max_size new_height = int(img.height * (max_size / img.width)) else: new_height = max_size new_width = int(img.width * (max_size / img.height)) img = img.resize((new_width, new_height), Image.LANCZOS) print(f"DEBUG OCR: Resized image from {original_size} to {img.size}") # Convert to RGB if needed (e.g., for PNG with transparency) if img.mode in ('RGBA', 'LA') or (img.mode == 'P' and 'transparency' in img.info): background = Image.new('RGB', img.size, (255, 255, 255)) if img.mode == 'P': img = img.convert('RGBA') background.paste(img, (0, 0), img) img = background print("DEBUG OCR: Converted image to RGB") elif img.mode != 'RGB': img = img.convert('RGB') print("DEBUG OCR: Converted image to RGB") # Save to BytesIO and encode import io buffer = io.BytesIO() img.save(buffer, format="JPEG", quality=85, optimize=True) buffer.seek(0) image_bytes = buffer.read() file_base64 = base64.b64encode(image_bytes).decode('utf-8') print(f"DEBUG OCR: Successfully processed and encoded image ({len(file_base64)} chars)") except Exception as img_error: error_msg = f"Error processing image: {str(img_error)}" print(f"DEBUG OCR ERROR: {error_msg}") print(traceback.format_exc()) return f"❌ {error_msg}" # Get prompt prompt = get_prompt(prompt_key) print(f"DEBUG OCR: Using prompt: {prompt[:50]}...") # Create model instance vision_model = genai.GenerativeModel(model) print(f"DEBUG OCR: Created model instance for model: {model}") # Create image data in the format required by the API image_data = { "mime_type": "image/jpeg", # Always using JPEG for consistency "data": file_base64 } print(f"DEBUG OCR: Image prepared for API. Generating content with retries...") # Initialize variables for retry logic retry_count = 0 current_delay = RETRY_DELAY last_error = None # Retry loop while retry_count < MAX_RETRIES: try: if retry_count > 0: print(f"DEBUG OCR: Retry {retry_count}/{MAX_RETRIES} after {current_delay}s delay") # Generate content with the image and the prompt response = vision_model.generate_content([ {"inline_data": image_data}, prompt ], generation_config={ "temperature": 0.2, "top_p": 0.95, "top_k": 40, "max_output_tokens": 1024, # Limit output size to avoid timeouts }) print(f"DEBUG OCR: Response received successfully") # Return the text response return response.text except Exception as api_error: last_error = api_error error_str = str(api_error) print(f"DEBUG OCR ERROR on attempt {retry_count+1}: {error_str}") # Check if this is a timeout or rate limit error (worth retrying) if "Deadline Exceeded" in error_str or "429" in error_str or "quota" in error_str.lower() or "timeout" in error_str.lower(): retry_count += 1 if retry_count < MAX_RETRIES: print(f"DEBUG OCR: Timeout or rate limit error, will retry in {current_delay}s") time.sleep(current_delay) current_delay *= 2 # Exponential backoff else: print("DEBUG OCR: Max retries reached") else: # Non-retryable error print("DEBUG OCR: Non-retryable error, giving up") break # If we get here, all retries failed error_detail = f"Error after {MAX_RETRIES} attempts: {str(last_error)}" print(f"DEBUG OCR ERROR: {error_detail}") return f"❌ Error in OCR: {error_detail}" except Exception as e: error_detail = f"Error: {str(e)}\n" error_detail += f"Exception type: {type(e).__name__}\n" error_detail += f"Traceback: {traceback.format_exc()}" print(f"DEBUG OCR ERROR: {error_detail}") return f"❌ Error in OCR: {str(e)}" def describe_image(file_path, api, model, prompt_key="image_desc", update_progress=None, is_cancelled=None): """ Process an image file and generate a description using the specified AI model. Uses direct base64 encoding for Gemini's vision capabilities. Implements retries with exponential backoff for handling timeouts. """ import sys import traceback import mimetypes import base64 import time from PIL import Image print(f"DEBUG IMAGE_DESC: Starting image description function for file: {file_path}") print(f"DEBUG IMAGE_DESC: Using API: {api}, Model: {model}, Prompt key: {prompt_key}") MAX_RETRIES = 3 RETRY_DELAY = 2 # Initial delay in seconds, will increase with each retry try: if update_progress: update_progress(20, "⏳ Loading image file...") if is_cancelled and is_cancelled(): return "Process cancelled." # Check if file exists if not os.path.exists(file_path): error_msg = f"File not found: {file_path}" print(f"DEBUG IMAGE_DESC: {error_msg}") if update_progress: update_progress(0, f"❌ {error_msg}") return f"❌ {error_msg}" # Get MIME type of image mime_type = mimetypes.guess_type(file_path)[0] or 'image/png' print(f"DEBUG IMAGE_DESC: File path: {file_path}") print(f"DEBUG IMAGE_DESC: MIME type: {mime_type}") # Read the image, resize if needed to reduce size, and encode it to base64 if update_progress: update_progress(40, "🔍 Processing image...") try: # Open and optimize the image img = Image.open(file_path) # Check image size - resize if too large (max 1600px on any side) max_size = 1600 original_size = img.size if img.width > max_size or img.height > max_size: # Calculate new size preserving aspect ratio if img.width > img.height: new_width = max_size new_height = int(img.height * (max_size / img.width)) else: new_height = max_size new_width = int(img.width * (max_size / img.height)) img = img.resize((new_width, new_height), Image.LANCZOS) print(f"DEBUG IMAGE_DESC: Resized image from {original_size} to {img.size}") # Convert to RGB if needed (e.g., for PNG with transparency) if img.mode in ('RGBA', 'LA') or (img.mode == 'P' and 'transparency' in img.info): # Create a white background image background = Image.new('RGB', img.size, (255, 255, 255)) # Paste the image on the background (handles transparency) if img.mode == 'P': img = img.convert('RGBA') background.paste(img, (0, 0), img) img = background print("DEBUG IMAGE_DESC: Converted image to RGB") elif img.mode != 'RGB': img = img.convert('RGB') print("DEBUG IMAGE_DESC: Converted image to RGB") # Save to BytesIO and encode import io buffer = io.BytesIO() img.save(buffer, format="JPEG", quality=85, optimize=True) buffer.seek(0) image_bytes = buffer.read() image_base64 = base64.b64encode(image_bytes).decode('utf-8') print(f"DEBUG IMAGE_DESC: Successfully processed and encoded image ({len(image_base64)} chars)") except Exception as e: error_msg = f"Error processing image file: {str(e)}" print(f"DEBUG IMAGE_DESC ERROR: {error_msg}") print(traceback.format_exc()) if update_progress: update_progress(0, f"❌ {error_msg}") return f"❌ {error_msg}" # Import Gemini libraries - using the correct package name try: print("DEBUG IMAGE_DESC: Attempting to import google-generativeai library") import google.generativeai as genai print("DEBUG IMAGE_DESC: Successfully imported google.generativeai library") except ImportError as ie: error_msg = f"Failed to import Google's generativeai library: {str(ie)}. Please install it with: pip install google-generativeai" print(f"DEBUG IMAGE_DESC ERROR: {error_msg}") if update_progress: update_progress(0, f"❌ {error_msg}") return f"❌ {error_msg}" # Configure API api_key = os.environ.get("GEMINI_API_KEY") if not api_key: error_msg = "GEMINI_API_KEY not found in environment variables" print(f"DEBUG IMAGE_DESC ERROR: {error_msg}") if update_progress: update_progress(0, f"❌ {error_msg}") return f"❌ {error_msg}" print(f"DEBUG IMAGE_DESC: Configuring API with key: {api_key[:4]}...{api_key[-4:] if len(api_key) > 8 else ''}") genai.configure(api_key=api_key) # Get prompt from configuration prompt = get_prompt(prompt_key) print(f"DEBUG IMAGE_DESC: Using prompt: {prompt[:50]}...") if update_progress: update_progress(60, "🤖 Generating description...") # Generation parameters - reduce complexity for better performance generation_config = { "temperature": 0.2, # Lower temperature for more deterministic output "top_p": 0.95, "top_k": 40, "max_output_tokens": 800, # Limit output size } print(f"DEBUG IMAGE_DESC: Creating model instance for model: {model}") # Create model instance vision_model = genai.GenerativeModel(model) # Format image for the API image_data = { "mime_type": "image/jpeg", # Always using JPEG for consistency "data": image_base64 } print(f"DEBUG IMAGE_DESC: Image prepared for API. Generating content with retries...") # Initialize variables for retry logic retry_count = 0 current_delay = RETRY_DELAY last_error = None # Retry loop while retry_count < MAX_RETRIES: try: if retry_count > 0: print(f"DEBUG IMAGE_DESC: Retry {retry_count}/{MAX_RETRIES} after {current_delay}s delay") if update_progress: update_progress(60, f"🔄 Retry {retry_count}/{MAX_RETRIES}...") # Generate content using the correct format for v0.3.1 response = vision_model.generate_content([ {"inline_data": image_data}, prompt ], generation_config=generation_config) print(f"DEBUG IMAGE_DESC: Successfully received response from Gemini API") if update_progress: update_progress(100, "✅ Description generated") # Get text response print(f"DEBUG IMAGE_DESC: Returning text response of length: {len(response.text) if response.text else 0}") return response.text except Exception as api_error: last_error = api_error error_str = str(api_error) print(f"DEBUG IMAGE_DESC ERROR on attempt {retry_count+1}: {error_str}") # Check if this is a timeout or rate limit error (worth retrying) if "Deadline Exceeded" in error_str or "429" in error_str or "quota" in error_str.lower() or "timeout" in error_str.lower(): retry_count += 1 if retry_count < MAX_RETRIES: print(f"DEBUG IMAGE_DESC: Timeout or rate limit error, will retry in {current_delay}s") time.sleep(current_delay) current_delay *= 2 # Exponential backoff else: print("DEBUG IMAGE_DESC: Max retries reached") else: # Non-retryable error print("DEBUG IMAGE_DESC: Non-retryable error, giving up") break # If we get here, all retries failed error_msg = f"Error generating content after {MAX_RETRIES} attempts: {str(last_error)}" print(f"DEBUG IMAGE_DESC ERROR: {error_msg}") if update_progress: update_progress(0, f"❌ {error_msg}") return f"❌ {error_msg}" except Exception as e: error_msg = f"Unexpected error: {str(e)}" print(f"DEBUG IMAGE_DESC ERROR: {error_msg}") print(traceback.format_exc()) if update_progress: update_progress(0, f"❌ {error_msg}") return f"❌ {error_msg}" def call_api_translation(api, model, text, target_language, prompt_key="translation"): prompt_template = get_prompt(prompt_key) prompt = prompt_template.format(target_language=target_language) + text try: import google.generativeai as genai except ImportError: raise ImportError("Please install 'google-generativeai' to use Gemini.") try: loop = asyncio.get_event_loop() except RuntimeError: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) # Get the API key api_key = os.environ.get("GEMINI_API_KEY") if not api_key: return "❌ GEMINI_API_KEY not found in environment variables" # Configure API genai.configure(api_key=api_key) # Create model instance text_model = genai.GenerativeModel(model) # Generate content - simpler format for v0.3.1 response = text_model.generate_content(prompt, generation_config={ "temperature": 0.2, "top_p": 0.95, "top_k": 40 }) # Get text response return response.text def embed_ocr_in_pdf(input_pdf, output_pdf): try: subprocess.run(["ocrmypdf", input_pdf, output_pdf], check=True) return True except Exception as e: return False def ocr_file_by_pages(file_path, api, model, prompt_key, update_progress, is_cancelled): final_text = "" if file_path.lower().endswith(".pdf"): try: pages = convert_from_path(file_path) except Exception as e: return f"❌ Error processing PDF: {str(e)}" total = len(pages) for i, page in enumerate(pages, start=1): if is_cancelled(): update_progress(0, "⏹️ Cancelled") return "Process cancelled." temp_filename = os.path.join(OUTPUT_FOLDER, f"temp_page_{uuid.uuid4().hex}.png") page.save(temp_filename, "PNG") page_text = call_api_ocr(api, model, temp_filename, prompt_key) final_text += f"[Page {i:04d}]\n{page_text}\n\n" os.remove(temp_filename) progress = int((i / total) * 100) update_progress(progress, f"📄 Processed page {i} of {total}.") time.sleep(1) return final_text else: return call_api_ocr(api, model, file_path, prompt_key) def translate_file_by_pages(file_path, api, model, target_language, prompt_key, update_progress, is_cancelled): final_translation = "" if file_path.lower().endswith(".pdf"): try: pages = convert_from_path(file_path) except Exception as e: return f"❌ Error processing PDF: {str(e)}" total = len(pages) for i, page in enumerate(pages, start=1): if is_cancelled(): update_progress(0, "⏹️ Cancelled") return "Process cancelled." temp_filename = os.path.join(OUTPUT_FOLDER, f"temp_page_{uuid.uuid4().hex}.png") page.save(temp_filename, "PNG") page_text = pytesseract.image_to_string(page, lang='eng') translated_page = call_api_translation(api, model, page_text, target_language, prompt_key) final_translation += f"[Page {i:04d}]\n{translated_page}\n\n" os.remove(temp_filename) progress = int((i / total) * 100) update_progress(progress, f"📄 Processed page {i} of {total}.") time.sleep(1) return final_translation elif file_path.lower().endswith(".txt"): with open(file_path, "r", encoding="utf-8") as f: text = f.read() translated = call_api_translation(api, model, text, target_language, prompt_key) update_progress(100, "🎉 Process completed") return f"[Page {1:04d}]\n{translated}" else: return "Unsupported file type for translation." def process_file(file_path, api, model, mode, prompt_key, update_progress, is_cancelled): """ NOTA: Ahora solo usamos [Page XXXX] al inicio de cada página (por Tesseract). """ if is_cancelled(): update_progress(0, "⏹️ Cancelled") return "Process cancelled." update_progress(25, "📤 File uploaded.") base_name = os.path.splitext(os.path.basename(file_path))[0] if mode == "OCR": processed_text = run_tesseract(file_path) if is_cancelled(): update_progress(25, "⏹️ Cancelled") return "Process cancelled." update_progress(50, "✅ Tesseract OCR completed.") pdf_output = os.path.join(OUTPUT_FOLDER, base_name + "_ocr.pdf") if file_path.lower().endswith(".pdf"): if embed_ocr_in_pdf(file_path, pdf_output): update_progress(95, "📄 OCR embedded into PDF.") else: shutil.copy(file_path, pdf_output) update_progress(95, "⚠️ Failed to embed OCR; original PDF copied.") else: shutil.copy(file_path, pdf_output) elif mode == "OCR + AI": processed_text = run_tesseract(file_path) if is_cancelled(): update_progress(25, "⏹️ Cancelled") return "Process cancelled." update_progress(50, "✅ Tesseract OCR completed.") processed_text = call_api_correction(api, model, processed_text, prompt_key) pdf_output = os.path.join(OUTPUT_FOLDER, base_name + "_ocr.pdf") if file_path.lower().endswith(".pdf"): if embed_ocr_in_pdf(file_path, pdf_output): update_progress(95, "📄 OCR embedded into PDF.") else: shutil.copy(file_path, pdf_output) update_progress(95, "⚠️ Failed to embed OCR; original PDF copied.") else: shutil.copy(file_path, pdf_output) elif mode == "AI": update_progress(25, "📂 File ready for full AI processing.") if file_path.lower().endswith(".pdf"): processed_text = ocr_file_by_pages(file_path, api, model, prompt_key, update_progress, is_cancelled) else: processed_text = call_api_ocr(api, model, file_path, prompt_key) else: processed_text = "Unrecognized processing mode." update_progress(25, "❌ Error: Unrecognized mode.") if is_cancelled(): update_progress(75, "⏹️ Cancelled") return "Process cancelled." update_progress(75, "🤖 API processing completed.") # Guardar el TXT final txt_file = os.path.join(OUTPUT_FOLDER, base_name + ".txt") with open(txt_file, "w", encoding="utf-8") as f: f.write(processed_text) update_progress(100, "🎉 Process completed") return processed_text def organize_paragraphs(text): """ Organiza el texto plano en párrafos de forma más flexible. Si se detecta doble salto de línea se usa como separador; si no, se procesa línea a línea para unirlas en párrafos, creando uno nuevo cuando: - Se encuentra una línea vacía, o - La línea actual termina en punto. Dentro de cada párrafo se unen las líneas; se inserta un
si la línea termina en punto. """ text = text.strip() paragraphs = [] if "\n\n" in text: blocks = re.split(r'\n\s*\n', text) else: lines = text.splitlines() blocks = [] buffer = "" for line in lines: stripped = line.strip() if not stripped: if buffer: blocks.append(buffer) buffer = "" else: if buffer and buffer.endswith("."): blocks.append(buffer) buffer = stripped else: if buffer: buffer += " " + stripped else: buffer = stripped if buffer: blocks.append(buffer) for block in blocks: line_list = block.splitlines() if len(line_list) == 1: paragraphs.append(line_list[0].strip()) else: new_block = "" for i, line in enumerate(line_list): line = line.strip() if not line: continue if i < len(line_list) - 1: if line.endswith("."): new_block += line + "
" else: new_block += line + " " else: new_block += line paragraphs.append(new_block.strip()) return paragraphs def convert_txt_to_pdf(txt_file_path): """ Convierte un archivo TXT a PDF siguiendo estas reglas: - Se detecta el patrón [Page XXXX] para separar páginas (solo al inicio). - Este marcador se convierte en un encabezado (h1). - Si el contenido proviene de un bloque markdown con ```html se elimina ese marcador y se parsea con BeautifulSoup para generar párrafos independientes. - Para contenido en texto plano se organiza en párrafos con organize_paragraphs. - Se inserta un PageBreak después de cada bloque de página. """ with open(txt_file_path, "r", encoding="utf-8") as f: content = f.read() base_name = os.path.splitext(os.path.basename(txt_file_path))[0] output_pdf = os.path.join(OUTPUT_FOLDER, base_name + "_txt.pdf") doc = SimpleDocTemplate( output_pdf, pagesize=A4, rightMargin=40, leftMargin=40, topMargin=40, bottomMargin=40 ) styles = getSampleStyleSheet() header_styles = { "h1": ParagraphStyle('Heading1', parent=styles['Heading1'], alignment=TA_CENTER), "h2": ParagraphStyle('Heading2', parent=styles['Heading2'], alignment=TA_CENTER), "h3": ParagraphStyle('Heading3', parent=styles['Heading3'], alignment=TA_CENTER), "h4": ParagraphStyle('Heading4', parent=styles['Heading4'], alignment=TA_CENTER), "h5": ParagraphStyle('Heading5', parent=styles['Heading5'], alignment=TA_CENTER), "h6": ParagraphStyle('Heading6', parent=styles['Heading6'], alignment=TA_CENTER), } normal_style = ParagraphStyle( 'Normal', parent=styles['Normal'], alignment=TA_JUSTIFY, leading=15, leftIndent=20 ) flowables = [] # Patrón para detectar el marcador [Page XXXX] page_pattern = re.compile(r'\[Page\s+\d{4}\]') parts = re.split(r'(\[Page\s+\d{4}\])', content) first_page_encountered = False for part in parts: part = part.strip() if not part: continue if page_pattern.fullmatch(part): if first_page_encountered: flowables.append(PageBreak()) else: first_page_encountered = True header_para = Paragraph(part, styles['Heading1']) flowables.append(header_para) flowables.append(Spacer(1, 12)) else: if part.startswith("```"): lines = part.splitlines() if lines and lines[0].startswith("```"): lines = lines[1:] if lines and lines[-1].strip() == "```": lines = lines[:-1] part = "\n".join(lines) # Si parece HTML, se procesa para separar cada etiqueta de interés if re.search(r'<\s*html', part, re.IGNORECASE) or re.search(r'<\s*(p|h[1-6])', part, re.IGNORECASE): if not part.lower().startswith("" soup = BeautifulSoup(part, "html.parser") for element in soup.find_all(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p']): if element.name.lower() in ["h1", "h2", "h3", "h4", "h5", "h6"]: style = header_styles.get(element.name.lower(), styles['Heading1']) else: style = normal_style text_content = element.get_text().strip() if text_content: flowables.append(Paragraph(text_content, style)) flowables.append(Spacer(1, 12)) else: # Texto plano -> organizar en párrafos paragraphs = organize_paragraphs(part) for para_text in paragraphs: if para_text: flowables.append(Paragraph(para_text, normal_style)) flowables.append(Spacer(1, 12)) doc.build(flowables) return output_pdf