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. """ traducciones = [] for i in range(0, len(textos), batch_size): batch = textos[i:i+batch_size] 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] 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. """ 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 def extraer_y_traducir_pdf(archivo_pdf, tokenizer, model, dispositivo, idioma_destino): """ Extrae el contenido del PDF, traduce el texto y crea un nuevo PDF traducido. """ 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"]: textos.append(span["text"]) posiciones.append((span["bbox"], span["font"], span["size"], numero_pagina)) # Traducir texto traducciones = traducir_texto(tokenizer, model, textos, dispositivo) # 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, alto = rect.width, rect.height # Ajustar el tamaño de página al tamaño original del PDF c.setPageSize((ancho, alto)) # Definir márgenes dinámicos en base al tamaño de la página, ej: 5% de ancho y alto margen_x = ancho * 0.05 margen_y = alto * 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"]: # Obtener el texto traducido correspondiente texto_traducido = traducciones[idx_texto] bbox, font, size, span_pagina = posiciones[idx_texto] idx_texto += 1 x0, y0, x1, y1 = bbox # Ajustar coordenadas al sistema de ReportLab (y invertida) x = x0 y = alto - 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 - 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 - y1 - alto_img, width=ancho_img, height=alto_img) except Exception as e: logger.error(f"Error al procesar imagen: {e}") continue c.showPage() c.save() return pdf_traducido_path def pdf_preview(file): """ Previsualiza la primera página del PDF como una imagen. """ try: doc = fitz.open(file.name) page = doc[0] 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() for file in files: if file.name.lower().endswith('.ttf'): destino = os.path.join(tempfile.gettempdir(), file.name) # Copiar el archivo desde la ruta temporal a destino shutil.copyfile(file.name, destino) nombre_fuente = os.path.splitext(file.name)[0] fuentes_cache[nombre_fuente] = destino logger.info(f"Fuente '{file.name}' 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 principal para procesar y traducir el PDF. """ try: if not archivo_pdf: return None, "No se ha subido ningún archivo PDF." # 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.") return archivo_pdf.name, "El idioma de origen y destino son iguales. No se realizó la traducción." # Cargar el modelo de traducción automáticamente tokenizer, model, dispositivo = cargar_modelo_traduccion(idioma_origen, idioma_sistema) # Traducir el PDF pdf_traducido_path = extraer_y_traducir_pdf(archivo_pdf, tokenizer, model, dispositivo, idioma_sistema) return pdf_traducido_path, "Traducción completada exitosamente." except Exception as e: logger.error(f"Error en procesar_pdf: {e}") return None, f"Error en la traducción: {e}" def actualizar_fuentes_cache(): """ Función para actualizar el caché de fuentes. """ try: cachear_fuentes() return "Fuentes cacheadas exitosamente." except Exception as e: logger.error(f"Error al cachear fuentes: {e}") return f"Error al cachear fuentes: {e}" # 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) traducir_btn = gr.Button("Traducir PDF") estado_traduccion = gr.Textbox(label="Estado", interactive=False) traducir_btn.click( fn=procesar_pdf, inputs=[pdf_input, fuentes_subidas], outputs=[gr.File(label="Descargar PDF traducido"), estado_traduccion] ) # Vista previa del PDF pdf_input.change( fn=pdf_preview, inputs=pdf_input, outputs=preview ) # Ejecutar la interfaz de usuario con la opción de compartir públicamente iface.launch(share=True)