PHOENIXREBORNAGAIN commited on
Commit
143388a
Β·
verified Β·
1 Parent(s): 1a1d9ee

Upload pptx_builder.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. pptx_builder.py +122 -152
pptx_builder.py CHANGED
@@ -54,52 +54,62 @@ THEMES = {
54
 
55
  # ── Image fetching ─────────────────────────────────────────────────────────
56
 
57
- def _fetch_image(keyword: str, width: int = 540, height: int = 400) -> str | None:
58
- """Download a relevant image from Unsplash. Returns temp file path or None."""
 
 
 
 
 
 
59
  if not keyword:
60
  return None
61
- try:
62
- encoded = urllib.parse.quote(keyword.strip())
63
- url = f"https://source.unsplash.com/{width}x{height}/?{encoded}"
64
- tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
65
- req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
66
- with urllib.request.urlopen(req, timeout=8) as resp:
67
- tmp.write(resp.read())
68
- tmp.close()
69
- return tmp.name if os.path.getsize(tmp.name) > 1000 else None
70
- except Exception:
71
- return None
 
 
 
 
 
 
72
 
73
  # ── Drawing helpers ────────────────────────────────────────────────────────
74
 
75
  def _blank_slide(prs):
76
  return prs.slides.add_slide(prs.slide_layouts[6])
77
 
78
- def _fill(shape, color: RGBColor):
79
  shape.fill.solid()
80
  shape.fill.fore_color.rgb = color
81
  shape.line.fill.background()
82
 
83
- def _set_bg(slide, color: RGBColor):
84
- bg = slide.background
85
- bg.fill.solid()
86
- bg.fill.fore_color.rgb = color
87
 
88
- def _rect(slide, l, t, w, h, color: RGBColor):
89
  s = slide.shapes.add_shape(1, l, t, w, h)
90
  _fill(s, color)
91
  return s
92
 
93
- def _oval(slide, l, t, w, h, color: RGBColor, alpha=0.15):
94
  s = slide.shapes.add_shape(9, l, t, w, h)
95
  _fill(s, color)
96
  try:
97
- spPr = s.element.spPr
98
- solidFill = spPr.find(qn("a:solidFill"))
99
- if solidFill is not None:
100
- srgb = solidFill.find(qn("a:srgbClr"))
101
- if srgb is not None:
102
- ae = etree.SubElement(srgb, qn("a:alpha"))
103
  ae.set("val", str(int(alpha * 100000)))
104
  except Exception:
105
  pass
@@ -121,13 +131,14 @@ def _txbox(slide, text, l, t, w, h, size, bold, color,
121
  r.font.color.rgb = color
122
  return tb
123
 
124
- def _add_picture_safe(slide, img_path, l, t, w, h):
125
- """Add picture with a rounded-corner placeholder if loading fails."""
126
- try:
127
- slide.shapes.add_picture(img_path, l, t, w, h)
128
- return True
129
- except Exception:
130
- return False
 
131
 
132
  # ── Slide builders ─────────────────────────────────────────────────────────
133
 
@@ -135,68 +146,49 @@ def _build_title_slide(prs, data, t, total):
135
  slide = _blank_slide(prs)
136
  _set_bg(slide, t["dark"])
137
 
138
- # Try background image (blended behind content)
139
- keyword = data.get("image_keyword", "")
140
- img_path = _fetch_image(keyword, width=1000, height=600)
141
  if img_path:
142
  try:
143
  pic = slide.shapes.add_picture(img_path, 0, 0, SLIDE_W, SLIDE_H)
144
- # Send image to back
145
  slide.shapes._spTree.remove(pic._element)
146
  slide.shapes._spTree.insert(2, pic._element)
147
- # Dark overlay rectangle
148
- overlay = _rect(slide, 0, 0, SLIDE_W, SLIDE_H, t["dark"])
149
- # Set overlay alpha ~80%
150
- spPr = overlay.element.spPr
151
- solidFill = spPr.find(qn("a:solidFill"))
152
- if solidFill is not None:
153
- srgb = solidFill.find(qn("a:srgbClr"))
154
- if srgb is not None:
155
- ae = etree.SubElement(srgb, qn("a:alpha"))
156
- ae.set("val", "80000")
157
  except Exception:
158
  pass
159
 
160
- # Decorative circles
161
- _oval(slide, Inches(9.5), Inches(-1.5), Inches(6), Inches(6), t["primary"], 0.20)
162
  _oval(slide, Inches(-0.8), Inches(4.8), Inches(3.5), Inches(3.5), t["secondary"], 0.18)
163
-
164
- # Bottom accent bar
165
  _rect(slide, 0, SLIDE_H - Inches(0.08), SLIDE_W, Inches(0.08), t["secondary"])
166
- # Left accent
167
  _rect(slide, 0, Inches(1.5), Inches(0.06), Inches(4.5), t["primary"])
168
 
169
- # Title
170
  tb = slide.shapes.add_textbox(Inches(0.5), Inches(1.8), Inches(12), Inches(2.5))
171
- tf = tb.text_frame
172
- tf.word_wrap = True
173
- p = tf.paragraphs[0]
174
- p.alignment = PP_ALIGN.LEFT
175
  r = p.add_run()
176
  r.text = data.get("title", "Presentation")
177
- r.font.name = "Calibri"
178
- r.font.size = Pt(52)
179
- r.font.bold = True
180
- r.font.color.rgb = t["white"]
181
 
182
- # Subtitle
183
  subtitle = data.get("subtitle", "")
184
  if subtitle:
185
  tb2 = slide.shapes.add_textbox(Inches(0.5), Inches(4.5), Inches(10), Inches(1))
186
- tf2 = tb2.text_frame
187
- tf2.word_wrap = True
188
- p2 = tf2.paragraphs[0]
189
- p2.alignment = PP_ALIGN.LEFT
190
  r2 = p2.add_run()
191
  r2.text = subtitle
192
- r2.font.name = "Calibri Light"
193
- r2.font.size = Pt(24)
194
  r2.font.color.rgb = t["secondary"]
195
 
196
- # Slide count badge
197
  _rect(slide, Inches(0.5), Inches(6.2), Inches(2.4), Inches(0.5), t["primary"])
198
- _txbox(slide, f"{total} slides", Inches(0.5), Inches(6.2), Inches(2.4), Inches(0.5),
199
- size=11, bold=False, color=t["white"], align=PP_ALIGN.CENTER)
200
 
201
  slide.notes_slide.notes_text_frame.text = data.get("speaker_notes", "")
202
 
@@ -205,137 +197,115 @@ def _build_content_slide(prs, data, slide_num, total, t):
205
  slide = _blank_slide(prs)
206
  _set_bg(slide, t["light"])
207
 
208
- keyword = data.get("image_keyword", "")
209
- img_path = _fetch_image(keyword, width=540, height=400)
210
  has_image = img_path is not None
211
 
212
  # ── Header band ──────────────────────────────────────────────────────
213
- _rect(slide, 0, 0, SLIDE_W, Inches(1.5), t["primary"])
214
- _rect(slide, SLIDE_W - Inches(1.5), 0, Inches(1.5), Inches(1.5), t["secondary"])
215
-
216
- # Slide number in corner block
217
  _txbox(slide, f"{slide_num:02d}",
218
- SLIDE_W - Inches(1.5), Inches(0.1), Inches(1.5), Inches(1.2),
219
- size=36, bold=True, color=t["white"], align=PP_ALIGN.CENTER)
220
-
221
- # Title in header
222
  _txbox(slide, data.get("title", ""),
223
- Inches(0.5), Inches(0.15), SLIDE_W - Inches(2.5), Inches(1.2),
224
- size=28, bold=True, color=t["white"])
225
 
226
- # ── Left accent stripe ────────────────────────────────────────────────
227
- _rect(slide, 0, Inches(1.5), Inches(0.06), Inches(5.6), t["secondary"])
228
 
229
- # ── Bullet content area ───────────────────────────────────────────────
230
  bullets = data.get("bullets", [])
231
- text_width = Inches(7.8) if has_image else Inches(12.5)
232
 
233
- content_box = slide.shapes.add_textbox(
234
- Inches(0.35), Inches(1.8), text_width, Inches(5.0)
235
- )
236
- tf = content_box.text_frame
237
  tf.word_wrap = True
238
 
239
- ICONS = ["β‘ ", "β‘‘", "β‘’", "β‘£", "β‘€", "β‘₯"]
240
- for idx, bullet_text in enumerate(bullets[:6]):
241
  p = tf.paragraphs[0] if idx == 0 else tf.add_paragraph()
242
  p.alignment = PP_ALIGN.LEFT
243
-
244
- # Spacing between bullets
245
- pPr = p._p.get_or_add_pPr()
246
  if idx > 0:
247
- spcBef = etree.SubElement(pPr, qn("a:spcBef"))
248
- spcPts = etree.SubElement(spcBef, qn("a:spcPts"))
249
- spcPts.set("val", "700")
250
-
251
- # Numbered icon
252
- r_num = p.add_run()
253
- r_num.text = f"{ICONS[idx]} "
254
- r_num.font.name = "Segoe UI"
255
- r_num.font.size = Pt(21)
256
- r_num.font.bold = True
257
- r_num.font.color.rgb = t["primary"]
258
-
259
- # Bullet text
260
- r_txt = p.add_run()
261
- r_txt.text = bullet_text
262
- r_txt.font.name = "Calibri"
263
- r_txt.font.size = Pt(20)
264
- r_txt.font.bold = False
265
- r_txt.font.color.rgb = t["text_dark"]
266
-
267
- # ── Image panel (right side) ──────────────────────────────────────────
268
  if has_image:
269
- img_l = Inches(8.3)
270
- img_t = Inches(1.7)
271
- img_w = Inches(4.7)
272
- img_h = Inches(4.8)
273
-
274
- # Colored image border / frame
275
- _rect(slide, img_l - Inches(0.06), img_t - Inches(0.06),
276
- img_w + Inches(0.12), img_h + Inches(0.12), t["secondary"])
277
-
278
- success = _add_picture_safe(slide, img_path, img_l, img_t, img_w, img_h)
279
- if not success:
280
- # Fallback: colored placeholder with keyword label
281
- _rect(slide, img_l, img_t, img_w, img_h, t["primary"])
282
- _txbox(slide, keyword.title(), img_l + Inches(0.2),
283
- img_t + Inches(1.8), img_w - Inches(0.4), Inches(1.2),
284
- size=16, bold=False, color=t["white"], align=PP_ALIGN.CENTER)
285
 
286
  # ── Footer ────────────────────────────────────────────────────────────
287
- _rect(slide, 0, SLIDE_H - Inches(0.35), SLIDE_W, Inches(0.35), t["primary"])
288
  _txbox(slide, f"Slide {slide_num} of {total}",
289
- SLIDE_W - Inches(2.5), SLIDE_H - Inches(0.35),
290
- Inches(2.4), Inches(0.35),
291
- size=10, bold=False, color=t["white"], align=PP_ALIGN.RIGHT)
292
 
293
  slide.notes_slide.notes_text_frame.text = data.get("speaker_notes", "")
294
 
295
 
296
- def _build_closing_slide(prs, presentation_title, t):
297
  slide = _blank_slide(prs)
298
  _set_bg(slide, t["dark"])
299
-
300
  _oval(slide, Inches(-1), Inches(-1), Inches(5), Inches(5), t["secondary"], 0.15)
301
  _oval(slide, Inches(10), Inches(3.5), Inches(5), Inches(5), t["primary"], 0.18)
302
  _rect(slide, 0, SLIDE_H - Inches(0.08), SLIDE_W, Inches(0.08), t["secondary"])
303
  _rect(slide, 0, Inches(1.5), Inches(0.06), Inches(4.5), t["secondary"])
304
-
305
  _txbox(slide, "Thank You", Inches(0.5), Inches(1.8), Inches(12), Inches(2),
306
- size=60, bold=True, color=t["white"])
307
- _txbox(slide, presentation_title, Inches(0.5), Inches(4.1), Inches(10), Inches(0.8),
308
- size=22, bold=False, color=t["secondary"])
309
  _rect(slide, Inches(0.5), Inches(5.1), Inches(3), Inches(0.05), t["primary"])
310
  _txbox(slide, "Questions & Discussion",
311
  Inches(0.5), Inches(5.3), Inches(8), Inches(0.6),
312
- size=16, bold=False, color=t["white"])
313
 
314
 
315
  # ── Entry point ────────────────────────────────────────────────────────────
316
 
317
  def build_pptx(presentation_data: dict, style: str) -> bytes:
318
  t = THEMES.get(style, THEMES["Professional"])
319
-
320
  prs = Presentation()
321
  prs.slide_width = SLIDE_W
322
  prs.slide_height = SLIDE_H
323
 
324
  slides = presentation_data.get("slides", [])
325
  pres_title = presentation_data.get("title", "Presentation")
 
 
 
326
  content_slides = [s for s in slides if s.get("type") != "title"]
327
- title_slides = [s for s in slides if s.get("type") == "title"]
328
- total_display = len(slides) + 1 # +1 for closing
329
 
330
- # 1 β€” Title slide
331
  for s in title_slides:
332
- _build_title_slide(prs, s, t, total_display)
333
 
334
- # 2 β€” Content slides
335
  for i, s in enumerate(content_slides):
336
- _build_content_slide(prs, s, i + 2, total_display, t)
337
 
338
- # 3 β€” Closing slide
339
  _build_closing_slide(prs, pres_title, t)
340
 
341
  buf = io.BytesIO()
 
54
 
55
  # ── Image fetching ─────────────────────────────────────────────────────────
56
 
57
+ IMAGE_SOURCES = [
58
+ # loremflickr β€” keyword-based, reliable, no API key
59
+ lambda kw, w, h: f"https://loremflickr.com/{w}/{h}/{urllib.parse.quote(kw)}",
60
+ # picsum β€” random beautiful photo (fallback)
61
+ lambda kw, w, h: f"https://picsum.photos/seed/{urllib.parse.quote(kw)}/{w}/{h}",
62
+ ]
63
+
64
+ def _fetch_image(keyword: str, width: int = 560, height: int = 420) -> str | None:
65
  if not keyword:
66
  return None
67
+ for source_fn in IMAGE_SOURCES:
68
+ try:
69
+ url = source_fn(keyword.strip(), width, height)
70
+ tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
71
+ req = urllib.request.Request(
72
+ url, headers={"User-Agent": "Mozilla/5.0 SlideAI/1.0"}
73
+ )
74
+ with urllib.request.urlopen(req, timeout=10) as resp:
75
+ data = resp.read()
76
+ if len(data) > 2000: # real image, not an error page
77
+ tmp.write(data)
78
+ tmp.close()
79
+ return tmp.name
80
+ tmp.close()
81
+ except Exception:
82
+ continue
83
+ return None
84
 
85
  # ── Drawing helpers ────────────────────────────────────────────────────────
86
 
87
  def _blank_slide(prs):
88
  return prs.slides.add_slide(prs.slide_layouts[6])
89
 
90
+ def _fill(shape, color):
91
  shape.fill.solid()
92
  shape.fill.fore_color.rgb = color
93
  shape.line.fill.background()
94
 
95
+ def _set_bg(slide, color):
96
+ slide.background.fill.solid()
97
+ slide.background.fill.fore_color.rgb = color
 
98
 
99
+ def _rect(slide, l, t, w, h, color):
100
  s = slide.shapes.add_shape(1, l, t, w, h)
101
  _fill(s, color)
102
  return s
103
 
104
+ def _oval(slide, l, t, w, h, color, alpha=0.15):
105
  s = slide.shapes.add_shape(9, l, t, w, h)
106
  _fill(s, color)
107
  try:
108
+ sf = s.element.spPr.find(qn("a:solidFill"))
109
+ if sf is not None:
110
+ sc = sf.find(qn("a:srgbClr"))
111
+ if sc is not None:
112
+ ae = etree.SubElement(sc, qn("a:alpha"))
 
113
  ae.set("val", str(int(alpha * 100000)))
114
  except Exception:
115
  pass
 
131
  r.font.color.rgb = color
132
  return tb
133
 
134
+ def _spc(para, before_pt=0, after_pt=0):
135
+ pPr = para._p.get_or_add_pPr()
136
+ if before_pt:
137
+ sb = etree.SubElement(pPr, qn("a:spcBef"))
138
+ etree.SubElement(sb, qn("a:spcPts")).set("val", str(before_pt * 100))
139
+ if after_pt:
140
+ sa = etree.SubElement(pPr, qn("a:spcAft"))
141
+ etree.SubElement(sa, qn("a:spcPts")).set("val", str(after_pt * 100))
142
 
143
  # ── Slide builders ─────────────────────────────────────────────────────────
144
 
 
146
  slide = _blank_slide(prs)
147
  _set_bg(slide, t["dark"])
148
 
149
+ # Background image with dark overlay
150
+ img_path = _fetch_image(data.get("image_keyword", ""), 1200, 700)
 
151
  if img_path:
152
  try:
153
  pic = slide.shapes.add_picture(img_path, 0, 0, SLIDE_W, SLIDE_H)
 
154
  slide.shapes._spTree.remove(pic._element)
155
  slide.shapes._spTree.insert(2, pic._element)
156
+ ov = _rect(slide, 0, 0, SLIDE_W, SLIDE_H, t["dark"])
157
+ sf = ov.element.spPr.find(qn("a:solidFill"))
158
+ if sf is not None:
159
+ sc = sf.find(qn("a:srgbClr"))
160
+ if sc is not None:
161
+ ae = etree.SubElement(sc, qn("a:alpha"))
162
+ ae.set("val", "78000")
 
 
 
163
  except Exception:
164
  pass
165
 
166
+ _oval(slide, Inches(9.5), Inches(-1.5), Inches(6), Inches(6), t["primary"], 0.22)
 
167
  _oval(slide, Inches(-0.8), Inches(4.8), Inches(3.5), Inches(3.5), t["secondary"], 0.18)
 
 
168
  _rect(slide, 0, SLIDE_H - Inches(0.08), SLIDE_W, Inches(0.08), t["secondary"])
 
169
  _rect(slide, 0, Inches(1.5), Inches(0.06), Inches(4.5), t["primary"])
170
 
 
171
  tb = slide.shapes.add_textbox(Inches(0.5), Inches(1.8), Inches(12), Inches(2.5))
172
+ tf = tb.text_frame; tf.word_wrap = True
173
+ p = tf.paragraphs[0]; p.alignment = PP_ALIGN.LEFT
 
 
174
  r = p.add_run()
175
  r.text = data.get("title", "Presentation")
176
+ r.font.name = "Calibri"; r.font.size = Pt(52)
177
+ r.font.bold = True; r.font.color.rgb = t["white"]
 
 
178
 
 
179
  subtitle = data.get("subtitle", "")
180
  if subtitle:
181
  tb2 = slide.shapes.add_textbox(Inches(0.5), Inches(4.5), Inches(10), Inches(1))
182
+ tf2 = tb2.text_frame; tf2.word_wrap = True
183
+ p2 = tf2.paragraphs[0]; p2.alignment = PP_ALIGN.LEFT
 
 
184
  r2 = p2.add_run()
185
  r2.text = subtitle
186
+ r2.font.name = "Calibri Light"; r2.font.size = Pt(24)
 
187
  r2.font.color.rgb = t["secondary"]
188
 
 
189
  _rect(slide, Inches(0.5), Inches(6.2), Inches(2.4), Inches(0.5), t["primary"])
190
+ _txbox(slide, f"{total} slides", Inches(0.5), Inches(6.2),
191
+ Inches(2.4), Inches(0.5), 11, False, t["white"], PP_ALIGN.CENTER)
192
 
193
  slide.notes_slide.notes_text_frame.text = data.get("speaker_notes", "")
194
 
 
197
  slide = _blank_slide(prs)
198
  _set_bg(slide, t["light"])
199
 
200
+ img_path = _fetch_image(data.get("image_keyword", ""), 560, 430)
 
201
  has_image = img_path is not None
202
 
203
  # ── Header band ──────────────────────────────────────────────────────
204
+ _rect(slide, 0, 0, SLIDE_W, Inches(1.4), t["primary"])
205
+ _rect(slide, SLIDE_W - Inches(1.4), 0, Inches(1.4), Inches(1.4), t["secondary"])
 
 
206
  _txbox(slide, f"{slide_num:02d}",
207
+ SLIDE_W - Inches(1.4), Inches(0.05), Inches(1.4), Inches(1.3),
208
+ 36, True, t["white"], PP_ALIGN.CENTER)
 
 
209
  _txbox(slide, data.get("title", ""),
210
+ Inches(0.5), Inches(0.12), SLIDE_W - Inches(2.2), Inches(1.15),
211
+ 26, True, t["white"])
212
 
213
+ # ── Left accent ───────────────────────────���───────────────────────────
214
+ _rect(slide, 0, Inches(1.4), Inches(0.06), SLIDE_H - Inches(1.75), t["secondary"])
215
 
216
+ # ── Bullets ───────────────────────────────────────────────────────────
217
  bullets = data.get("bullets", [])
218
+ text_w = Inches(7.6) if has_image else Inches(12.8)
219
 
220
+ cb = slide.shapes.add_textbox(Inches(0.3), Inches(1.6), text_w, Inches(5.55))
221
+ tf = cb.text_frame
 
 
222
  tf.word_wrap = True
223
 
224
+ ICONS = ["β‘ ", "β‘‘", "β‘’", "β‘£", "β‘€", "β‘₯", "⑦"]
225
+ for idx, bullet in enumerate(bullets[:7]):
226
  p = tf.paragraphs[0] if idx == 0 else tf.add_paragraph()
227
  p.alignment = PP_ALIGN.LEFT
 
 
 
228
  if idx > 0:
229
+ _spc(p, before_pt=5)
230
+
231
+ rn = p.add_run()
232
+ rn.text = f"{ICONS[idx]} "
233
+ rn.font.name = "Segoe UI"
234
+ rn.font.size = Pt(17)
235
+ rn.font.bold = True
236
+ rn.font.color.rgb = t["primary"]
237
+
238
+ rt = p.add_run()
239
+ rt.text = bullet
240
+ rt.font.name = "Calibri"
241
+ rt.font.size = Pt(17)
242
+ rt.font.bold = False
243
+ rt.font.color.rgb = t["text_dark"]
244
+
245
+ # ── Image panel ───────────────────────────────────────────────────────
 
 
 
 
246
  if has_image:
247
+ il = Inches(8.2); it = Inches(1.55)
248
+ iw = Inches(4.85); ih = Inches(5.55)
249
+
250
+ # Border frame
251
+ _rect(slide, il - Inches(0.07), it - Inches(0.07),
252
+ iw + Inches(0.14), ih + Inches(0.14), t["secondary"])
253
+
254
+ try:
255
+ slide.shapes.add_picture(img_path, il, it, iw, ih)
256
+ except Exception:
257
+ _rect(slide, il, it, iw, ih, t["primary"])
258
+ _txbox(slide, data.get("image_keyword", "").title(),
259
+ il + Inches(0.3), it + Inches(2.2), iw - Inches(0.6), Inches(1),
260
+ 14, False, t["white"], PP_ALIGN.CENTER)
 
 
261
 
262
  # ── Footer ────────────────────────────────────────────────────────────
263
+ _rect(slide, 0, SLIDE_H - Inches(0.32), SLIDE_W, Inches(0.32), t["primary"])
264
  _txbox(slide, f"Slide {slide_num} of {total}",
265
+ SLIDE_W - Inches(2.5), SLIDE_H - Inches(0.32),
266
+ Inches(2.4), Inches(0.32), 9, False, t["white"], PP_ALIGN.RIGHT)
 
267
 
268
  slide.notes_slide.notes_text_frame.text = data.get("speaker_notes", "")
269
 
270
 
271
+ def _build_closing_slide(prs, title, t):
272
  slide = _blank_slide(prs)
273
  _set_bg(slide, t["dark"])
 
274
  _oval(slide, Inches(-1), Inches(-1), Inches(5), Inches(5), t["secondary"], 0.15)
275
  _oval(slide, Inches(10), Inches(3.5), Inches(5), Inches(5), t["primary"], 0.18)
276
  _rect(slide, 0, SLIDE_H - Inches(0.08), SLIDE_W, Inches(0.08), t["secondary"])
277
  _rect(slide, 0, Inches(1.5), Inches(0.06), Inches(4.5), t["secondary"])
 
278
  _txbox(slide, "Thank You", Inches(0.5), Inches(1.8), Inches(12), Inches(2),
279
+ 60, True, t["white"])
280
+ _txbox(slide, title, Inches(0.5), Inches(4.1), Inches(10), Inches(0.8),
281
+ 22, False, t["secondary"])
282
  _rect(slide, Inches(0.5), Inches(5.1), Inches(3), Inches(0.05), t["primary"])
283
  _txbox(slide, "Questions & Discussion",
284
  Inches(0.5), Inches(5.3), Inches(8), Inches(0.6),
285
+ 16, False, t["white"])
286
 
287
 
288
  # ── Entry point ────────────────────────────────────────────────────────────
289
 
290
  def build_pptx(presentation_data: dict, style: str) -> bytes:
291
  t = THEMES.get(style, THEMES["Professional"])
 
292
  prs = Presentation()
293
  prs.slide_width = SLIDE_W
294
  prs.slide_height = SLIDE_H
295
 
296
  slides = presentation_data.get("slides", [])
297
  pres_title = presentation_data.get("title", "Presentation")
298
+ total = len(slides) + 1 # +1 for closing slide
299
+
300
+ title_slides = [s for s in slides if s.get("type") == "title"]
301
  content_slides = [s for s in slides if s.get("type") != "title"]
 
 
302
 
 
303
  for s in title_slides:
304
+ _build_title_slide(prs, s, t, total)
305
 
 
306
  for i, s in enumerate(content_slides):
307
+ _build_content_slide(prs, s, i + 2, total, t)
308
 
 
309
  _build_closing_slide(prs, pres_title, t)
310
 
311
  buf = io.BytesIO()