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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +354 -208
app.py CHANGED
@@ -10,103 +10,202 @@ import os
10
  import json
11
  import re
12
  import google.generativeai as genai
 
13
 
 
14
  # Configurar Gemini (ensure GEMINI_API_KEY is set in environment)
15
  GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
16
  if not GEMINI_API_KEY:
17
- st.error("GEMINI_API_KEY no encontrada. Por favor configúrala.")
18
- st.stop() # Stop execution if key is missing
19
  genai.configure(api_key=GEMINI_API_KEY)
20
 
21
  # Obtener la clave API de Pixabay desde los secretos de Streamlit
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.")
25
- st.stop() # Stop execution if key is missing
26
 
 
27
  @st.cache_resource
28
  def get_gemini_model():
29
  return genai.GenerativeModel("gemini-1.5-flash")
30
 
31
- def extract_and_clean_json(text):
32
- # Intenta encontrar JSON entre ```json ... ``` o directamente
33
- match = re.search(r"```json\s*(\{.*?\})\s*```", text, re.DOTALL)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  if match:
35
  json_str = match.group(1)
36
  else:
37
- json_match = re.search(r'\{.*\}', text, re.DOTALL)
 
38
  if json_match:
39
- json_str = json_match.group()
40
  else:
 
 
41
  return None
42
 
43
- json_str = re.sub(r'[\n\t\r]', '', json_str)
44
- json_str = re.sub(r',\s*}', '}', json_str)
45
- json_str = re.sub(r',\s*]', ']', json_str)
46
  return json_str
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  def generate_presentation_content(topic, client, max_retries=3):
 
49
  prompt = f"""Genera una presentación de PowerPoint sobre el tema: "{topic}".
50
  Debes crear exactamente 9 diapositivas. Cada diapositiva debe tener un título y contenido.
51
  Es CRUCIAL que tu respuesta sea ÚNICAMENTE un JSON válido con la siguiente estructura exacta, sin texto adicional antes o después:
52
-
53
  {{
54
  "slides": [
55
- {{"title": "Título de la diapositiva 1", "content": "Contenido de la diapositiva 1"}},
56
- {{"title": "Título de la diapositiva 2", "content": "Contenido de la diapositiva 2"}},
57
- {{"title": "Título de la diapositiva 3", "content": "Contenido de la diapositiva 3"}},
58
- {{"title": "Título de la diapositiva 4", "content": "Contenido de la diapositiva 4"}},
59
- {{"title": "Título de la diapositiva 5", "content": "Contenido de la diapositiva 5"}},
60
- {{"title": "Título de la diapositiva 6", "content": "Contenido de la diapositiva 6"}},
61
- {{"title": "Título de la diapositiva 7", "content": "Contenido de la diapositiva 7"}},
62
- {{"title": "Título de la diapositiva 8", "content": "Contenido de la diapositiva 8"}},
63
- {{"title": "Título de la diapositiva 9", "content": "Contenido de la diapositiva 9"}}
64
  ]
65
  }}
66
-
67
  No incluyas ningún otro texto, explicación o saludo. Solo el JSON.
68
  Asegúrate de que el contenido sea conciso y adecuado para una diapositiva.
69
  Evita usar caracteres especiales o comillas dentro de los títulos o contenidos que puedan romper el JSON.
70
  Usa comillas dobles para las claves y los valores string del JSON.
71
  """
72
-
 
73
  for attempt in range(max_retries):
74
  try:
75
  response_obj = client.generate_content(prompt)
76
- # Check for empty parts or other safety issues if necessary
77
  if not response_obj.parts:
78
  raise ValueError("Respuesta del modelo vacía o mal formada.")
79
- response = response_obj.text
80
- json_str = extract_and_clean_json(response)
81
- if json_str:
82
- slides_data = json.loads(json_str)
83
- if 'slides' in slides_data and isinstance(slides_data['slides'], list) and len(slides_data['slides']) == 9:
84
- for s_idx, s_data in enumerate(slides_data['slides']):
85
- if not (isinstance(s_data, dict) and 'title' in s_data and 'content' in s_data):
86
- raise ValueError(f"Diapositiva {s_idx+1} tiene estructura incorrecta: falta title o content, o no es un diccionario.")
87
- return slides_data['slides']
88
- else:
89
- num_slides = len(slides_data.get('slides', [])) if isinstance(slides_data.get('slides'), list) else "Formato de 'slides' incorrecto"
90
- raise ValueError(f"Estructura JSON incorrecta o número de diapositivas no es 9. Diapositivas encontradas/formato: {num_slides}")
91
- else:
92
- raise ValueError("No se pudo extraer JSON válido de la respuesta.")
93
- except (json.JSONDecodeError, ValueError) as e:
94
- st.warning(f"Intento {attempt + 1}/{max_retries} fallido al procesar la respuesta: {str(e)}")
95
  if attempt == max_retries - 1:
96
- st.error(f"Error al generar el contenido después de {max_retries} intentos.")
97
- st.text("Última respuesta del modelo (puede ser muy larga):")
98
- st.code(response[:2000] + "..." if len(response) > 2000 else response)
99
  return None
100
- else:
101
- st.warning("Reintentando...")
102
  except Exception as e: # Captura otras excepciones de genai
103
- st.warning(f"Error inesperado del API de Gemini en intento {attempt + 1}/{max_retries}: {str(e)}")
 
104
  if attempt == max_retries - 1:
105
- st.error(f"Error al generar el contenido después de {max_retries} intentos con el API.")
106
  return None
107
- else:
108
- st.warning("Reintentando...")
109
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
 
111
  def buscar_imagen_pixabay(query):
112
  if not PIXABAY_API_KEY:
@@ -119,7 +218,7 @@ def buscar_imagen_pixabay(query):
119
  data = response.json()
120
 
121
  if data.get('hits') and len(data['hits']) > 0:
122
- image_info = data['hits'][0]
123
  image_url = image_info.get('largeImageURL', image_info.get('webformatURL'))
124
  if not image_url: return None
125
 
@@ -127,77 +226,76 @@ def buscar_imagen_pixabay(query):
127
  image_response.raise_for_status()
128
 
129
  img_bytes = BytesIO(image_response.content)
130
- img = Image.open(img_bytes)
131
- img.verify()
132
-
133
- # Reabrir después de verify, ya que verify puede cerrar el stream
134
- img_bytes.seek(0)
135
- img = Image.open(img_bytes)
136
- return img
 
 
 
137
  except requests.exceptions.RequestException as e:
138
  print(f"Error en la solicitud a Pixabay ({query}): {e}")
139
- except Exception as e:
140
- print(f"Error procesando imagen de Pixabay ({query}): {e}")
141
  return None
142
 
143
- def create_powerpoint(slides_content, template_path):
144
- prs = Presentation(template_path)
 
 
 
 
 
 
145
 
146
- slide_width_emu = prs.slide_width # Already in EMUs (int)
147
- slide_height_emu = prs.slide_height # Already in EMUs (int)
 
 
 
 
 
 
 
 
 
 
 
148
 
149
- for i, slide_data in enumerate(slides_content):
150
- slide_layout = prs.slide_layouts[1]
151
- slide = prs.slides.add_slide(slide_layout)
152
 
153
- if slide.shapes.title:
154
- slide.shapes.title.text = slide_data['title']
155
-
156
- content_placeholder = None
157
- if slide.placeholders and len(slide.placeholders) > 1 and slide.placeholders[1]:
158
- content_placeholder = slide.placeholders[1]
159
-
160
- margin_emu = Inches(0.3) # int
161
-
162
- # Calculate dimensions, these can be float initially
163
- img_area_width_float = slide_width_emu * 0.40
164
- img_area_left_float = slide_width_emu - img_area_width_float - margin_emu
165
-
166
- text_area_width_float = slide_width_emu - img_area_width_float - (2 * margin_emu)
167
- text_area_left_emu = margin_emu # int
168
-
169
- common_top_emu = Inches(1.6) # int
170
- common_height_emu = slide_height_emu - common_top_emu - margin_emu # int
171
-
172
- if content_placeholder:
173
- # Apply int() casting for all dimensions set
174
- content_placeholder.left = int(text_area_left_emu)
175
- content_placeholder.top = int(common_top_emu)
176
- content_placeholder.width = int(text_area_width_float) # CRITICAL FIX
177
- content_placeholder.height = int(common_height_emu)
178
-
179
- tf = content_placeholder.text_frame
180
- tf.clear()
181
- p = tf.add_paragraph()
182
- p.text = slide_data['content']
183
- tf.word_wrap = True
184
- tf.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE
185
- else:
186
- txBox = slide.shapes.add_textbox(
187
- int(text_area_left_emu),
188
- int(common_top_emu),
189
- int(text_area_width_float), # CRITICAL FIX
190
- int(common_height_emu)
191
- )
192
- tf = txBox.text_frame
193
- tf.text = slide_data['content']
194
- tf.word_wrap = True
195
- tf.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE
196
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  try:
198
  first_sentence = slide_data['content'].split('.')[0] if '.' in slide_data['content'] else slide_data['content'][:70]
199
  image_query = f"{slide_data['title']} {first_sentence}"
200
-
201
  pil_image = buscar_imagen_pixabay(image_query)
202
 
203
  if pil_image:
@@ -211,165 +309,213 @@ def create_powerpoint(slides_content, template_path):
211
 
212
  img_native_width_px, img_native_height_px = pil_image.size
213
 
214
- # Area for image in EMUs (can be float from calculation)
215
- available_width_for_img_float = img_area_width_float
216
- available_height_for_img_emu = common_height_emu # This is already int
217
 
218
- # Convert native pixel dimensions to EMU for comparison, assuming 96 DPI
219
- # This is a rough conversion; Inches() is more robust for direct sizing
220
- # Better: calculate desired Inches and let Inches() convert to EMU
221
-
222
- # Desired display area in Inches
223
- target_img_display_width_inches = available_width_for_img_float / 914400.0 # Convert EMU to Inches
224
- target_img_display_height_inches = available_height_for_img_emu / 914400.0 # Convert EMU to Inches
225
 
226
- # Calculate scaling ratios based on Inches
227
- ratio_w = target_img_display_width_inches / (img_native_width_px / 96.0) if (img_native_width_px > 0) else 1.0
228
- ratio_h = target_img_display_height_inches / (img_native_height_px / 96.0) if (img_native_height_px > 0) else 1.0
229
 
230
- scale_ratio = min(ratio_w, ratio_h, 1.0) # Don't scale up beyond 100% of original if it's small
231
- # Or, simply min(ratio_w, ratio_h) to fill space
 
232
 
233
- # Final picture dimensions in Inches, then converted to EMU by Inches()
234
- pic_width_emu = Inches((img_native_width_px / 96.0) * scale_ratio) # int
235
- pic_height_emu = Inches((img_native_height_px / 96.0) * scale_ratio) # int
236
 
237
- # Centering calculation (can result in floats)
238
- pic_left_float = img_area_left_float + (available_width_for_img_float - pic_width_emu) / 2.0
239
- pic_top_float = float(common_top_emu) + (float(available_height_for_img_emu) - pic_height_emu) / 2.0
 
 
 
 
240
 
241
  slide.shapes.add_picture(
242
  img_byte_arr,
243
- int(pic_left_float), # CRITICAL FIX
244
- int(pic_top_float), # CRITICAL FIX
245
- width=pic_width_emu, # Already int
246
- height=pic_height_emu # Already int
247
  )
248
  except Exception as e:
249
- print(f"No se pudo procesar o insertar imagen para slide {i+1} ('{slide_data['title']}'): {e}")
 
 
 
 
 
 
 
 
 
 
 
 
250
 
251
- final_slide_layout = prs.slide_layouts[1]
 
 
 
 
 
 
 
 
 
 
252
  final_slide = prs.slides.add_slide(final_slide_layout)
253
  if final_slide.shapes.title:
254
  final_slide.shapes.title.text = "¡Gracias por su atención!"
255
 
 
256
  if len(final_slide.placeholders) > 1 and final_slide.placeholders[1]:
257
- try: # Attempt to remove the content placeholder
258
  content_ph = final_slide.placeholders[1]
259
  sp = content_ph.element
260
  sp.getparent().remove(sp)
261
  except Exception as e:
262
- print(f"Could not remove content placeholder from final slide: {e}") # Non-critical
263
 
264
  pptx_buffer = io.BytesIO()
265
  prs.save(pptx_buffer)
266
  pptx_buffer.seek(0)
267
  return pptx_buffer
268
 
 
 
269
  def main():
270
- st.set_page_config(page_title="PowerPoint Mágico", layout="wide")
271
  st.title("✨ PowerPoint Mágico con el Búho 🦉")
272
- st.markdown("Genera presentaciones impactantes con IA y un toque de magia.")
273
 
274
- # Ensure GEMINI_API_KEY and PIXABAY_API_KEY are checked at the start or handled if None
275
- if not GEMINI_API_KEY or not PIXABAY_API_KEY:
276
- # Error messages already shown at the top
277
- return
278
 
279
  client = get_gemini_model()
280
 
281
- topic = st.text_input("📝 Por favor, ingrese el tema de la presentación:", placeholder="Ej: El futuro de la inteligencia artificial")
 
 
282
 
 
283
  plantillas_dir = "PLANTILLAS"
284
  if not os.path.exists(plantillas_dir):
285
  try:
286
  os.makedirs(plantillas_dir)
287
- st.info(f"Directorio '{plantillas_dir}' creado. Por favor, añada sus plantillas .pptx allí.")
288
  except OSError as e:
289
- st.error(f"No se pudo crear el directorio de plantillas '{plantillas_dir}': {e}")
290
  st.stop()
291
 
292
-
293
  available_templates = []
294
  try:
295
  if os.path.isdir(plantillas_dir):
296
  available_templates = [f for f in os.listdir(plantillas_dir) if f.endswith(".pptx") and os.path.isfile(os.path.join(plantillas_dir, f))]
297
  except Exception as e:
298
- st.error(f"Error al acceder al directorio de plantillas: {e}")
299
- # available_templates remains empty
300
 
301
  if not available_templates:
302
- st.warning(f"No se encontraron plantillas .pptx en el directorio '{plantillas_dir}'.")
303
- # Attempt to create a default one
304
  default_prs = Presentation()
305
- # Add a title slide layout if not present by default for a blank presentation
306
- try:
307
- title_slide_layout = default_prs.slide_layouts[0] # Typically Title Slide
308
- default_prs.slides.add_slide(title_slide_layout)
309
- except IndexError:
310
- st.warning("Plantilla por defecto no pudo añadir slide layout inicial.")
311
 
312
  default_template_path = os.path.join(plantillas_dir, "default.pptx")
313
  try:
314
  default_prs.save(default_template_path)
315
- st.info(f"Plantilla por defecto 'default.pptx' creada en '{plantillas_dir}'.")
316
  available_templates.append("default.pptx")
317
  except Exception as e:
318
- st.error(f"No se pudo crear la plantilla por defecto: {e}")
319
- # If default creation fails and no other templates, it will fail at selectbox
320
-
321
  template_options = {os.path.splitext(t)[0]: os.path.join(plantillas_dir, t) for t in available_templates}
322
 
323
  if not template_options:
324
- st.error("Error crítico: No hay plantillas disponibles y no se pudo crear una por defecto. Por favor, cree el directorio 'PLANTILLAS' y añada un archivo .pptx.")
325
  st.stop()
326
 
327
- selected_template_name = st.selectbox("🎨 Seleccione una plantilla:", list(template_options.keys()))
328
-
329
- if st.button("🚀 Generar Presentación", type="primary"):
330
- if topic and selected_template_name:
331
- template_path = template_options[selected_template_name]
332
- if not os.path.exists(template_path):
333
- st.error(f"No se encontró la plantilla seleccionada: {template_path}")
334
- return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
 
336
- progress_bar = st.progress(0)
337
- status_text = st.empty()
 
 
 
 
338
 
339
- status_text.text("🧠 Generando contenido de la presentación con Gemini...")
340
- slides_content = generate_presentation_content(topic, client)
341
 
342
- if not slides_content: # Check if content generation failed
343
- progress_bar.progress(100) # Show full bar on failure too
344
- status_text.error("No se pudo generar el contenido para la presentación. Verifique los mensajes de error anteriores.")
345
- return # Stop further execution
346
-
347
- progress_bar.progress(50) # Content generated
348
-
349
- status_text.text("🖼️ Añadiendo imágenes y creando archivo PowerPoint...")
350
- try:
351
- pptx_buffer = create_powerpoint(slides_content, template_path)
352
- progress_bar.progress(100)
353
- status_text.success("¡Presentación generada con éxito!")
354
-
355
- clean_topic = re.sub(r'[^\w\s-]', '', topic).strip().replace(' ', '_')
356
- file_name = f"{clean_topic}_{selected_template_name}_presentacion.pptx"
357
-
358
- st.download_button(
359
- label="📥 Descargar Presentación",
360
- data=pptx_buffer,
361
- file_name=file_name,
362
- mime="application/vnd.openxmlformats-officedocument.presentationml.presentation"
363
- )
364
- except Exception as e:
365
- progress_bar.progress(100)
366
- status_text.error(f"Ocurrió un error al crear el archivo PowerPoint: {str(e)}")
367
- st.exception(e)
368
- else:
369
- if not topic:
370
- st.warning("⚠️ Por favor, ingrese un tema para la presentación.")
371
- if not selected_template_name: # Should not happen if template_options is populated
372
- st.warning("⚠️ Por favor, seleccione una plantilla.")
373
 
374
  if __name__ == "__main__":
375
  main()
 
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.")
27
+ st.stop()
28
 
29
+ # --- MODELO CACHEADO ---
30
  @st.cache_resource
31
  def get_gemini_model():
32
  return genai.GenerativeModel("gemini-1.5-flash")
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.")
77
+
78
+ try:
79
+ data = json.loads(json_str)
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
+
86
+ slides = data[expected_slides_key]
87
+ if not isinstance(slides, list):
88
+ raise ValueError(f"La clave '{expected_slides_key}' debe contener una lista de diapositivas.")
89
+
90
+ if expected_num_slides is not None and len(slides) != expected_num_slides:
91
+ raise ValueError(f"Se esperaban {expected_num_slides} diapositivas, pero se recibieron {len(slides)}.")
92
+
93
+ for i, slide in enumerate(slides):
94
+ if not (isinstance(slide, dict) and 'title' in slide and 'content' in slide):
95
+ raise ValueError(f"La diapositiva {i+1} tiene una estructura incorrecta. Debe ser un diccionario con 'title' y 'content'.")
96
+ if not (isinstance(slide['title'], str) and isinstance(slide['content'], str)):
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:
 
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
 
 
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:
 
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
 
323
+ ratio_w = available_width_inches / native_width_inches if native_width_inches > 0 else 1.0
324
+ ratio_h = available_height_inches / native_height_inches if native_height_inches > 0 else 1.0
325
+ scale_ratio = min(ratio_w, ratio_h)
326
 
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
+
390
+ # --- INTERFAZ DE STREAMLIT (main) ---
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()