NoeGH commited on
Commit
2dcc73d
·
verified ·
1 Parent(s): 98dcfd2

Upload 5 files

Browse files
Files changed (6) hide show
  1. .gitattributes +1 -0
  2. LOGO_ADVISION_AI_TRANSPARENTE.png +3 -0
  3. README.md +136 -16
  4. app.py +1448 -0
  5. packages.txt +25 -0
  6. requirements.txt +31 -3
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ LOGO_ADVISION_AI_TRANSPARENTE.png filter=lfs diff=lfs merge=lfs -text
LOGO_ADVISION_AI_TRANSPARENTE.png ADDED

Git LFS Details

  • SHA256: 86840a04749c1fabc9684f631fb940e2de226956b833329ac60beb491d59de39
  • Pointer size: 131 Bytes
  • Size of remote file: 213 kB
README.md CHANGED
@@ -1,20 +1,140 @@
1
- ---
2
- title: Libretranslate Prodoc
3
- emoji: 🚀
4
- colorFrom: red
5
- colorTo: red
6
- sdk: docker
7
- app_port: 8501
8
- tags:
9
- - streamlit
10
  pinned: false
11
- short_description: 'Traduce documentos de PDF y Word desde y a cualquier idioma '
12
- license: other
13
- ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
- # Welcome to Streamlit!
16
 
17
- Edit `/src/streamlit_app.py` to customize this app to your heart's desire. :heart:
18
 
19
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
20
- forums](https://discuss.streamlit.io).
 
1
+ \---
2
+
3
+ title: LibreTranslate ProDoc
4
+ emoji: 🌐
5
+ colorFrom: blue
6
+ colorTo: purple
7
+ sdk: streamlit
8
+ sdk\_version: "1.35.0"
9
+ app\_file: app.py
10
  pinned: false
11
+ license: mit
12
+ short\_description: Traduce PDF y Word offline manteniendo el diseño original
13
+ tags:
14
+
15
+ * translation
16
+ * pdf
17
+ * docx
18
+ * argos-translate
19
+ * offline
20
+ * document-processing
21
+
22
+ \---
23
+
24
+ # 🌐 LibreTranslate ProDoc
25
+
26
+ > \*\*Traducción offline de documentos PDF y Word con preservación de diseño.\*\*
27
+ > Motor: \[Argos Translate](https://github.com/argosopentech/argos-translate) · Desarrollado por AdVision AI
28
+
29
+ \---
30
+
31
+ ## ✨ Características Principales
32
+
33
+ |Característica|Detalle|
34
+ |-|-|
35
+ |📄 **Formatos**|PDF (vía PyMuPDF) y DOCX (vía python-docx)|
36
+ |🔒 **Privacidad**|100% offline, tus documentos no salen del servidor|
37
+ |🎨 **Diseño**|Preserva layout, fuentes, negritas, cursivas y tablas|
38
+ |🗑️ **Seguridad**|Auto-limpieza: archivos eliminados a los 5 minutos|
39
+ |🌍 **Idiomas**|20 idiomas: EN, ES, FR, DE, IT, PT, ZH, JA, KO, RU...|
40
+ |⚡ **Lazy Loading**|Los modelos se descargan solo cuando los necesitas|
41
+
42
+ \---
43
+
44
+ ## 🚀 Cómo Usar la App
45
+
46
+ 1. **Selecciona los idiomas** en la barra lateral izquierda
47
+
48
+ * Idioma Origen: el idioma del documento original
49
+ * Idioma Destino: el idioma al que quieres traducir
50
+ 2. **Carga tu documento** en el área central
51
+
52
+ * Arrastra el archivo o haz clic en "Browse files"
53
+ * Formatos aceptados: `.pdf` · `.docx`
54
+ * Tamaño máximo: 50 MB
55
+ 3. **Haz clic en "Traducir Documento"**
56
+
57
+ * La primera vez, descargará el modelo de idioma (\~150MB)
58
+ * Las siguientes veces será instantáneo (usa el caché)
59
+ 4. **Descarga el resultado**
60
+
61
+ * Aparecerá un botón de descarga con el documento traducido
62
+ * El archivo se eliminará del servidor en 5 minutos automáticamente
63
+
64
+ \---
65
+
66
+ ## 🌍 Idiomas Soportados
67
+
68
+ |Código|Idioma|Código|Idioma|
69
+ |-|-|-|-|
70
+ |`en`|🇺🇸 Inglés|`zh`|🇨🇳 Chino|
71
+ |`es`|🇪🇸 Español|`ja`|🇯🇵 Japonés|
72
+ |`fr`|🇫🇷 Francés|`ko`|🇰🇷 Coreano|
73
+ |`de`|🇩🇪 Alemán|`tr`|🇹🇷 Turco|
74
+ |`it`|🇮🇹 Italiano|`sv`|🇸🇪 Sueco|
75
+ |`pt`|🇵🇹 Portugués|`da`|🇩🇰 Danés|
76
+ |`nl`|🇳🇱 Neerlandés|`fi`|🇫🇮 Finlandés|
77
+ |`pl`|🇵🇱 Polaco|`nb`|🇳🇴 Noruego|
78
+ |`ru`|🇷🇺 Ruso|`cs`|🇨🇿 Checo|
79
+ |`ar`|🇸🇦 Árabe|`hu`|🇭🇺 Húngaro|
80
+
81
+ \---
82
+
83
+ ## ⚙️ Arquitectura Técnica
84
+
85
+ ```
86
+ ┌─────────────────────────────────────────┐
87
+ │ LibreTranslate ProDoc │
88
+ │ (Streamlit UI) │
89
+ └──────────────────┬──────────────────────┘
90
+
91
+ ┌──────────┴──────────┐
92
+ │ │
93
+ ┌────▼────┐ ┌─────▼─────┐
94
+ │ PDF │ │ DOCX │
95
+ │PyMuPDF │ │python-docx│
96
+ └────┬────┘ └─────┬─────┘
97
+ │ │
98
+ └──────────┬──────────┘
99
+
100
+ ┌────────▼────────┐
101
+ │ Argos Translate │
102
+ │ (Offline NMT) │
103
+ └────────┬─────────┘
104
+
105
+ ┌────────▼─────────┐
106
+ │ Auto-Cleanup │
107
+ │ threading.Timer │
108
+ │ (5 minutos) │
109
+ └──────────────────┘
110
+ ```
111
+
112
+ \---
113
+
114
+ ## ⚠️ Limitaciones Conocidas
115
+
116
+ * **PDFs escaneados (imágenes):** El motor extrae texto digital. Los PDFs que son imágenes escaneadas sin OCR no pueden ser procesados.
117
+ * **Fuentes personalizadas:** Si el PDF usa fuentes muy específicas no instaladas en el servidor, el texto traducido usará una fuente de respaldo estándar.
118
+ * **Textos muy largos:** Los documentos muy extensos (>200 páginas) pueden tardar varios minutos. Ten paciencia.
119
+ * **Longitud del texto:** El texto traducido puede ser más largo/corto que el original, lo que puede afectar el layout en PDFs muy ajustados.
120
+
121
+ \---
122
+
123
+ ## 📄 Licencia
124
+
125
+ MIT License — Libre para uso personal y comercial con atribución.
126
+
127
+ \---
128
+
129
+ ## 💙 Créditos
130
+
131
+ * [**Argos Translate**](https://github.com/argosopentech/argos-translate) — Motor de traducción NMT offline
132
+ * [**PyMuPDF**](https://pymupdf.readthedocs.io/) — Procesamiento de PDFs
133
+ * [**python-docx**](https://python-docx.readthedocs.io/) — Procesamiento de Word
134
+ * [**Streamlit**](https://streamlit.io/) — Framework de UI
135
+ * **AdVision AI** — Diseño y desarrollo
136
 
137
+ \---
138
 
139
+ *¿Te resulta útil? ¡Apóyanos con una donación vía* [*PayPal*](https://www.paypal.me/TU_USUARIO_AQUI) *o contáctanos por* [*WhatsApp*](https://wa.me/521XXXXXXXXXX)*!*
140
 
 
 
app.py ADDED
@@ -0,0 +1,1448 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # LibreTranslate ProDoc — app.py
3
+ # Desarrollado para Hugging Face Spaces (SDK: Streamlit)
4
+ # Motor de traducción: Argos Translate (100% offline)
5
+ # Autor: AdVision AI | Versión: 1.0.0
6
+ # =============================================================================
7
+ # DESCRIPCIÓN GENERAL:
8
+ # Aplicación que traduce archivos PDF y DOCX manteniendo el diseño original.
9
+ # Utiliza Argos Translate para funcionar sin conexión a internet una vez
10
+ # descargados los modelos de idioma.
11
+ # =============================================================================
12
+
13
+ import streamlit as st
14
+ import os
15
+ import sys
16
+ import time
17
+ import threading
18
+ import tempfile
19
+ import shutil
20
+ import logging
21
+ from pathlib import Path
22
+ from datetime import datetime
23
+
24
+ # ─── Configuración de logging ──────────────────────────────────────────────────
25
+ logging.basicConfig(
26
+ level=logging.INFO,
27
+ format="%(asctime)s [%(levelname)s] %(message)s"
28
+ )
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # ─── CONFIGURACIÓN DE PÁGINA (DEBE IR PRIMERO) ─────────────────────────────────
32
+ st.set_page_config(
33
+ page_title="LibreTranslate ProDoc",
34
+ page_icon="🌐",
35
+ layout="wide",
36
+ initial_sidebar_state="expanded",
37
+ )
38
+
39
+ # =============================================================================
40
+ # SECCIÓN 1: INYECCIÓN DE CSS PERSONALIZADO
41
+ # Paleta de colores extraída del logotipo AdVision AI:
42
+ # - Fondo principal: #0a0a0f (negro profundo)
43
+ # - Cian eléctrico: #00e5ff
44
+ # - Azul vibrante: #3d5afe
45
+ # - Violeta: #9c27b0
46
+ # - Magenta: #e91e8c
47
+ # =============================================================================
48
+ CUSTOM_CSS = """
49
+ <style>
50
+ /* ── Importar fuentes de Google Fonts ─────────────────────────────────────── */
51
+ @import url('https://fonts.googleapis.com/css2?family=Exo+2:wght@300;400;600;700;900&family=Space+Mono:wght@400;700&display=swap');
52
+
53
+ /* ── Variables CSS globales ───────────────────────────────────────────────── */
54
+ :root {
55
+ --bg-primary: #0a0a0f;
56
+ --bg-secondary: #12121a;
57
+ --bg-card: #1a1a2e;
58
+ --cyan: #00e5ff;
59
+ --blue: #3d5afe;
60
+ --violet: #9c27b0;
61
+ --magenta: #e91e8c;
62
+ --white: #f0f0f5;
63
+ --gray: #8888aa;
64
+ --gradient-main: linear-gradient(135deg, #00e5ff 0%, #3d5afe 35%, #9c27b0 65%, #e91e8c 100%);
65
+ --gradient-glow: linear-gradient(135deg, rgba(0,229,255,0.15) 0%, rgba(233,30,140,0.15) 100%);
66
+ --radius-lg: 16px;
67
+ --radius-md: 10px;
68
+ --radius-sm: 6px;
69
+ --shadow-glow: 0 0 30px rgba(0,229,255,0.12), 0 0 60px rgba(156,39,176,0.08);
70
+ }
71
+
72
+ /* ── Reset general de Streamlit ───────────────────────────────────────────── */
73
+ html, body, [data-testid="stAppViewContainer"] {
74
+ background-color: var(--bg-primary) !important;
75
+ font-family: 'Exo 2', sans-serif !important;
76
+ color: var(--white) !important;
77
+ }
78
+
79
+ /* ── Fondo con patrón de cuadrícula sutil ─────────────────────────────────── */
80
+ [data-testid="stAppViewContainer"]::before {
81
+ content: "";
82
+ position: fixed;
83
+ top: 0; left: 0; right: 0; bottom: 0;
84
+ background-image:
85
+ linear-gradient(rgba(0,229,255,0.03) 1px, transparent 1px),
86
+ linear-gradient(90deg, rgba(0,229,255,0.03) 1px, transparent 1px);
87
+ background-size: 40px 40px;
88
+ pointer-events: none;
89
+ z-index: 0;
90
+ }
91
+
92
+ /* ── Sidebar ──────────────────────────────────────────────────────────────── */
93
+ [data-testid="stSidebar"] {
94
+ background: var(--bg-secondary) !important;
95
+ border-right: 1px solid rgba(0,229,255,0.15) !important;
96
+ box-shadow: 4px 0 20px rgba(0,0,0,0.4) !important;
97
+ }
98
+
99
+ [data-testid="stSidebar"] * {
100
+ color: var(--white) !important;
101
+ }
102
+
103
+ /* ── Título principal con degradado ──────────────────────────────────────── */
104
+ .app-title {
105
+ font-family: 'Exo 2', sans-serif;
106
+ font-weight: 900;
107
+ font-size: 2.6rem;
108
+ background: var(--gradient-main);
109
+ -webkit-background-clip: text;
110
+ -webkit-text-fill-color: transparent;
111
+ background-clip: text;
112
+ text-align: center;
113
+ letter-spacing: -0.5px;
114
+ margin-bottom: 0.2rem;
115
+ }
116
+
117
+ .app-subtitle {
118
+ font-family: 'Space Mono', monospace;
119
+ font-size: 0.78rem;
120
+ color: var(--cyan);
121
+ text-align: center;
122
+ letter-spacing: 3px;
123
+ text-transform: uppercase;
124
+ opacity: 0.7;
125
+ margin-bottom: 1.5rem;
126
+ }
127
+
128
+ /* ── Contenedores / cards ─────────────────────────────────────────────────── */
129
+ .pro-card {
130
+ background: var(--bg-card);
131
+ border: 1px solid rgba(0,229,255,0.12);
132
+ border-radius: var(--radius-lg);
133
+ padding: 1.8rem;
134
+ margin: 1rem 0;
135
+ box-shadow: var(--shadow-glow);
136
+ position: relative;
137
+ overflow: hidden;
138
+ transition: border-color 0.3s ease;
139
+ }
140
+
141
+ .pro-card::before {
142
+ content: "";
143
+ position: absolute;
144
+ top: 0; left: 0; right: 0;
145
+ height: 2px;
146
+ background: var(--gradient-main);
147
+ border-radius: var(--radius-lg) var(--radius-lg) 0 0;
148
+ }
149
+
150
+ .pro-card:hover {
151
+ border-color: rgba(0,229,255,0.3);
152
+ }
153
+
154
+ /* ── Separador con degradado ──────────────────────────────────────────────── */
155
+ .gradient-divider {
156
+ height: 1px;
157
+ background: var(--gradient-main);
158
+ margin: 1.2rem 0;
159
+ opacity: 0.4;
160
+ border: none;
161
+ }
162
+
163
+ /* ── Etiquetas de sección ─────────────────────────────────────────────────── */
164
+ .section-label {
165
+ font-family: 'Space Mono', monospace;
166
+ font-size: 0.68rem;
167
+ color: var(--cyan);
168
+ letter-spacing: 2.5px;
169
+ text-transform: uppercase;
170
+ margin-bottom: 0.6rem;
171
+ opacity: 0.85;
172
+ }
173
+
174
+ /* ── Badge de estado ──────────────────────────────────────────────────────── */
175
+ .status-badge {
176
+ display: inline-flex;
177
+ align-items: center;
178
+ gap: 6px;
179
+ padding: 4px 12px;
180
+ border-radius: 20px;
181
+ font-size: 0.75rem;
182
+ font-weight: 600;
183
+ font-family: 'Space Mono', monospace;
184
+ }
185
+
186
+ .status-badge.ready {
187
+ background: rgba(0,229,255,0.12);
188
+ border: 1px solid rgba(0,229,255,0.3);
189
+ color: var(--cyan);
190
+ }
191
+
192
+ .status-badge.processing {
193
+ background: rgba(156,39,176,0.15);
194
+ border: 1px solid rgba(156,39,176,0.4);
195
+ color: #ce93d8;
196
+ }
197
+
198
+ .status-badge.success {
199
+ background: rgba(76,175,80,0.12);
200
+ border: 1px solid rgba(76,175,80,0.35);
201
+ color: #a5d6a7;
202
+ }
203
+
204
+ .status-badge.error {
205
+ background: rgba(244,67,54,0.12);
206
+ border: 1px solid rgba(244,67,54,0.35);
207
+ color: #ef9a9a;
208
+ }
209
+
210
+ /* ── Botones de Streamlit ─────────────────────────────────────────────────── */
211
+ .stButton > button {
212
+ background: var(--gradient-main) !important;
213
+ color: #000 !important;
214
+ font-family: 'Exo 2', sans-serif !important;
215
+ font-weight: 700 !important;
216
+ font-size: 0.95rem !important;
217
+ border: none !important;
218
+ border-radius: var(--radius-md) !important;
219
+ padding: 0.65rem 2rem !important;
220
+ letter-spacing: 0.5px !important;
221
+ transition: all 0.3s ease !important;
222
+ box-shadow: 0 4px 20px rgba(0,229,255,0.25) !important;
223
+ }
224
+
225
+ .stButton > button:hover {
226
+ transform: translateY(-2px) !important;
227
+ box-shadow: 0 8px 30px rgba(0,229,255,0.4) !important;
228
+ }
229
+
230
+ .stButton > button:active {
231
+ transform: translateY(0) !important;
232
+ }
233
+
234
+ /* ── Selectbox y otros widgets ────────────────────────────────────────────── */
235
+ .stSelectbox > div > div {
236
+ background: var(--bg-card) !important;
237
+ border: 1px solid rgba(0,229,255,0.2) !important;
238
+ border-radius: var(--radius-sm) !important;
239
+ color: var(--white) !important;
240
+ }
241
+
242
+ /* ── Progress bar ─────────────────────────────────────────────────────────── */
243
+ .stProgress > div > div > div {
244
+ background: var(--gradient-main) !important;
245
+ border-radius: 4px !important;
246
+ }
247
+
248
+ /* ── File uploader ────────────────────────────────────────────────────────── */
249
+ [data-testid="stFileUploader"] {
250
+ background: rgba(0,229,255,0.03) !important;
251
+ border: 2px dashed rgba(0,229,255,0.25) !important;
252
+ border-radius: var(--radius-lg) !important;
253
+ transition: all 0.3s ease !important;
254
+ }
255
+
256
+ [data-testid="stFileUploader"]:hover {
257
+ border-color: rgba(0,229,255,0.5) !important;
258
+ background: rgba(0,229,255,0.06) !important;
259
+ }
260
+
261
+ /* ── Texto de info/warning/error ──────────────────────────────────────────── */
262
+ .stAlert {
263
+ border-radius: var(--radius-md) !important;
264
+ border: 1px solid rgba(0,229,255,0.15) !important;
265
+ }
266
+
267
+ /* ── Sidebar botón de PayPal ──────────────────────────────────────────────── */
268
+ .paypal-btn-container {
269
+ text-align: center;
270
+ margin: 1rem 0;
271
+ }
272
+
273
+ .paypal-btn-container a {
274
+ display: inline-block;
275
+ background: linear-gradient(135deg, #003087, #009cde, #012169);
276
+ color: #fff !important;
277
+ font-family: 'Exo 2', sans-serif;
278
+ font-weight: 700;
279
+ font-size: 0.85rem;
280
+ padding: 10px 20px;
281
+ border-radius: 25px;
282
+ text-decoration: none !important;
283
+ letter-spacing: 0.5px;
284
+ box-shadow: 0 4px 15px rgba(0,156,222,0.4);
285
+ transition: all 0.3s ease;
286
+ }
287
+
288
+ .paypal-btn-container a:hover {
289
+ transform: translateY(-2px);
290
+ box-shadow: 0 8px 25px rgba(0,156,222,0.6);
291
+ }
292
+
293
+ /* ── Botón de WhatsApp ────────────────────────────────────────────────────── */
294
+ .whatsapp-btn {
295
+ text-align: center;
296
+ margin: 0.5rem 0;
297
+ }
298
+
299
+ .whatsapp-btn a {
300
+ display: inline-block;
301
+ background: linear-gradient(135deg, #25d366, #128c7e);
302
+ color: #fff !important;
303
+ font-family: 'Exo 2', sans-serif;
304
+ font-weight: 700;
305
+ font-size: 0.82rem;
306
+ padding: 9px 18px;
307
+ border-radius: 25px;
308
+ text-decoration: none !important;
309
+ box-shadow: 0 4px 15px rgba(37,211,102,0.3);
310
+ transition: all 0.3s ease;
311
+ }
312
+
313
+ .whatsapp-btn a:hover {
314
+ transform: translateY(-2px);
315
+ box-shadow: 0 8px 25px rgba(37,211,102,0.5);
316
+ }
317
+
318
+ /* ── Sección de donaciones en sidebar ────────────────────────────────────── */
319
+ .donation-section {
320
+ background: linear-gradient(135deg, rgba(0,229,255,0.06), rgba(233,30,140,0.06));
321
+ border: 1px solid rgba(0,229,255,0.15);
322
+ border-radius: var(--radius-md);
323
+ padding: 1rem;
324
+ margin: 0.8rem 0;
325
+ }
326
+
327
+ /* ── Logo sidebar ─────────────────────────────────────────────────────────── */
328
+ .sidebar-logo-container {
329
+ text-align: center;
330
+ padding: 0.5rem 0 1rem 0;
331
+ }
332
+
333
+ /* ── Footer ───────────────────────────────────────────────────────────────── */
334
+ .footer-text {
335
+ font-family: 'Space Mono', monospace;
336
+ font-size: 0.65rem;
337
+ color: var(--gray);
338
+ text-align: center;
339
+ letter-spacing: 1px;
340
+ margin-top: 2rem;
341
+ opacity: 0.6;
342
+ }
343
+
344
+ /* ── Scrollbar personalizado ──────────────────────────────────────────────── */
345
+ ::-webkit-scrollbar { width: 6px; }
346
+ ::-webkit-scrollbar-track { background: var(--bg-primary); }
347
+ ::-webkit-scrollbar-thumb {
348
+ background: linear-gradient(var(--cyan), var(--magenta));
349
+ border-radius: 3px;
350
+ }
351
+
352
+ /* ── Animación de pulso para badges ───────────────────────────────────────── */
353
+ @keyframes pulse-glow {
354
+ 0% { box-shadow: 0 0 5px rgba(0,229,255,0.3); }
355
+ 50% { box-shadow: 0 0 20px rgba(0,229,255,0.6); }
356
+ 100% { box-shadow: 0 0 5px rgba(0,229,255,0.3); }
357
+ }
358
+
359
+ .pulse { animation: pulse-glow 2s infinite; }
360
+
361
+ /* ── Métricas ─────────────────────────────────────────────────────────────── */
362
+ [data-testid="stMetric"] {
363
+ background: var(--bg-card) !important;
364
+ border: 1px solid rgba(0,229,255,0.1) !important;
365
+ border-radius: var(--radius-md) !important;
366
+ padding: 0.8rem !important;
367
+ }
368
+ </style>
369
+ """
370
+
371
+ # Inyectar el CSS al inicio de la app
372
+ st.markdown(CUSTOM_CSS, unsafe_allow_html=True)
373
+
374
+
375
+ # =============================================================================
376
+ # SECCIÓN 2: CONSTANTES Y CONFIGURACIÓN GLOBAL
377
+ # =============================================================================
378
+
379
+ # Directorio donde Argos guardará los modelos descargados (persiste en runtime)
380
+ MODELS_DIR = Path(os.environ.get("ARGOS_PACKAGES_DIR", "/tmp/argos_models"))
381
+ MODELS_DIR.mkdir(parents=True, exist_ok=True)
382
+
383
+ # Tiempo de vida de los archivos generados (en segundos) — 5 minutos
384
+ FILE_TTL_SECONDS = 300
385
+
386
+ # Tamaño máximo de archivo permitido (50 MB)
387
+ MAX_FILE_SIZE_MB = 80
388
+ MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
389
+
390
+ # Diccionario de idiomas soportados: código ISO → nombre para mostrar
391
+ SUPPORTED_LANGUAGES = {
392
+ "en": "🇺🇸 Inglés",
393
+ "es": "🇪🇸 Español",
394
+ "fr": "🇫🇷 Francés",
395
+ "de": "🇩🇪 Alemán",
396
+ "it": "🇮🇹 Italiano",
397
+ "pt": "🇵🇹 Portugués",
398
+ "nl": "🇳🇱 Neerlandés",
399
+ "pl": "🇵🇱 Polaco",
400
+ "ru": "🇷🇺 Ruso",
401
+ "ar": "🇸🇦 Árabe",
402
+ "zh": "🇨🇳 Chino (Simplificado)",
403
+ "ja": "🇯🇵 Japonés",
404
+ "ko": "🇰🇷 Coreano",
405
+ "tr": "🇹🇷 Turco",
406
+ "sv": "🇸🇪 Sueco",
407
+ "da": "🇩🇰 Danés",
408
+ "fi": "🇫🇮 Finlandés",
409
+ "nb": "🇳🇴 Noruego",
410
+ "cs": "🇨🇿 Checo",
411
+ "hu": "🇭🇺 Húngaro",
412
+ }
413
+
414
+ # Mapa inverso: nombre de display → código
415
+ LANG_NAME_TO_CODE = {v: k for k, v in SUPPORTED_LANGUAGES.items()}
416
+
417
+
418
+ # =============================================================================
419
+ # SECCIÓN 3: SISTEMA DE LIMPIEZA AUTOMÁTICA (AUTO-CLEANUP)
420
+ # Cada archivo generado se elimina automáticamente 5 minutos después
421
+ # usando threading.Timer para no bloquear la interfaz.
422
+ # =============================================================================
423
+
424
+ def schedule_file_deletion(filepath: str, delay: int = FILE_TTL_SECONDS) -> None:
425
+ """
426
+ Programa la eliminación automática de un archivo tras 'delay' segundos.
427
+
428
+ Args:
429
+ filepath: Ruta absoluta del archivo a eliminar.
430
+ delay: Segundos hasta la eliminación (por defecto 300 = 5 minutos).
431
+ """
432
+ def _delete():
433
+ try:
434
+ if os.path.exists(filepath):
435
+ os.remove(filepath)
436
+ logger.info(f"🗑️ Archivo eliminado automáticamente: {filepath}")
437
+ except Exception as e:
438
+ logger.warning(f"⚠️ No se pudo eliminar {filepath}: {e}")
439
+
440
+ # Crear un timer en hilo demonio para no bloquear el proceso principal
441
+ timer = threading.Timer(delay, _delete)
442
+ timer.daemon = True # El timer muere si el proceso principal termina
443
+ timer.start()
444
+ logger.info(f"⏱️ Eliminación programada en {delay}s para: {filepath}")
445
+
446
+
447
+ # =============================================================================
448
+ # SECCIÓN 4: MOTOR DE TRADUCCIÓN — ARGOS TRANSLATE
449
+ # Implementa "Lazy Loading": los paquetes solo se descargan cuando el usuario
450
+ # selecciona un par de idiomas y ese modelo no está en caché local.
451
+ # =============================================================================
452
+
453
+ def _configure_argos_paths() -> None:
454
+ """
455
+ Configura Argos Translate para usar el directorio de modelos definido en
456
+ MODELS_DIR, garantizando persistencia entre reinicios del espacio.
457
+ """
458
+ try:
459
+ from argostranslate import package as argos_pkg
460
+ argos_pkg.update_package_index()
461
+ except Exception:
462
+ pass # Si no hay internet, usa los modelos ya descargados
463
+
464
+
465
+ @st.cache_resource(show_spinner=False)
466
+ def get_installed_packages():
467
+ """
468
+ Retorna el conjunto de paquetes Argos ya instalados en el sistema.
469
+ Se cachea en memoria para evitar consultas repetitivas al filesystem.
470
+ """
471
+ try:
472
+ from argostranslate import package as argos_pkg
473
+ os.environ["ARGOS_PACKAGES_DIR"] = str(MODELS_DIR)
474
+ installed = argos_pkg.get_installed_packages()
475
+ return {(p.from_code, p.to_code): p for p in installed}
476
+ except ImportError:
477
+ logger.error("Argos Translate no está instalado correctamente.")
478
+ return {}
479
+ except Exception as e:
480
+ logger.error(f"Error al obtener paquetes instalados: {e}")
481
+ return {}
482
+
483
+
484
+ def ensure_language_pair(from_code: str, to_code: str, status_placeholder) -> bool:
485
+ """
486
+ Verifica si el par de idiomas (from_code → to_code) está instalado.
487
+ Si no lo está, lo descarga (Lazy Loading) y muestra progreso al usuario.
488
+
489
+ Args:
490
+ from_code: Código ISO del idioma origen (ej: "es").
491
+ to_code: Código ISO del idioma destino (ej: "en").
492
+ status_placeholder: Elemento st.empty() para mostrar mensajes de estado.
493
+
494
+ Returns:
495
+ True si el par está disponible, False si ocurrió un error.
496
+ """
497
+ try:
498
+ from argostranslate import package as argos_pkg, translate as argos_translate
499
+
500
+ # Establecer directorio de modelos
501
+ os.environ["ARGOS_PACKAGES_DIR"] = str(MODELS_DIR)
502
+
503
+ # Verificar si ya está instalado
504
+ installed_pkgs = {
505
+ (p.from_code, p.to_code)
506
+ for p in argos_pkg.get_installed_packages()
507
+ }
508
+
509
+ if (from_code, to_code) in installed_pkgs:
510
+ logger.info(f"✅ Par ya instalado: {from_code} → {to_code}")
511
+ return True
512
+
513
+ # ── Intentar descarga ───────────────────────────────────────────────
514
+ status_placeholder.markdown(
515
+ f'<div class="status-badge processing pulse">⬇️ &nbsp;Descargando modelo {from_code}→{to_code}... (solo la primera vez)</div>',
516
+ unsafe_allow_html=True
517
+ )
518
+
519
+ # Actualizar índice de paquetes disponibles
520
+ argos_pkg.update_package_index()
521
+ available = argos_pkg.get_available_packages()
522
+
523
+ # Buscar el paquete específico
524
+ target_pkg = next(
525
+ (p for p in available if p.from_code == from_code and p.to_code == to_code),
526
+ None
527
+ )
528
+
529
+ if target_pkg is None:
530
+ # Intentar ruta inversa si el par directo no existe
531
+ status_placeholder.warning(
532
+ f"⚠️ Par directo {from_code}→{to_code} no disponible. "
533
+ "Se usará traducción vía inglés como pivote."
534
+ )
535
+ return False
536
+
537
+ # Descargar e instalar el paquete
538
+ download_path = target_pkg.download()
539
+ argos_pkg.install_from_path(download_path)
540
+
541
+ # Limpiar caché de st.cache_resource para reflejar el nuevo paquete
542
+ get_installed_packages.clear()
543
+
544
+ status_placeholder.markdown(
545
+ '<div class="status-badge success">✅ Modelo instalado correctamente</div>',
546
+ unsafe_allow_html=True
547
+ )
548
+ return True
549
+
550
+ except Exception as e:
551
+ logger.error(f"Error al asegurar par de idiomas {from_code}→{to_code}: {e}")
552
+ status_placeholder.markdown(
553
+ f'<div class="status-badge error">❌ Error descargando modelo: {str(e)[:80]}</div>',
554
+ unsafe_allow_html=True
555
+ )
556
+ return False
557
+
558
+
559
+ def translate_text(text: str, from_code: str, to_code: str) -> str:
560
+ """
561
+ Traduce un fragmento de texto usando Argos Translate.
562
+ Si el par directo no existe, intenta traducir vía inglés (pivote).
563
+
564
+ Args:
565
+ text: Texto a traducir.
566
+ from_code: Código del idioma origen.
567
+ to_code: Código del idioma destino.
568
+
569
+ Returns:
570
+ Texto traducido, o el texto original si ocurre un error.
571
+ """
572
+ if not text or not text.strip():
573
+ return text # Evitar llamadas innecesarias con texto vacío
574
+
575
+ try:
576
+ from argostranslate import translate as argos_translate
577
+
578
+ # Obtener idiomas instalados
579
+ installed_langs = argos_translate.get_installed_languages()
580
+ lang_map = {lang.code: lang for lang in installed_langs}
581
+
582
+ if from_code not in lang_map or to_code not in lang_map:
583
+ logger.warning(f"Idioma no disponible: {from_code} o {to_code}")
584
+ return text # Devolver original si no hay modelo
585
+
586
+ from_lang = lang_map[from_code]
587
+ to_lang = lang_map[to_code]
588
+
589
+ # Obtener el objeto de traducción directa
590
+ translation = from_lang.get_translation(to_lang)
591
+
592
+ if translation is None:
593
+ # ── Traducción con pivote inglés ────────────────────────────────
594
+ logger.info(f"Usando pivote en: {from_code}→en→{to_code}")
595
+ if "en" in lang_map:
596
+ en_lang = lang_map["en"]
597
+ step1 = from_lang.get_translation(en_lang)
598
+ step2 = en_lang.get_translation(to_lang)
599
+ if step1 and step2:
600
+ intermediate = step1.translate(text)
601
+ return step2.translate(intermediate)
602
+ return text # Sin ruta posible, devolver original
603
+
604
+ return translation.translate(text)
605
+
606
+ except Exception as e:
607
+ logger.error(f"Error en traducción '{text[:30]}...': {e}")
608
+ return text # En caso de error, devolver texto original
609
+
610
+
611
+ # =============================================================================
612
+ # SECCIÓN 5: PROCESAMIENTO DE PDF
613
+ # Preserva el diseño usando PyMuPDF (fitz):
614
+ # 1. Extrae bloques de texto con sus coordenadas exactas
615
+ # 2. "Tapa" el texto original dibujando un rectángulo del color del fondo
616
+ # 3. Escribe la traducción encima en la misma posición
617
+ # =============================================================================
618
+
619
+ def _get_background_color(page) -> tuple:
620
+ """
621
+ Intenta detectar el color de fondo de una página PDF.
622
+ Por defecto retorna blanco (1, 1, 1) si no se puede determinar.
623
+ """
624
+ try:
625
+ # Analizar el área de la página buscando el color dominante
626
+ mat = page.get_pixmap(matrix=__import__("fitz").Matrix(0.1, 0.1))
627
+ # Obtener el color del píxel en la esquina superior izquierda
628
+ pixel = mat.pixel(0, 0)
629
+ # Normalizar valores RGB a rango 0-1 que usa PyMuPDF
630
+ return (pixel[0]/255.0, pixel[1]/255.0, pixel[2]/255.0)
631
+ except Exception:
632
+ return (1.0, 1.0, 1.0) # Blanco por defecto
633
+
634
+
635
+ def translate_pdf(
636
+ input_path: str,
637
+ from_code: str,
638
+ to_code: str,
639
+ progress_bar,
640
+ status_text
641
+ ) -> str:
642
+ """
643
+ Traduce un archivo PDF página por página, preservando el layout original.
644
+
645
+ Proceso por página:
646
+ 1. Obtener bloques de texto con coordenadas (x0, y0, x1, y1)
647
+ 2. Detectar el color de fondo local del área del texto
648
+ 3. Cubrir el texto original con un rectángulo opaco
649
+ 4. Insertar la traducción en la misma posición con el mismo tamaño de fuente
650
+
651
+ Args:
652
+ input_path: Ruta al PDF original.
653
+ from_code: Código de idioma origen.
654
+ to_code: Código de idioma destino.
655
+ progress_bar: Componente st.progress() para mostrar avance.
656
+ status_text: Componente st.empty() para mensajes de estado.
657
+
658
+ Returns:
659
+ Ruta al PDF traducido (guardado en /tmp).
660
+
661
+ Raises:
662
+ ValueError: Si el archivo no es un PDF válido.
663
+ MemoryError: Si el PDF es demasiado grande para procesarlo.
664
+ """
665
+ try:
666
+ import fitz # PyMuPDF
667
+
668
+ # Abrir el documento PDF
669
+ doc = fitz.open(input_path)
670
+ total_pages = len(doc)
671
+
672
+ if total_pages == 0:
673
+ raise ValueError("El PDF no contiene páginas.")
674
+
675
+ # Informar al usuario
676
+ status_text.markdown(
677
+ f'<div class="status-badge processing">📄 Procesando {total_pages} página(s)...</div>',
678
+ unsafe_allow_html=True
679
+ )
680
+
681
+ for page_num in range(total_pages):
682
+ page = doc[page_num]
683
+
684
+ # Obtener color de fondo de esta página
685
+ bg_color = _get_background_color(page)
686
+
687
+ # Determinar si el fondo es oscuro (para usar texto blanco)
688
+ is_dark_bg = (bg_color[0] + bg_color[1] + bg_color[2]) / 3 < 0.5
689
+ text_color = (1.0, 1.0, 1.0) if is_dark_bg else (0.0, 0.0, 0.0)
690
+
691
+ # Extraer bloques de texto con posición y tamaño de fuente
692
+ # get_text("dict") retorna estructura JSON con bloques detallados
693
+ page_data = page.get_text("dict", flags=fitz.TEXT_PRESERVE_WHITESPACE)
694
+
695
+ for block in page_data.get("blocks", []):
696
+ # Solo procesar bloques de texto (type=0), omitir imágenes (type=1)
697
+ if block.get("type") != 0:
698
+ continue
699
+
700
+ for line in block.get("lines", []):
701
+ for span in line.get("spans", []):
702
+ original_text = span.get("text", "").strip()
703
+
704
+ # Omitir spans vacíos o muy cortos (espacios, signos)
705
+ if len(original_text) < 2:
706
+ continue
707
+
708
+ # Coordenadas del span (rectángulo del texto)
709
+ bbox = fitz.Rect(span["bbox"])
710
+ font_size = span.get("size", 11)
711
+
712
+ # Traducir el fragmento de texto
713
+ translated = translate_text(original_text, from_code, to_code)
714
+
715
+ # ── PASO 1: Cubrir el texto original ────────────────
716
+ # Dibujar rectángulo opaco del color del fondo
717
+ # Expandir ligeramente el rect para cubrir bien
718
+ cover_rect = bbox + (-1, -1, 1, 1)
719
+ page.draw_rect(cover_rect, color=None, fill=bg_color)
720
+
721
+ # ── PASO 2: Escribir la traducción encima ────────────
722
+ try:
723
+ # Insertar el texto traducido en la misma posición
724
+ rc = page.insert_textbox(
725
+ bbox,
726
+ translated,
727
+ fontsize=font_size * 0.92, # Reducir 8% para que quepa
728
+ color=text_color,
729
+ align=0, # 0=izquierda, 1=centro, 2=derecha
730
+ overlay=True # Colocar encima de todo
731
+ )
732
+ # rc < 0 indica que el texto no cupo en el box
733
+ if rc < 0:
734
+ # Reducir fuente hasta que quepa
735
+ page.insert_textbox(
736
+ bbox,
737
+ translated,
738
+ fontsize=max(6, font_size * 0.7),
739
+ color=text_color,
740
+ align=0,
741
+ overlay=True
742
+ )
743
+ except Exception as te:
744
+ logger.warning(f"Error insertando texto en pág {page_num+1}: {te}")
745
+
746
+ # Actualizar barra de progreso
747
+ progress = (page_num + 1) / total_pages
748
+ progress_bar.progress(progress, text=f"Página {page_num + 1} / {total_pages}")
749
+
750
+ # ── Guardar el PDF traducido en /tmp ───────────────────────────────
751
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
752
+ output_filename = f"traducido_{from_code}_to_{to_code}_{timestamp}.pdf"
753
+ output_path = os.path.join(tempfile.gettempdir(), output_filename)
754
+
755
+ doc.save(output_path, garbage=4, deflate=True)
756
+ doc.close()
757
+
758
+ # Programar eliminación automática
759
+ schedule_file_deletion(output_path)
760
+
761
+ logger.info(f"✅ PDF traducido guardado: {output_path}")
762
+ return output_path
763
+
764
+ except MemoryError:
765
+ raise MemoryError(
766
+ f"El PDF es demasiado pesado para procesarlo en memoria. "
767
+ f"Por favor, divídelo en partes de máximo {MAX_FILE_SIZE_MB}MB."
768
+ )
769
+ except Exception as e:
770
+ logger.error(f"Error al traducir PDF: {e}")
771
+ raise
772
+
773
+
774
+ # =============================================================================
775
+ # SECCIÓN 6: PROCESAMIENTO DE WORD (DOCX)
776
+ # Usa python-docx para preservar:
777
+ # - Negritas, cursivas, subrayado
778
+ # - Alineación de párrafos
779
+ # - Contenido de tablas (celda por celda)
780
+ # =============================================================================
781
+
782
+ def translate_docx(
783
+ input_path: str,
784
+ from_code: str,
785
+ to_code: str,
786
+ progress_bar,
787
+ status_text
788
+ ) -> str:
789
+ """
790
+ Traduce un archivo Word (.docx) respetando el formato original.
791
+
792
+ Proceso:
793
+ - Párrafos: traducir cada run preservando bold/italic/underline/color
794
+ - Tablas: iterar filas → celdas → párrafos → runs
795
+ - Headers y Footers: traducir si contienen texto relevante
796
+
797
+ Args:
798
+ input_path: Ruta al .docx original.
799
+ from_code: Código de idioma origen.
800
+ to_code: Código de idioma destino.
801
+ progress_bar: Componente st.progress().
802
+ status_text: Componente st.empty().
803
+
804
+ Returns:
805
+ Ruta al .docx traducido.
806
+ """
807
+ try:
808
+ from docx import Document
809
+ from docx.oxml.ns import qn
810
+ import copy
811
+
812
+ # Abrir el documento
813
+ doc = Document(input_path)
814
+
815
+ # Contar total de elementos para la barra de progreso
816
+ total_paragraphs = len(doc.paragraphs)
817
+ total_tables = sum(
818
+ len(table.rows) * len(table.columns)
819
+ for table in doc.tables
820
+ )
821
+ total_elements = max(total_paragraphs + total_tables, 1)
822
+ processed = 0
823
+
824
+ status_text.markdown(
825
+ f'<div class="status-badge processing">📝 Procesando {total_paragraphs} párrafo(s) + {len(doc.tables)} tabla(s)...</div>',
826
+ unsafe_allow_html=True
827
+ )
828
+
829
+ # ── Función auxiliar para traducir runs de un párrafo ─────────────
830
+ def translate_paragraph_runs(paragraph):
831
+ """
832
+ Traduce el contenido de un párrafo preservando el formato de cada run.
833
+
834
+ Estrategia: agrupa todos los runs en un solo texto para contexto,
835
+ traduce, y redistribuye el resultado respetando los runs originales.
836
+ """
837
+ # Recopilar texto completo del párrafo
838
+ full_text = "".join(run.text for run in paragraph.runs)
839
+
840
+ if not full_text.strip():
841
+ return # Párrafo vacío, nada que hacer
842
+
843
+ # Traducir el texto completo del párrafo
844
+ translated_text = translate_text(full_text, from_code, to_code)
845
+
846
+ if not paragraph.runs:
847
+ return
848
+
849
+ # Estrategia: poner toda la traducción en el primer run
850
+ # y vaciar los demás (preserva el formato del primer run)
851
+ paragraph.runs[0].text = translated_text
852
+ for run in paragraph.runs[1:]:
853
+ run.text = ""
854
+
855
+ # ── Procesar párrafos del cuerpo principal ─────────────────────────
856
+ for para in doc.paragraphs:
857
+ try:
858
+ translate_paragraph_runs(para)
859
+ except Exception as pe:
860
+ logger.warning(f"Error en párrafo: {pe}")
861
+ finally:
862
+ processed += 1
863
+ progress_bar.progress(
864
+ min(processed / total_elements, 1.0),
865
+ text=f"Párrafo {processed} / {total_paragraphs}"
866
+ )
867
+
868
+ # ── Procesar tablas ────────────────────────────────────────────────
869
+ for table_idx, table in enumerate(doc.tables):
870
+ for row_idx, row in enumerate(table.rows):
871
+ for cell_idx, cell in enumerate(row.cells):
872
+ for para in cell.paragraphs:
873
+ try:
874
+ translate_paragraph_runs(para)
875
+ except Exception as ce:
876
+ logger.warning(
877
+ f"Error en tabla {table_idx}, "
878
+ f"fila {row_idx}, celda {cell_idx}: {ce}"
879
+ )
880
+ processed += 1
881
+ progress_bar.progress(
882
+ min(processed / total_elements, 1.0),
883
+ text=f"Tabla {table_idx+1}, celda {row_idx+1},{cell_idx+1}"
884
+ )
885
+
886
+ # ── Procesar encabezados y pies de página ─────────────────────────
887
+ for section in doc.sections:
888
+ # Encabezado (header)
889
+ if section.header:
890
+ for para in section.header.paragraphs:
891
+ try:
892
+ translate_paragraph_runs(para)
893
+ except Exception:
894
+ pass
895
+ # Pie de página (footer)
896
+ if section.footer:
897
+ for para in section.footer.paragraphs:
898
+ try:
899
+ translate_paragraph_runs(para)
900
+ except Exception:
901
+ pass
902
+
903
+ # ── Guardar el documento traducido ─────────────────────────────────
904
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
905
+ output_filename = f"traducido_{from_code}_to_{to_code}_{timestamp}.docx"
906
+ output_path = os.path.join(tempfile.gettempdir(), output_filename)
907
+
908
+ doc.save(output_path)
909
+
910
+ # Programar eliminación automática
911
+ schedule_file_deletion(output_path)
912
+
913
+ logger.info(f"✅ DOCX traducido guardado: {output_path}")
914
+ return output_path
915
+
916
+ except MemoryError:
917
+ raise MemoryError(
918
+ "El documento Word es demasiado pesado. "
919
+ "Por favor, divídelo en documentos más pequeños."
920
+ )
921
+ except Exception as e:
922
+ logger.error(f"Error al traducir DOCX: {e}")
923
+ raise
924
+
925
+
926
+ # =============================================================================
927
+ # SECCIÓN 7: VALIDACIÓN DE ARCHIVOS
928
+ # Verifica formato, extensión y tamaño antes de procesar.
929
+ # =============================================================================
930
+
931
+ def validate_uploaded_file(uploaded_file) -> tuple[bool, str]:
932
+ """
933
+ Valida que el archivo subido sea válido para procesamiento.
934
+
935
+ Verificaciones:
936
+ 1. Extensión permitida (.pdf o .docx)
937
+ 2. Tamaño máximo (MAX_FILE_SIZE_BYTES)
938
+ 3. Que el archivo no esté vacío
939
+
940
+ Args:
941
+ uploaded_file: Objeto retornado por st.file_uploader().
942
+
943
+ Returns:
944
+ Tupla (es_válido: bool, mensaje_error: str).
945
+ Si es_válido=True, mensaje_error estará vacío.
946
+ """
947
+ if uploaded_file is None:
948
+ return False, "No se ha subido ningún archivo."
949
+
950
+ # Verificar extensión
951
+ filename = uploaded_file.name.lower()
952
+ if not (filename.endswith(".pdf") or filename.endswith(".docx")):
953
+ return False, (
954
+ "❌ Formato no permitido. Solo se aceptan archivos `.pdf` y `.docx`.\n"
955
+ "Si tienes un `.doc` antiguo, conviértelo primero a `.docx` con Word o LibreOffice."
956
+ )
957
+
958
+ # Verificar tamaño
959
+ file_size = uploaded_file.size
960
+ if file_size > MAX_FILE_SIZE_BYTES:
961
+ size_mb = file_size / (1024 * 1024)
962
+ return False, (
963
+ f"❌ El archivo pesa {size_mb:.1f} MB, excede el límite de {MAX_FILE_SIZE_MB} MB.\n"
964
+ "Por favor, divide el archivo en partes más pequeñas."
965
+ )
966
+
967
+ # Verificar que no esté vacío
968
+ if file_size == 0:
969
+ return False, "❌ El archivo está vacío."
970
+
971
+ return True, ""
972
+
973
+
974
+ # =============================================================================
975
+ # SECCIÓN 8: INTERFAZ DE USUARIO — SIDEBAR
976
+ # Contiene: logo, selectores de idioma, donaciones, soporte.
977
+ # =============================================================================
978
+
979
+ def render_sidebar() -> tuple[str, str]:
980
+ """
981
+ Renderiza la barra lateral completa con todos sus componentes.
982
+
983
+ Returns:
984
+ Tupla (código_idioma_origen, código_idioma_destino).
985
+ """
986
+ with st.sidebar:
987
+ # ── Logo de la empresa ─────────────────────────────────────────────
988
+ st.markdown('<div class="sidebar-logo-container">', unsafe_allow_html=True)
989
+ try:
990
+ # Intentar mostrar el logo si está disponible
991
+ logo_path = Path("LOGO_ADVISION_AI_TRANSPARENTE.png")
992
+ if logo_path.exists():
993
+ st.image(str(logo_path), use_container_width=True)
994
+ else:
995
+ # Fallback: texto estilizado si no hay imagen
996
+ st.markdown(
997
+ '<div style="font-family:\'Exo 2\',sans-serif;font-weight:900;'
998
+ 'font-size:1.4rem;background:linear-gradient(135deg,#00e5ff,#e91e8c);'
999
+ '-webkit-background-clip:text;-webkit-text-fill-color:transparent;'
1000
+ 'background-clip:text;text-align:center;">AdVision AI</div>',
1001
+ unsafe_allow_html=True
1002
+ )
1003
+ except Exception:
1004
+ pass
1005
+ st.markdown('</div>', unsafe_allow_html=True)
1006
+
1007
+ # ── Divisor visual ─────────────────────────────────────────────────
1008
+ st.markdown('<hr class="gradient-divider">', unsafe_allow_html=True)
1009
+
1010
+ # ── Selector de idioma origen ──────────────────────────────────────
1011
+ st.markdown('<p class="section-label">🔤 Idioma Origen</p>', unsafe_allow_html=True)
1012
+ lang_names = list(SUPPORTED_LANGUAGES.values())
1013
+
1014
+ # Índice por defecto: Español (índice 1)
1015
+ default_from_idx = lang_names.index("🇪🇸 Español") if "🇪🇸 Español" in lang_names else 0
1016
+ from_lang_name = st.selectbox(
1017
+ "Origen",
1018
+ options=lang_names,
1019
+ index=default_from_idx,
1020
+ label_visibility="collapsed",
1021
+ key="select_from_lang"
1022
+ )
1023
+
1024
+ # ── Flecha indicadora de dirección ─────────────────────────────────
1025
+ st.markdown(
1026
+ '<div style="text-align:center;font-size:1.3rem;margin:4px 0;'
1027
+ 'background:linear-gradient(135deg,#00e5ff,#e91e8c);'
1028
+ '-webkit-background-clip:text;-webkit-text-fill-color:transparent;">⬇️</div>',
1029
+ unsafe_allow_html=True
1030
+ )
1031
+
1032
+ # ── Selector de idioma destino ─────────────────────────────────────
1033
+ st.markdown('<p class="section-label">🎯 Idioma Destino</p>', unsafe_allow_html=True)
1034
+
1035
+ # Índice por defecto: Inglés (índice 0)
1036
+ default_to_idx = lang_names.index("🇺🇸 Inglés") if "🇺🇸 Inglés" in lang_names else 1
1037
+ to_lang_name = st.selectbox(
1038
+ "Destino",
1039
+ options=lang_names,
1040
+ index=default_to_idx,
1041
+ label_visibility="collapsed",
1042
+ key="select_to_lang"
1043
+ )
1044
+
1045
+ # Aviso si el usuario seleccionó el mismo idioma en origen y destino
1046
+ from_code = LANG_NAME_TO_CODE[from_lang_name]
1047
+ to_code = LANG_NAME_TO_CODE[to_lang_name]
1048
+
1049
+ if from_code == to_code:
1050
+ st.warning("⚠️ Selecciona idiomas diferentes para origen y destino.")
1051
+
1052
+ # ── Divisor ────────────────────────────────────────────────────────
1053
+ st.markdown('<hr class="gradient-divider">', unsafe_allow_html=True)
1054
+
1055
+ # ── Sección de Soporte y Donaciones ───────────────────────────────
1056
+ st.markdown(
1057
+ '<div class="donation-section">'
1058
+ '<p class="section-label" style="text-align:center;">💎 Soporte & Donaciones</p>'
1059
+ '<p style="font-size:0.78rem;color:#aaa;text-align:center;margin-bottom:0.8rem;">'
1060
+ 'Si esta herramienta te resulta útil, considera apoyar su desarrollo:</p>',
1061
+ unsafe_allow_html=True
1062
+ )
1063
+
1064
+ # Botón de PayPal — REEMPLAZA TU_USUARIO_AQUI con tu usuario real
1065
+ st.markdown(
1066
+ '<div class="paypal-btn-container">'
1067
+ '<a href="https://www.paypal.me/Noru3D" target="_blank" rel="noopener">'
1068
+ '💙 Donar con PayPal'
1069
+ '</a>'
1070
+ '</div>',
1071
+ unsafe_allow_html=True
1072
+ )
1073
+
1074
+ st.markdown('</div>', unsafe_allow_html=True)
1075
+
1076
+ # ── Botón de contacto WhatsApp ─────────────────────────────────────
1077
+ st.markdown(
1078
+ '<div class="whatsapp-btn" style="margin-top:0.6rem;">'
1079
+ # REEMPLAZA el número con tu número real de WhatsApp (con código de país)
1080
+ '<a href="https://wa.me/5215537494034?text=Hola%2C%20necesito%20soporte%20con%20LibreTranslate%20ProDoc" '
1081
+ 'target="_blank" rel="noopener">'
1082
+ '💬 Soporte por WhatsApp'
1083
+ '</a>'
1084
+ '</div>',
1085
+ unsafe_allow_html=True
1086
+ )
1087
+
1088
+ # ── Información adicional ──────────────────────────────────────────
1089
+ st.markdown('<hr class="gradient-divider">', unsafe_allow_html=True)
1090
+ st.markdown(
1091
+ '<p style="font-size:0.68rem;color:#555;text-align:center;line-height:1.5;">'
1092
+ '🔒 Sin telemetría · 🌐 Offline · 🗑️ Auto-limpieza 5 min<br>'
1093
+ '<span style="color:#333;">Motor: Argos Translate</span>'
1094
+ '</p>',
1095
+ unsafe_allow_html=True
1096
+ )
1097
+
1098
+ return from_code, to_code
1099
+
1100
+
1101
+ # =============================================================================
1102
+ # SECCIÓN 9: INTERFAZ DE USUARIO — ÁREA PRINCIPAL
1103
+ # =============================================================================
1104
+
1105
+ def render_main_area(from_code: str, to_code: str) -> None:
1106
+ """
1107
+ Renderiza el área principal de la aplicación:
1108
+ - Encabezado con título y descripción
1109
+ - Zona de carga de archivo
1110
+ - Panel de información
1111
+ - Proceso de traducción y descarga
1112
+
1113
+ Args:
1114
+ from_code: Código del idioma origen seleccionado en el sidebar.
1115
+ to_code: Código del idioma destino seleccionado en el sidebar.
1116
+ """
1117
+
1118
+ # ── Encabezado principal ───────────────────────────────────────────────
1119
+ st.markdown('<h1 class="app-title">LibreTranslate ProDoc</h1>', unsafe_allow_html=True)
1120
+ st.markdown(
1121
+ '<p class="app-subtitle">Traducción offline · Preserva el diseño · Sin límites</p>',
1122
+ unsafe_allow_html=True
1123
+ )
1124
+
1125
+ # ── Descripción / intro ────────────────────────────────────────────────
1126
+ col_info1, col_info2, col_info3 = st.columns(3)
1127
+
1128
+ with col_info1:
1129
+ st.markdown(
1130
+ '<div class="pro-card" style="text-align:center;">'
1131
+ '<div style="font-size:2rem;">📄</div>'
1132
+ '<div style="font-weight:700;margin:6px 0;font-size:0.9rem;">PDF & Word</div>'
1133
+ '<div style="font-size:0.75rem;color:#888;">Soporte completo para .pdf y .docx con preservación de diseño</div>'
1134
+ '</div>',
1135
+ unsafe_allow_html=True
1136
+ )
1137
+
1138
+ with col_info2:
1139
+ st.markdown(
1140
+ '<div class="pro-card" style="text-align:center;">'
1141
+ '<div style="font-size:2rem;">🔒</div>'
1142
+ '<div style="font-weight:700;margin:6px 0;font-size:0.9rem;">100% Offline</div>'
1143
+ '<div style="font-size:0.75rem;color:#888;">Motor Argos Translate local. Tus documentos no salen del servidor</div>'
1144
+ '</div>',
1145
+ unsafe_allow_html=True
1146
+ )
1147
+
1148
+ with col_info3:
1149
+ st.markdown(
1150
+ '<div class="pro-card" style="text-align:center;">'
1151
+ '<div style="font-size:2rem;">🗑️</div>'
1152
+ '<div style="font-weight:700;margin:6px 0;font-size:0.9rem;">Auto-limpieza</div>'
1153
+ '<div style="font-size:0.75rem;color:#888;">Los archivos se eliminan automáticamente del servidor en 5 minutos</div>'
1154
+ '</div>',
1155
+ unsafe_allow_html=True
1156
+ )
1157
+
1158
+ st.markdown("<br>", unsafe_allow_html=True)
1159
+
1160
+ # ── Mostrar idiomas seleccionados ──────────────────────────────────────
1161
+ from_name = SUPPORTED_LANGUAGES.get(from_code, from_code)
1162
+ to_name = SUPPORTED_LANGUAGES.get(to_code, to_code)
1163
+
1164
+ st.markdown(
1165
+ f'<div class="pro-card">'
1166
+ f'<p class="section-label">Par de traducción activo</p>'
1167
+ f'<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">'
1168
+ f'<span class="status-badge ready">{from_name}</span>'
1169
+ f'<span style="font-size:1.2rem;opacity:0.6;">→</span>'
1170
+ f'<span class="status-badge ready">{to_name}</span>'
1171
+ f'</div>'
1172
+ f'</div>',
1173
+ unsafe_allow_html=True
1174
+ )
1175
+
1176
+ # ── Zona de carga de archivo ───────────────────────────────────────────
1177
+ st.markdown(
1178
+ '<div class="pro-card">'
1179
+ '<p class="section-label">📤 Cargar Documento</p>',
1180
+ unsafe_allow_html=True
1181
+ )
1182
+
1183
+ uploaded_file = st.file_uploader(
1184
+ label="Arrastra tu archivo aquí o haz clic para seleccionarlo",
1185
+ type=["pdf", "docx"],
1186
+ help=f"Formatos permitidos: PDF y DOCX · Tamaño máximo: {MAX_FILE_SIZE_MB} MB",
1187
+ accept_multiple_files=False
1188
+ )
1189
+
1190
+ st.markdown('</div>', unsafe_allow_html=True)
1191
+
1192
+ # ── Validación y proceso de traducción ────────────────────────────────
1193
+ if uploaded_file is not None:
1194
+ # Validar el archivo antes de continuar
1195
+ is_valid, error_msg = validate_uploaded_file(uploaded_file)
1196
+
1197
+ if not is_valid:
1198
+ st.error(error_msg)
1199
+ return
1200
+
1201
+ # Mostrar información del archivo
1202
+ file_size_mb = uploaded_file.size / (1024 * 1024)
1203
+ file_ext = Path(uploaded_file.name).suffix.lower()
1204
+
1205
+ st.markdown(
1206
+ f'<div class="pro-card">'
1207
+ f'<p class="section-label">📁 Archivo Cargado</p>'
1208
+ f'<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">'
1209
+ f'<span class="status-badge ready">{"📄 PDF" if file_ext == ".pdf" else "📝 DOCX"}</span>'
1210
+ f'<span style="font-size:0.85rem;color:#ccc;">{uploaded_file.name}</span>'
1211
+ f'<span style="font-size:0.8rem;color:#888;">{file_size_mb:.2f} MB</span>'
1212
+ f'</div>'
1213
+ f'</div>',
1214
+ unsafe_allow_html=True
1215
+ )
1216
+
1217
+ # Verificar que los idiomas sean diferentes
1218
+ if from_code == to_code:
1219
+ st.warning("⚠️ Selecciona idiomas diferentes en la barra lateral para continuar.")
1220
+ return
1221
+
1222
+ # ── Botón principal de traducción ──────────────────────────────────
1223
+ col_btn, col_space = st.columns([1, 3])
1224
+ with col_btn:
1225
+ translate_btn = st.button(
1226
+ "🌐 Traducir Documento",
1227
+ use_container_width=True,
1228
+ type="primary"
1229
+ )
1230
+
1231
+ if translate_btn:
1232
+ _process_translation(
1233
+ uploaded_file=uploaded_file,
1234
+ from_code=from_code,
1235
+ to_code=to_code,
1236
+ file_ext=file_ext
1237
+ )
1238
+
1239
+
1240
+ def _process_translation(
1241
+ uploaded_file,
1242
+ from_code: str,
1243
+ to_code: str,
1244
+ file_ext: str
1245
+ ) -> None:
1246
+ """
1247
+ Orquesta el proceso completo de traducción:
1248
+ 1. Descarga el modelo de idioma si es necesario (Lazy Loading)
1249
+ 2. Guarda el archivo subido en /tmp
1250
+ 3. Llama al traductor correspondiente (PDF o DOCX)
1251
+ 4. Ofrece el archivo traducido para descarga
1252
+ 5. Limpia el archivo de entrada
1253
+
1254
+ Args:
1255
+ uploaded_file: Archivo subido por el usuario (BytesIO-like object).
1256
+ from_code: Código del idioma origen.
1257
+ to_code: Código del idioma destino.
1258
+ file_ext: Extensión del archivo ('.pdf' o '.docx').
1259
+ """
1260
+
1261
+ # ── Contenedores de estado para retroalimentación visual ───────────────
1262
+ status_placeholder = st.empty()
1263
+ progress_placeholder = st.empty()
1264
+
1265
+ # Barra de progreso inicial
1266
+ progress_bar = progress_placeholder.progress(0, text="Iniciando traducción...")
1267
+
1268
+ try:
1269
+ # ── PASO 1: Verificar/descargar modelo de idioma ───────────────────
1270
+ status_placeholder.markdown(
1271
+ '<div class="status-badge processing pulse">⚙️ &nbsp;Verificando modelos de idioma...</div>',
1272
+ unsafe_allow_html=True
1273
+ )
1274
+
1275
+ model_ok = ensure_language_pair(from_code, to_code, status_placeholder)
1276
+
1277
+ if not model_ok:
1278
+ # Intentar con pivote inglés (en→to_code si from!=en, o from→en si to!=en)
1279
+ st.info(
1280
+ "ℹ️ El par directo no está disponible. "
1281
+ "Se intentará la traducción vía inglés como idioma pivote. "
1282
+ "Esto puede requerir descargar hasta 2 modelos adicionales."
1283
+ )
1284
+ # Descargar from→en
1285
+ if from_code != "en":
1286
+ ensure_language_pair(from_code, "en", status_placeholder)
1287
+ # Descargar en→to
1288
+ if to_code != "en":
1289
+ ensure_language_pair("en", to_code, status_placeholder)
1290
+
1291
+ # ── PASO 2: Guardar archivo subido a /tmp ──────────────────────────
1292
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1293
+ input_filename = f"input_{timestamp}{file_ext}"
1294
+ input_path = os.path.join(tempfile.gettempdir(), input_filename)
1295
+
1296
+ with open(input_path, "wb") as f:
1297
+ f.write(uploaded_file.getbuffer())
1298
+
1299
+ # Programar eliminación del archivo de entrada también
1300
+ schedule_file_deletion(input_path)
1301
+
1302
+ progress_bar.progress(0.05, text="Archivo cargado al servidor (se eliminará en 5 min)...")
1303
+
1304
+ # ── PASO 3: Ejecutar la traducción según el tipo de archivo ────────
1305
+ status_placeholder.markdown(
1306
+ f'<div class="status-badge processing pulse">🔄 &nbsp;Traduciendo {file_ext.upper()[1:]}...</div>',
1307
+ unsafe_allow_html=True
1308
+ )
1309
+
1310
+ start_time = time.time()
1311
+
1312
+ if file_ext == ".pdf":
1313
+ output_path = translate_pdf(
1314
+ input_path=input_path,
1315
+ from_code=from_code,
1316
+ to_code=to_code,
1317
+ progress_bar=progress_bar,
1318
+ status_text=status_placeholder
1319
+ )
1320
+ elif file_ext == ".docx":
1321
+ output_path = translate_docx(
1322
+ input_path=input_path,
1323
+ from_code=from_code,
1324
+ to_code=to_code,
1325
+ progress_bar=progress_bar,
1326
+ status_text=status_placeholder
1327
+ )
1328
+ else:
1329
+ raise ValueError(f"Extensión no soportada: {file_ext}")
1330
+
1331
+ elapsed_time = time.time() - start_time
1332
+ progress_bar.progress(1.0, text="✅ ¡Traducción completada!")
1333
+
1334
+ # ── PASO 4: Mostrar resultado y botón de descarga ──────────────────
1335
+ status_placeholder.markdown(
1336
+ f'<div class="status-badge success">✅ &nbsp;Traducción completada en {elapsed_time:.1f}s</div>',
1337
+ unsafe_allow_html=True
1338
+ )
1339
+
1340
+ # Leer el archivo traducido para la descarga
1341
+ with open(output_path, "rb") as f:
1342
+ translated_bytes = f.read()
1343
+
1344
+ # Nombre sugerido para la descarga
1345
+ original_stem = Path(uploaded_file.name).stem
1346
+ download_name = f"{original_stem}_traducido_{to_code}{file_ext}"
1347
+
1348
+ # ── Card de resultado ──────────────────────────────────────────────
1349
+ st.markdown(
1350
+ '<div class="pro-card">'
1351
+ '<p class="section-label">✨ Documento Traducido</p>',
1352
+ unsafe_allow_html=True
1353
+ )
1354
+
1355
+ col_dl, col_info = st.columns([1, 2])
1356
+
1357
+ with col_dl:
1358
+ # Determinar el MIME type para la descarga
1359
+ mime_type = "application/pdf" if file_ext == ".pdf" else (
1360
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
1361
+ )
1362
+ st.download_button(
1363
+ label=f"⬇️ Descargar {file_ext.upper()[1:]} Traducido",
1364
+ data=translated_bytes,
1365
+ file_name=download_name,
1366
+ mime=mime_type,
1367
+ use_container_width=True
1368
+ )
1369
+
1370
+ with col_info:
1371
+ output_size_mb = len(translated_bytes) / (1024 * 1024)
1372
+ st.markdown(
1373
+ f'<div style="font-size:0.8rem;color:#888;line-height:1.8;">'
1374
+ f'📁 <b style="color:#ccc;">{download_name}</b><br>'
1375
+ f'📦 Tamaño: <b style="color:#ccc;">{output_size_mb:.2f} MB</b><br>'
1376
+ f'⏱️ Tiempo: <b style="color:#ccc;">{elapsed_time:.1f} segundos</b><br>'
1377
+ f'🗑️ Se eliminará del servidor en <b style="color:#00e5ff;">5 minutos</b>'
1378
+ f'</div>',
1379
+ unsafe_allow_html=True
1380
+ )
1381
+
1382
+ st.markdown('</div>', unsafe_allow_html=True)
1383
+
1384
+ # ── Advertencia sobre limitaciones ─────────────────────────────────
1385
+ st.info(
1386
+ "📌 **Nota:** La preservación del diseño depende de la complejidad del documento. "
1387
+ "PDFs con fuentes incrustadas no estándar o con mucho contenido de imagen "
1388
+ "pueden mostrar diferencias visuales. El texto traducido puede ser más largo "
1389
+ "que el original, lo que ocasionalmente afecta el layout."
1390
+ )
1391
+
1392
+ except MemoryError as me:
1393
+ progress_bar.progress(0, text="Error")
1394
+ st.error(str(me))
1395
+ status_placeholder.markdown(
1396
+ '<div class="status-badge error">❌ Error: Archivo demasiado grande</div>',
1397
+ unsafe_allow_html=True
1398
+ )
1399
+
1400
+ except Exception as e:
1401
+ progress_bar.progress(0, text="Error")
1402
+ error_detail = str(e)
1403
+ st.error(
1404
+ f"❌ **Error durante la traducción:**\n\n"
1405
+ f"```\n{error_detail[:300]}\n```\n\n"
1406
+ "Por favor, intenta con un archivo más pequeño o verifica que no esté corrupto."
1407
+ )
1408
+ status_placeholder.markdown(
1409
+ '<div class="status-badge error">❌ Error en el procesamiento</div>',
1410
+ unsafe_allow_html=True
1411
+ )
1412
+ logger.error(f"Error en _process_translation: {e}", exc_info=True)
1413
+
1414
+
1415
+ # =============================================================================
1416
+ # SECCIÓN 10: PUNTO DE ENTRADA PRINCIPAL
1417
+ # =============================================================================
1418
+
1419
+ def main():
1420
+ """
1421
+ Función principal que orquesta el renderizado de la aplicación completa.
1422
+
1423
+ Flujo de ejecución:
1424
+ 1. Renderizar sidebar y obtener preferencias de idioma del usuario
1425
+ 2. Renderizar el área principal con la lógica de carga y traducción
1426
+ 3. Mostrar footer
1427
+ """
1428
+
1429
+ # ── Renderizar sidebar y obtener selección de idiomas ──────────────────
1430
+ from_code, to_code = render_sidebar()
1431
+
1432
+ # ── Renderizar área principal ──────────────────────────────────────────
1433
+ render_main_area(from_code, to_code)
1434
+
1435
+ # ── Footer ─────────────────────────────────────────────────────────────
1436
+ st.markdown(
1437
+ '<div class="footer-text">'
1438
+ 'LibreTranslate ProDoc · Powered by Argos Translate & PyMuPDF · '
1439
+ 'Desarrollado con ❤️ por AdVision AI · '
1440
+ f'Versión 1.0.0'
1441
+ '</div>',
1442
+ unsafe_allow_html=True
1443
+ )
1444
+
1445
+
1446
+ # ── Ejecutar la aplicación ─────────────────────────────────────────────────
1447
+ if __name__ == "__main__":
1448
+ main()
packages.txt ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # packages.txt — LibreTranslate ProDoc
3
+ # Dependencias de sistema Linux (Ubuntu/Debian) para Hugging Face Spaces
4
+ # =============================================================================
5
+ # Estas se instalan con apt-get ANTES que los paquetes Python.
6
+ # Hugging Face Spaces las lee automáticamente en el despliegue.
7
+ # =============================================================================
8
+
9
+ # ── Requerido por PyMuPDF (fitz) para renderizado de fuentes ──────────────
10
+ libmupdf-dev
11
+
12
+ # ── Soporte de fuentes del sistema para inserción de texto en PDFs ─────────
13
+ fonts-liberation
14
+ fonts-dejavu-core
15
+ fonts-noto
16
+ fonts-noto-cjk
17
+
18
+ # ── Requerido por Argos Translate para compilación de modelos CTranslate2 ──
19
+ libgomp1
20
+
21
+ # ── Herramientas de construcción necesarias para compilar algunas deps ──────
22
+ build-essential
23
+
24
+ # ── SSL y certificados (necesario para descargar paquetes de Argos) ─────────
25
+ ca-certificates
requirements.txt CHANGED
@@ -1,3 +1,31 @@
1
- altair
2
- pandas
3
- streamlit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # requirements.txt — LibreTranslate ProDoc
3
+ # Hugging Face Spaces (SDK: Streamlit)
4
+ # =============================================================================
5
+ # INSTRUCCIONES:
6
+ # Hugging Face Spaces instala estas dependencias automáticamente al desplegar.
7
+ # NO modifiques las versiones fijas sin probar compatibilidad primero.
8
+ # =============================================================================
9
+
10
+ # ── Framework principal de UI ──────────────────────────────────────────────
11
+ streamlit>=1.35.0,<2.0.0
12
+
13
+ # ── Motor de traducción offline ────────────────────────────────────────────
14
+ # argostranslate: Traducción local sin APIs externas
15
+ argostranslate>=1.9.6
16
+
17
+ # ── Procesamiento de PDF ───────────────────────────────────────────────────
18
+ # PyMuPDF (importado como 'fitz'): Lectura y escritura de PDFs con soporte
19
+ # para extracción de texto por coordenadas y dibujo sobre páginas
20
+ PyMuPDF>=1.24.0
21
+
22
+ # ── Procesamiento de Word (.docx) ──────────────────────────────────────────
23
+ # python-docx: Leer y escribir documentos Word preservando formato
24
+ python-docx>=1.1.0
25
+
26
+ # ── Utilidades estándar (incluidas en Python, no requieren instalación) ─────
27
+ # threading — ya incluido en stdlib
28
+ # tempfile — ya incluido en stdlib
29
+ # pathlib — ya incluido en stdlib
30
+ # logging — ya incluido en stdlib
31
+ # os, sys — ya incluidos en stdlib