#
tokens: 7241/50000 3/3 files
lines: on (toggle) GitHub
raw markdown copy reset
# Directory Structure

```
├── .gitignore
├── README.md
└── server.py
```

# Files

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
1 | __pycache__
2 | 
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
1 | # MCP PPTX Server
2 | This is a FastMCP server that can be used to create and manipulate PowerPoints.
3 | 
4 | ## How to install to Claude Desktop
5 | ```bash
6 | fastmcp install .\server.py
7 | ```
```

--------------------------------------------------------------------------------
/server.py:
--------------------------------------------------------------------------------

```python
  1 | """
  2 | FastMCP PowerPoint Manager
  3 | Give Claude tools to create and manipulate PowerPoint presentations.
  4 | """
  5 | from fastmcp import FastMCP, Image
  6 | import io
  7 | from typing import List, Optional, Dict, Any, Union
  8 | from pydantic import BaseModel, Field
  9 | 
 10 | # Create server with dependencies
 11 | mcp = FastMCP(
 12 |     "PowerPoint Manager", 
 13 |     dependencies=["python-pptx", "Pillow"]
 14 | )
 15 | 
 16 | # === Models for structured inputs ===
 17 | 
 18 | class TextFormatting(BaseModel):
 19 |     """Text formatting options"""
 20 |     font_name: Optional[str] = Field(None, description="Font name (e.g., 'Calibri', 'Arial')")
 21 |     font_size: Optional[int] = Field(None, description="Font size in points (e.g., 12, 18, 24)")
 22 |     bold: Optional[bool] = Field(None, description="Bold text")
 23 |     italic: Optional[bool] = Field(None, description="Italic text")
 24 |     color: Optional[str] = Field(None, description="Text color in hex (e.g., '#000000' for black)")
 25 | 
 26 | class BulletPoint(BaseModel):
 27 |     """Model for a bullet point with text and level"""
 28 |     text: str = Field(..., description="Text content of the bullet point")
 29 |     level: int = Field(0, description="Indentation level (0 = main, 1 = sub-bullet, etc.)")
 30 |     formatting: Optional[TextFormatting] = Field(None, description="Optional formatting for this bullet point")
 31 | 
 32 | class Slide(BaseModel):
 33 |     """Model for slide content with professional formatting"""
 34 |     title: Optional[str] = Field(None, description="Slide title")
 35 |     subtitle: Optional[str] = Field(None, description="Slide subtitle (for title slides)")
 36 |     bullets: Optional[List[BulletPoint]] = Field(None, description="List of bullet points with levels")
 37 |     content: Optional[str] = Field(None, description="Plain text content (as alternative to bullets)")
 38 |     layout: str = Field("Title and Content", description="Slide layout name")
 39 |     title_formatting: Optional[TextFormatting] = Field(None, description="Formatting for the title")
 40 |     content_formatting: Optional[TextFormatting] = Field(None, description="Base formatting for content")
 41 |     notes: Optional[str] = Field(None, description="Speaker notes for this slide")
 42 | 
 43 | class TextPosition(BaseModel):
 44 |     """Model for text position and formatting"""
 45 |     left: float = Field(..., description="Left position in inches")
 46 |     top: float = Field(..., description="Top position in inches")
 47 |     width: Optional[float] = Field(None, description="Width in inches")
 48 |     height: Optional[float] = Field(None, description="Height in inches")
 49 |     formatting: Optional[TextFormatting] = Field(None, description="Text formatting options")
 50 | 
 51 | class PresentationTheme(BaseModel):
 52 |     """Model for presentation-wide theme settings"""
 53 |     title_font: Optional[TextFormatting] = Field(None, description="Formatting for slide titles")
 54 |     body_font: Optional[TextFormatting] = Field(None, description="Formatting for body text")
 55 |     background_color: Optional[str] = Field(None, description="Background color in hex")
 56 | 
 57 | # === Helper Functions ===
 58 | 
 59 | def apply_text_formatting(text_frame, formatting):
 60 |     """Apply text formatting to a text frame"""
 61 |     from pptx.dml.color import RGBColor
 62 |     from pptx.util import Pt
 63 |     
 64 |     if not formatting:
 65 |         return
 66 |         
 67 |     for paragraph in text_frame.paragraphs:
 68 |         for run in paragraph.runs:
 69 |             if formatting.font_name:
 70 |                 run.font.name = formatting.font_name
 71 |                 
 72 |             if formatting.font_size:
 73 |                 run.font.size = Pt(formatting.font_size)
 74 |                 
 75 |             if formatting.bold is not None:
 76 |                 run.font.bold = formatting.bold
 77 |                 
 78 |             if formatting.italic is not None:
 79 |                 run.font.italic = formatting.italic
 80 |                 
 81 |             if formatting.color:
 82 |                 # Convert hex color to RGB
 83 |                 color = formatting.color.lstrip('#')
 84 |                 r, g, b = tuple(int(color[i:i+2], 16) for i in (0, 2, 4))
 85 |                 run.font.color.rgb = RGBColor(r, g, b)
 86 | 
 87 | def apply_theme_to_presentation(prs, theme):
 88 |     """Apply a theme to all slides in the presentation"""
 89 |     if not theme:
 90 |         return
 91 |         
 92 |     # Apply to each slide
 93 |     for slide in prs.slides:
 94 |         # Apply title formatting
 95 |         if theme.title_font and slide.shapes.title:
 96 |             apply_text_formatting(slide.shapes.title.text_frame, theme.title_font)
 97 |         
 98 |         # Apply body formatting to all text placeholders
 99 |         if theme.body_font:
100 |             for shape in slide.placeholders:
101 |                 if hasattr(shape, 'text_frame'):
102 |                     apply_text_formatting(shape.text_frame, theme.body_font)
103 |         
104 |         # Apply background color
105 |         if theme.background_color:
106 |             color = theme.background_color.lstrip('#')
107 |             r, g, b = tuple(int(color[i:i+2], 16) for i in (0, 2, 4))
108 |             from pptx.dml.color import RGBColor
109 |             slide.background.fill.solid()
110 |             slide.background.fill.fore_color.rgb = RGBColor(r, g, b)
111 | 
112 | # === Core Presentation Tools ===
113 | 
114 | @mcp.tool()
115 | def create_presentation(title: str, slides: List[Slide], theme: Optional[PresentationTheme] = None) -> str:
116 |     """
117 |     Create a new PowerPoint presentation with the given title and slides.
118 |     Applies professional formatting and layout.
119 |     Returns the filename of the saved presentation.
120 |     """
121 |     from pptx import Presentation
122 |     from pptx.util import Pt
123 |     import os
124 |     
125 |     # Create a new presentation
126 |     prs = Presentation()
127 |     
128 |     # Create a user-accessible directory
129 |     user_docs = os.path.expanduser("~/Documents/ClaudePresentations")
130 |     os.makedirs(user_docs, exist_ok=True)
131 |     
132 |     # Process each slide
133 |     for slide_data in slides:
134 |         # Get the appropriate layout
135 |         layout_name = slide_data.layout
136 |         layout = None
137 |         
138 |         # Find the right layout by name
139 |         for layout_idx, layout_obj in enumerate(prs.slide_layouts):
140 |             if layout_obj.name.lower() == layout_name.lower():
141 |                 layout = layout_obj
142 |                 break
143 |         
144 |         # If layout not found, default to Title and Content
145 |         if not layout:
146 |             layout = prs.slide_layouts[1]  # Title and Content
147 |         
148 |         # Add a slide
149 |         slide = prs.slides.add_slide(layout)
150 |         
151 |         # Set title if applicable and if the slide has a title placeholder
152 |         if slide_data.title and hasattr(slide, 'shapes') and hasattr(slide.shapes, 'title') and slide.shapes.title:
153 |             slide.shapes.title.text = slide_data.title
154 |             
155 |             # Apply title formatting
156 |             if slide_data.title_formatting:
157 |                 apply_text_formatting(slide.shapes.title.text_frame, slide_data.title_formatting)
158 |         
159 |         # Set subtitle if applicable (usually for title slides)
160 |         if slide_data.subtitle:
161 |             for shape in slide.placeholders:
162 |                 if shape.placeholder_format.type == 2:  # Subtitle placeholder
163 |                     shape.text = slide_data.subtitle
164 |                     break
165 |         
166 |         # Add content - either as bullets or plain text
167 |         if slide_data.bullets or slide_data.content:
168 |             content_shape = None
169 |             
170 |             # Find the content placeholder
171 |             for shape in slide.placeholders:
172 |                 if shape.placeholder_format.type not in [1, 2]:  # Not title or subtitle
173 |                     content_shape = shape
174 |                     break
175 |             
176 |             if content_shape:
177 |                 text_frame = content_shape.text_frame
178 |                 text_frame.clear()  # Clear any default text
179 |                 
180 |                 # If using structured bullets
181 |                 if slide_data.bullets:
182 |                     for i, bullet in enumerate(slide_data.bullets):
183 |                         if i == 0:
184 |                             p = text_frame.paragraphs[0]
185 |                         else:
186 |                             p = text_frame.add_paragraph()
187 |                         
188 |                         p.text = bullet.text
189 |                         p.level = bullet.level
190 |                         
191 |                         # Apply individual bullet formatting if specified
192 |                         if bullet.formatting:
193 |                             for run in p.runs:
194 |                                 if bullet.formatting.font_name:
195 |                                     run.font.name = bullet.formatting.font_name
196 |                                 if bullet.formatting.font_size:
197 |                                     run.font.size = Pt(bullet.formatting.font_size)
198 |                                 if bullet.formatting.bold is not None:
199 |                                     run.font.bold = bullet.formatting.bold
200 |                                 if bullet.formatting.italic is not None:
201 |                                     run.font.italic = bullet.formatting.italic
202 |                 
203 |                 # If using simple content
204 |                 elif slide_data.content:
205 |                     lines = slide_data.content.strip().split('\n')
206 |                     for i, line in enumerate(lines):
207 |                         if i == 0:
208 |                             p = text_frame.paragraphs[0]
209 |                         else:
210 |                             p = text_frame.add_paragraph()
211 |                         p.text = line
212 |                 
213 |                 # Apply overall content formatting
214 |                 if slide_data.content_formatting:
215 |                     apply_text_formatting(text_frame, slide_data.content_formatting)
216 |         
217 |         # Add speaker notes if provided
218 |         if slide_data.notes:
219 |             notes_slide = slide.notes_slide
220 |             notes_slide.notes_text_frame.text = slide_data.notes
221 |     
222 |     # Apply theme if provided
223 |     if theme:
224 |         apply_theme_to_presentation(prs, theme)
225 |     
226 |     # Save the presentation in the user-accessible directory
227 |     filename = f"{title.replace(' ', '_')}.pptx"
228 |     full_path = os.path.join(user_docs, filename)
229 |     prs.save(full_path)
230 |     
231 |     return f"Created presentation at: {full_path}"
232 | 
233 | 
234 | @mcp.tool()
235 | def add_slide(filename: str, new_slide: Slide) -> str:
236 |     """
237 |     Add a professionally formatted slide to an existing presentation.
238 |     Returns the filename of the updated presentation.
239 |     """
240 |     from pptx import Presentation
241 |     import os
242 |     
243 |     # If filename doesn't include path, assume it's in the user directory
244 |     if not os.path.dirname(filename):
245 |         user_docs = os.path.expanduser("~/Documents/ClaudePresentations")
246 |         filename = os.path.join(user_docs, filename)
247 |     
248 |     # Check if file exists
249 |     if not os.path.exists(filename):
250 |         return f"Error: File not found: {filename}"
251 |     
252 |     # Open the existing presentation
253 |     prs = Presentation(filename)
254 |     
255 |     # Get the appropriate layout
256 |     layout_name = new_slide.layout
257 |     layout = None
258 |     for layout_idx, layout_obj in enumerate(prs.slide_layouts):
259 |         if layout_obj.name.lower() == layout_name.lower():
260 |             layout = layout_obj
261 |             break
262 |     
263 |     # If layout not found, default to Title and Content
264 |     if not layout:
265 |         layout = prs.slide_layouts[1]  # Title and Content
266 |     
267 |     # Add a slide
268 |     slide = prs.slides.add_slide(layout)
269 |     
270 |     # Set title if applicable
271 |     if new_slide.title and hasattr(slide, 'shapes') and hasattr(slide.shapes, 'title') and slide.shapes.title:
272 |         slide.shapes.title.text = new_slide.title
273 |         
274 |         # Apply title formatting
275 |         if new_slide.title_formatting:
276 |             apply_text_formatting(slide.shapes.title.text_frame, new_slide.title_formatting)
277 |     
278 |     # Set subtitle if applicable (usually for title slides)
279 |     if new_slide.subtitle:
280 |         for shape in slide.placeholders:
281 |             if shape.placeholder_format.type == 2:  # Subtitle placeholder
282 |                 shape.text = new_slide.subtitle
283 |                 break
284 |     
285 |     # Add content - either as bullets or plain text
286 |     if new_slide.bullets or new_slide.content:
287 |         content_shape = None
288 |         
289 |         # Find the content placeholder
290 |         for shape in slide.placeholders:
291 |             if shape.placeholder_format.type not in [1, 2]:  # Not title or subtitle
292 |                 content_shape = shape
293 |                 break
294 |         
295 |         if content_shape:
296 |             text_frame = content_shape.text_frame
297 |             text_frame.clear()  # Clear any default text
298 |             
299 |             # If using structured bullets
300 |             if new_slide.bullets:
301 |                 for i, bullet in enumerate(new_slide.bullets):
302 |                     if i == 0:
303 |                         p = text_frame.paragraphs[0]
304 |                     else:
305 |                         p = text_frame.add_paragraph()
306 |                     
307 |                     p.text = bullet.text
308 |                     p.level = bullet.level
309 |                     
310 |                     # Apply individual bullet formatting if specified
311 |                     if bullet.formatting:
312 |                         for run in p.runs:
313 |                             if bullet.formatting.font_name:
314 |                                 run.font.name = bullet.formatting.font_name
315 |                             if bullet.formatting.font_size:
316 |                                 run.font.size = Pt(bullet.formatting.font_size)
317 |                             if bullet.formatting.bold is not None:
318 |                                 run.font.bold = bullet.formatting.bold
319 |                             if bullet.formatting.italic is not None:
320 |                                 run.font.italic = bullet.formatting.italic
321 |             
322 |             # If using simple content
323 |             elif new_slide.content:
324 |                 lines = new_slide.content.strip().split('\n')
325 |                 for i, line in enumerate(lines):
326 |                     if i == 0:
327 |                         p = text_frame.paragraphs[0]
328 |                     else:
329 |                         p = text_frame.add_paragraph()
330 |                     p.text = line
331 |             
332 |             # Apply overall content formatting
333 |             if new_slide.content_formatting:
334 |                 apply_text_formatting(text_frame, new_slide.content_formatting)
335 |     
336 |     # Add speaker notes if provided
337 |     if new_slide.notes:
338 |         notes_slide = slide.notes_slide
339 |         notes_slide.notes_text_frame.text = new_slide.notes
340 |     
341 |     # Save the presentation
342 |     prs.save(filename)
343 |     return f"Updated presentation at: {filename}"
344 | 
345 | 
346 | @mcp.tool()
347 | def list_available_layouts() -> str:
348 |     """
349 |     List all available slide layouts with descriptions to help create professional slides.
350 |     """
351 |     from pptx import Presentation
352 |     
353 |     # Create a temporary presentation to inspect layouts
354 |     prs = Presentation()
355 |     
356 |     layouts = []
357 |     layouts.append("# Available Slide Layouts\n")
358 |     layouts.append("Use these layouts when creating slides for professional presentations:\n")
359 |     
360 |     layout_descriptions = {
361 |         "Title Slide": "Main title slide with title and subtitle",
362 |         "Title and Content": "Standard slide with title and content area (bullets, text, etc.)",
363 |         "Section Header": "Section divider slide with large title",
364 |         "Two Content": "Side-by-side content areas with title",
365 |         "Comparison": "Side-by-side content with title and headings for comparison",
366 |         "Title Only": "Just a title, rest of slide is blank for custom content",
367 |         "Blank": "Completely blank slide with no placeholders",
368 |         "Content with Caption": "Content with side caption text",
369 |         "Picture with Caption": "Picture with caption text below"
370 |     }
371 |     
372 |     for i, layout in enumerate(prs.slide_layouts):
373 |         layout_name = layout.name
374 |         description = layout_descriptions.get(layout_name, "Standard slide layout")
375 |         layouts.append(f"## {layout_name}")
376 |         layouts.append(f"{description}")
377 |         
378 |         # Count placeholders to help understand what's available
379 |         placeholder_counts = {}
380 |         for shape in layout.placeholders:
381 |             ph_type = shape.placeholder_format.type
382 |             name = {1: "Title", 2: "Subtitle/Body", 3: "Date", 4: "Slide Number", 
383 |                    5: "Footer", 7: "Content", 18: "Picture"}.get(ph_type, f"Type {ph_type}")
384 |             
385 |             placeholder_counts[name] = placeholder_counts.get(name, 0) + 1
386 |         
387 |         if placeholder_counts:
388 |             layouts.append("Contains: " + ", ".join(f"{count} {name}" for name, count in placeholder_counts.items()))
389 |         
390 |         layouts.append("")  # Empty line between layouts
391 |     
392 |     return "\n".join(layouts)
393 | 
394 | 
395 | @mcp.tool()
396 | def get_slide_preview(filename: str, slide_index: int = 0) -> Image:
397 |     """
398 |     Generate a preview image of a specific slide from the presentation.
399 |     Returns the slide as an image.
400 |     """
401 |     from pptx import Presentation
402 |     from PIL import Image as PILImage, ImageDraw, ImageFont
403 |     import io
404 |     import os
405 |     
406 |     # If filename doesn't include path, assume it's in the user directory
407 |     if not os.path.dirname(filename):
408 |         user_docs = os.path.expanduser("~/Documents/ClaudePresentations")
409 |         filename = os.path.join(user_docs, filename)
410 |     
411 |     try:
412 |         # Open the presentation
413 |         prs = Presentation(filename)
414 |         
415 |         # Make sure slide index is valid
416 |         if slide_index < 0 or slide_index >= len(prs.slides):
417 |             slide_index = 0
418 |             
419 |         # Create a representation of the slide
420 |         img = PILImage.new('RGB', (960, 540), color='white')  # 16:9 aspect ratio
421 |         draw = ImageDraw.Draw(img)
422 |         
423 |         # Try to get a system font
424 |         try:
425 |             font = ImageFont.truetype("Arial", 24)
426 |             small_font = ImageFont.truetype("Arial", 16)
427 |         except:
428 |             font = ImageFont.load_default()
429 |             small_font = ImageFont.load_default()
430 |         
431 |         # Draw slide frame
432 |         draw.rectangle([20, 20, 940, 520], outline=(200, 200, 200))
433 |         
434 |         # Get slide info
435 |         slide = prs.slides[slide_index]
436 |         
437 |         # Draw title if present
438 |         if slide.shapes.title:
439 |             title_text = slide.shapes.title.text
440 |             draw.text((60, 40), title_text, fill=(0, 0, 0), font=font)
441 |         
442 |         # Draw content placeholder representation
443 |         y_pos = 100
444 |         for shape in slide.placeholders:
445 |             if hasattr(shape, 'text') and shape.text and shape.placeholder_format.type != 1:  # Not title
446 |                 lines = shape.text.split('\n')
447 |                 for line in lines:
448 |                     draw.text((60, y_pos), line, fill=(0, 0, 0), font=small_font)
449 |                     y_pos += 30
450 |         
451 |         # Draw slide number
452 |         draw.text((880, 500), f"Slide {slide_index + 1}", fill=(100, 100, 100), font=small_font)
453 |         
454 |         # Save to buffer with high quality
455 |         buffer = io.BytesIO()
456 |         img.save(buffer, format="PNG")
457 |         
458 |         return Image(data=buffer.getvalue(), format="png")
459 |     
460 |     except Exception as e:
461 |         # Create an error image
462 |         img = PILImage.new('RGB', (960, 540), color=(245, 245, 245))
463 |         draw = ImageDraw.Draw(img)
464 |         
465 |         try:
466 |             font = ImageFont.truetype("Arial", 24)
467 |             small_font = ImageFont.truetype("Arial", 16)
468 |         except:
469 |             font = ImageFont.load_default()
470 |             small_font = ImageFont.load_default()
471 |             
472 |         draw.text((100, 100), f"Error loading slide preview:", fill=(200, 0, 0), font=font)
473 |         draw.text((100, 150), str(e), fill=(100, 0, 0), font=small_font)
474 |         draw.text((100, 230), f"File: {filename}", fill=(0, 0, 0), font=small_font)
475 |         draw.text((100, 260), f"Slide index: {slide_index}", fill=(0, 0, 0), font=small_font)
476 |         
477 |         buffer = io.BytesIO()
478 |         img.save(buffer, format="PNG")
479 |         
480 |         return Image(data=buffer.getvalue(), format="png")
481 | 
482 | 
483 | @mcp.tool()
484 | def apply_professional_theme(filename: str, theme_name: str = "professional") -> str:
485 |     """
486 |     Apply a professional theme to an existing presentation.
487 |     Available themes: professional, minimal, bold, corporate, creative
488 |     Returns the filename of the updated presentation.
489 |     """
490 |     from pptx import Presentation
491 |     import os
492 |     
493 |     # If filename doesn't include path, assume it's in the user directory
494 |     if not os.path.dirname(filename):
495 |         user_docs = os.path.expanduser("~/Documents/ClaudePresentations")
496 |         filename = os.path.join(user_docs, filename)
497 |     
498 |     # Define professional themes
499 |     themes = {
500 |         "professional": PresentationTheme(
501 |             title_font=TextFormatting(font_name="Calibri", font_size=32, bold=True, color="#1F497D"),
502 |             body_font=TextFormatting(font_name="Calibri", font_size=18, color="#333333"),
503 |             background_color="#FFFFFF"
504 |         ),
505 |         "minimal": PresentationTheme(
506 |             title_font=TextFormatting(font_name="Arial", font_size=36, bold=True, color="#333333"),
507 |             body_font=TextFormatting(font_name="Arial", font_size=20, color="#505050"),
508 |             background_color="#F7F7F7"
509 |         ),
510 |         "bold": PresentationTheme(
511 |             title_font=TextFormatting(font_name="Verdana", font_size=40, bold=True, color="#FFFFFF"),
512 |             body_font=TextFormatting(font_name="Verdana", font_size=20, color="#FFFFFF"),
513 |             background_color="#2D2D2D"
514 |         ),
515 |         "corporate": PresentationTheme(
516 |             title_font=TextFormatting(font_name="Segoe UI", font_size=36, bold=True, color="#003366"),
517 |             body_font=TextFormatting(font_name="Segoe UI", font_size=18, color="#333333"),
518 |             background_color="#F2F2F2"
519 |         ),
520 |         "creative": PresentationTheme(
521 |             title_font=TextFormatting(font_name="Georgia", font_size=38, bold=True, color="#663399"),
522 |             body_font=TextFormatting(font_name="Georgia", font_size=20, color="#333333"),
523 |             background_color="#FFF8E1"
524 |         )
525 |     }
526 |     
527 |     # Get the appropriate theme
528 |     selected_theme = themes.get(theme_name.lower(), themes["professional"])
529 |     
530 |     # Open the existing presentation
531 |     prs = Presentation(filename)
532 |     
533 |     # Apply the theme
534 |     apply_theme_to_presentation(prs, selected_theme)
535 |     
536 |     # Save the presentation
537 |     prs.save(filename)
538 |     return f"Applied '{theme_name}' theme to presentation at: {filename}"
539 | 
540 | 
541 | @mcp.tool()
542 | def get_save_location() -> str:
543 |     """
544 |     Returns the directory where presentations are saved.
545 |     """
546 |     import os
547 |     user_docs = os.path.expanduser("~/Documents/ClaudePresentations")
548 |     return f"Presentations are saved to: {user_docs}"
549 | 
550 | 
551 | @mcp.tool()
552 | def create_professional_presentation(title: str, subtitle: str, content_slides: List[Dict[str, Any]]) -> str:
553 |     """
554 |     Create a professionally formatted presentation with standard slides.
555 |     This is a simplified interface for creating complete presentations.
556 |     
557 |     Parameters:
558 |     - title: Presentation title
559 |     - subtitle: Presentation subtitle
560 |     - content_slides: List of dictionaries with 'title' and 'points' keys (points should be a list of strings)
561 |     
562 |     Returns the path to the created presentation.
563 |     """
564 |     from pptx import Presentation
565 |     import os
566 |     
567 |     # Create user directory
568 |     user_docs = os.path.expanduser("~/Documents/ClaudePresentations")
569 |     os.makedirs(user_docs, exist_ok=True)
570 |     
571 |     # Create a list of properly formatted slides
572 |     slides = []
573 |     
574 |     # Add title slide
575 |     title_slide = Slide(
576 |         title=title,
577 |         subtitle=subtitle,
578 |         layout="Title Slide",
579 |         title_formatting=TextFormatting(font_size=40, bold=True, color="#1F497D"),
580 |     )
581 |     slides.append(title_slide)
582 |     
583 |     # Add content slides
584 |     for slide_data in content_slides:
585 |         # Convert string points to proper BulletPoint objects
586 |         bullet_points = []
587 |         if "points" in slide_data:
588 |             for point in slide_data["points"]:
589 |                 # Check if it's a string or already has level info
590 |                 if isinstance(point, str):
591 |                     bullet_points.append(BulletPoint(text=point, level=0))
592 |                 elif isinstance(point, dict) and "text" in point:
593 |                     level = point.get("level", 0)
594 |                     bullet_points.append(BulletPoint(text=point["text"], level=level))
595 |         
596 |         # Create the slide
597 |         content_slide = Slide(
598 |             title=slide_data.get("title", ""),
599 |             bullets=bullet_points,
600 |             layout="Title and Content",
601 |             title_formatting=TextFormatting(font_size=32, bold=True, color="#1F497D"),
602 |             content_formatting=TextFormatting(font_size=20, color="#333333")
603 |         )
604 |         slides.append(content_slide)
605 |     
606 |     # Define a professional theme
607 |     theme = PresentationTheme(
608 |         title_font=TextFormatting(font_name="Calibri", font_size=32, bold=True, color="#1F497D"),
609 |         body_font=TextFormatting(font_name="Calibri", font_size=20, color="#333333"),
610 |         background_color="#FFFFFF"
611 |     )
612 |     
613 |     # Create the presentation
614 |     prs = Presentation()
615 |     
616 |     # Process each slide
617 |     for slide_data in slides:
618 |         # Get layout
619 |         layout_name = slide_data.layout
620 |         layout = None
621 |         for lo in prs.slide_layouts:
622 |             if lo.name.lower() == layout_name.lower():
623 |                 layout = lo
624 |                 break
625 |         
626 |         if not layout:
627 |             layout = prs.slide_layouts[1]  # Default to Title and Content
628 |         
629 |         # Add slide
630 |         slide = prs.slides.add_slide(layout)
631 |         
632 |         # Set title
633 |         if slide_data.title and slide.shapes.title:
634 |             slide.shapes.title.text = slide_data.title
635 |             apply_text_formatting(slide.shapes.title.text_frame, slide_data.title_formatting)
636 |         
637 |         # Set subtitle (for title slide)
638 |         if slide_data.subtitle:
639 |             for shape in slide.placeholders:
640 |                 if shape.placeholder_format.type == 2:  # Subtitle
641 |                     shape.text = slide_data.subtitle
642 |                     break
643 |         
644 |         # Add bullets
645 |         if slide_data.bullets:
646 |             for shape in slide.placeholders:
647 |                 if shape.placeholder_format.type not in [1, 2]:  # Not title or subtitle
648 |                     text_frame = shape.text_frame
649 |                     text_frame.clear()
650 |                     
651 |                     for i, bullet in enumerate(slide_data.bullets):
652 |                         if i == 0:
653 |                             p = text_frame.paragraphs[0]
654 |                         else:
655 |                             p = text_frame.add_paragraph()
656 |                         
657 |                         p.text = bullet.text
658 |                         p.level = bullet.level
659 |                     
660 |                     # Apply formatting
661 |                     if slide_data.content_formatting:
662 |                         apply_text_formatting(text_frame, slide_data.content_formatting)
663 |                     
664 |                     break
665 |     
666 |     # Apply theme
667 |     apply_theme_to_presentation(prs, theme)
668 |     
669 |     # Save file
670 |     filename = f"{title.replace(' ', '_')}.pptx"
671 |     full_path = os.path.join(user_docs, filename)
672 |     prs.save(full_path)
673 |     
674 |     return f"Created professional presentation at: {full_path}"
```