iflow-mcp-jenstangen1-pptx 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,766 @@
1
+ import win32com.client
2
+ import pythoncom
3
+ from typing import List, Optional, Dict, Any, Union
4
+ from mcp.server.fastmcp import FastMCP
5
+ import os
6
+ import pywintypes # Import for specific exception types
7
+
8
+ # Constants from PowerPoint VBA Object Library (usually obtained via makepy)
9
+ # Using magic numbers for simplicity here, but using makepy is recommended practice
10
+ # Example: from win32com.client import constants
11
+ # ppSaveAsDefault = 1 (This might vary, better to use format-specific saves)
12
+ ppSaveAsOpenXMLPresentation = 24 # .pptx
13
+ msoShapeRectangle = 1
14
+ msoTextOrientationHorizontal = 1
15
+ msoPlaceholder = 14 # Shape type for placeholders
16
+
17
+ # Placeholder type constants (might vary slightly by PP version)
18
+ ppPlaceholderTitle = 1
19
+ ppPlaceholderBody = 2
20
+ ppPlaceholderCenterTitle = 13 # Often used for Title Only layout
21
+ ppPlaceholderSubtitle = 14
22
+ ppPlaceholderDate = 16
23
+ ppPlaceholderSlideNumber = 15
24
+ ppPlaceholderFooter = 17
25
+ ppPlaceholderHeader = 18
26
+ ppPlaceholderObject = 7 # Generic object/content placeholder
27
+
28
+
29
+ # Mapping for user-friendly placeholder names to constants
30
+ PLACEHOLDER_NAME_MAP = {
31
+ "title": ppPlaceholderTitle,
32
+ "body": ppPlaceholderBody,
33
+ "centertitle": ppPlaceholderCenterTitle,
34
+ "subtitle": ppPlaceholderSubtitle,
35
+ "date": ppPlaceholderDate,
36
+ "slidenumber": ppPlaceholderSlideNumber,
37
+ "footer": ppPlaceholderFooter,
38
+ "header": ppPlaceholderHeader,
39
+ "object": ppPlaceholderObject,
40
+ "content": ppPlaceholderObject, # Common synonym
41
+ }
42
+
43
+ # Mapping for user-friendly shape type names (reverse of _get_shape_type_name)
44
+ SHAPE_TYPE_NAME_MAP = {
45
+ "rectangle": 1,
46
+ "textbox": 17, # MSO_SHAPE_TYPE.TEXT_BOX (Note: might also be AutoShape with text)
47
+ "oval": 9, # MSO_SHAPE_TYPE.OVAL (Note: Check MSO AutoShape constants for more specific ovals)
48
+ "table": 19, # MSO_SHAPE_TYPE.TABLE
49
+ "chart": 3, # MSO_SHAPE_TYPE.CHART
50
+ "picture": 13, # MSO_SHAPE_TYPE.PICTURE
51
+ "line": 20, # MSO_SHAPE_TYPE.LINE
52
+ "connector": 10, # MSO_SHAPE_TYPE.CONNECTOR (check AutoShape types)
53
+ "placeholder": msoPlaceholder,
54
+ # Add more basic types as needed
55
+ }
56
+
57
+
58
+ class PowerPointEditorWin32:
59
+ def __init__(self):
60
+ self.app = None
61
+ self._connect_or_launch_powerpoint()
62
+
63
+ def _connect_or_launch_powerpoint(self):
64
+ """Connects to a running instance of PowerPoint or launches a new one."""
65
+ try:
66
+ # Use the Pywin32 CoInitializeEx to avoid threading issues with COM
67
+ pythoncom.CoInitializeEx(pythoncom.COINIT_APARTMENTTHREADED)
68
+ self.app = win32com.client.GetActiveObject("PowerPoint.Application")
69
+ print("Connected to running PowerPoint application.")
70
+ except pywintypes.com_error:
71
+ try:
72
+ self.app = win32com.client.Dispatch("PowerPoint.Application")
73
+ self.app.Visible = True # Make the application visible
74
+ print("Launched new PowerPoint application.")
75
+ except Exception as e:
76
+ print(f"Error launching PowerPoint: {e}")
77
+ self.app = None
78
+ except Exception as e:
79
+ print(f"An unexpected error occurred: {e}")
80
+ self.app = None
81
+
82
+ def _ensure_connection(self):
83
+ """Ensures the PowerPoint application object is valid."""
84
+ if self.app is None:
85
+ self._connect_or_launch_powerpoint()
86
+ if self.app is None:
87
+ raise ConnectionError("Could not connect to or launch PowerPoint.")
88
+ # Basic check if the app object seems responsive
89
+ try:
90
+ _ = self.app.Version
91
+ except Exception as e:
92
+ print(f"PowerPoint connection lost or unresponsive: {e}")
93
+ self._connect_or_launch_powerpoint() # Try reconnecting
94
+ if self.app is None:
95
+ raise ConnectionError("Could not reconnect to PowerPoint.")
96
+
97
+ def list_open_presentations(self) -> List[Dict[str, Any]]:
98
+ """Lists all currently open presentations."""
99
+ self._ensure_connection()
100
+ presentations_info = []
101
+ try:
102
+ for i in range(1, self.app.Presentations.Count + 1):
103
+ pres = self.app.Presentations(i)
104
+ presentations_info.append({
105
+ "name": pres.Name,
106
+ "path": pres.FullName if pres.Path else None,
107
+ "slides": pres.Slides.Count,
108
+ "saved": pres.Saved,
109
+ "index": i # Provide the 1-based index for reference
110
+ })
111
+ except Exception as e:
112
+ print(f"Error listing presentations: {e}")
113
+ # Maybe attempt reconnect if it's a COM error
114
+ if "RPC server is unavailable" in str(e):
115
+ self._connect_or_launch_powerpoint()
116
+ # Retry might be needed here depending on strategy
117
+ raise
118
+ return presentations_info
119
+
120
+ def get_presentation(self, identifier: str) -> Optional[Any]:
121
+ """
122
+ Gets a presentation object by its name, path, or 1-based index.
123
+
124
+ Args:
125
+ identifier (str): The name (e.g., "Presentation1.pptx"),
126
+ full path, or 1-based index (as string or int).
127
+ """
128
+ self._ensure_connection()
129
+ try:
130
+ # Try by index first if it's an integer
131
+ if isinstance(identifier, int) or identifier.isdigit():
132
+ idx = int(identifier)
133
+ if 1 <= idx <= self.app.Presentations.Count:
134
+ return self.app.Presentations(idx)
135
+ else:
136
+ print(f"Index {identifier} out of range.")
137
+ return None
138
+
139
+ # Try by name or path
140
+ for i in range(1, self.app.Presentations.Count + 1):
141
+ pres = self.app.Presentations(i)
142
+ if pres.Name == identifier or pres.FullName == identifier:
143
+ return pres
144
+ print(f"Presentation '{identifier}' not found.")
145
+ return None
146
+ except Exception as e:
147
+ print(f"Error getting presentation '{identifier}': {e}")
148
+ raise
149
+
150
+ def save_presentation(self, identifier: str, save_path: Optional[str] = None):
151
+ """
152
+ Saves the specified presentation.
153
+
154
+ Args:
155
+ identifier (str): Name, path, or index of the presentation.
156
+ save_path (Optional[str]): Path to save to. If None, saves to its current path.
157
+ If the presentation is new, save_path is required.
158
+ """
159
+ self._ensure_connection()
160
+ pres = self.get_presentation(identifier)
161
+ if not pres:
162
+ raise ValueError(f"Presentation '{identifier}' not found.")
163
+
164
+ try:
165
+ if save_path:
166
+ # Ensure the directory exists
167
+ abs_path = os.path.abspath(save_path)
168
+ os.makedirs(os.path.dirname(abs_path), exist_ok=True)
169
+ pres.SaveAs(abs_path, ppSaveAsOpenXMLPresentation)
170
+ print(f"Presentation saved as '{abs_path}'.")
171
+ elif pres.Path: # Can only save if it has a path already
172
+ pres.Save()
173
+ print(f"Presentation '{pres.Name}' saved.")
174
+ else:
175
+ raise ValueError("save_path is required for a new presentation.")
176
+ except Exception as e:
177
+ print(f"Error saving presentation '{identifier}': {e}")
178
+ raise
179
+
180
+ def add_slide(self, identifier: str, layout_index: int = 1) -> int:
181
+ """
182
+ Adds a new slide to the presentation.
183
+
184
+ Args:
185
+ identifier (str): Name, path, or index of the presentation.
186
+ layout_index (int): 1-based index of the slide layout to use.
187
+
188
+ Returns:
189
+ int: The 1-based index of the newly added slide.
190
+ """
191
+ self._ensure_connection()
192
+ pres = self.get_presentation(identifier)
193
+ if not pres:
194
+ raise ValueError(f"Presentation '{identifier}' not found.")
195
+
196
+ try:
197
+ # Ensure layout_index is valid
198
+ if not (1 <= layout_index <= pres.SlideMaster.CustomLayouts.Count):
199
+ print(f"Warning: Layout index {layout_index} invalid. Using layout 1.")
200
+ layout_index = 1
201
+ layout = pres.SlideMaster.CustomLayouts(layout_index)
202
+
203
+ # Add the slide (returns the new Slide object)
204
+ new_slide = pres.Slides.AddSlide(pres.Slides.Count + 1, layout)
205
+ print(f"Added slide {new_slide.SlideIndex} to '{pres.Name}'.")
206
+ return new_slide.SlideIndex
207
+ except Exception as e:
208
+ print(f"Error adding slide to '{identifier}': {e}")
209
+ raise
210
+
211
+ def get_slide(self, identifier: str, slide_index: int) -> Optional[Any]:
212
+ """
213
+ Gets a slide object from a presentation.
214
+
215
+ Args:
216
+ identifier (str): Name, path, or index of the presentation.
217
+ slide_index (int): 1-based index of the slide.
218
+
219
+ Returns:
220
+ Optional[Any]: The slide object or None if not found.
221
+ """
222
+ self._ensure_connection()
223
+ pres = self.get_presentation(identifier)
224
+ if not pres:
225
+ return None
226
+
227
+ try:
228
+ if 1 <= slide_index <= pres.Slides.Count:
229
+ return pres.Slides(slide_index)
230
+ else:
231
+ print(f"Slide index {slide_index} out of range for '{pres.Name}'.")
232
+ return None
233
+ except Exception as e:
234
+ print(f"Error getting slide {slide_index} from '{identifier}': {e}")
235
+ raise
236
+
237
+ def add_text_box(self, identifier: str, slide_index: int, text: str,
238
+ left: float, top: float, width: float, height: float) -> int:
239
+ """
240
+ Adds a text box to a slide.
241
+
242
+ Args:
243
+ identifier (str): Presentation identifier.
244
+ slide_index (int): 1-based slide index.
245
+ text (str): Text content for the box.
246
+ left, top, width, height (float): Position and size in points.
247
+
248
+ Returns:
249
+ int: The unique ID of the newly added shape.
250
+ """
251
+ self._ensure_connection()
252
+ slide = self.get_slide(identifier, slide_index)
253
+ if not slide:
254
+ raise ValueError(f"Slide {slide_index} not found in presentation '{identifier}'.")
255
+
256
+ try:
257
+ shape = slide.Shapes.AddTextbox(msoTextOrientationHorizontal, left, top, width, height)
258
+ shape.TextFrame.TextRange.Text = text
259
+ shape.Name = f"TextBox_{shape.Id}" # Assign a default name
260
+ print(f"Added text box (ID: {shape.Id}) to slide {slide_index}.")
261
+ return shape.Id
262
+ except Exception as e:
263
+ print(f"Error adding text box to slide {slide_index}: {e}")
264
+ raise
265
+
266
+ def add_shape(self, identifier: str, slide_index: int, shape_type: int,
267
+ left: float, top: float, width: float, height: float) -> int:
268
+ """
269
+ Adds a basic auto shape to a slide.
270
+
271
+ Args:
272
+ identifier (str): Presentation identifier.
273
+ slide_index (int): 1-based slide index.
274
+ shape_type (int): MSO AutoShapeType constant (e.g., msoShapeRectangle).
275
+ left, top, width, height (float): Position and size in points.
276
+
277
+ Returns:
278
+ int: The unique ID of the newly added shape.
279
+ """
280
+ self._ensure_connection()
281
+ slide = self.get_slide(identifier, slide_index)
282
+ if not slide:
283
+ raise ValueError(f"Slide {slide_index} not found in presentation '{identifier}'.")
284
+
285
+ try:
286
+ # Use AddShape for AutoShapes
287
+ shape = slide.Shapes.AddShape(shape_type, left, top, width, height)
288
+ shape.Name = f"Shape_{shape.Id}" # Assign a default name
289
+ print(f"Added shape (ID: {shape.Id}, Type: {shape_type}) to slide {slide_index}.")
290
+ return shape.Id
291
+ except Exception as e:
292
+ print(f"Error adding shape to slide {slide_index}: {e}")
293
+ raise
294
+
295
+ def get_shape_by_id(self, identifier: str, slide_index: int, shape_id: int) -> Optional[Any]:
296
+ """Gets a shape object by its unique ID."""
297
+ self._ensure_connection()
298
+ slide = self.get_slide(identifier, slide_index)
299
+ if not slide:
300
+ return None
301
+
302
+ try:
303
+ # Iterate through shapes to find by ID
304
+ for i in range(1, slide.Shapes.Count + 1):
305
+ shape = slide.Shapes(i)
306
+ if shape.Id == shape_id:
307
+ return shape
308
+ print(f"Shape with ID {shape_id} not found on slide {slide_index}.")
309
+ return None
310
+ except Exception as e:
311
+ # Handle potential errors if shape is deleted during iteration etc.
312
+ print(f"Error finding shape ID {shape_id} on slide {slide_index}: {e}")
313
+ return None
314
+
315
+ def get_shape_by_name(self, identifier: str, slide_index: int, shape_name: str) -> Optional[Any]:
316
+ """Gets a shape object by its name."""
317
+ self._ensure_connection()
318
+ slide = self.get_slide(identifier, slide_index)
319
+ if not slide:
320
+ return None
321
+
322
+ try:
323
+ # Accessing by name directly might fail if name is not unique or contains odd chars
324
+ return slide.Shapes(shape_name)
325
+ except pywintypes.com_error as e:
326
+ # Handle common error for item not found
327
+ if e.hresult == -2147024809: # 0x80070057 (E_INVALIDARG often means not found by name)
328
+ print(f"Shape with name '{shape_name}' not found on slide {slide_index}.")
329
+ else:
330
+ print(f"Error finding shape name '{shape_name}' on slide {slide_index}: {e}")
331
+ return None
332
+ except Exception as e:
333
+ print(f"Error finding shape name '{shape_name}' on slide {slide_index}: {e}")
334
+ return None
335
+
336
+ def find_shape_by_text(self, identifier: Union[str, int], slide_index: int, search_text: str, partial_match: bool = True) -> List[Dict[str, Any]]:
337
+ """
338
+ Finds shapes on a slide containing specific text.
339
+
340
+ Args:
341
+ identifier (Union[str, int]): Presentation identifier.
342
+ slide_index (int): 1-based slide index.
343
+ search_text (str): The text to search for (case-insensitive).
344
+ partial_match (bool): If True, finds shapes where search_text is a substring.
345
+ If False, requires an exact match (ignoring case).
346
+
347
+ Returns:
348
+ List[Dict[str, Any]]: List of matching shapes with their info.
349
+ """
350
+ self._ensure_connection()
351
+ matches = []
352
+ slide = self.get_slide(identifier, slide_index)
353
+ if not slide:
354
+ print(f"Cannot find shapes by text: Slide {slide_index} not found in presentation '{identifier}'.")
355
+ return []
356
+
357
+ search_lower = search_text.lower()
358
+ try:
359
+ for i in range(1, slide.Shapes.Count + 1):
360
+ shape = slide.Shapes(i)
361
+ shape_text = ""
362
+ has_text_frame = False
363
+ try:
364
+ # Check text frame exists and has text
365
+ if shape.HasTextFrame and shape.TextFrame.HasText:
366
+ has_text_frame = True
367
+ shape_text = shape.TextFrame.TextRange.Text
368
+ except Exception:
369
+ continue # Skip shapes that error on text access
370
+
371
+ if has_text_frame:
372
+ shape_text_lower = shape_text.lower()
373
+ found = False
374
+ if partial_match:
375
+ if search_lower in shape_text_lower:
376
+ found = True
377
+ else: # Exact match (case-insensitive)
378
+ if search_lower == shape_text_lower:
379
+ found = True
380
+
381
+ if found:
382
+ shape_data = self._get_shape_basic_info(shape)
383
+ shape_data["text_preview"] = shape_text[:100] + "..." if len(shape_text) > 100 else shape_text
384
+ matches.append(shape_data)
385
+
386
+ except Exception as e:
387
+ print(f"Error searching for text '{search_text}' on slide {slide_index}: {e}")
388
+ return matches
389
+
390
+ def find_shapes_by_type(self, identifier: Union[str, int], slide_index: int, shape_type_name: str) -> List[Dict[str, Any]]:
391
+ """
392
+ Finds shapes on a slide matching a specific type name.
393
+
394
+ Args:
395
+ identifier (Union[str, int]): Presentation identifier.
396
+ slide_index (int): 1-based slide index.
397
+ shape_type_name (str): The user-friendly name of the shape type (e.g., "rectangle", "textbox"). Case-insensitive.
398
+
399
+ Returns:
400
+ List[Dict[str, Any]]: List of matching shapes with their info.
401
+ """
402
+ self._ensure_connection()
403
+ matches = []
404
+ slide = self.get_slide(identifier, slide_index)
405
+ if not slide:
406
+ print(f"Cannot find shapes by type: Slide {slide_index} not found in presentation '{identifier}'.")
407
+ return []
408
+
409
+ type_name_lower = shape_type_name.lower()
410
+ target_type_id = SHAPE_TYPE_NAME_MAP.get(type_name_lower)
411
+
412
+ if target_type_id is None:
413
+ print(f"Warning: Unknown shape type name '{shape_type_name}'. Supported types: {list(SHAPE_TYPE_NAME_MAP.keys())}")
414
+ return []
415
+
416
+ try:
417
+ for i in range(1, slide.Shapes.Count + 1):
418
+ shape = slide.Shapes(i)
419
+ # Special handling for textbox which might be an AutoShape
420
+ is_match = False
421
+ if target_type_id == SHAPE_TYPE_NAME_MAP["textbox"]:
422
+ # Check MSO_SHAPE_TYPE.TEXT_BOX or if it's an AutoShape with text
423
+ if shape.Type == SHAPE_TYPE_NAME_MAP["textbox"]:
424
+ is_match = True
425
+ elif shape.Type == 1: # msoAutoShape
426
+ try:
427
+ if shape.HasTextFrame and shape.TextFrame.HasText:
428
+ # Could refine this - maybe check shape.AutoShapeType?
429
+ # For now, consider any autoshape with text as potential textbox match
430
+ is_match = True
431
+ except: pass # Ignore errors accessing text frame
432
+ elif shape.Type == target_type_id:
433
+ is_match = True
434
+
435
+ if is_match:
436
+ matches.append(self._get_shape_basic_info(shape))
437
+
438
+ except Exception as e:
439
+ print(f"Error searching for shape type '{shape_type_name}' on slide {slide_index}: {e}")
440
+ return matches
441
+
442
+ def get_placeholder_shape(self, identifier: Union[str, int], slide_index: int, placeholder_name: str) -> Optional[Dict[str, Any]]:
443
+ """
444
+ Finds a specific placeholder shape on a slide by its common name.
445
+
446
+ Args:
447
+ identifier (Union[str, int]): Presentation identifier.
448
+ slide_index (int): 1-based slide index.
449
+ placeholder_name (str): Common name of the placeholder (e.g., "title", "body", "footer"). Case-insensitive.
450
+
451
+ Returns:
452
+ Optional[Dict[str, Any]]: Info of the first matching placeholder shape, or None if not found.
453
+ """
454
+ self._ensure_connection()
455
+ slide = self.get_slide(identifier, slide_index)
456
+ if not slide:
457
+ print(f"Cannot find placeholder: Slide {slide_index} not found in presentation '{identifier}'.")
458
+ return None
459
+
460
+ name_lower = placeholder_name.lower()
461
+ target_ph_type = PLACEHOLDER_NAME_MAP.get(name_lower)
462
+
463
+ if target_ph_type is None:
464
+ print(f"Warning: Unknown placeholder name '{placeholder_name}'. Supported names: {list(PLACEHOLDER_NAME_MAP.keys())}")
465
+ return None
466
+
467
+ try:
468
+ for i in range(1, slide.Shapes.Count + 1):
469
+ shape = slide.Shapes(i)
470
+ try:
471
+ # Check if it's a placeholder and its type matches
472
+ if shape.Type == msoPlaceholder and shape.PlaceholderFormat.Type == target_ph_type:
473
+ print(f"Found placeholder '{placeholder_name}' (ID: {shape.Id}) on slide {slide_index}.")
474
+ return self._get_shape_basic_info(shape)
475
+ except Exception:
476
+ # Some shapes might error on PlaceholderFormat access if not placeholders
477
+ continue
478
+ except Exception as e:
479
+ print(f"Error searching for placeholder '{placeholder_name}' on slide {slide_index}: {e}")
480
+
481
+ print(f"Placeholder '{placeholder_name}' not found on slide {slide_index}.")
482
+ return None
483
+
484
+ def edit_element(self, identifier: str, slide_index: int, shape_identifier: Union[int, str],
485
+ properties: Dict[str, Any]) -> bool:
486
+ """
487
+ Edits properties of a shape identified by ID or name.
488
+
489
+ Args:
490
+ identifier (str): Presentation identifier.
491
+ slide_index (int): 1-based slide index.
492
+ shape_identifier (Union[int, str]): The shape's ID (int) or Name (str).
493
+ properties (Dict[str, Any]): Dictionary of properties to change.
494
+ Supported: 'text', 'left', 'top', 'width', 'height', 'name'.
495
+
496
+ Returns:
497
+ bool: True if successful, False otherwise.
498
+ """
499
+ self._ensure_connection()
500
+ if isinstance(shape_identifier, int):
501
+ shape = self.get_shape_by_id(identifier, slide_index, shape_identifier)
502
+ elif isinstance(shape_identifier, str):
503
+ shape = self.get_shape_by_name(identifier, slide_index, shape_identifier)
504
+ else:
505
+ print("Invalid shape_identifier type. Use int (ID) or str (Name).")
506
+ return False
507
+
508
+ if not shape:
509
+ return False
510
+
511
+ try:
512
+ if 'text' in properties and shape.HasTextFrame and shape.TextFrame.HasText:
513
+ shape.TextFrame.TextRange.Text = str(properties['text'])
514
+ if 'left' in properties:
515
+ shape.Left = float(properties['left'])
516
+ if 'top' in properties:
517
+ shape.Top = float(properties['top'])
518
+ if 'width' in properties:
519
+ shape.Width = float(properties['width'])
520
+ if 'height' in properties:
521
+ shape.Height = float(properties['height'])
522
+ if 'name' in properties:
523
+ shape.Name = str(properties['name']) # Allow renaming
524
+
525
+ print(f"Edited properties for shape '{shape_identifier}' on slide {slide_index}.")
526
+ return True
527
+ except Exception as e:
528
+ print(f"Error editing shape '{shape_identifier}' on slide {slide_index}: {e}")
529
+ return False
530
+
531
+ def list_shapes(self, identifier: Union[str, int], slide_index: int) -> List[Dict[str, Any]]:
532
+ """Lists shapes on a slide with their ID, Name, Type, and basic geometry."""
533
+ self._ensure_connection()
534
+ shapes_info = []
535
+ slide = self.get_slide(identifier, slide_index)
536
+ if not slide:
537
+ print(f"Cannot list shapes: Slide {slide_index} not found in presentation '{identifier}'.")
538
+ return []
539
+
540
+ try:
541
+ for i in range(1, slide.Shapes.Count + 1):
542
+ shape = slide.Shapes(i)
543
+ shapes_info.append(self._get_shape_basic_info(shape)) # Use helper
544
+ except Exception as e:
545
+ print(f"Error listing shapes on slide {slide_index}: {e}")
546
+ # Don't raise, just return what we have or empty list
547
+ return shapes_info
548
+
549
+ def _get_shape_basic_info(self, shape: Any) -> Dict[str, Any]:
550
+ """Helper to get common info dictionary for a shape."""
551
+ info = {
552
+ "id": -1, "name": "Unknown", "type_id": -1, "type_name": "Unknown",
553
+ "left": 0, "top": 0, "width": 0, "height": 0,
554
+ "has_text": False, "is_placeholder": False
555
+ }
556
+ try:
557
+ info["id"] = shape.Id
558
+ info["name"] = shape.Name
559
+ info["type_id"] = shape.Type
560
+ info["type_name"] = self._get_shape_type_name(shape.Type)
561
+ info["left"] = shape.Left
562
+ info["top"] = shape.Top
563
+ info["width"] = shape.Width
564
+ info["height"] = shape.Height
565
+
566
+ # Check for text
567
+ try:
568
+ if shape.HasTextFrame and shape.TextFrame.HasText:
569
+ info["has_text"] = True
570
+ except Exception: pass
571
+
572
+ # Check if placeholder
573
+ try:
574
+ if shape.Type == msoPlaceholder:
575
+ info["is_placeholder"] = True
576
+ info["placeholder_type_id"] = shape.PlaceholderFormat.Type
577
+ # Add friendly name for placeholder type if possible
578
+ for name, ph_id in PLACEHOLDER_NAME_MAP.items():
579
+ if ph_id == info["placeholder_type_id"]:
580
+ info["placeholder_type_name"] = name
581
+ break
582
+ except Exception: pass
583
+
584
+ except Exception as e:
585
+ print(f"Error getting basic info for a shape: {e}")
586
+ # Return partial info if possible
587
+ return info
588
+
589
+ def _get_shape_type_name(self, type_id: int) -> str:
590
+ """Returns a readable name for MSO_SHAPE_TYPE IDs (add more as needed)."""
591
+ # This is a simplified mapping. A full mapping would be extensive.
592
+ # Consider reversing SHAPE_TYPE_NAME_MAP for consistency
593
+ mapping = {
594
+ 1: "Rectangle/AutoShape", # Generic AutoShape
595
+ 17: "TextBox",
596
+ 9: "Oval", # Generic Oval (likely AutoShape)
597
+ 19: "Table",
598
+ 3: "Chart",
599
+ 13: "Picture",
600
+ 20: "Line",
601
+ 10: "Connector",
602
+ msoPlaceholder: "Placeholder",
603
+ # Add more from MSO_SHAPE_TYPE if needed
604
+ 6: "Group",
605
+ 7: "EmbeddedObject",
606
+ 8: "FormControl",
607
+ 11: "Freeform",
608
+ 12: "Media",
609
+ 15: "OLEControlObject",
610
+ 16: "ScriptAnchor",
611
+ 18: "Canvas",
612
+ 21: "Ink",
613
+ 22: "InkComment",
614
+ 23: "Diagram", # SmartArt? Check MSO_SHAPE_TYPE constants
615
+ 24: "WebVideo",
616
+ 25: "ContentApp", # Office Add-in
617
+ 26: "GraphicFrame", # Holds Table, Chart, SmartArt, etc.
618
+ 27: "LinkedOLEObject",
619
+ 28: "LinkedPicture",
620
+ 29: "Model3D",
621
+ 30: "LinkedContentApp",
622
+ }
623
+ return mapping.get(type_id, f"Unknown ({type_id})")
624
+
625
+
626
+ # --- MCP Server Setup ---
627
+
628
+ # Create the PowerPoint editor instance
629
+ editor = PowerPointEditorWin32()
630
+
631
+ # Create MCP server
632
+ mcp = FastMCP("PowerPoint MCP (Win32)")
633
+
634
+ @mcp.tool()
635
+ def list_open_presentations():
636
+ """Lists currently open PowerPoint presentations."""
637
+ try:
638
+ return {"presentations": editor.list_open_presentations()}
639
+ except Exception as e:
640
+ return {"error": f"Failed to list presentations: {str(e)}"}
641
+
642
+ @mcp.tool()
643
+ def save_presentation(identifier: str, save_path: str = None):
644
+ """Saves the specified presentation. Use index, name, or full path as identifier."""
645
+ try:
646
+ editor.save_presentation(identifier, save_path)
647
+ return {"message": f"Save command issued for presentation '{identifier}'."}
648
+ except Exception as e:
649
+ return {"error": f"Failed to save presentation '{identifier}': {str(e)}"}
650
+
651
+ @mcp.tool()
652
+ def add_slide(identifier: str, layout_index: int = 1):
653
+ """Adds a slide to the specified presentation. Use index, name, or full path as identifier."""
654
+ try:
655
+ new_slide_index = editor.add_slide(identifier, layout_index)
656
+ return {"message": "Slide added successfully.", "slide_index": new_slide_index}
657
+ except Exception as e:
658
+ return {"error": f"Failed to add slide to '{identifier}': {str(e)}"}
659
+
660
+ @mcp.tool()
661
+ def add_text_box(identifier: str, slide_index: int, text: str,
662
+ left: float = 72, top: float = 72, width: float = 288, height: float = 72):
663
+ """Adds a text box to a specific slide (dimensions in points)."""
664
+ try:
665
+ shape_id = editor.add_text_box(identifier, slide_index, text, left, top, width, height)
666
+ return {"message": "Text box added successfully.", "shape_id": shape_id}
667
+ except Exception as e:
668
+ return {"error": f"Failed to add text box to slide {slide_index} in '{identifier}': {str(e)}"}
669
+
670
+ @mcp.tool()
671
+ def add_rectangle(identifier: str, slide_index: int,
672
+ left: float = 72, top: float = 150, width: float = 144, height: float = 72):
673
+ """Adds a rectangle shape to a specific slide (dimensions in points)."""
674
+ try:
675
+ # msoShapeRectangle = 1
676
+ shape_id = editor.add_shape(identifier, slide_index, msoShapeRectangle, left, top, width, height)
677
+ return {"message": "Rectangle added successfully.", "shape_id": shape_id}
678
+ except Exception as e:
679
+ return {"error": f"Failed to add rectangle to slide {slide_index} in '{identifier}': {str(e)}"}
680
+
681
+ @mcp.tool()
682
+ def edit_element(identifier: str, slide_index: int, shape_identifier: Union[int, str],
683
+ properties: Dict[str, Any]):
684
+ """Edits a shape's properties (text, left, top, width, height, name). Identify shape by ID (int) or Name (str)."""
685
+ try:
686
+ success = editor.edit_element(identifier, slide_index, shape_identifier, properties)
687
+ if success:
688
+ return {"message": f"Element '{shape_identifier}' updated successfully."}
689
+ else:
690
+ # Editor method already prints errors, return a generic failure
691
+ return {"error": f"Failed to update element '{shape_identifier}'. See server logs for details."}
692
+ except Exception as e:
693
+ return {"error": f"Failed to edit element '{shape_identifier}' on slide {slide_index}: {str(e)}"}
694
+
695
+ @mcp.tool()
696
+ def list_shapes(identifier: str, slide_index: int):
697
+ """Lists all shapes on a given slide with their ID, Name, and Type."""
698
+ try:
699
+ shapes = editor.list_shapes(identifier, slide_index)
700
+ return {"shapes": shapes}
701
+ except Exception as e:
702
+ return {"error": f"Failed to list shapes on slide {slide_index} in '{identifier}': {str(e)}"}
703
+
704
+ @mcp.tool()
705
+ def find_shape_by_text(identifier: Union[str, int], slide_index: int, search_text: str, partial_match: bool = True):
706
+ """Finds shapes on a slide containing specific text (case-insensitive). Set partial_match=False for exact match."""
707
+ if not editor: return {"error": "PowerPoint editor not initialized."}
708
+ try:
709
+ matches = editor.find_shape_by_text(identifier, slide_index, search_text, partial_match)
710
+ return {"matches": matches}
711
+ except Exception as e:
712
+ return _handle_tool_error("find_shape_by_text", e)
713
+
714
+ @mcp.tool()
715
+ def find_shapes_by_type(identifier: Union[str, int], slide_index: int, shape_type_name: str):
716
+ """Finds shapes on a slide by type name (e.g., 'rectangle', 'textbox', 'picture', 'placeholder')."""
717
+ if not editor: return {"error": "PowerPoint editor not initialized."}
718
+ supported_types = list(SHAPE_TYPE_NAME_MAP.keys())
719
+ if shape_type_name.lower() not in supported_types:
720
+ return {"error": f"Unsupported shape type name '{shape_type_name}'. Try one of: {supported_types}"}
721
+ try:
722
+ matches = editor.find_shapes_by_type(identifier, slide_index, shape_type_name)
723
+ return {"matches": matches}
724
+ except Exception as e:
725
+ return _handle_tool_error("find_shapes_by_type", e)
726
+
727
+ @mcp.tool()
728
+ def get_placeholder_shape(identifier: Union[str, int], slide_index: int, placeholder_name: str):
729
+ """Gets a specific placeholder shape by name (e.g., 'title', 'body', 'footer', 'slidenumber')."""
730
+ if not editor: return {"error": "PowerPoint editor not initialized."}
731
+ supported_placeholders = list(PLACEHOLDER_NAME_MAP.keys())
732
+ if placeholder_name.lower() not in supported_placeholders:
733
+ return {"error": f"Unsupported placeholder name '{placeholder_name}'. Try one of: {supported_placeholders}"}
734
+ try:
735
+ match = editor.get_placeholder_shape(identifier, slide_index, placeholder_name)
736
+ if match:
737
+ return {"placeholder_found": True, "shape_info": match}
738
+ else:
739
+ return {"placeholder_found": False, "message": f"Placeholder '{placeholder_name}' not found on slide {slide_index}."}
740
+ except Exception as e:
741
+ return _handle_tool_error("get_placeholder_shape", e)
742
+
743
+
744
+ # You might want to add cleanup for COM objects, although Python's garbage collection
745
+ # combined with pywin32's handling often manages this. Explicitly setting self.app = None
746
+ # and maybe calling pythoncom.CoUninitialize() on shutdown could be added for robustness.
747
+
748
+ if __name__ == "__main__":
749
+ print("Starting PowerPoint MCP Server (Win32)...")
750
+ # The editor instance is created globally, attempting connection immediately.
751
+ if editor.app is None:
752
+ print("Warning: Failed to connect to PowerPoint on startup.")
753
+ # Server will still run, but tools will fail until PowerPoint is available
754
+ # and a tool call triggers a reconnect attempt.
755
+
756
+ # Run the MCP server
757
+ mcp.run()
758
+
759
+ # Make sure pywin32 is installed: pip install pywin32
760
+ # You might need to run `python Scripts/pywin32_postinstall.py -install`
761
+ # from your Python environment's directory if COM doesn't work initially.
762
+
763
+ # To get constants like ppSaveAsOpenXMLPresentation, run makepy:
764
+ # import win32com.client
765
+ # win32com.client.gencache.EnsureModule('{91493440-5A91-11CF-8700-00AA0060263B}', 0, 2, 12) # For Office/PPT 16.0
766
+ # Then you can use: from win32com.client import constants