JairoCesar commited on
Commit
1af37fb
·
verified ·
1 Parent(s): e054dd5

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +211 -264
app.py CHANGED
@@ -10,17 +10,15 @@ import os
10
  import json
11
  import re
12
  import google.generativeai as genai
13
- import pdfplumber # Nueva importación
14
 
15
  # --- CONFIGURACIÓN ---
16
- # Configurar Gemini (ensure GEMINI_API_KEY is set in environment)
17
  GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
18
  if not GEMINI_API_KEY:
19
  st.error("GEMINI_API_KEY no encontrada. Por favor configúrala en tus variables de entorno.")
20
  st.stop()
21
  genai.configure(api_key=GEMINI_API_KEY)
22
 
23
- # Obtener la clave API de Pixabay desde los secretos de Streamlit
24
  PIXABAY_API_KEY = st.secrets.get("pixabay")
25
  if not PIXABAY_API_KEY:
26
  st.error("Clave API de Pixabay no encontrada en los secretos de Streamlit.")
@@ -33,44 +31,31 @@ def get_gemini_model():
33
 
34
  # --- FUNCIONES AUXILIARES ---
35
  def extract_text_from_pdf(uploaded_file):
36
- """Extrae texto de un archivo PDF cargado."""
37
  if uploaded_file is None:
38
  return None
39
  try:
40
  with pdfplumber.open(uploaded_file) as pdf:
41
- full_text = []
42
- for page in pdf.pages:
43
- page_text = page.extract_text()
44
- if page_text:
45
- full_text.append(page_text)
46
  return "\n".join(full_text)
47
  except Exception as e:
48
  st.error(f"Error al procesar el PDF: {e}")
49
  return None
50
 
51
  def extract_and_clean_json(text_response):
52
- """Extrae y limpia una cadena JSON de una respuesta de texto."""
53
- # Intenta encontrar JSON entre ```json ... ```
54
  match = re.search(r"```json\s*(\{.*?\})\s*```", text_response, re.DOTALL)
55
  if match:
56
  json_str = match.group(1)
57
  else:
58
- # Si no está en formato markdown, busca el primer '{' hasta el último '}'
59
  json_match = re.search(r'\{.*\}', text_response, re.DOTALL)
60
  if json_match:
61
  json_str = json_match.group(0)
62
  else:
63
- # st.warning("No se encontró un bloque JSON delimitado por ```json o llaves {}.")
64
- # st.text_area("Respuesta recibida (sin JSON claro):", text_response, height=100)
65
  return None
66
-
67
- # Limpiezas comunes
68
- json_str = re.sub(r'[\n\t\r]', '', json_str) # Eliminar saltos de línea, tabs dentro del JSON
69
- json_str = re.sub(r',\s*([}\]])', r'\1', json_str) # Eliminar comas flotantes antes de } o ]
70
  return json_str
71
 
72
- def parse_gemini_response_for_slides(response_text, expected_slides_key="slides", expected_num_slides=None):
73
- """Parsea la respuesta de Gemini esperando una lista de diapositivas."""
74
  json_str = extract_and_clean_json(response_text)
75
  if not json_str:
76
  raise ValueError("No se pudo extraer una cadena JSON válida de la respuesta.")
@@ -80,6 +65,13 @@ def parse_gemini_response_for_slides(response_text, expected_slides_key="slides"
80
  except json.JSONDecodeError as e:
81
  raise ValueError(f"Error al decodificar JSON: {e}. JSON intentado: {json_str[:500]}...")
82
 
 
 
 
 
 
 
 
83
  if not isinstance(data, dict) or expected_slides_key not in data:
84
  raise ValueError(f"La estructura del JSON es incorrecta. Falta la clave '{expected_slides_key}'.")
85
 
@@ -97,226 +89,216 @@ def parse_gemini_response_for_slides(response_text, expected_slides_key="slides"
97
  raise ValueError(f"El título y el contenido de la diapositiva {i+1} deben ser cadenas de texto.")
98
  return slides
99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
 
101
  def generate_presentation_content(topic, client, max_retries=3):
102
- """Genera el contenido principal de la presentación (9 diapositivas)."""
103
  prompt = f"""Genera una presentación de PowerPoint sobre el tema: "{topic}".
104
- Debes crear exactamente 9 diapositivas. Cada diapositiva debe tener un título y contenido.
105
  Es CRUCIAL que tu respuesta sea ÚNICAMENTE un JSON válido con la siguiente estructura exacta, sin texto adicional antes o después:
106
  {{
107
  "slides": [
108
  {{"title": "Título Diapositiva 1", "content": "Contenido Diapositiva 1"}},
109
- {{"title": "Título Diapositiva 2", "content": "Contenido Diapositiva 2"}},
110
- {{"title": "Título Diapositiva 3", "content": "Contenido Diapositiva 3"}},
111
- {{"title": "Título Diapositiva 4", "content": "Contenido Diapositiva 4"}},
112
- {{"title": "Título Diapositiva 5", "content": "Contenido Diapositiva 5"}},
113
- {{"title": "Título Diapositiva 6", "content": "Contenido Diapositiva 6"}},
114
- {{"title": "Título Diapositiva 7", "content": "Contenido Diapositiva 7"}},
115
- {{"title": "Título Diapositiva 8", "content": "Contenido Diapositiva 8"}},
116
  {{"title": "Título Diapositiva 9", "content": "Contenido Diapositiva 9"}}
117
  ]
118
  }}
119
- No incluyas ningún otro texto, explicación o saludo. Solo el JSON.
120
- Asegúrate de que el contenido sea conciso y adecuado para una diapositiva.
121
- Evita usar caracteres especiales o comillas dentro de los títulos o contenidos que puedan romper el JSON.
122
- Usa comillas dobles para las claves y los valores string del JSON.
123
  """
124
- last_error = None
125
- last_response_text = ""
126
  for attempt in range(max_retries):
127
  try:
128
  response_obj = client.generate_content(prompt)
129
- if not response_obj.parts:
130
- raise ValueError("Respuesta del modelo vacía o mal formada.")
131
  last_response_text = response_obj.text
132
- slides_data = parse_gemini_response_for_slides(last_response_text, expected_num_slides=9)
133
- return slides_data
134
  except (ValueError, json.JSONDecodeError) as e:
135
- last_error = e
136
- st.warning(f"Intento {attempt + 1}/{max_retries} (contenido principal) fallido: {e}")
137
  if attempt == max_retries - 1:
138
- st.error(f"Error final al generar contenido principal después de {max_retries} intentos: {last_error}")
139
- st.text_area("Última respuesta (contenido principal):", last_response_text, height=150)
140
- return None
141
- except Exception as e: # Captura otras excepciones de genai
142
- last_error = e
143
- st.warning(f"Error inesperado del API de Gemini (contenido principal) en intento {attempt + 1}/{max_retries}: {e}")
144
- if attempt == max_retries - 1:
145
- st.error(f"Error final con API de Gemini (contenido principal) después de {max_retries} intentos: {last_error}")
146
- return None
147
- return None # Should not be reached if loop completes
148
 
149
- def summarize_text_for_slides(text_to_summarize, num_summary_slides, client, max_retries=3):
150
- """Resume texto y lo formatea para diapositivas de PowerPoint."""
151
- if not text_to_summarize:
152
- return []
153
-
154
- # Limitar la longitud del texto para evitar problemas con el API (ajustar según sea necesario)
155
- # Gemini 1.5 Flash tiene un context window grande, pero seamos precavidos.
156
- max_len = 30000 # Caracteres (aproximadamente 7k-8k tokens)
157
  if len(text_to_summarize) > max_len:
158
- st.warning(f"El texto del PDF es muy largo ({len(text_to_summarize)} caracteres). Se truncará a {max_len} caracteres para el resumen.")
159
  text_to_summarize = text_to_summarize[:max_len]
160
 
161
- prompt = f"""Eres un asistente experto en resumir documentos para presentaciones.
162
- Aquí tienes un texto extraído de un documento PDF:
163
- --- TEXTO DEL PDF ---
164
  {text_to_summarize}
165
- --- FIN DEL TEXTO DEL PDF ---
166
-
167
- Por favor, resume este texto. El resumen debe ser adecuado para ser presentado en {num_summary_slides} diapositiva(s) de PowerPoint.
168
- Si num_summary_slides es mayor que 1, divide el resumen en partes lógicas, cada una con un título y contenido.
169
 
 
 
 
 
170
  Es CRUCIAL que tu respuesta sea ÚNICAMENTE un JSON válido con la siguiente estructura exacta, sin texto adicional antes o después:
171
  {{
172
  "slides": [
173
- {{"title": "Resumen del PDF: Parte 1", "content": "Contenido del resumen parte 1..."}},
174
- // ... (más diapositivas de resumen si num_summary_slides > 1, hasta num_summary_slides)
175
- {{"title": "Resumen del PDF: Parte {num_summary_slides}", "content": "Contenido del resumen parte {num_summary_slides}..."}}
176
  ]
177
  }}
178
- El título de cada diapositiva de resumen debe indicar claramente que es parte del resumen del PDF (ej: "Resumen PDF: Puntos Clave", "Análisis del Documento PDF").
179
- Debe haber exactamente {num_summary_slides} objetos en la lista "slides".
180
- Asegúrate de que el contenido de cada diapositiva sea conciso y relevante.
181
- Evita usar caracteres especiales o comillas dentro de los títulos o contenidos que puedan romper el JSON.
182
- Usa comillas dobles para las claves y los valores string del JSON.
183
  """
184
- last_error = None
185
- last_response_text = ""
186
  for attempt in range(max_retries):
187
  try:
188
  response_obj = client.generate_content(prompt)
189
- if not response_obj.parts:
190
- raise ValueError("Respuesta del modelo vacía o mal formada (resumen PDF).")
191
  last_response_text = response_obj.text
192
- summary_slides_data = parse_gemini_response_for_slides(last_response_text, expected_num_slides=num_summary_slides)
193
- return summary_slides_data
194
  except (ValueError, json.JSONDecodeError) as e:
195
- last_error = e
196
- st.warning(f"Intento {attempt + 1}/{max_retries} (resumen PDF) fallido: {e}")
197
- if attempt == max_retries - 1:
198
- st.error(f"Error final al generar resumen PDF después de {max_retries} intentos: {last_error}")
199
- st.text_area("Última respuesta (resumen PDF):", last_response_text, height=150)
200
- return [] # Retorna lista vacía en caso de error final
201
- except Exception as e: # Captura otras excepciones de genai
202
- last_error = e
203
- st.warning(f"Error inesperado del API de Gemini (resumen PDF) en intento {attempt + 1}/{max_retries}: {e}")
204
  if attempt == max_retries - 1:
205
- st.error(f"Error final con API de Gemini (resumen PDF) después de {max_retries} intentos: {last_error}")
206
- return []
207
- return [] # Should not be reached
 
 
 
208
 
209
 
210
  def buscar_imagen_pixabay(query):
211
- if not PIXABAY_API_KEY:
212
- print("Pixabay API Key no configurada.")
213
- return None
214
  url = f"https://pixabay.com/api/?key={PIXABAY_API_KEY}&q={requests.utils.quote(query)}&image_type=photo&per_page=3&safesearch=true"
215
  try:
216
- response = requests.get(url, timeout=10)
217
- response.raise_for_status()
218
  data = response.json()
219
-
220
- if data.get('hits') and len(data['hits']) > 0:
221
- image_info = data['hits'][0] # Tomar la primera imagen relevante
222
  image_url = image_info.get('largeImageURL', image_info.get('webformatURL'))
223
  if not image_url: return None
224
-
225
- image_response = requests.get(image_url, timeout=10)
226
- image_response.raise_for_status()
227
-
228
  img_bytes = BytesIO(image_response.content)
229
- # Validar y reabrir imagen con PIL
230
  try:
231
- img = Image.open(img_bytes)
232
- img.verify() # Puede cerrar el stream de BytesIO
233
- img_bytes.seek(0) # Rebobinar para reabrir
234
- img = Image.open(img_bytes)
235
- return img
236
- except Exception as pil_e:
237
- print(f"Error con PIL al procesar imagen de Pixabay ({query}): {pil_e}")
238
- return None
239
- except requests.exceptions.RequestException as e:
240
- print(f"Error en la solicitud a Pixabay ({query}): {e}")
241
- except Exception as e: # Otros errores (ej. JSONDecodeError de Pixabay)
242
- print(f"Error general procesando imagen de Pixabay ({query}): {e}")
243
  return None
244
 
245
- def add_slide_content_and_image(slide, slide_data, prs_slide_width, prs_slide_height, add_image=True):
246
- """Añade título, contenido y opcionalmente una imagen a una diapositiva."""
247
  if slide.shapes.title:
248
  slide.shapes.title.text = slide_data['title']
249
 
250
  content_placeholder = None
251
- if slide.placeholders and len(slide.placeholders) > 1 and slide.placeholders[1]:
252
- content_placeholder = slide.placeholders[1]
253
-
 
 
 
254
  margin_emu = Inches(0.3)
255
- common_top_emu = Inches(1.6) # Ajustar si el título es más grande/pequeño
 
 
 
 
256
  content_bottom_emu = prs_slide_height - margin_emu
257
 
258
- # Por defecto, el texto ocupa más si no hay imagen
259
  text_area_width_float = prs_slide_width - (2 * margin_emu)
260
  text_area_left_emu = margin_emu
261
 
262
  img_area_width_float = 0
263
- if add_image:
264
  img_area_width_float = prs_slide_width * 0.40
265
- text_area_width_float = prs_slide_width - img_area_width_float - (2 * margin_emu)
266
- # img_area_left_float = text_area_left_emu + text_area_width_float + margin_emu (No es necesario calcular aquí)
267
 
268
 
269
  common_height_emu = content_bottom_emu - common_top_emu
270
-
 
 
271
  if content_placeholder:
272
  content_placeholder.left = int(text_area_left_emu)
273
- content_placeholder.top = int(common_top_emu)
 
274
  content_placeholder.width = int(text_area_width_float)
275
  content_placeholder.height = int(common_height_emu)
276
 
277
- tf = content_placeholder.text_frame
278
- tf.clear()
279
- p = tf.add_paragraph()
280
- p.text = slide_data['content']
281
  tf.word_wrap = True
282
- tf.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE
283
- else: # Fallback si no hay placeholder de contenido (típico para slide_layouts[5] o [6])
 
 
 
 
 
 
 
284
  txBox = slide.shapes.add_textbox(
285
- int(text_area_left_emu),
286
- int(common_top_emu),
287
- int(text_area_width_float),
288
- int(common_height_emu)
289
  )
290
- tf = txBox.text_frame
291
- tf.text = slide_data['content']
292
  tf.word_wrap = True
293
- tf.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE
 
 
 
294
 
295
- if add_image:
 
296
  try:
297
  first_sentence = slide_data['content'].split('.')[0] if '.' in slide_data['content'] else slide_data['content'][:70]
298
  image_query = f"{slide_data['title']} {first_sentence}"
299
  pil_image = buscar_imagen_pixabay(image_query)
300
 
301
  if pil_image:
302
- img_byte_arr = io.BytesIO()
303
- img_format = pil_image.format if pil_image.format in ['JPEG', 'PNG', 'GIF'] else 'PNG'
304
- if img_format == 'JPEG' and pil_image.mode == 'RGBA':
305
- pil_image = pil_image.convert('RGB')
306
-
307
- pil_image.save(img_byte_arr, format=img_format)
308
- img_byte_arr.seek(0)
309
 
310
  img_native_width_px, img_native_height_px = pil_image.size
311
-
312
- available_width_for_img_emu = int(img_area_width_float) # ya es float * EMU_per_inch
313
  available_height_for_img_emu = common_height_emu
314
 
315
- # Convertir dimensiones nativas a EMUs para ratio (asumiendo 96 DPI para px -> inches)
316
- native_width_inches = img_native_width_px / 96.0
317
- native_height_inches = img_native_height_px / 96.0
318
-
319
- # Convertir area disponible a Inches para ratio
320
  available_width_inches = available_width_for_img_emu / 914400.0
321
  available_height_inches = available_height_for_img_emu / 914400.0
322
 
@@ -327,63 +309,55 @@ def add_slide_content_and_image(slide, slide_data, prs_slide_width, prs_slide_he
327
  pic_width_emu = Inches(native_width_inches * scale_ratio)
328
  pic_height_emu = Inches(native_height_inches * scale_ratio)
329
 
330
- # Posicionar imagen a la derecha del texto
331
- img_left_float = prs_slide_width - margin_emu - pic_width_emu # Alinear a la derecha con margen
332
- if img_left_float < text_area_left_emu + text_area_width_float + Inches(0.1): # Si se solapa, ajustar
333
  img_left_float = text_area_left_emu + text_area_width_float + Inches(0.1)
334
 
335
- # Centrar verticalmente la imagen en el espacio común
336
  img_top_float = float(common_top_emu) + (float(common_height_emu) - pic_height_emu) / 2.0
337
 
338
  slide.shapes.add_picture(
339
- img_byte_arr,
340
- int(img_left_float),
341
- int(img_top_float),
342
- width=pic_width_emu,
343
- height=pic_height_emu
344
  )
345
  except Exception as e:
346
- print(f"No se pudo procesar o insertar imagen para slide ('{slide_data['title']}'): {e}")
347
 
348
 
349
- def create_powerpoint(main_slides_content, template_path, summary_slides_content=None):
350
  prs = Presentation(template_path)
351
  slide_width_emu = prs.slide_width
352
  slide_height_emu = prs.slide_height
 
353
 
354
- # 1. Añadir diapositivas principales (con imágenes)
 
 
 
 
 
 
 
355
  for slide_data in main_slides_content:
356
  slide_layout = prs.slide_layouts[1] # Título y Contenido
357
  slide = prs.slides.add_slide(slide_layout)
358
  add_slide_content_and_image(slide, slide_data, slide_width_emu, slide_height_emu, add_image=True)
359
 
360
- # 2. Añadir diapositivas de resumen del PDF (sin imágenes por ahora)
361
- if summary_slides_content:
362
- for slide_data in summary_slides_content:
363
- slide_layout = prs.slide_layouts[1] # Título y Contenido
364
  slide = prs.slides.add_slide(slide_layout)
365
- # Llamamos a la misma función, pero con add_image=False
366
  add_slide_content_and_image(slide, slide_data, slide_width_emu, slide_height_emu, add_image=False)
367
 
368
-
369
- # 3. Agregar diapositiva final de agradecimiento
370
- final_slide_layout = prs.slide_layouts[1] # O un layout de solo título si existe (ej. layout 5)
371
  final_slide = prs.slides.add_slide(final_slide_layout)
372
- if final_slide.shapes.title:
373
- final_slide.shapes.title.text = "¡Gracias por su atención!"
374
-
375
- # Limpiar el placeholder de contenido si existe en la diapositiva final y no se usa
376
  if len(final_slide.placeholders) > 1 and final_slide.placeholders[1]:
377
- try:
378
- content_ph = final_slide.placeholders[1]
379
- sp = content_ph.element
380
- sp.getparent().remove(sp)
381
- except Exception as e:
382
- print(f"No se pudo eliminar el placeholder de contenido de la diapositiva final: {e}")
383
 
384
- pptx_buffer = io.BytesIO()
385
- prs.save(pptx_buffer)
386
- pptx_buffer.seek(0)
387
  return pptx_buffer
388
 
389
 
@@ -391,131 +365,104 @@ def create_powerpoint(main_slides_content, template_path, summary_slides_content
391
  def main():
392
  st.set_page_config(page_title="PowerPoint Mágico", layout="wide", initial_sidebar_state="expanded")
393
  st.title("✨ PowerPoint Mágico con el Búho 🦉")
394
- st.markdown("Genera presentaciones impactantes con IA, incluyendo resúmenes de PDF.")
395
 
396
  if not GEMINI_API_KEY or not PIXABAY_API_KEY: return
397
-
398
  client = get_gemini_model()
399
 
400
- # --- COLUMNA DE CONFIGURACIÓN (IZQUIERDA) ---
401
  st.sidebar.header("Configuración de la Presentación")
402
  topic = st.sidebar.text_input("📝 Tema principal de la presentación:", placeholder="Ej: El futuro de la IA")
403
 
404
- # Plantillas
405
  plantillas_dir = "PLANTILLAS"
 
406
  if not os.path.exists(plantillas_dir):
407
- try:
408
- os.makedirs(plantillas_dir)
409
- st.sidebar.info(f"Directorio '{plantillas_dir}' creado. Añada sus plantillas .pptx allí.")
410
- except OSError as e:
411
- st.sidebar.error(f"No se pudo crear el dir de plantillas '{plantillas_dir}': {e}")
412
- st.stop()
413
-
414
  available_templates = []
415
  try:
416
  if os.path.isdir(plantillas_dir):
417
  available_templates = [f for f in os.listdir(plantillas_dir) if f.endswith(".pptx") and os.path.isfile(os.path.join(plantillas_dir, f))]
418
- except Exception as e:
419
- st.sidebar.error(f"Error al acceder al dir de plantillas: {e}")
420
-
421
  if not available_templates:
422
  st.sidebar.warning(f"No hay plantillas .pptx en '{plantillas_dir}'.")
423
  default_prs = Presentation()
424
- # Asegurar que hay al menos un layout usable
425
  if not default_prs.slide_layouts: default_prs.slide_master.slide_layouts.add_slide_layout()
426
  if default_prs.slide_layouts: default_prs.slides.add_slide(default_prs.slide_layouts[0])
427
-
428
  default_template_path = os.path.join(plantillas_dir, "default.pptx")
429
- try:
430
- default_prs.save(default_template_path)
431
- st.sidebar.info(f"Plantilla 'default.pptx' creada.")
432
- available_templates.append("default.pptx")
433
- except Exception as e:
434
- st.sidebar.error(f"No se pudo crear plantilla por defecto: {e}")
435
-
436
  template_options = {os.path.splitext(t)[0]: os.path.join(plantillas_dir, t) for t in available_templates}
437
-
438
- if not template_options:
439
- st.sidebar.error("CRÍTICO: No hay plantillas. Cree 'PLANTILLAS' y añada un .pptx.")
440
- st.stop()
441
-
442
  selected_template_name = st.sidebar.selectbox("🎨 Seleccione una plantilla:", list(template_options.keys()))
443
 
444
- # Carga y resumen de PDF
445
- st.sidebar.subheader("Resumen de PDF (Opcional)")
446
- uploaded_pdf = st.sidebar.file_uploader("📄 Cargue un PDF para resumir:", type="pdf")
447
- num_summary_slides = 0
448
  if uploaded_pdf:
449
- num_summary_slides = st.sidebar.number_input("Nº de diapositivas para el resumen del PDF:", min_value=1, max_value=5, value=2, step=1)
450
 
451
- # --- ÁREA PRINCIPAL (DERECHA) ---
452
  if st.button("🚀 Generar Presentación", type="primary", use_container_width=True):
453
- if not topic:
454
- st.warning("⚠️ Por favor, ingrese un tema para la presentación.")
455
- return
456
- if not selected_template_name:
457
- st.warning("⚠️ Por favor, seleccione una plantilla.") # Debería ser prevenido por la lógica anterior
458
- return
459
 
460
  template_path = template_options[selected_template_name]
461
- if not os.path.exists(template_path):
462
- st.error(f"No se encontró la plantilla: {template_path}")
463
- return
464
 
 
465
  main_slides_content = []
466
- summary_slides_content = []
467
 
468
  overall_progress = st.progress(0)
469
  status_text = st.empty()
470
 
 
 
 
 
 
 
 
 
 
 
 
471
  # 1. Generar contenido principal
472
  status_text.info("🧠 Generando contenido principal con Gemini...")
473
  main_slides_content = generate_presentation_content(topic, client)
474
  if not main_slides_content:
475
- status_text.error("❌ Falló la generación del contenido principal.")
476
- overall_progress.progress(100) # Finalizar barra
477
- return
478
- status_text.success("👍 Contenido principal generado.")
479
- overall_progress.progress(33)
480
-
481
- # 2. Procesar y resumir PDF si se cargó
482
- if uploaded_pdf and num_summary_slides > 0:
483
- status_text.info(f"📄 Procesando PDF '{uploaded_pdf.name}'...")
484
- pdf_text = extract_text_from_pdf(uploaded_pdf)
485
- overall_progress.progress(45)
486
 
 
 
 
 
487
  if pdf_text:
488
- status_text.info("✍️ Resumiendo texto del PDF con Gemini...")
489
- summary_slides_content = summarize_text_for_slides(pdf_text, num_summary_slides, client)
490
- if summary_slides_content:
491
- status_text.success(f"👍 Resumen del PDF generado en {len(summary_slides_content)} diapositiva(s).")
492
- else:
493
- status_text.warning("⚠️ No se pudo generar el resumen del PDF o la respuesta fue vacía.")
494
- else:
495
- status_text.warning("⚠️ No se pudo extraer texto del PDF.")
496
- overall_progress.progress(66)
497
 
498
  # 3. Crear archivo PowerPoint
499
  status_text.info("🛠️ Creando archivo PowerPoint...")
500
  try:
501
- pptx_buffer = create_powerpoint(main_slides_content, template_path, summary_slides_content)
 
 
502
  overall_progress.progress(100)
503
  status_text.success("🎉 ¡Presentación generada con éxito!")
504
-
505
  clean_topic = re.sub(r'[^\w\s-]', '', topic).strip().replace(' ', '_')
506
  file_name = f"{clean_topic}_{selected_template_name}_presentacion.pptx"
507
-
508
  st.download_button(
509
- label="📥 Descargar Presentación",
510
- data=pptx_buffer,
511
- file_name=file_name,
512
  mime="application/vnd.openxmlformats-officedocument.presentationml.presentation",
513
  use_container_width=True
514
  )
515
  except Exception as e:
516
- overall_progress.progress(100)
517
- status_text.error(f"❌ Ocurrió un error al crear el archivo PowerPoint: {str(e)}")
518
- st.exception(e)
519
 
520
  if __name__ == "__main__":
521
  main()
 
10
  import json
11
  import re
12
  import google.generativeai as genai
13
+ import pdfplumber
14
 
15
  # --- CONFIGURACIÓN ---
 
16
  GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
17
  if not GEMINI_API_KEY:
18
  st.error("GEMINI_API_KEY no encontrada. Por favor configúrala en tus variables de entorno.")
19
  st.stop()
20
  genai.configure(api_key=GEMINI_API_KEY)
21
 
 
22
  PIXABAY_API_KEY = st.secrets.get("pixabay")
23
  if not PIXABAY_API_KEY:
24
  st.error("Clave API de Pixabay no encontrada en los secretos de Streamlit.")
 
31
 
32
  # --- FUNCIONES AUXILIARES ---
33
  def extract_text_from_pdf(uploaded_file):
 
34
  if uploaded_file is None:
35
  return None
36
  try:
37
  with pdfplumber.open(uploaded_file) as pdf:
38
+ full_text = [page.extract_text() for page in pdf.pages if page.extract_text()]
 
 
 
 
39
  return "\n".join(full_text)
40
  except Exception as e:
41
  st.error(f"Error al procesar el PDF: {e}")
42
  return None
43
 
44
  def extract_and_clean_json(text_response):
 
 
45
  match = re.search(r"```json\s*(\{.*?\})\s*```", text_response, re.DOTALL)
46
  if match:
47
  json_str = match.group(1)
48
  else:
 
49
  json_match = re.search(r'\{.*\}', text_response, re.DOTALL)
50
  if json_match:
51
  json_str = json_match.group(0)
52
  else:
 
 
53
  return None
54
+ json_str = re.sub(r'[\n\t\r]', '', json_str)
55
+ json_str = re.sub(r',\s*([}\]])', r'\1', json_str)
 
 
56
  return json_str
57
 
58
+ def parse_gemini_response_for_slides(response_text, expected_slides_key="slides", expected_num_slides=None, is_single_slide=False):
 
59
  json_str = extract_and_clean_json(response_text)
60
  if not json_str:
61
  raise ValueError("No se pudo extraer una cadena JSON válida de la respuesta.")
 
65
  except json.JSONDecodeError as e:
66
  raise ValueError(f"Error al decodificar JSON: {e}. JSON intentado: {json_str[:500]}...")
67
 
68
+ if is_single_slide: # Para el título creativo
69
+ if not (isinstance(data, dict) and 'title' in data and 'content' in data):
70
+ raise ValueError("La estructura del JSON para la diapositiva de título es incorrecta. Se esperaba 'title' y 'content'.")
71
+ if not (isinstance(data['title'], str) and isinstance(data['content'], str)):
72
+ raise ValueError("El título y el contenido de la diapositiva de título deben ser cadenas.")
73
+ return [data] # Devolver como lista de una diapositiva
74
+
75
  if not isinstance(data, dict) or expected_slides_key not in data:
76
  raise ValueError(f"La estructura del JSON es incorrecta. Falta la clave '{expected_slides_key}'.")
77
 
 
89
  raise ValueError(f"El título y el contenido de la diapositiva {i+1} deben ser cadenas de texto.")
90
  return slides
91
 
92
+ def generate_creative_title_slide(topic, client, max_retries=3):
93
+ """Genera una única diapositiva de título creativa."""
94
+ prompt = f"""Genera un título MUY llamativo y creativo para una presentación sobre "{topic}".
95
+ Además, proporciona una breve frase o subtítulo de impacto para acompañar el título.
96
+ Es CRUCIAL que tu respuesta sea ÚNICAMENTE un JSON válido con la siguiente estructura exacta, sin texto adicional antes o después:
97
+ {{
98
+ "title": "Título Creativo y Llamativo Generado",
99
+ "content": "Breve subtítulo o frase de impacto."
100
+ }}
101
+ No incluyas ningún otro texto, explicación o saludo. Solo el JSON.
102
+ El título debe despertar curiosidad e interés. El contenido debe ser muy conciso.
103
+ """
104
+ last_error = None
105
+ last_response_text = ""
106
+ for attempt in range(max_retries):
107
+ try:
108
+ response_obj = client.generate_content(prompt)
109
+ if not response_obj.parts: raise ValueError("Respuesta del modelo vacía (título creativo).")
110
+ last_response_text = response_obj.text
111
+ title_slide_data = parse_gemini_response_for_slides(last_response_text, is_single_slide=True)
112
+ return title_slide_data # Es una lista con un solo diccionario de slide
113
+ except (ValueError, json.JSONDecodeError) as e:
114
+ last_error = e
115
+ st.warning(f"Intento {attempt + 1}/{max_retries} (título creativo) fallido: {e}")
116
+ if attempt == max_retries - 1:
117
+ st.error(f"Error final al generar título creativo: {last_error}")
118
+ st.text_area("Última respuesta (título creativo):", last_response_text, height=100)
119
+ return None
120
+ except Exception as e:
121
+ last_error = e; st.warning(f"Error API Gemini (título creativo) intento {attempt + 1}: {e}")
122
+ if attempt == max_retries - 1: st.error(f"Error API Gemini (título creativo): {last_error}"); return None
123
+ return None
124
+
125
 
126
  def generate_presentation_content(topic, client, max_retries=3):
 
127
  prompt = f"""Genera una presentación de PowerPoint sobre el tema: "{topic}".
128
+ Debes crear exactamente 9 diapositivas de contenido principal. Cada diapositiva debe tener un título y contenido.
129
  Es CRUCIAL que tu respuesta sea ÚNICAMENTE un JSON válido con la siguiente estructura exacta, sin texto adicional antes o después:
130
  {{
131
  "slides": [
132
  {{"title": "Título Diapositiva 1", "content": "Contenido Diapositiva 1"}},
133
+ // ...hasta...
 
 
 
 
 
 
134
  {{"title": "Título Diapositiva 9", "content": "Contenido Diapositiva 9"}}
135
  ]
136
  }}
137
+ No incluyas ningún otro texto. Solo el JSON. Los títulos deben ser informativos y seguir una progresión lógica.
138
+ El contenido debe ser conciso. Usa comillas dobles para claves y strings.
 
 
139
  """
140
+ last_error = None; last_response_text = ""
 
141
  for attempt in range(max_retries):
142
  try:
143
  response_obj = client.generate_content(prompt)
144
+ if not response_obj.parts: raise ValueError("Respuesta del modelo vacía (contenido principal).")
 
145
  last_response_text = response_obj.text
146
+ return parse_gemini_response_for_slides(last_response_text, expected_num_slides=9)
 
147
  except (ValueError, json.JSONDecodeError) as e:
148
+ last_error = e; st.warning(f"Intento {attempt + 1}/{max_retries} (contenido principal) fallido: {e}")
 
149
  if attempt == max_retries - 1:
150
+ st.error(f"Error final (contenido principal): {last_error}")
151
+ st.text_area("Última resp. (contenido principal):", last_response_text, height=150); return None
152
+ except Exception as e:
153
+ last_error = e; st.warning(f"Error API Gemini (contenido principal) intento {attempt + 1}: {e}")
154
+ if attempt == max_retries - 1: st.error(f"Error API Gemini (contenido principal): {last_error}"); return None
155
+ return None
 
 
 
 
156
 
157
+ def generate_conclusion_slides_from_text(text_to_summarize, num_conclusion_slides, topic, client, max_retries=3):
158
+ """Genera diapositivas de conclusión basadas en el texto y el tema general."""
159
+ if not text_to_summarize: return []
160
+ max_len = 30000
 
 
 
 
161
  if len(text_to_summarize) > max_len:
162
+ st.warning(f"Texto del PDF ({len(text_to_summarize)} chars) truncado a {max_len} para conclusiones.")
163
  text_to_summarize = text_to_summarize[:max_len]
164
 
165
+ prompt = f"""Dado el siguiente texto (extraído de un documento relacionado con el tema general "{topic}"):
166
+ --- TEXTO DEL DOCUMENTO ---
 
167
  {text_to_summarize}
168
+ --- FIN DEL TEXTO ---
 
 
 
169
 
170
+ Genera {num_conclusion_slides} diapositiva(s) de CONCLUSIÓN para una presentación sobre "{topic}".
171
+ Estas diapositivas deben sintetizar las ideas clave del texto proporcionado y conectarlas como conclusiones relevantes para el tema "{topic}".
172
+ Los títulos de estas diapositivas deben sonar como conclusiones o puntos finales (ej: "Reflexiones Finales", "Mirando Hacia Adelante", "Conclusiones Clave", "Implicaciones y Futuro"). NO uses la palabra "PDF" ni "Resumen" en los títulos.
173
+
174
  Es CRUCIAL que tu respuesta sea ÚNICAMENTE un JSON válido con la siguiente estructura exacta, sin texto adicional antes o después:
175
  {{
176
  "slides": [
177
+ {{"title": "Título Conclusión 1", "content": "Contenido conclusión 1..."}},
178
+ // ... (hasta num_conclusion_slides)
179
+ {{"title": "Título Conclusión {num_conclusion_slides}", "content": "Contenido conclusión {num_conclusion_slides}..."}}
180
  ]
181
  }}
182
+ Debe haber exactamente {num_conclusion_slides} objetos en la lista "slides". Contenido conciso.
183
+ Usa comillas dobles para claves y strings.
 
 
 
184
  """
185
+ last_error = None; last_response_text = ""
 
186
  for attempt in range(max_retries):
187
  try:
188
  response_obj = client.generate_content(prompt)
189
+ if not response_obj.parts: raise ValueError("Respuesta del modelo vacía (conclusiones).")
 
190
  last_response_text = response_obj.text
191
+ return parse_gemini_response_for_slides(last_response_text, expected_num_slides=num_conclusion_slides)
 
192
  except (ValueError, json.JSONDecodeError) as e:
193
+ last_error = e; st.warning(f"Intento {attempt + 1}/{max_retries} (conclusiones) fallido: {e}")
 
 
 
 
 
 
 
 
194
  if attempt == max_retries - 1:
195
+ st.error(f"Error final (conclusiones): {last_error}")
196
+ st.text_area("Última resp. (conclusiones):", last_response_text, height=150); return []
197
+ except Exception as e:
198
+ last_error = e; st.warning(f"Error API Gemini (conclusiones) intento {attempt + 1}: {e}")
199
+ if attempt == max_retries - 1: st.error(f"Error API Gemini (conclusiones): {last_error}"); return []
200
+ return []
201
 
202
 
203
  def buscar_imagen_pixabay(query):
204
+ if not PIXABAY_API_KEY: return None
 
 
205
  url = f"https://pixabay.com/api/?key={PIXABAY_API_KEY}&q={requests.utils.quote(query)}&image_type=photo&per_page=3&safesearch=true"
206
  try:
207
+ response = requests.get(url, timeout=10); response.raise_for_status()
 
208
  data = response.json()
209
+ if data.get('hits'):
210
+ image_info = data['hits'][0]
 
211
  image_url = image_info.get('largeImageURL', image_info.get('webformatURL'))
212
  if not image_url: return None
213
+ image_response = requests.get(image_url, timeout=10); image_response.raise_for_status()
 
 
 
214
  img_bytes = BytesIO(image_response.content)
 
215
  try:
216
+ img = Image.open(img_bytes); img.verify(); img_bytes.seek(0)
217
+ return Image.open(img_bytes)
218
+ except Exception as pil_e: print(f"Error PIL ({query}): {pil_e}"); return None
219
+ except requests.exceptions.RequestException as e: print(f"Error Pixabay req ({query}): {e}")
220
+ except Exception as e: print(f"Error Pixabay gral ({query}): {e}")
 
 
 
 
 
 
 
221
  return None
222
 
223
+ def add_slide_content_and_image(slide, slide_data, prs_slide_width, prs_slide_height, add_image=True, is_title_slide=False):
 
224
  if slide.shapes.title:
225
  slide.shapes.title.text = slide_data['title']
226
 
227
  content_placeholder = None
228
+ # Para la diapositiva de título, el placeholder de contenido puede ser el primero si el layout es de título solo
229
+ if is_title_slide and slide.placeholders and slide.placeholders[0] == slide.shapes.title and len(slide.placeholders) > 1:
230
+ content_placeholder = slide.placeholders[1] # Subtitle placeholder
231
+ elif not is_title_slide and slide.placeholders and len(slide.placeholders) > 1 and slide.placeholders[1]:
232
+ content_placeholder = slide.placeholders[1] # Body placeholder
233
+
234
  margin_emu = Inches(0.3)
235
+ # Si es diapositiva de título, el contenido (subtítulo) puede estar más arriba
236
+ common_top_emu = Inches(0.5) if is_title_slide and slide.shapes.title else Inches(1.6)
237
+ if is_title_slide and content_placeholder: # Ajustar top del subtitulo
238
+ common_top_emu = content_placeholder.top
239
+
240
  content_bottom_emu = prs_slide_height - margin_emu
241
 
 
242
  text_area_width_float = prs_slide_width - (2 * margin_emu)
243
  text_area_left_emu = margin_emu
244
 
245
  img_area_width_float = 0
246
+ if add_image and not is_title_slide: # No añadir imagen a la diapositiva de título principal
247
  img_area_width_float = prs_slide_width * 0.40
248
+ text_area_width_float = prs_slide_width - img_area_width_float - (3 * margin_emu) # +margen entre texto e img
 
249
 
250
 
251
  common_height_emu = content_bottom_emu - common_top_emu
252
+ if is_title_slide and content_placeholder: # Ajustar altura del subtitulo
253
+ common_height_emu = content_placeholder.height
254
+
255
  if content_placeholder:
256
  content_placeholder.left = int(text_area_left_emu)
257
+ if not is_title_slide: # No sobreescribir el top del subtítulo si ya está bien
258
+ content_placeholder.top = int(common_top_emu)
259
  content_placeholder.width = int(text_area_width_float)
260
  content_placeholder.height = int(common_height_emu)
261
 
262
+ tf = content_placeholder.text_frame; tf.clear()
263
+ p = tf.add_paragraph(); p.text = slide_data['content']
 
 
264
  tf.word_wrap = True
265
+ tf.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE if not is_title_slide else MSO_AUTO_SIZE.NONE
266
+ if is_title_slide: # Centrar subtítulo
267
+ p.alignment = PP_ALIGN.CENTER # Necesitarías: from pptx.enum.text import PP_ALIGN
268
+ tf.vertical_anchor = MSO_ANCHOR.MIDDLE
269
+ else:
270
+ txBox_top = common_top_emu
271
+ if is_title_slide and slide.shapes.title: # Colocar subtítulo debajo del título principal
272
+ txBox_top = slide.shapes.title.top + slide.shapes.title.height + Inches(0.2)
273
+
274
  txBox = slide.shapes.add_textbox(
275
+ int(text_area_left_emu), int(txBox_top),
276
+ int(text_area_width_float), int(common_height_emu / (1.5 if is_title_slide else 1) ) # Menos altura para subtitulo
 
 
277
  )
278
+ tf = txBox.text_frame; tf.text = slide_data['content']
 
279
  tf.word_wrap = True
280
+ tf.auto_size = MSO_AUTO_SIZE.SHAPE_TO_FIT_TEXT if is_title_slide else MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE
281
+ if is_title_slide: # Centrar subtítulo
282
+ p = tf.paragraphs[0]; p.alignment = PP_ALIGN.CENTER # Necesitarías: from pptx.enum.text import PP_ALIGN
283
+ tf.vertical_anchor = MSO_ANCHOR.TOP
284
 
285
+
286
+ if add_image and not is_title_slide:
287
  try:
288
  first_sentence = slide_data['content'].split('.')[0] if '.' in slide_data['content'] else slide_data['content'][:70]
289
  image_query = f"{slide_data['title']} {first_sentence}"
290
  pil_image = buscar_imagen_pixabay(image_query)
291
 
292
  if pil_image:
293
+ img_byte_arr = io.BytesIO(); img_format = pil_image.format if pil_image.format in ['JPEG', 'PNG', 'GIF'] else 'PNG'
294
+ if img_format == 'JPEG' and pil_image.mode == 'RGBA': pil_image = pil_image.convert('RGB')
295
+ pil_image.save(img_byte_arr, format=img_format); img_byte_arr.seek(0)
 
 
 
 
296
 
297
  img_native_width_px, img_native_height_px = pil_image.size
298
+ available_width_for_img_emu = int(img_area_width_float)
 
299
  available_height_for_img_emu = common_height_emu
300
 
301
+ native_width_inches = img_native_width_px / 96.0; native_height_inches = img_native_height_px / 96.0
 
 
 
 
302
  available_width_inches = available_width_for_img_emu / 914400.0
303
  available_height_inches = available_height_for_img_emu / 914400.0
304
 
 
309
  pic_width_emu = Inches(native_width_inches * scale_ratio)
310
  pic_height_emu = Inches(native_height_inches * scale_ratio)
311
 
312
+ img_left_float = prs_slide_width - margin_emu - pic_width_emu
313
+ if img_left_float < text_area_left_emu + text_area_width_float + Inches(0.1):
 
314
  img_left_float = text_area_left_emu + text_area_width_float + Inches(0.1)
315
 
 
316
  img_top_float = float(common_top_emu) + (float(common_height_emu) - pic_height_emu) / 2.0
317
 
318
  slide.shapes.add_picture(
319
+ img_byte_arr, int(img_left_float), int(img_top_float),
320
+ width=pic_width_emu, height=pic_height_emu
 
 
 
321
  )
322
  except Exception as e:
323
+ print(f"No se pudo procesar/insertar imagen ('{slide_data['title']}'): {e}")
324
 
325
 
326
+ def create_powerpoint(title_slide_content, main_slides_content, template_path, conclusion_slides_content=None):
327
  prs = Presentation(template_path)
328
  slide_width_emu = prs.slide_width
329
  slide_height_emu = prs.slide_height
330
+ from pptx.enum.text import PP_ALIGN # Importar aquí para evitar error si no se usa add_slide_content_and_image
331
 
332
+ # 0. Diapositiva de Título Creativa (usar layout de título, ej. 0 o 5)
333
+ if title_slide_content: # es una lista con 1 slide_data
334
+ # Intentar usar un layout de solo título si está disponible, sino el de título y contenido.
335
+ title_layout = prs.slide_layouts[0] if prs.slide_layouts else prs.slide_layouts[5] if len(prs.slide_layouts) > 5 else prs.slide_layouts[1]
336
+ slide = prs.slides.add_slide(title_layout)
337
+ add_slide_content_and_image(slide, title_slide_content[0], slide_width_emu, slide_height_emu, add_image=False, is_title_slide=True)
338
+
339
+ # 1. Diapositivas principales (con imágenes)
340
  for slide_data in main_slides_content:
341
  slide_layout = prs.slide_layouts[1] # Título y Contenido
342
  slide = prs.slides.add_slide(slide_layout)
343
  add_slide_content_and_image(slide, slide_data, slide_width_emu, slide_height_emu, add_image=True)
344
 
345
+ # 2. Diapositivas de Conclusión (sin imágenes)
346
+ if conclusion_slides_content:
347
+ for slide_data in conclusion_slides_content:
348
+ slide_layout = prs.slide_layouts[1]
349
  slide = prs.slides.add_slide(slide_layout)
 
350
  add_slide_content_and_image(slide, slide_data, slide_width_emu, slide_height_emu, add_image=False)
351
 
352
+ # 3. Diapositiva final de agradecimiento
353
+ final_slide_layout = prs.slide_layouts[1]
 
354
  final_slide = prs.slides.add_slide(final_slide_layout)
355
+ if final_slide.shapes.title: final_slide.shapes.title.text = "¡Gracias por su atención!"
 
 
 
356
  if len(final_slide.placeholders) > 1 and final_slide.placeholders[1]:
357
+ try: content_ph = final_slide.placeholders[1]; sp = content_ph.element; sp.getparent().remove(sp)
358
+ except Exception as e: print(f"No se pudo quitar placeholder de diap. final: {e}")
 
 
 
 
359
 
360
+ pptx_buffer = io.BytesIO(); prs.save(pptx_buffer); pptx_buffer.seek(0)
 
 
361
  return pptx_buffer
362
 
363
 
 
365
  def main():
366
  st.set_page_config(page_title="PowerPoint Mágico", layout="wide", initial_sidebar_state="expanded")
367
  st.title("✨ PowerPoint Mágico con el Búho 🦉")
368
+ st.markdown("Genera presentaciones impactantes con IA, incluyendo conclusiones de PDF.")
369
 
370
  if not GEMINI_API_KEY or not PIXABAY_API_KEY: return
 
371
  client = get_gemini_model()
372
 
 
373
  st.sidebar.header("Configuración de la Presentación")
374
  topic = st.sidebar.text_input("📝 Tema principal de la presentación:", placeholder="Ej: El futuro de la IA")
375
 
 
376
  plantillas_dir = "PLANTILLAS"
377
+ # ... (resto de la lógica de plantillas sin cambios, solo movida a sidebar si no estaba ya) ...
378
  if not os.path.exists(plantillas_dir):
379
+ try: os.makedirs(plantillas_dir); st.sidebar.info(f"Directorio '{plantillas_dir}' creado.")
380
+ except OSError as e: st.sidebar.error(f"No se pudo crear dir '{plantillas_dir}': {e}"); st.stop()
 
 
 
 
 
381
  available_templates = []
382
  try:
383
  if os.path.isdir(plantillas_dir):
384
  available_templates = [f for f in os.listdir(plantillas_dir) if f.endswith(".pptx") and os.path.isfile(os.path.join(plantillas_dir, f))]
385
+ except Exception as e: st.sidebar.error(f"Error al acceder dir plantillas: {e}")
 
 
386
  if not available_templates:
387
  st.sidebar.warning(f"No hay plantillas .pptx en '{plantillas_dir}'.")
388
  default_prs = Presentation()
 
389
  if not default_prs.slide_layouts: default_prs.slide_master.slide_layouts.add_slide_layout()
390
  if default_prs.slide_layouts: default_prs.slides.add_slide(default_prs.slide_layouts[0])
 
391
  default_template_path = os.path.join(plantillas_dir, "default.pptx")
392
+ try: default_prs.save(default_template_path); st.sidebar.info(f"Plantilla 'default.pptx' creada."); available_templates.append("default.pptx")
393
+ except Exception as e: st.sidebar.error(f"No se pudo crear plantilla por defecto: {e}")
 
 
 
 
 
394
  template_options = {os.path.splitext(t)[0]: os.path.join(plantillas_dir, t) for t in available_templates}
395
+ if not template_options: st.sidebar.error("CRÍTICO: No hay plantillas."); st.stop()
 
 
 
 
396
  selected_template_name = st.sidebar.selectbox("🎨 Seleccione una plantilla:", list(template_options.keys()))
397
 
398
+
399
+ st.sidebar.subheader("Conclusiones desde PDF (Opcional)")
400
+ uploaded_pdf = st.sidebar.file_uploader("📄 Cargue un PDF para generar conclusiones:", type="pdf")
401
+ num_conclusion_slides = 0
402
  if uploaded_pdf:
403
+ num_conclusion_slides = st.sidebar.number_input("Nº de diapositivas para las conclusiones del PDF:", min_value=1, max_value=3, value=1, step=1)
404
 
 
405
  if st.button("🚀 Generar Presentación", type="primary", use_container_width=True):
406
+ if not topic: st.warning("⚠️ Ingrese un tema."); return
407
+ if not selected_template_name: st.warning("⚠️ Seleccione una plantilla."); return
 
 
 
 
408
 
409
  template_path = template_options[selected_template_name]
410
+ if not os.path.exists(template_path): st.error(f"No se encontró plantilla: {template_path}"); return
 
 
411
 
412
+ title_slide_content = None
413
  main_slides_content = []
414
+ conclusion_slides_content = []
415
 
416
  overall_progress = st.progress(0)
417
  status_text = st.empty()
418
 
419
+ # 0. Generar título creativo
420
+ status_text.info("🎨 Creando un título impactante...")
421
+ title_slide_content = generate_creative_title_slide(topic, client)
422
+ if not title_slide_content:
423
+ status_text.warning("⚠️ No se pudo generar el título creativo, continuando sin él.")
424
+ # No detenemos la ejecución, solo no habrá diapositiva de título.
425
+ else:
426
+ status_text.success("👍 Título creativo generado.")
427
+ overall_progress.progress(10)
428
+
429
+
430
  # 1. Generar contenido principal
431
  status_text.info("🧠 Generando contenido principal con Gemini...")
432
  main_slides_content = generate_presentation_content(topic, client)
433
  if not main_slides_content:
434
+ status_text.error("❌ Falló la generación del contenido principal."); overall_progress.progress(100); return
435
+ status_text.success("👍 Contenido principal generado."); overall_progress.progress(50)
 
 
 
 
 
 
 
 
 
436
 
437
+ # 2. Procesar PDF para conclusiones
438
+ if uploaded_pdf and num_conclusion_slides > 0:
439
+ status_text.info(f"📄 Procesando PDF '{uploaded_pdf.name}' para conclusiones...")
440
+ pdf_text = extract_text_from_pdf(uploaded_pdf); overall_progress.progress(60)
441
  if pdf_text:
442
+ status_text.info("✍️ Generando diapositivas de conclusión con Gemini...")
443
+ conclusion_slides_content = generate_conclusion_slides_from_text(pdf_text, num_conclusion_slides, topic, client)
444
+ if conclusion_slides_content: status_text.success(f"👍 Conclusiones generadas ({len(conclusion_slides_content)} diap.).")
445
+ else: status_text.warning("⚠️ No se pudo generar conclusiones del PDF.")
446
+ else: status_text.warning("⚠️ No se pudo extraer texto del PDF.")
447
+ overall_progress.progress(80)
 
 
 
448
 
449
  # 3. Crear archivo PowerPoint
450
  status_text.info("🛠️ Creando archivo PowerPoint...")
451
  try:
452
+ # Necesitamos PP_ALIGN para centrar el subtítulo de la diapositiva de título
453
+ from pptx.enum.text import PP_ALIGN
454
+ pptx_buffer = create_powerpoint(title_slide_content, main_slides_content, template_path, conclusion_slides_content)
455
  overall_progress.progress(100)
456
  status_text.success("🎉 ¡Presentación generada con éxito!")
 
457
  clean_topic = re.sub(r'[^\w\s-]', '', topic).strip().replace(' ', '_')
458
  file_name = f"{clean_topic}_{selected_template_name}_presentacion.pptx"
 
459
  st.download_button(
460
+ label="📥 Descargar Presentación", data=pptx_buffer, file_name=file_name,
 
 
461
  mime="application/vnd.openxmlformats-officedocument.presentationml.presentation",
462
  use_container_width=True
463
  )
464
  except Exception as e:
465
+ overall_progress.progress(100); status_text.error(f"❌ Error al crear PowerPoint: {str(e)}"); st.exception(e)
 
 
466
 
467
  if __name__ == "__main__":
468
  main()