import os import json import platform import locale import logging import tempfile import shutil import torch from transformers import MarianMTModel, MarianTokenizer from langdetect import detect import fitz # PyMuPDF from reportlab.pdfgen import canvas from reportlab.lib.pagesizes import A4 from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont import gradio as gr import numpy as np # Configuración del logger logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) # Definición inicial de los modelos de traducción MODELOS_TRADUCCION = { 'Inglés a Español': 'Helsinki-NLP/opus-mt-en-es', 'Español a Inglés': 'Helsinki-NLP/opus-mt-es-en', 'Inglés a Francés': 'Helsinki-NLP/opus-mt-en-fr', 'Francés a Inglés': 'Helsinki-NLP/opus-mt-fr-en', 'Inglés a Alemán': 'Helsinki-NLP/opus-mt-en-de', 'Alemán a Inglés': 'Helsinki-NLP/opus-mt-de-en', 'Inglés a Italiano': 'Helsinki-NLP/opus-mt-en-it', 'Italiano a Inglés': 'Helsinki-NLP/opus-mt-it-en', 'Inglés a Portugués': 'Helsinki-NLP/opus-mt-en-pt', 'Portugués a Inglés': 'Helsinki-NLP/opus-mt-pt-en', } # Mapeo de nombres completos de idiomas a códigos de idioma LANGUAGE_MAP = { 'english': 'en', 'spanish': 'es', 'french': 'fr', 'german': 'de', 'italian': 'it', 'portuguese': 'pt', # Agrega más idiomas según sea necesario } def detectar_idioma_sistema(): """ Detecta el idioma del sistema operativo utilizando locale. Retorna el código del idioma (e.g., 'en', 'es'). """ try: # Establecer la configuración regional para evitar DeprecationWarning locale.setlocale(locale.LC_ALL, '') idioma, _ = locale.getlocale() if idioma: idioma = idioma.split('_')[0] idioma_lower = idioma.lower() idioma_code = LANGUAGE_MAP.get(idioma_lower, 'es') # Predeterminado a 'es' si no se encuentra else: idioma_code = 'es' # Predeterminado a español si no se detecta logger.info(f"Idioma del sistema detectado: {idioma_code}") return idioma_code except Exception as e: logger.warning(f"No se pudo detectar el idioma del sistema: {e}") return 'es' # Predeterminado a español en caso de error def detectar_idioma_texto(texto): """ Detecta el idioma predominante del texto utilizando langdetect. Retorna el código del idioma (e.g., 'en', 'es'). """ try: idioma = detect(texto) logger.info(f"Idioma detectado del texto: {idioma}") return idioma except Exception as e: logger.error(f"Error al detectar el idioma: {e}") return 'en' # Predeterminado a inglés si falla la detección def actualizar_modelos_traduccion(idioma_origen, idioma_destino): """ Actualiza dinámicamente los modelos de traducción disponibles basado en el par de idiomas. Retorna una tupla (clave, modelo_nombre) si existe el modelo, de lo contrario (None, None). """ mapa_idiomas = { 'en': 'Inglés', 'es': 'Español', 'fr': 'Francés', 'de': 'Alemán', 'it': 'Italiano', 'pt': 'Portugués', # Agrega más idiomas según sea necesario } clave_origen = mapa_idiomas.get(idioma_origen, idioma_origen.capitalize()) clave_destino = mapa_idiomas.get(idioma_destino, idioma_destino.capitalize()) clave = f"{clave_origen} a {clave_destino}" modelo = MODELOS_TRADUCCION.get(clave) if modelo: logger.info(f"Modelo de traducción encontrado para {clave}: {modelo}") return clave, modelo else: logger.warning(f"No se encontró modelo de traducción para {clave}") return None, None def cargar_modelo_traduccion(origen, destino): """ Carga el modelo de traducción basado en los idiomas de origen y destino. Retorna una tupla (tokenizer, model, dispositivo). """ clave, modelo_nombre = actualizar_modelos_traduccion(origen, destino) if not modelo_nombre: raise ValueError(f"No hay modelo de traducción disponible para {origen} a {destino}") logger.info(f"Cargando el modelo de traducción: {clave}...") tokenizer = MarianTokenizer.from_pretrained(modelo_nombre) model = MarianMTModel.from_pretrained(modelo_nombre) dispositivo = torch.device("cuda" if torch.cuda.is_available() else "cpu") model.to(dispositivo) logger.info(f"Modelo '{clave}' cargado en: {dispositivo}\n") return tokenizer, model, dispositivo def traducir_texto(tokenizer, model, textos, dispositivo, batch_size=8): """ Traduce una lista de textos utilizando el modelo y tokenizer proporcionados. """ if not textos: # Add a check for empty text list logger.warning("Lista de textos vacía. Saltando traducción.") return [] traducciones = [] try: for i in range(0, len(textos), batch_size): batch = textos[i:i+batch_size] # Skip empty texts within the batch batch = [texto for texto in batch if texto and texto.strip()] if not batch: continue inputs = tokenizer(batch, return_tensors="pt", padding=True, truncation=True) inputs = {k: v.to(dispositivo) for k, v in inputs.items()} # Mover inputs al dispositivo with torch.no_grad(): traduccion = model.generate(**inputs) traducciones += [tokenizer.decode(t, skip_special_tokens=True) for t in traduccion] except Exception as e: logger.error(f"Error en traducción de texto: {e}") # Fallback: devolver textos originales si la traducción falla traducciones = textos return traducciones def obtener_rutas_fuentes(): """ Obtiene las rutas de las fuentes del sistema operativo. """ sistema = platform.system() rutas_fuentes = [] if sistema == 'Windows': rutas_fuentes = [ os.path.join(os.environ.get('WINDIR', 'C:\\Windows'), 'Fonts'), os.path.expanduser('~\\AppData\\Local\\Microsoft\\Windows\\Fonts'), os.path.join(os.path.expanduser('~'), 'AppData', 'Local', 'Microsoft', 'Windows', 'Fonts') ] elif sistema == 'Darwin': # macOS rutas_fuentes = [ '/System/Library/Fonts', '/Library/Fonts', os.path.expanduser('~/Library/Fonts') ] elif sistema == 'Linux': rutas_fuentes = [ '/usr/share/fonts', '/usr/local/share/fonts', os.path.expanduser('~/.fonts') ] else: logger.warning(f"Sistema operativo no soportado: {sistema}") return rutas_fuentes def cachear_fuentes(): """ Cachea las fuentes disponibles en el sistema en un archivo JSON. """ rutas_fuentes = obtener_rutas_fuentes() fuentes = {} for ruta in rutas_fuentes: if os.path.exists(ruta): for root, dirs, files in os.walk(ruta): for file in files: if file.lower().endswith(('.ttf', '.otf')): nombre_fuente = os.path.splitext(file)[0] path_fuente = os.path.join(root, file) # Evitar sobrescribir fuentes con el mismo nombre if nombre_fuente not in fuentes: fuentes[nombre_fuente] = path_fuente cache_path = os.path.join(tempfile.gettempdir(), 'fuentes_sistema.json') with open(cache_path, 'w', encoding='utf-8') as f: json.dump(fuentes, f, ensure_ascii=False, indent=4) logger.info(f"Fuentes cacheadas en: {cache_path}") return fuentes def cargar_fuentes_cache(): """ Carga las fuentes desde el caché o crea una nueva caché si no existe. """ cache_path = os.path.join(tempfile.gettempdir(), 'fuentes_sistema.json') if not os.path.exists(cache_path): logger.info("Cache de fuentes no encontrado. Creando cache...") return cachear_fuentes() with open(cache_path, 'r', encoding='utf-8') as f: fuentes = json.load(f) logger.info("Fuentes cargadas desde el cache.") return fuentes def registrar_fuentes(fuentes_sistema): """ Registra las fuentes disponibles en ReportLab. Solo registra fuentes .ttf compatibles. """ fuentes_registradas = set(pdfmetrics.getRegisteredFontNames()) for nombre, path in fuentes_sistema.items(): # Verificar si el archivo es .ttf if not path.lower().endswith('.ttf'): logger.warning(f"Fuente {nombre} no es .ttf. Se omite su registro.") continue # Crear un nombre único para la fuente nombre_registro = nombre if nombre_registro not in fuentes_registradas: try: pdfmetrics.registerFont(TTFont(nombre_registro, path)) fuentes_registradas.add(nombre_registro) logger.info(f"Fuente registrada: {nombre_registro}") except Exception as e: logger.warning(f"No se pudo registrar la fuente {nombre}: {e}") def buscar_fuente_similar(nombre_fuente_pdf, fuentes_sistema): """ Busca una fuente similar en las fuentes del sistema. Si no encuentra una, retorna 'Helvetica'. """ nombre_fuente_pdf_lower = nombre_fuente_pdf.lower() for nombre, path in fuentes_sistema.items(): if nombre_fuente_pdf_lower in nombre.lower(): return nombre # Retorna el nombre registrado en ReportLab logger.warning(f"No se encontró una fuente similar para '{nombre_fuente_pdf}'. Usando 'Helvetica'.") return "Helvetica" def ajustar_tamano_fuente(texto, bbox, c, max_width, tamaño_fuente_original): """ Ajusta el tamaño de la fuente para que el texto se ajuste al ancho máximo. """ # Prevent division by zero if not texto or max_width <= 0 or tamaño_fuente_original <= 0: return tamaño_fuente_original try: width_texto = c.stringWidth(texto, c._fontname, tamaño_fuente_original) if width_texto > max_width: nuevo_tamaño = tamaño_fuente_original * (max_width / width_texto) return max(min(nuevo_tamaño, tamaño_fuente_original), 6) return tamaño_fuente_original except Exception as e: logger.warning(f"Error al ajustar tamaño de fuente: {e}") return tamaño_fuente_original def extraer_y_traducir_pdf_generador(archivo_pdf, tokenizer, model, dispositivo, idioma_destino): """ Extrae el contenido del PDF, traduce el texto y crea un nuevo PDF traducido. Esta versión utiliza 'yield' para emitir actualizaciones. """ try: documento = fitz.open(archivo_pdf.name) pdf_traducido_path = os.path.splitext(archivo_pdf.name)[0] + f"_traducido_{idioma_destino}.pdf" fuentes_sistema = cargar_fuentes_cache() registrar_fuentes(fuentes_sistema) # Crear un canvas ReportLab con el tamaño de la primera página primera_pagina = documento.load_page(0) rect = primera_pagina.rect ancho, alto = rect.width, rect.height c = canvas.Canvas(pdf_traducido_path, pagesize=(ancho, alto)) textos = [] posiciones = [] # Extraer todos los textos y sus posiciones for numero_pagina in range(len(documento)): pagina = documento.load_page(numero_pagina) bloques = pagina.get_text("dict")["blocks"] for bloque in bloques: if bloque['type'] == 0: # texto for linea in bloque["lines"]: for span in linea["spans"]: # Filtrar textos no vacíos y que no sean solo espacios o caracteres especiales texto_limpio = span["text"].strip() if texto_limpio and len(texto_limpio) > 1: textos.append(texto_limpio) posiciones.append((span["bbox"], span["font"], span["size"], numero_pagina)) # Verificar si hay textos para traducir if not textos: logger.warning("No se encontraron textos válidos para traducir en el PDF.") documento.close() yield ("No se encontraron textos para traducir.", None) return pdf_traducido_path # Traducir texto try: traducciones = traducir_texto(tokenizer, model, textos, dispositivo) except Exception as e: logger.error(f"Error en traducción: {e}") traducciones = textos # Fallback a textos originales # Asegurar que el número de traducciones coincida con el número de textos originales if len(traducciones) != len(textos): logger.warning(f"Discrepancia en traducciones. Textos: {len(textos)}, Traducciones: {len(traducciones)}") # Rellenar con textos originales si es necesario traducciones = traducciones + textos[len(traducciones):] # Dibujar el texto traducido idx_texto = 0 total_paginas = len(documento) for numero_pagina in range(total_paginas): pagina = documento.load_page(numero_pagina) rect = pagina.rect ancho_pagina, alto_pagina = rect.width, rect.height # Ajustar el tamaño de página al tamaño original del PDF c.setPageSize((ancho_pagina, alto_pagina)) # Definir márgenes dinámicos en base al tamaño de la página, ej: 5% de ancho y alto margen_x = ancho_pagina * 0.05 margen_y = alto_pagina * 0.05 # Procesar texto de esta página pagina_bloques = pagina.get_text("dict")["blocks"] for bloque in pagina_bloques: if bloque['type'] == 0: for linea in bloque["lines"]: for span in linea["spans"]: texto_original = span["text"].strip() # Saltar textos vacíos o muy cortos if not texto_original or len(texto_original) <= 1: continue # Obtener el texto traducido correspondiente if idx_texto < len(traducciones): texto_traducido = traducciones[idx_texto] bbox, font, size, span_pagina = posiciones[idx_texto] idx_texto += 1 else: # Fallback si se agotan las traducciones texto_traducido = texto_original bbox, font, size, span_pagina = posiciones[idx_texto - 1] x0, y0, x1, y1 = bbox # Ajustar coordenadas al sistema de ReportLab (y invertida) x = x0 y = alto_pagina - y1 # Buscar fuente similar fuente_encontrada = buscar_fuente_similar(font, fuentes_sistema) # Intentar establecer la fuente encontrada try: c.setFont(fuente_encontrada, size) except: logger.warning(f"Fuente '{fuente_encontrada}' no registrada. Usando 'Helvetica'.") fuente_encontrada = "Helvetica" c.setFont(fuente_encontrada, size) # Ajustar el tamaño del texto si excede el ancho disponible max_width = (x1 - x0) - margen_x if (x1 - x0) > 0 else (ancho_pagina - 2 * margen_x) nuevo_tamaño = ajustar_tamano_fuente(texto_traducido, bbox, c, max_width, size) # Establecer el nuevo tamaño de fuente try: c.setFont(fuente_encontrada, nuevo_tamaño) except: logger.warning(f"No se pudo ajustar el tamaño de la fuente para '{fuente_encontrada}'. Usando 'Helvetica'.") fuente_encontrada = "Helvetica" c.setFont(fuente_encontrada, nuevo_tamaño) # Dibujar texto try: c.drawString(x, y, texto_traducido) except Exception as e: logger.error(f"Error al dibujar texto: {e}") # Intentar con Helvetica por defecto c.setFont("Helvetica", nuevo_tamaño) c.drawString(x, y, texto_traducido) # Procesar imágenes imagenes = [b for b in pagina_bloques if b['type'] == 1] for imagen in imagenes: if 'xref' not in imagen: continue try: x0, y0, x1, y1 = imagen["bbox"] ancho_img, alto_img = x1 - x0, y1 - y0 img = fitz.Pixmap(documento, imagen["xref"]) if img.n > 4: img = fitz.Pixmap(fitz.csRGB, img) imagen_path = os.path.join(tempfile.gettempdir(), f"imagen_{numero_pagina}.png") img.save(imagen_path) img.close() c.drawImage(imagen_path, x0, alto_pagina - y1 - alto_img, width=ancho_img, height=alto_img) except Exception as e: logger.error(f"Error al procesar imagen: {e}") continue # Emitir estado y vista previa de la página actual yield (f"Traduciendo página {numero_pagina + 1} de {total_paginas} al idioma {idioma_destino}...", pdf_preview(archivo_pdf.name, numero_pagina)) c.showPage() c.save() documento.close() yield ("Traducción completada exitosamente.", pdf_preview(pdf_traducido_path, pagina=0)) return pdf_traducido_path except Exception as e: logger.error(f"Error al extraer y traducir pdf: {e}") def pdf_preview(file_path, pagina=0): """ Previsualiza una página específica del PDF como una imagen. """ try: doc = fitz.open(file_path) if pagina >= len(doc): pagina = 0 # Fallback a la primera página si el número es inválido page = doc[pagina] pix = page.get_pixmap() image = np.frombuffer(pix.samples, np.uint8).reshape(pix.height, pix.width, pix.n) if pix.n == 4: image = image[:, :, :3] return image except Exception as e: logger.error(f"Error al previsualizar el PDF: {e}") return None def boton_actualizar_fuentes(files): """ Actualiza las fuentes del sistema subiendo nuevas fuentes. """ try: if files: fuentes_cache = cargar_fuentes_cache() # Definir el subdirectorio para fuentes subidas subdir_fuentes = os.path.join(tempfile.gettempdir(), 'fuentes_subidas') os.makedirs(subdir_fuentes, exist_ok=True) for file in files: if file.name.lower().endswith('.ttf'): # Obtener solo el nombre del archivo sin el path completo nombre_archivo = os.path.basename(file.name) destino = os.path.join(subdir_fuentes, nombre_archivo) # Verificar si el archivo ya existe para evitar sobrescritura if os.path.exists(destino): # Puedes optar por sobrescribir, renombrar o saltar # Aquí optamos por renombrar añadiendo un sufijo numérico base, ext = os.path.splitext(nombre_archivo) contador = 1 while os.path.exists(os.path.join(subdir_fuentes, f"{base}_{contador}{ext}")): contador += 1 destino = os.path.join(subdir_fuentes, f"{base}_{contador}{ext}") # Copiar el archivo al subdirectorio shutil.copyfile(file.name, destino) nombre_fuente = os.path.splitext(nombre_archivo)[0] fuentes_cache[nombre_fuente] = destino logger.info(f"Fuente '{nombre_archivo}' subida y guardada en {destino}") else: logger.warning(f"Archivo '{file.name}' no es una fuente .ttf y será omitido.") # Actualizar el caché cache_path = os.path.join(tempfile.gettempdir(), 'fuentes_sistema.json') with open(cache_path, 'w', encoding='utf-8') as f: json.dump(fuentes_cache, f, ensure_ascii=False, indent=4) # Volver a registrar fuentes registrar_fuentes(fuentes_cache) else: cachear_fuentes() return "Fuentes actualizadas exitosamente." except Exception as e: logger.error(f"Error al actualizar fuentes: {e}") return f"Error al actualizar fuentes: {e}" def procesar_pdf(archivo_pdf, fuentes_subidas): """ Función generadora para procesar y traducir el PDF. Emite actualizaciones de estado y vista previa durante el proceso. """ try: if not archivo_pdf: yield ("No se ha subido ningún archivo PDF.", None, None) return # Emitir estado inicial yield ("Iniciando traducción...", pdf_preview(archivo_pdf.name, pagina=0), None) # Extraer texto para detectar el idioma documento = fitz.open(archivo_pdf.name) texto_completo = "" for pagina in documento: texto_completo += pagina.get_text() idioma_origen = detectar_idioma_texto(texto_completo) idioma_sistema = detectar_idioma_sistema() # Si el idioma de origen y destino son iguales, no realizar traducción if idioma_origen == idioma_sistema: logger.info("El idioma de origen y destino son iguales. No se realizará la traducción.") yield ("El idioma de origen y destino son iguales. No se realizó la traducción.", pdf_preview(archivo_pdf.name, pagina=0), None) return # Cargar el modelo de traducción automáticamente tokenizer, model, dispositivo = cargar_modelo_traduccion(idioma_origen, idioma_sistema) # Realizar la traducción y emitir actualizaciones generator = extraer_y_traducir_pdf_generador( archivo_pdf, tokenizer, model, dispositivo, idioma_sistema ) for update in generator: status, imagen = update yield (status, imagen, None) # Emitir estado final pdf_traducido_path = os.path.splitext(archivo_pdf.name)[0] + f"_traducido_{idioma_sistema}.pdf" yield ("Traducción completada exitosamente.", pdf_preview(pdf_traducido_path, pagina=0), pdf_traducido_path) except Exception as e: logger.error(f"Error en procesar_pdf: {e}") yield (f"Error en la traducción: {e}", None, None) # Interfaz de usuario con Gradio with gr.Blocks( title="Traductor de PDF Multilenguaje", theme=gr.themes.Default( primary_hue="blue", spacing_size="md", radius_size="lg" ) ) as iface: with gr.Row(): with gr.Column(scale=1): gr.Markdown("# Traductor de PDF Multilenguaje") pdf_input = gr.File(label="Sube tu PDF", file_types=['.pdf']) # Eliminamos el Dropdown de selección manual del modelo de traducción fuentes_subidas = gr.File(label="Sube fuentes faltantes (opcional)", file_count="multiple", file_types=['.ttf']) actualizar_fuentes_btn = gr.Button("Actualizar Fuentes del Sistema") actualizar_fuentes_output = gr.Textbox(label="Actualización de Fuentes", interactive=False) actualizar_fuentes_btn.click( fn=boton_actualizar_fuentes, inputs=fuentes_subidas, outputs=actualizar_fuentes_output ) with gr.Column(scale=1): gr.Markdown("## Vista Previa") preview = gr.Image(label="Vista Previa", visible=True) status_md = gr.Markdown("**Estado:** Esperando traducción...") traducir_btn = gr.Button("Traducir PDF") traducir_output = gr.File(label="Descargar PDF traducido", visible=True) # Configurar el botón para usar la función generadora traducir_btn.click( fn=procesar_pdf, inputs=[pdf_input, fuentes_subidas], outputs=[status_md, preview, traducir_output], show_progress=True # Opcional: muestra una barra de progreso ) # Vista previa del PDF def actualizar_preview(file): return pdf_preview(file.name, pagina=0) if file else None pdf_input.change( fn=actualizar_preview, inputs=pdf_input, outputs=preview ) # Ejecutar la interfaz de usuario con la opción de compartir públicamente iface.launch(share=True)