Manual de Empleo del Proyecto Traductor de Textos

Este proyecto comprende un sistema completo de traducción automática que extrae texto de archivos, los segmenta en frases, los almacena en MongoDB y los traduce entre múltiples idiomas usando IA local. El proyecto ha evolucionado hacia una aplicación unificada que integra todo el proceso en una interfaz gráfica intuitiva.

Estado Actual del Proyecto

🏆 Aplicación Principal - traductor_documentos.py

🔧 Scripts de Desarrollo y Debugging

Los scripts individuales representan la evolución modular del proyecto:

Versiones CLI (Línea de Comandos)

Versiones GUI Modulares

Evolución del Proyecto: Los scripts en la carpeta debugeo/ representan el desarrollo modular original que ha sido consolidado en la aplicación unificada traductor_documentos.py. Estos scripts siguen siendo funcionales para desarrollo específico y compatibilidad.

Arquitectura Evolutiva

Del Desarrollo Modular a la Integración Completa

El proyecto evolucionó desde scripts separados hacia una aplicación unificada que mantiene todas las funcionalidades pero ofrece una experiencia más intuitiva:

  1. Fase 1: Scripts CLI separados para funciones específicas
  2. Fase 2: Interfaces GUI individuales por cada función
  3. Fase 3: Aplicación integrada con múltiples pestañas

Explicación Detallada de Cada Script

Script 1: 1-extraer_texto_a mongodb.py

Este script se encarga de leer un archivo de texto y prepararlo para su procesamiento posterior. Específicamente:

Script 2: 2-traducir_desde_mongodb.py

Este script toma los datos preparados por el script anterior y los traduce usando inteligencia artificial. Realiza los siguientes pasos:

Script GUI 1: 1-extraer_texto_a mongodb_con_gui.py

Esta aplicación con interfaz gráfica permite una experiencia más intuitiva para extraer texto a MongoDB:

Script GUI 2: 2-traducir_desde_mongodb_con_gui.py

Interfaz avanzada para traducción masiva y gestión de colecciones:

Script 3: 3-componer.py

Esta aplicación gráfica permite componer textos finales desde las colecciones traducidas almacenadas en MongoDB:

Requisitos Previos

Requisitos General

Dependencias para Versión CLI

Dependencias para Versión GUI Unificada

⚠️ Nota sobre dependencias

Para la aplicación unificada: Solo necesitas la instalación completa una vez. Para los scripts individuales de debugeo/, las dependencias varían según la funcionalidad específica.

Cómo Ejecutar el Proyecto

🏆 Aplicación Unificada Recomendada (Opción Más Simple)

Ejecución con traductor_documentos.py

La aplicación más sencilla y completa para usar el proyecto.

  1. Instala dependencias: pip install pymongo requests PySide6 PyPDF2 reportlab
  2. Ejecuta la aplicación:
  3. python traductor_documentos.py
  4. Flujo de uso típico:
    Pestaña 1 - Extraer: Selecciona archivo .txt o .pdf
    Pestaña 2 - Traducir: Elige idiomas y colecciones
    Pestaña 3 - Componer: Genera archivos finales .txt y .pdf
✅ Ventajas: Una sola aplicación para todo el proceso, interfaz intuitiva con tres pestañas, proceso completo con barras de progreso individuales.

Ejecución con Versión CLI

  1. Asegúrate de que MongoDB esté corriendo.
  2. Verifica que Ollama esté ejecutándose con el modelo correcto.
  3. Ejecuta el primer script de extracción:
  4. python debugeo/1-extraer_texto_a mongodb.py
  5. Ejecuta el segundo script de traducción:
  6. python debugeo/2-traducir_desde_mongodb.py

Ejecución con Scripts GUI Independientes

  1. Asegúrate de que estén cumplidos todos los requisitos previos (incluyendo PySide6).
  2. Ejecuta la aplicación de extracción GUI:
  3. python debugeo/1-extraer_texto_a mongodb_con_gui.py
  4. Clic en "Seleccionar archivo" y elige el archivo de texto deseado.
  5. Clic en "Procesar" y observa la barra de progreso.
  6. Ejecuta la aplicación de traducción GUI:
  7. python debugeo/2-traducir_desde_mongodb_con_gui.py
  8. Selecciona las colecciones que deseas traducir usando el checkbox o "Seleccionar Todo".
  9. Clic en "Iniciar Traducción" y observa el registro de actividad y progreso.
  10. La interfaz GUI permite cancelar operaciones en cualquier momento.
  11. Ejecuta la aplicación de composición GUI:
  12. python debugeo/3-componer.py
    
    # Para modo de diagnóstico/prueba (consola):
    python debugeo/3-componer.py --test
  13. Clic en "Actualizar Lista" para obtener las colecciones traducidas.
  14. Selecciona las colecciones a procesar usando checkboxes.
  15. Opcionalmente, marca "Exportar también a PDF" para generar PDF además de texto.
  16. Clic en "Procesar Seleccionadas" y observa el progreso y registro.
  17. El modo --test permite verificar conexiones y contenido sin interfaz gráfica.

ℹ️ Sobre los Scripts en la Carpeta debugeo/

Los archivos en debugeo/ son versiones individuales que precedieron a la aplicación unificada. Son útiles para desarrollo específico pero requieren ejecutar múltiples scripts por separado. Se mantienen por compatibilidad y debugging avanzado.

Nota importante: El orden de ejecución es crítico. Siempre ejecuta primero la extracción antes de la traducción, ya sea en versión CLI o GUI. Los scripts segmentan el texto conservando saltos de línea y puntos, mientras que los scripts de traducción procesan líneas vacías eficientemente.

Configuración de MongoDB

Nota sobre bases de datos: Los scripts detectan automáticamente varias variaciones del nombre de base de datos ('traducciones', 'traducir', 'translations') para mayor compatibilidad.

Código de la Aplicación Unificada - traductor_documentos.py

📋 Arquitectura de la Aplicación Unificada

La aplicación traductor_documentos.py integra tres componentes principales en una sola interfaz:

🏗️ Componentes Principales

1. Pestaña de Extracción (ExtractionWorker)

Imagen de la pestaña Extraer Texto

2. Pestaña de Traducción (TranslationWorker)

Imagen de la pestaña Traducir

3. Pestaña de Composición (CompositionWorker)

Imagen de la pestaña Componer

🎨 Características Técnicas Avanzadas

Arquitectura con Hilos (QThread)

Procesamiento Asíncrono
  • Cada operación crítica se ejecuta en hilos separados
  • Interfaz permanece responsiva durante operaciones largas
  • Cantidad controlada de hilos para optimizar rendimiento
  • Comunicación segura entre hilos usando señales Qt

Tema Oscuro Profesional

Interfaz Moderna y Consistente
  • Paleta de colores oscura aplicada globalmente
  • Estilos consistentes para todos los widgets PySide6
  • Animaciones sutiles y transiciones suaves
  • Optimización visual para uso prolongado

Gestión Completa de Conexiones

Bases de Datos y Servicios Externos
  • MongoDB: Conexión automática con detección de bases de datos variantes
  • Ollama: Verificación de conectividad y modelos disponibles
  • Control de errores: Manejo específico para cada tipo de conexión
  • Recuperación automática: Intentos de reconexión ante fallos temporales

🚀 Ejecución Simplificada

Comando Principal
python traductor_documentos.py
Flujo de Uso Típico:
  1. Iniciar aplicación: Se abre interfaz con tres pestañas
  2. Pestaña 1 - Extraer: Seleccionar archivo (.txt o .pdf) y procesar
  3. Pestaña 2 - Traducir: Elegir colecciones e idiomas de destino
  4. Pestaña 3 - Componer: Generar archivos finales (.txt y .pdf)

⚙️ Configuración Global

Constantes Principales
# Configuración principal
DATABASE_NAME = "traducciones"
OLLAMA_URL = "http://localhost:11434/api/generate"
MODEL_NAME = "gemma3:4b"

# Idiomas soportados
IDIOMA_CODES = ["es", "en", "fr"]
IDIOMA_NAMES = {
    "es": "Español",
    "en": "English",
    "fr": "Français"
}

# Configuración MongoDB
MONGO_TIMEOUT = 5000
CONNECTION_RETRIES = 3

🛡️ Sistema de Manejo de Errores

Solución de Problemas

Problemas Generales

Problemas Específicos de GUI

Errores Comunes

Código Completo del Archivo traductor_documentos.py

📄 Código Python Formateado

Abajo se presenta el código completo del archivo traductor_documentos.py, incluyendo la explicación detallada de su funcionalidad.

"""
Traductor Completo: Extraer, Traducir y Componer en una sola interfaz GUI con PySide6.

Este script integra las funcionalidades de:
1. Extraer texto de archivos PDF/TXT y almacenarlo en MongoDB
2. Traducir colecciones de MongoDB utilizando Ollama
3. Componer archivos finales (TXT y PDF) desde las traducciones

Uso:
    python traductor_completo_gui.py

Dependencias:
    - pymongo: Para MongoDB
    - PySide6: Para la interfaz GUI
    - PyPDF2: Para leer PDFs
    - requests: Para API de Ollama
    - reportlab: Para generar PDFs
    - Ollama: Debe estar corriendo localmente en http://localhost:11434
"""
import sys
import os
from PySide6.QtWidgets import (QApplication, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
                               QProgressBar, QTextEdit, QMessageBox, QFileDialog, QGroupBox,
                               QListWidget, QListWidgetItem, QComboBox, QCheckBox, QSplitter, QTabWidget, QLineEdit)
from PySide6.QtGui import QPalette, QColor, QFont
from PySide6.QtCore import QThread, Signal, QObject
import pymongo
import PyPDF2
import requests
from reportlab.lib.pagesizes import letter
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.enums import TA_JUSTIFY
from reportlab.lib.units import inch
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak


# Constantes
DATABASE_NAME = "traducciones"
OLLAMA_URL = "http://localhost:11434/api/generate"
MODEL_NAME = "gemma3:4b"

IDIOMA_CODES = ["es", "en", "fr"]
IDIOMA_NAMES = {
    "es": "Español",
    "en": "English",
    "fr": "Français"
}

IDIOMA_NAMES_PROMPT = {
    "es": "español",
    "en": "inglés",
    "fr": "francés"
}

# Funciones auxiliares
def traducir_con_ollama(texto: str, idioma_origen: str, idioma_destino: str) -> str:
    """Traduce texto usando Ollama."""
    nombre_origen = IDIOMA_NAMES_PROMPT[idioma_origen]
    nombre_destino = IDIOMA_NAMES_PROMPT[idioma_destino]

    prompt = ""
    if idioma_origen == "es":
        prompt = f"Translate the following text from Spanish to {nombre_destino}. Provide only the {nombre_destino} translation, without explanations or additional modifications:\\n\\n{texto}"
    elif idioma_origen == "en":
        prompt = f"Translate the following text from English to {nombre_destino}. Provide only the {nombre_destino} translation, without explanations or additional modifications:\\n\\n{texto}"
    elif idioma_origen == "fr":
        prompt = f"Translate the following text from French to {nombre_destino}. Provide only the {nombre_destino} translation, without explanations or additional modifications:\\n\\n{texto}"

    payload = {
        "model": MODEL_NAME,
        "prompt": prompt,
        "stream": False
    }

    response = requests.post(OLLAMA_URL, json=payload)
    if response.status_code == 200:
        data = response.json()
        return data.get("response", "").strip()
    else:
        raise Exception(f"Error en petición Ollama: {response.status_code}")

def segmentar_frases(ruta_archivo: str):
    """Segmenta archivo PDF o TXT en líneas."""
    frases = []
    contenido = ""

    ext = os.path.splitext(ruta_archivo)[1].lower()
    if ext == ".txt":
        with open(ruta_archivo, "r", encoding="utf-8") as f:
            contenido = f.read()
    elif ext == ".pdf":
        with open(ruta_archivo, "rb") as f:
            pdf_reader = PyPDF2.PdfReader(f)
            for page in pdf_reader.pages:
                contenido += (page.extract_text() or "") + "\\n"
    else:
        raise ValueError("Formato no soportado. Solo .txt y .pdf.")

    frases = contenido.split('\\n')
    return frases

# Hilos de trabajo
class ExtractionWorker(QObject):
    progress = Signal(int)
    log = Signal(str)
    finished = Signal(bool, str)

    def __init__(self, file_path):
        super().__init__()
        self.file_path = file_path
        self.cancelled = False

    def run(self):
        try:
            frases = segmentar_frases(self.file_path)

            client = pymongo.MongoClient()
            db = client[DATABASE_NAME]
            base = os.path.basename(self.file_path)
            coleccion_nombre = os.path.splitext(base)[0]
            coleccion = db[coleccion_nombre]
            coleccion.delete_many({})

            total = len(frases)
            for i, frase in enumerate(frases, 1):
                if self.cancelled:
                    client.close()
                    self.finished.emit(False, "Extracción cancelada.")
                    return
                coleccion.insert_one({"_id": i, "linea": frase})
                self.progress.emit(int((i / total) * 100))
                self.log.emit(f"Línea {i} procesada.")

            client.close()
            self.finished.emit(True, f"Extracción completada. {total} líneas guardadas en '{coleccion_nombre}'.")
        except Exception as e:
            self.finished.emit(False, f"Error extracción: {str(e)}")

    def cancel(self):
        self.cancelled = True

class TranslationWorker(QObject):
    progress = Signal(int)
    log = Signal(str)
    finished = Signal(bool, str)

    def __init__(self, collections, source_lang, target_lang):
        super().__init__()
        self.collections = collections
        self.source_lang = source_lang
        self.target_lang = target_lang
        self.cancelled = False

    def run(self):
        try:
            client = pymongo.MongoClient()
            db = client[DATABASE_NAME]
            total_collections = len(self.collections)
            processed = 0

            for collection_name in self.collections:
                if self.cancelled:
                    client.close()
                    self.finished.emit(False, "Traducción cancelada.")
                    return

                self.log.emit(f"Traduciendo colección: {collection_name}")
                collection_origen = db[collection_name]
                collection_destino = db[f"{collection_name}_traducido_a_{self.target_lang}"]
                collection_destino.drop()

                total_docs = collection_origen.count_documents({})
                processed_docs = 0

                for documento in collection_origen.find():
                    if self.cancelled:
                        client.close()
                        self.finished.emit(False, "Traducción cancelada.")
                        return

                    linea_numero = documento["_id"]
                    linea_texto = documento["linea"]

                    if not linea_texto.strip():
                        collection_destino.insert_one({"_id": linea_numero, "linea": linea_texto})
                        processed_docs += 1
                        self.log.emit(f"Línea {linea_numero}: SKIP (vacía)")
                    else:
                        traduccion = traducir_con_ollama(linea_texto, self.source_lang, self.target_lang)
                        collection_destino.insert_one({"_id": linea_numero, "linea": traduccion})
                        processed_docs += 1
                        # Truncate long translations for display
                        display_traduccion = traduccion[:80] + "..." if len(traduccion) > 80 else traduccion
                        self.log.emit(f"Línea {linea_numero}: '{display_traduccion}'")

                    collection_progress = int((processed_docs / total_docs) * 100) if total_docs > 0 else 100
                    overall_progress = int(((processed + collection_progress / 100) / total_collections) * 100)
                    self.progress.emit(overall_progress)

                processed += 1
                self.log.emit(f"Finalizada colección {collection_name}")

            client.close()
            self.finished.emit(True, "Traducción completada.")
        except Exception as e:
            self.finished.emit(False, f"Error traducción: {str(e)}")

    def cancel(self):
        self.cancelled = True

class CompositionWorker(QObject):
    progress = Signal(int)
    log = Signal(str)
    finished = Signal(bool, str)

    def __init__(self, collections, save_dir, export_pdf):
        super().__init__()
        self.collections = collections
        self.save_dir = save_dir
        self.export_pdf = export_pdf
        self.cancelled = False

    def run(self):
        try:
            client = pymongo.MongoClient()
            db = client[DATABASE_NAME]
            total = len(self.collections)

            for i, coll_name in enumerate(self.collections, 1):
                if self.cancelled:
                    client.close()
                    self.finished.emit(False, "Composición cancelada.")
                    return

                self.log.emit(f"Procesando colección: {coll_name}")
                collection = db[coll_name]
                all_docs = list(collection.find(sort=[("_id", 1)]))

                output_file = os.path.join(self.save_dir, f"{coll_name}.txt")
                try:
                    with open(output_file, 'w', encoding='utf-8') as f:
                        for doc in all_docs:
                            if self.cancelled:
                                client.close()
                                self.finished.emit(False, "Composición cancelada.")
                                return
                            if 'linea' in doc:
                                linea_content = str(doc['linea'])
                                f.write(linea_content + '\\n')
                    self.log.emit(f"Archivo TXT creado: {output_file}")
                except Exception as e:
                    self.log.emit(f"Error creando TXT '{output_file}': {str(e)}")
                    # continue to try PDF creation (or next collection)

                if self.export_pdf:
                    try:
                        pdf_file = os.path.join(self.save_dir, f"{coll_name}.pdf")

                        # Crear un estilo personalizado con justificación (no sobrescribimos el stylesheet global)
                        styles = getSampleStyleSheet()
                        body_style = ParagraphStyle(
                            'CustomBody',
                            parent=styles['BodyText'],
                            fontName="Helvetica",
                            fontSize=12,
                            alignment=TA_JUSTIFY,
                            leading=14
                        )

                        # Función para dibujar el número de página en el footer
                        def draw_page_number(canvas, doc):
                            try:
                                canvas.setFont('Helvetica', 10)
                                page_num = canvas.getPageNumber()
                                text = f"Página {page_num}"
                                canvas.drawRightString(letter[0] - 0.5 * inch, 0.75 * inch, text)
                            except Exception as e:
                                # Logger del hilo (no arrojar excepción a ReportLab)
                                self.log.emit(f"Error en draw_page_number: {str(e)}")

                        # Crear el documento con SimpleDocTemplate (callbacks se pasan a build)
                        pdf_template = SimpleDocTemplate(
                            pdf_file,
                            pagesize=letter,
                            leftMargin=0.5*inch,
                            rightMargin=0.5*inch,
                            topMargin=0.75*inch,
                            bottomMargin=1*inch
                        )

                        story = []

                        # Add some space at the top
                        story.append(Spacer(1, 0.25*inch))

                        num_paragraphs = 0
                        for doc in all_docs:
                            if 'linea' in doc:
                                linea_content = str(doc['linea']).strip()
                                if linea_content:
                                    p = Paragraph(linea_content, body_style)
                                    story.append(p)
                                    story.append(Spacer(1, 0.1*inch))
                                    num_paragraphs += 1

                        if num_paragraphs == 0:
                            # Agregar mensaje por defecto si no hay contenido
                            p = Paragraph("No hay contenido disponible.", body_style)
                            story.append(p)

                        self.log.emit(f"Preparado PDF con {num_paragraphs} párrafos.")
                        self.log.emit(f"Generando PDF - export_pdf: {self.export_pdf}, len(story): {len(story)}")

                        # Aquí pasamos los callbacks a build (corrección clave)
                        pdf_template.build(story, onFirstPage=draw_page_number, onLaterPages=draw_page_number)

                        # Verificar que el archivo exista y tenga tamaño
                        if os.path.exists(pdf_file) and os.path.getsize(pdf_file) > 0:
                            self.log.emit(f"Archivo PDF creado: {pdf_file}")
                        else:
                            self.log.emit(f"PDF creado pero está vacío o no existe: {pdf_file}")

                    except Exception as e:
                        self.log.emit(f"Error generando PDF para '{coll_name}': {str(e)}")

                self.progress.emit(int((i / total) * 100))

            client.close()
            self.finished.emit(True, "Composición completada.")
        except Exception as e:
            self.finished.emit(False, f"Error composición: {str(e)}")

    def cancel(self):
        self.cancelled = True

# Main Window
class MainWindow(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Traductor Completo - Extraer, Traducir y Componer")
        self.setGeometry(100, 100, 900, 700)
        self.apply_dark_theme()
        self.setup_ui()
        self.connect_signals()
        self.load_collections()

    def apply_dark_theme(self):
        self.setStyleSheet("""
            QWidget {
                background-color: #2b2b2b;
                color: #ffffff;
                font-family: Arial, sans-serif;
            }
            QPushButton {
                background-color: #404040;
                color: #ffffff;
                border: 1px solid #606060;
                padding: 8px;
                border-radius: 4px;
                height: 35px;
            }
            QPushButton:hover {
                background-color: #505050;
            }
            QPushButton:pressed {
                background-color: #303030;
            }
            QPushButton:disabled {
                background-color: #303030;
                color: #666666;
            }
            QLabel {
                color: #ffffff;
                font-size: 12px;
            }
            QListWidget {
                background-color: #404040;
                color: #ffffff;
                border: 1px solid #606060;
                border-radius: 4px;
                selection-background-color: #4CAF50;
            }
            QTextEdit {
                background-color: #404040;
                color: #ffffff;
                border: 1px solid #606060;
                border-radius: 4px;
                font-family: 'Courier New', monospace;
                font-size: 11px;
            }
            QProgressBar {
                background-color: #404040;
                border: 1px solid #606060;
                border-radius: 4px;
                text-align: center;
            }
            QProgressBar::chunk {
                background-color: #4CAF50;
            }
            QComboBox {
                background-color: #404040;
                color: #ffffff;
                border: 1px solid #606060;
                border-radius: 4px;
                padding: 4px;
            }
            QLineEdit, QCheckBox {
                color: #ffffff;
                font-size: 12px;
            }
            QCheckBox::indicator {
                width: 15px;
                height: 15px;
                border: 1px solid #606060;
                background-color: #404040;
            }
            QCheckBox::indicator:checked {
                background-color: #4CAF50;
            }
            QGroupBox {
                color: #ffffff;
                font-weight: bold;
                border: 2px solid #606060;
                border-radius: 5px;
                margin-top: 10px;
            }
            QGroupBox::title {
                subcontrol-origin: margin;
                left: 10px;
                padding: 0 5px 0 5px;
            }
        """)

    def setup_ui(self):
        main_layout = QVBoxLayout()

        # Tabs
        self.tab_widget = QTabWidget()
        self.tab_widget.addTab(self.setup_extraction_tab(), "1. Extraer Texto")
        self.tab_widget.addTab(self.setup_translation_tab(), "2. Traducir")
        self.tab_widget.addTab(self.setup_composition_tab(), "3. Componer Archivos TXT y PDF")

        main_layout.addWidget(self.tab_widget)

        # Global progress and log
        self.progress_group = QGroupBox("Progreso Global")
        progress_layout = QVBoxLayout()
        self.global_progress = QProgressBar()
        self.global_progress.setRange(0, 100)
        self.global_progress.setValue(0)
        progress_layout.addWidget(self.global_progress)

        self.log_group = QGroupBox("Registro de Actividad")
        log_layout = QVBoxLayout()
        self.log_text = QTextEdit()
        log_layout.addWidget(self.log_text)

        main_layout.addWidget(self.progress_group)
        main_layout.addWidget(self.log_group)

        self.setLayout(main_layout)

        self.worker = None
        self.thread = None

    def setup_extraction_tab(self):
        tab = QWidget()
        layout = QVBoxLayout()

        # File selection
        file_group = QGroupBox("Seleccionar Archivo")
        file_layout = QVBoxLayout()
        self.extraction_file_label = QLabel("Archivo seleccionado: Ninguno")
        self.select_file_btn = QPushButton("Seleccionar Archivo (TXT/PDF)")
        file_layout.addWidget(self.extraction_file_label)
        file_layout.addWidget(self.select_file_btn)
        file_group.setLayout(file_layout)

        # Controls
        controls_group = QGroupBox("Controles")
        controls_layout = QVBoxLayout()
        self.extract_btn = QPushButton("Extraer Texto")
        self.extract_btn.setEnabled(False)
        controls_layout.addWidget(self.extract_btn)
        controls_group.setLayout(controls_layout)

        layout.addWidget(file_group)
        layout.addWidget(controls_group)
        layout.addStretch()

        tab.setLayout(layout)
        return tab

    def setup_translation_tab(self):
        tab = QWidget()
        layout = QVBoxLayout()

        # Collections
        collections_group = QGroupBox("Colecciones por Traducir")
        collections_layout = QVBoxLayout()
        self.translation_list = QListWidget()
        self.translation_list.setSelectionMode(QListWidget.MultiSelection)
        self.refresh_btn = QPushButton("Actualizar Colecciones")
        collections_layout.addWidget(self.translation_list)
        collections_layout.addWidget(self.refresh_btn)
        collections_group.setLayout(collections_layout)

        # Language selection
        language_group = QGroupBox("Idiomas")
        language_layout = QVBoxLayout()
        source_layout = QHBoxLayout()
        source_layout.addWidget(QLabel("Desde:"))
        self.source_combo = QComboBox()
        self.source_combo.addItems([f"{code} - {name}" for code, name in IDIOMA_NAMES.items()])
        self.source_combo.setCurrentIndex(0)
        source_layout.addWidget(self.source_combo)

        target_layout = QHBoxLayout()
        target_layout.addWidget(QLabel("Hasta:"))
        self.target_combo = QComboBox()
        self.target_combo.addItems([f"{code} - {name}" for code, name in IDIOMA_NAMES.items()])
        self.target_combo.setCurrentIndex(1)
        target_layout.addWidget(self.target_combo)

        language_layout.addLayout(source_layout)
        language_layout.addLayout(target_layout)
        language_group.setLayout(language_layout)

        # Controls
        controls_group = QGroupBox("Controles")
        controls_layout = QVBoxLayout()
        self.translate_btn = QPushButton("Traducir Seleccionadas")
        self.translate_btn.setEnabled(False)
        self.translate_cancel_btn = QPushButton("Cancelar")
        self.translate_cancel_btn.setEnabled(False)
        controls_layout.addWidget(self.translate_btn)
        controls_layout.addWidget(self.translate_cancel_btn)
        controls_group.setLayout(controls_layout)

        # Translation Progress and Log
        progress_group = QGroupBox("Progreso de Traducción")
        progress_layout = QVBoxLayout()
        self.translation_progress = QProgressBar()
        self.translation_progress.setRange(0, 100)
        self.translation_progress.setValue(0)
        progress_layout.addWidget(self.translation_progress)

        translation_log_label = QLabel("Registro de Traducción:")
        self.translation_log = QTextEdit()
        self.translation_log.setMaximumHeight(300)
        self.translation_log.setFont(QFont("Monospace", 9))
        self.translation_log.setReadOnly(True)
        progress_layout.addWidget(translation_log_label)
        progress_layout.addWidget(self.translation_log)

        progress_group.setLayout(progress_layout)

        layout.addWidget(collections_group)
        layout.addWidget(language_group)
        layout.addWidget(controls_group)
        layout.addWidget(progress_group)

        tab.setLayout(layout)
        return tab

    def setup_composition_tab(self):
        tab = QWidget()
        layout = QVBoxLayout()

        # Collections
        collections_group = QGroupBox("Colecciones Traducidas")
        collections_layout = QVBoxLayout()
        self.composition_list = QListWidget()
        self.composition_list.setSelectionMode(QListWidget.MultiSelection)
        self.refresh_translation_btn = QPushButton("Actualizar Colecciones Traducidas")
        collections_layout.addWidget(self.composition_list)
        collections_layout.addWidget(self.refresh_translation_btn)
        collections_group.setLayout(collections_layout)

        # Controls
        controls_group = QGroupBox("Controles")
        controls_layout = QVBoxLayout()
        self.compose_btn = QPushButton("Componer Archivos TXT y PDF")
        self.compose_btn.setEnabled(False)
        controls_layout.addWidget(self.compose_btn)
        controls_group.setLayout(controls_layout)

        layout.addWidget(collections_group)
        layout.addWidget(controls_group)
        layout.addStretch()

        tab.setLayout(layout)
        return tab

    def connect_signals(self):
        # Extraction
        self.select_file_btn.clicked.connect(self.select_file)
        self.extract_btn.clicked.connect(self.start_extraction)

        # Translation
        self.refresh_btn.clicked.connect(self.load_collections)
        self.translate_btn.clicked.connect(self.start_translation)
        self.translate_cancel_btn.clicked.connect(self.cancel_translation)

        # Composition
        self.refresh_translation_btn.clicked.connect(self.load_translated_collections)
        self.compose_btn.clicked.connect(self.start_composition)

    def select_file(self):
        file_path, _ = QFileDialog.getOpenFileName(self, "Seleccionar archivo", "", "Archivos de texto (*.txt);;PDF (*.pdf)")
        if file_path:
            self.extraction_file_path = file_path
            self.extraction_file_label.setText(f"Archivo seleccionado: {os.path.basename(file_path)}")
            self.extract_btn.setEnabled(True)
            self.log("Archivo seleccionado: " + os.path.basename(file_path))
        else:
            self.extract_btn.setEnabled(False)
            self.extraction_file_label.setText("Archivo seleccionado: Ninguno")

    def load_collections(self):
        try:
            client = pymongo.MongoClient()
            db = client[DATABASE_NAME]
            collections = db.list_collection_names()

            # Filter original collections (no translated ones)
            known_suffixes = [f"_traducido_a_{lang}" for lang in IDIOMA_CODES]
            original_collections = [coll for coll in collections if not any(coll.endswith(suf) for suf in known_suffixes)]

            self.translation_list.clear()
            for coll in sorted(original_collections):
                item = QListWidgetItem(coll)
                self.translation_list.addItem(item)

            if original_collections:
                self.translate_btn.setEnabled(True)
            else:
                self.translate_btn.setEnabled(False)

            client.close()
            self.log(f"Colecciones cargadas: {len(original_collections)} originales")
        except Exception as e:
            QMessageBox.warning(self, "Error", f"No se pudo cargar colecciones: {str(e)}")

    def load_translated_collections(self):
        try:
            client = pymongo.MongoClient()
            db = client[DATABASE_NAME]
            collections = db.list_collection_names()

            # Filter translated collections
            translated_collections = [coll for coll in collections if '_traducido_' in coll]

            self.composition_list.clear()
            for coll in sorted(translated_collections):
                item = QListWidgetItem(coll)
                self.composition_list.addItem(item)

            if translated_collections:
                self.compose_btn.setEnabled(True)
            else:
                self.compose_btn.setEnabled(False)

            client.close()
            self.log(f"Colecciones traducidas cargadas: {len(translated_collections)}")
        except Exception as e:
            QMessageBox.warning(self, "Error", f"No se pudo cargar colecciones traducidas: {str(e)}")

    def check_ollama_connection(self):
        """Simple check for Ollama connection"""
        try:
            payload = {
                "model": MODEL_NAME,
                "prompt": "Hello",
                "stream": False
            }
            response = requests.post(OLLAMA_URL, json=payload, timeout=5)
            return response.status_code == 200
        except:
            return False

    def start_extraction(self):
        if not hasattr(self, 'extraction_file_path'):
            QMessageBox.warning(self, "Error", "Seleccione un archivo primero.")
            return

        # Check if thread is running
        if self.thread and self.thread.isRunning():
            return

        self.worker = ExtractionWorker(self.extraction_file_path)
        self.worker.progress.connect(self.global_progress.setValue)
        self.worker.log.connect(self.log)
        self.worker.finished.connect(self.on_worker_finished)

        from PySide6.QtCore import QThread
        self.thread = QThread()
        self.worker.moveToThread(self.thread)
        self.thread.started.connect(self.worker.run)
        self.worker.finished.connect(self.thread.quit)

        self.extract_btn.setText("Extrayendo...")
        self.extract_btn.setEnabled(False)
        self.thread.start()

    def start_translation(self):
        selected_items = self.translation_list.selectedItems()
        if not selected_items:
            QMessageBox.warning(self, "Error", "Seleccione al menos una colección.")
            return

        if not self.check_ollama_connection():
            QMessageBox.warning(self, "Error", "No se puede conectar a Ollama. Asegúrese de que esté ejecutándose.")
            return

        collections = [item.text() for item in selected_items]
        source_idx = self.source_combo.currentIndex()
        target_idx = self.target_combo.currentIndex()
        source_lang = IDIOMA_CODES[source_idx]
        target_lang = IDIOMA_CODES[target_idx]

        if source_lang == target_lang:
            QMessageBox.warning(self, "Error", "Los idiomas deben ser diferentes.")
            return

        # Check if thread is running
        if self.thread and self.thread.isRunning():
            return

        self.worker = TranslationWorker(collections, source_lang, target_lang)
        self.worker.progress.connect(self.global_progress.setValue)
        self.worker.progress.connect(self.translation_progress.setValue)
        self.worker.log.connect(self.log)
        self.worker.log.connect(self.log_translation)
        self.worker.finished.connect(self.on_worker_finished)

        from PySide6.QtCore import QThread
        self.thread = QThread()
        self.thread.started.connect(self.worker.run)
        self.worker.finished.connect(self.thread.quit)

        # Clear translation log and reset progress
        self.translation_log.clear()
        self.translation_progress.setValue(0)

        self.translate_btn.setText("Traduciendo...")
        self.translate_btn.setEnabled(False)
        self.translate_cancel_btn.setText("Cancelar")
        self.translate_cancel_btn.setEnabled(True)
        self.thread.start()

    def cancel_translation(self):
        if self.worker and isinstance(self.worker, TranslationWorker) and self.thread and self.thread.isRunning():
            self.worker.cancel()
            self.translate_cancel_btn.setText("Cancelando...")
            self.translate_cancel_btn.setEnabled(False)
            self.log("Progreso de traducción cancelado por el usuario.")

    def start_composition(self):
        selected_items = self.composition_list.selectedItems()
        if not selected_items:
            QMessageBox.warning(self, "Error", "Seleccione al menos una colección.")
            return

        collections = [item.text() for item in selected_items]
        save_dir = QFileDialog.getExistingDirectory(self, "Seleccionar directorio para guardar archivos")
        if not save_dir:
            save_dir = os.getcwd()

        # Check if thread is running
        if self.thread and self.thread.isRunning():
            return

        self.worker = CompositionWorker(collections, save_dir, True)
        self.worker.progress.connect(self.global_progress.setValue)
        self.worker.log.connect(self.log)
        self.worker.finished.connect(self.on_worker_finished)

        from PySide6.QtCore import QThread
        self.thread = QThread()
        self.worker.moveToThread(self.thread)
        self.thread.started.connect(self.worker.run)
        self.worker.finished.connect(self.thread.quit)

        self.compose_btn.setText("Componiendo...")
        self.compose_btn.setEnabled(False)
        self.thread.start()

    def on_worker_finished(self, success, message):
        self.global_progress.setValue(100 if success else 0)
        self.log(message)

        # Reset buttons
        self.extract_btn.setText("Extraer Texto")
        self.extract_btn.setEnabled(True)
        self.translate_btn.setText("Traducir Seleccionadas")
        self.translate_btn.setEnabled(True)
        self.translate_cancel_btn.setText("Cancelar")
        self.translate_cancel_btn.setEnabled(False)
        self.compose_btn.setText("Componer Archivos")
        self.compose_btn.setEnabled(True)

        if self.thread:
            self.thread.quit()
            self.thread.wait()

        if success:
            QMessageBox.information(self, "Éxito", message)
        else:
            QMessageBox.warning(self, "Error", message)

        # Refresh collections if needed
        self.load_collections()
        self.load_translated_collections()

        # Reset worker and thread
        self.worker = None
        self.thread = None

    def log(self, message):
        self.log_text.append(message)
        # Auto-scroll to bottom
        scrollbar = self.log_text.verticalScrollBar()
        scrollbar.setValue(scrollbar.maximum())

    def log_translation(self, message):
        self.translation_log.append(message)
        # Auto-scroll to bottom
        scrollbar = self.translation_log.verticalScrollBar()
        scrollbar.setValue(scrollbar.maximum())

if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    sys.exit(app.exec())

📋 Explicación de la Funcionalidad

El código anterior representa una aplicación completa de traducción automática:

1. Arquitectura General
2. Funciones Core
3. Worker Classes
4. Soporte Multiidioma

Soporta español, inglés y francés con prompts optimizados por idioma de origen.

5. Generación de PDFs

Usa ReportLab para crear documentos justificados con números de página automáticos.

Esta aplicación es el resultado final de la evolución del proyecto, consolidando módulos separados en una experiencia coherente.

Contacto

Para más información o soporte, visita bgonpin.github.io.