JairoCesar commited on
Commit
c7e54dd
·
verified ·
1 Parent(s): 18522a9

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +251 -72
app.py CHANGED
@@ -1,6 +1,7 @@
1
  import streamlit as st
2
  from pptx import Presentation
3
- from pptx.util import Inches
 
4
  import requests
5
  from PIL import Image
6
  from io import BytesIO
@@ -12,24 +13,37 @@ import google.generativeai as genai
12
 
13
  # Configurar Gemini
14
  GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
 
 
 
15
  genai.configure(api_key=GEMINI_API_KEY)
16
 
17
  # Obtener la clave API de Pixabay desde los secretos de Streamlit
18
- PIXABAY_API_KEY = st.secrets["pixabay"]
 
 
 
19
 
20
  @st.cache_resource
21
  def get_gemini_model():
22
  return genai.GenerativeModel("gemini-1.5-flash")
23
 
24
  def extract_and_clean_json(text):
25
- json_match = re.search(r'\{.*\}', text, re.DOTALL)
26
- if json_match:
27
- json_str = json_match.group()
28
- json_str = re.sub(r'[\n\t\r]', '', json_str)
29
- json_str = re.sub(r',\s*}', '}', json_str)
30
- json_str = re.sub(r',\s*]', ']', json_str)
31
- return json_str
32
- return None
 
 
 
 
 
 
 
33
 
34
  def generate_presentation_content(topic, client, max_retries=3):
35
  prompt = f"""Genera una presentación de PowerPoint sobre el tema: "{topic}".
@@ -51,68 +65,194 @@ def generate_presentation_content(topic, client, max_retries=3):
51
  }}
52
 
53
  No incluyas ningún otro texto, explicación o saludo. Solo el JSON.
 
 
 
54
  """
55
 
56
  for attempt in range(max_retries):
57
  try:
58
  response = client.generate_content(prompt).text
 
59
  json_str = extract_and_clean_json(response)
60
  if json_str:
61
  slides_data = json.loads(json_str)
62
- if 'slides' in slides_data and len(slides_data['slides']) == 9:
 
 
 
 
63
  return slides_data['slides']
64
- raise ValueError("JSON inválido o estructura incorrecta")
 
 
 
65
  except (json.JSONDecodeError, ValueError) as e:
 
66
  if attempt == max_retries - 1:
67
- st.error(f"Error al generar el contenido después de {max_retries} intentos: {str(e)}")
68
- st.text("Última respuesta del modelo:")
69
- st.code(response)
70
  return None
71
  else:
72
- st.warning(f"Intento {attempt + 1} fallido. Reintentando...")
73
  return None
74
 
75
  def buscar_imagen_pixabay(query):
76
- url = f"https://pixabay.com/api/?key={PIXABAY_API_KEY}&q={requests.utils.quote(query)}&image_type=photo&per_page=10"
77
- response = requests.get(url)
78
- data = response.json()
79
-
80
- if data.get('hits'):
81
- image_url = data['hits'][0]['webformatURL']
82
- image_response = requests.get(image_url)
83
- return Image.open(BytesIO(image_response.content))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  return None
85
 
86
- def create_powerpoint(slides, template_path):
87
  prs = Presentation(template_path)
 
 
 
 
 
 
88
 
89
- for slide_data in slides:
90
- slide = prs.slides.add_slide(prs.slide_layouts[1])
91
- title_shape = slide.shapes.title
92
- content_shape = slide.placeholders[1] if len(slide.placeholders) > 1 else None
93
-
94
- if title_shape:
95
- title_shape.text = slide_data['title']
96
- if content_shape:
97
- content_shape.text = slide_data['content']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  else:
99
- txBox = slide.shapes.add_textbox(Inches(0.5), Inches(1.5), Inches(9), Inches(5))
 
100
  tf = txBox.text_frame
101
  tf.text = slide_data['content']
 
 
 
102
 
103
- # Buscar e insertar imagen
104
  try:
105
- img = buscar_imagen_pixabay(slide_data['title'])
106
- if img:
107
- img_path = "/tmp/temp_img.jpg"
108
- img.save(img_path)
109
- slide.shapes.add_picture(img_path, Inches(5), Inches(1.5), width=Inches(4))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  except Exception as e:
111
- print(f"No se pudo insertar imagen: {e}")
 
 
 
 
 
 
 
112
 
113
- # Agregar diapositiva final
114
- final_slide = prs.slides.add_slide(prs.slide_layouts[1])
115
- final_slide.shapes.title.text = "Gracias"
 
 
116
 
117
  pptx_buffer = io.BytesIO()
118
  prs.save(pptx_buffer)
@@ -120,47 +260,86 @@ def create_powerpoint(slides, template_path):
120
  return pptx_buffer
121
 
122
  def main():
123
- st.title("PowerPoint Mágico con el Búho")
 
 
124
 
125
  client = get_gemini_model()
126
 
127
- topic = st.text_input("Por favor, ingrese el tema de la presentación:")
128
 
129
- template_options = {
130
- "Simple": "PLANTILLAS/Simple.pptx",
131
- "Corporativo": "PLANTILLAS/Corporativo.pptx",
132
- "Moderno": "PLANTILLAS/Moderno.pptx"
133
- }
134
 
135
- selected_template = st.selectbox("Seleccione una plantilla", list(template_options.keys()))
 
 
 
 
136
 
137
- if st.button("Generar Presentación"):
138
- if topic:
139
- try:
140
- with st.spinner("Generando contenido de la presentación..."):
141
- slides = generate_presentation_content(topic, client)
 
 
 
142
 
143
- if slides:
144
- template_path = template_options[selected_template]
145
- if not os.path.exists(template_path):
146
- st.error(f"No se encontró la plantilla: {template_path}")
147
- return
148
 
149
- with st.spinner("Creando archivo PowerPoint..."):
150
- pptx_buffer = create_powerpoint(slides, template_path)
 
 
 
151
 
152
- st.success("¡Presentación generada con éxito!")
153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  st.download_button(
155
- label="Descargar Presentación",
156
  data=pptx_buffer,
157
- file_name=f"{topic.replace(' ', '_')}_{selected_template}_presentacion.pptx",
158
  mime="application/vnd.openxmlformats-officedocument.presentationml.presentation"
159
  )
160
- except Exception as e:
161
- st.error(f"Ocurrió un error al generar la presentación: {str(e)}")
 
 
 
 
 
162
  else:
163
- st.warning("Por favor, ingrese un tema para la presentación.")
 
 
 
164
 
165
  if __name__ == "__main__":
166
- main()
 
1
  import streamlit as st
2
  from pptx import Presentation
3
+ from pptx.util import Inches, Pt
4
+ from pptx.enum.text import MSO_ANCHOR, MSO_AUTO_SIZE
5
  import requests
6
  from PIL import Image
7
  from io import BytesIO
 
13
 
14
  # Configurar Gemini
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()
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()
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}".
 
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 = client.generate_content(prompt).text
76
+ # st.text(f"Respuesta cruda del modelo (intento {attempt + 1}):\n{response}") # Para depuración
77
  json_str = extract_and_clean_json(response)
78
  if json_str:
79
  slides_data = json.loads(json_str)
80
+ if 'slides' in slides_data and isinstance(slides_data['slides'], list) and len(slides_data['slides']) == 9:
81
+ # Validar que cada slide tenga title y content
82
+ for s in slides_data['slides']:
83
+ if not ('title' in s and 'content' in s):
84
+ raise ValueError("Estructura de slide incorrecta: falta title o content.")
85
  return slides_data['slides']
86
+ else:
87
+ raise ValueError(f"Estructura JSON incorrecta o número de diapositivas no es 9. Diapositivas encontradas: {len(slides_data.get('slides', [])) if 'slides' in slides_data else 'Key slides no encontrada'}")
88
+ else:
89
+ raise ValueError("No se pudo extraer JSON válido de la respuesta.")
90
  except (json.JSONDecodeError, ValueError) as e:
91
+ st.warning(f"Intento {attempt + 1}/{max_retries} fallido al procesar la respuesta: {str(e)}")
92
  if attempt == max_retries - 1:
93
+ st.error(f"Error al generar el contenido después de {max_retries} intentos.")
94
+ st.text("Última respuesta del modelo (puede ser muy larga):")
95
+ st.code(response[:1000] + "..." if len(response) > 1000 else response) # Muestra solo una parte
96
  return None
97
  else:
98
+ st.warning("Reintentando...")
99
  return None
100
 
101
  def buscar_imagen_pixabay(query):
102
+ if not PIXABAY_API_KEY:
103
+ print("Pixabay API Key no configurada.")
104
+ return None
105
+ url = f"https://pixabay.com/api/?key={PIXABAY_API_KEY}&q={requests.utils.quote(query)}&image_type=photo&per_page=3&safesearch=true"
106
+ try:
107
+ response = requests.get(url, timeout=10)
108
+ response.raise_for_status() # Lanza excepción para errores HTTP
109
+ data = response.json()
110
+
111
+ if data.get('hits') and len(data['hits']) > 0:
112
+ # Intenta obtener una imagen con buena resolución pero no excesivamente grande
113
+ # priorizando webformatURL o largeImageURL si está disponible
114
+ image_info = data['hits'][0]
115
+ image_url = image_info.get('largeImageURL', image_info.get('webformatURL'))
116
+ if not image_url: return None
117
+
118
+ image_response = requests.get(image_url, timeout=10)
119
+ image_response.raise_for_status()
120
+
121
+ # Abrir con PIL para verificar y potencialmente convertir/redimensionar
122
+ img = Image.open(BytesIO(image_response.content))
123
+ img.verify() # Verifica que sea una imagen válida
124
+ # Reabrir después de verify
125
+ img = Image.open(BytesIO(image_response.content))
126
+ return img
127
+ except requests.exceptions.RequestException as e:
128
+ print(f"Error en la solicitud a Pixabay ({query}): {e}")
129
+ except Exception as e:
130
+ print(f"Error procesando imagen de Pixabay ({query}): {e}")
131
  return None
132
 
133
+ def create_powerpoint(slides_content, template_path):
134
  prs = Presentation(template_path)
135
+
136
+ # Dimensiones típicas de una diapositiva 16:9 (en EMUs, convertidas a Inches)
137
+ # PowerPoint usa 914400 EMUs por pulgada.
138
+ # Ancho: 10 pulgadas, Alto: 5.625 pulgadas (para layout 16:9)
139
+ # O más común: Ancho 10 pulgadas, Alto 7.5 pulgadas (para layout 4:3)
140
+ # Las plantillas suelen definir esto. Usaremos prs.slide_width y prs.slide_height
141
 
142
+ slide_width = prs.slide_width
143
+ slide_height = prs.slide_height
144
+
145
+ for i, slide_data in enumerate(slides_content):
146
+ # Usar layout 1 (Título y Contenido) consistentemente
147
+ slide_layout = prs.slide_layouts[1]
148
+ slide = prs.slides.add_slide(slide_layout)
149
+
150
+ # Título
151
+ if slide.shapes.title:
152
+ slide.shapes.title.text = slide_data['title']
153
+
154
+ # Contenido y Espacio para Imagen
155
+ # Asumimos que placeholder 1 es el cuerpo del contenido para el layout 1
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
+ # --- Definir áreas para texto e imagen ---
161
+ # Margen general
162
+ margin = Inches(0.3)
163
+
164
+ # Área de imagen (derecha de la diapositiva)
165
+ img_area_width = slide_width * 0.40 # 40% para la imagen
166
+ img_area_left = slide_width - img_area_width - margin
167
+
168
+ # Área de texto (izquierda de la diapositiva)
169
+ text_area_width = slide_width - img_area_width - (2 * margin) # Ancho restante menos márgenes
170
+ text_area_left = margin
171
+
172
+ # Altura y Top común para texto e imagen (debajo del título)
173
+ # El título suele ocupar hasta Inches(1.5) o Inches(1.8) desde arriba
174
+ common_top = Inches(1.6)
175
+ common_height = slide_height - common_top - margin
176
+
177
+ if content_placeholder:
178
+ content_placeholder.left = text_area_left
179
+ content_placeholder.top = common_top
180
+ content_placeholder.width = text_area_width
181
+ content_placeholder.height = common_height
182
+
183
+ tf = content_placeholder.text_frame
184
+ tf.clear() # Limpiar cualquier texto preexistente del placeholder
185
+ p = tf.add_paragraph()
186
+ p.text = slide_data['content']
187
+ tf.word_wrap = True
188
+ tf.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE # Ajustar texto al contenedor
189
+ # Ajustar anclaje vertical si es necesario
190
+ # tf.vertical_anchor = MSO_ANCHOR.MIDDLE
191
  else:
192
+ # Fallback si no hay placeholder de contenido (menos ideal)
193
+ txBox = slide.shapes.add_textbox(text_area_left, common_top, text_area_width, common_height)
194
  tf = txBox.text_frame
195
  tf.text = slide_data['content']
196
+ tf.word_wrap = True
197
+ tf.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE
198
+
199
 
200
+ # --- Buscar e insertar imagen ---
201
  try:
202
+ # Mejorar la query para la imagen
203
+ first_sentence = slide_data['content'].split('.')[0] if '.' in slide_data['content'] else slide_data['content'][:70]
204
+ image_query = f"{slide_data['title']} {first_sentence}"
205
+
206
+ pil_image = buscar_imagen_pixabay(image_query)
207
+
208
+ if pil_image:
209
+ # Convertir imagen PIL a BytesIO para python-pptx
210
+ img_byte_arr = io.BytesIO()
211
+ # Guardar en el formato original si es posible, o default a PNG/JPEG
212
+ # Asegurarse que el formato sea compatible (JPEG, PNG, GIF, TIFF, EMF, WMF)
213
+ img_format = pil_image.format if pil_image.format in ['JPEG', 'PNG', 'GIF'] else 'PNG'
214
+ if img_format == 'JPEG' and pil_image.mode == 'RGBA': # JPEG no soporta alpha
215
+ pil_image = pil_image.convert('RGB')
216
+
217
+ pil_image.save(img_byte_arr, format=img_format)
218
+ img_byte_arr.seek(0)
219
+
220
+ # Calcular dimensiones de la imagen manteniendo aspect ratio para que quepa en el área
221
+ img_native_width, img_native_height = pil_image.size
222
+
223
+ # Área disponible para la imagen
224
+ available_width = img_area_width
225
+ available_height = common_height
226
+
227
+ ratio_w = available_width / Inches(img_native_width / 96) # Suponiendo 96 DPI para conversión px a inches
228
+ ratio_h = available_height / Inches(img_native_height / 96)
229
+
230
+ # Escalar manteniendo el aspect ratio
231
+ scale_ratio = min(ratio_w, ratio_h) # Usar el ratio más pequeño para que quepa
232
+
233
+ pic_width = Inches((img_native_width / 96) * scale_ratio)
234
+ pic_height = Inches((img_native_height / 96) * scale_ratio)
235
+
236
+ # Centrar la imagen en su área designada
237
+ pic_left = img_area_left + (available_width - pic_width) / 2
238
+ pic_top = common_top + (available_height - pic_height) / 2
239
+
240
+ slide.shapes.add_picture(img_byte_arr, pic_left, pic_top, width=pic_width, height=pic_height)
241
  except Exception as e:
242
+ print(f"No se pudo procesar o insertar imagen para slide {i+1} ('{slide_data['title']}'): {e}")
243
+ # st.warning(f"No se pudo insertar imagen para slide '{slide_data['title']}': {e}")
244
+
245
+ # Agregar diapositiva final de agradecimiento (sin imagen)
246
+ final_slide_layout = prs.slide_layouts[1] # O un layout de solo título si existe (ej. layout 5)
247
+ final_slide = prs.slides.add_slide(final_slide_layout)
248
+ if final_slide.shapes.title:
249
+ final_slide.shapes.title.text = "¡Gracias por su atención!"
250
 
251
+ # Limpiar el placeholder de contenido si existe en la diapositiva final y no se usa
252
+ if len(final_slide.placeholders) > 1 and final_slide.placeholders[1]:
253
+ content_ph = final_slide.placeholders[1]
254
+ sp = content_ph.element
255
+ sp.getparent().remove(sp) # Elimina el placeholder de contenido
256
 
257
  pptx_buffer = io.BytesIO()
258
  prs.save(pptx_buffer)
 
260
  return pptx_buffer
261
 
262
  def main():
263
+ st.set_page_config(page_title="PowerPoint Mágico", layout="wide")
264
+ st.title("✨ PowerPoint Mágico con el Búho 🦉")
265
+ st.markdown("Genera presentaciones impactantes con IA y un toque de magia.")
266
 
267
  client = get_gemini_model()
268
 
269
+ topic = st.text_input("📝 Por favor, ingrese el tema de la presentación:", placeholder="Ej: El futuro de la inteligencia artificial")
270
 
271
+ # Asegurarse que el directorio PLANTILLAS existe
272
+ plantillas_dir = "PLANTILLAS"
273
+ if not os.path.exists(plantillas_dir):
274
+ os.makedirs(plantillas_dir)
275
+ st.info(f"Directorio '{plantillas_dir}' creado. Por favor, añada sus plantillas .pptx allí.")
276
 
277
+ # Listar plantillas disponibles
278
+ try:
279
+ available_templates = [f for f in os.listdir(plantillas_dir) if f.endswith(".pptx")]
280
+ except FileNotFoundError:
281
+ available_templates = []
282
 
283
+ if not available_templates:
284
+ st.warning(f"No se encontraron plantillas .pptx en el directorio '{plantillas_dir}'. "
285
+ "Se usará una presentación por defecto. Para mejores resultados, añada plantillas.")
286
+ # Crear una plantilla por defecto si no hay ninguna para que el código no falle
287
+ default_prs = Presentation()
288
+ default_template_path = os.path.join(plantillas_dir, "default.pptx")
289
+ default_prs.save(default_template_path)
290
+ available_templates.append("default.pptx")
291
 
 
 
 
 
 
292
 
293
+ template_options = {os.path.splitext(t)[0]: os.path.join(plantillas_dir, t) for t in available_templates}
294
+
295
+ if not template_options: # Doble chequeo por si la creación de default falló
296
+ st.error("Error crítico: No hay plantillas disponibles y no se pudo crear una por defecto.")
297
+ st.stop()
298
 
299
+ selected_template_name = st.selectbox("🎨 Seleccione una plantilla:", list(template_options.keys()))
300
 
301
+ if st.button("🚀 Generar Presentación", type="primary"):
302
+ if topic and selected_template_name:
303
+ template_path = template_options[selected_template_name]
304
+ if not os.path.exists(template_path):
305
+ st.error(f"No se encontró la plantilla seleccionada: {template_path}")
306
+ return
307
+
308
+ progress_bar = st.progress(0)
309
+ status_text = st.empty()
310
+
311
+ status_text.text("🧠 Generando contenido de la presentación con Gemini...")
312
+ slides_content = generate_presentation_content(topic, client)
313
+ progress_bar.progress(50)
314
+
315
+ if slides_content:
316
+ status_text.text("🖼️ Añadiendo imágenes y creando archivo PowerPoint...")
317
+ try:
318
+ pptx_buffer = create_powerpoint(slides_content, template_path)
319
+ progress_bar.progress(100)
320
+ status_text.success("¡Presentación generada con éxito!")
321
+
322
+ clean_topic = re.sub(r'[^\w\s-]', '', topic).strip().replace(' ', '_')
323
+ file_name = f"{clean_topic}_{selected_template_name}_presentacion.pptx"
324
+
325
  st.download_button(
326
+ label="📥 Descargar Presentación",
327
  data=pptx_buffer,
328
+ file_name=file_name,
329
  mime="application/vnd.openxmlformats-officedocument.presentationml.presentation"
330
  )
331
+ except Exception as e:
332
+ progress_bar.progress(100) # Completar barra incluso en error
333
+ status_text.error(f"Ocurrió un error al crear el archivo PowerPoint: {str(e)}")
334
+ st.exception(e) # Muestra el traceback completo para depuración
335
+ else:
336
+ progress_bar.progress(100)
337
+ status_text.error("No se pudo generar el contenido para la presentación.")
338
  else:
339
+ if not topic:
340
+ st.warning("⚠️ Por favor, ingrese un tema para la presentación.")
341
+ if not selected_template_name:
342
+ st.warning("⚠️ Por favor, seleccione una plantilla.")
343
 
344
  if __name__ == "__main__":
345
+ main()