mcpcn-office-powerpoint-mcp-server 2.1.1__py3-none-any.whl → 2.1.2__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.
tools/content_tools.py CHANGED
@@ -1,779 +1,967 @@
1
- """
2
- Content management tools for PowerPoint MCP Server.
3
- Handles slides, text, images, and content manipulation.
4
- """
5
- from typing import Dict, List, Optional, Any, Union
6
- from mcp.server.fastmcp import FastMCP
7
- import utils as ppt_utils
8
- import tempfile
9
- import base64
10
- import os
11
- import urllib.request
12
- import urllib.parse
13
-
14
- # Optional: requests for better HTTP handling
15
- try:
16
- import requests # type: ignore
17
- except Exception:
18
- requests = None
19
-
20
-
21
- def register_content_tools(app: FastMCP, presentations: Dict, get_current_presentation_id, validate_parameters, is_positive, is_non_negative, is_in_range, is_valid_rgb):
22
- """Register content management tools with the FastMCP app"""
23
-
24
- @app.tool()
25
- def add_slide(
26
- layout_index: int = 1,
27
- title: Optional[str] = None,
28
- background_type: Optional[str] = None, # "solid", "gradient", "professional_gradient"
29
- background_colors: Optional[List[List[int]]] = None, # For gradient: [[start_rgb], [end_rgb]]
30
- gradient_direction: str = "horizontal",
31
- color_scheme: str = "modern_blue",
32
- presentation_id: Optional[str] = None
33
- ) -> Dict:
34
- """Add a new slide to the presentation with optional background styling."""
35
- pres_id = presentation_id if presentation_id is not None else get_current_presentation_id()
36
-
37
- if pres_id is None or pres_id not in presentations:
38
- return {
39
- "error": "No presentation is currently loaded or the specified ID is invalid"
40
- }
41
-
42
- pres = presentations[pres_id]
43
-
44
- # Validate layout index
45
- if layout_index < 0 or layout_index >= len(pres.slide_layouts):
46
- return {
47
- "error": f"Invalid layout index: {layout_index}. Available layouts: 0-{len(pres.slide_layouts) - 1}"
48
- }
49
-
50
- try:
51
- # Add the slide
52
- slide, layout = ppt_utils.add_slide(pres, layout_index)
53
- slide_index = len(pres.slides) - 1
54
-
55
- # Set title if provided
56
- if title:
57
- ppt_utils.set_title(slide, title)
58
-
59
- # Apply background if specified
60
- if background_type == "gradient" and background_colors and len(background_colors) >= 2:
61
- ppt_utils.set_slide_gradient_background(
62
- slide, background_colors[0], background_colors[1], gradient_direction
63
- )
64
- elif background_type == "professional_gradient":
65
- ppt_utils.create_professional_gradient_background(
66
- slide, color_scheme, "subtle", gradient_direction
67
- )
68
-
69
- return {
70
- "message": f"Added slide {slide_index} with layout {layout_index}",
71
- "slide_index": slide_index,
72
- "layout_name": layout.name if hasattr(layout, 'name') else f"Layout {layout_index}"
73
- }
74
- except Exception as e:
75
- return {
76
- "error": f"Failed to add slide: {str(e)}"
77
- }
78
-
79
- @app.tool()
80
- def get_slide_info(slide_index: int, presentation_id: Optional[str] = None) -> Dict:
81
- """Get information about a specific slide."""
82
- pres_id = presentation_id if presentation_id is not None else get_current_presentation_id()
83
-
84
- if pres_id is None or pres_id not in presentations:
85
- return {
86
- "error": "No presentation is currently loaded or the specified ID is invalid"
87
- }
88
-
89
- pres = presentations[pres_id]
90
-
91
- if slide_index < 0 or slide_index >= len(pres.slides):
92
- return {
93
- "error": f"Invalid slide index: {slide_index}. Available slides: 0-{len(pres.slides) - 1}"
94
- }
95
-
96
- slide = pres.slides[slide_index]
97
-
98
- try:
99
- return ppt_utils.get_slide_info(slide, slide_index)
100
- except Exception as e:
101
- return {
102
- "error": f"Failed to get slide info: {str(e)}"
103
- }
104
-
105
- @app.tool()
106
- def extract_slide_text(slide_index: int, presentation_id: Optional[str] = None) -> Dict:
107
- """Extract all text content from a specific slide."""
108
- pres_id = presentation_id if presentation_id is not None else get_current_presentation_id()
109
-
110
- if pres_id is None or pres_id not in presentations:
111
- return {
112
- "error": "No presentation is currently loaded or the specified ID is invalid"
113
- }
114
-
115
- pres = presentations[pres_id]
116
-
117
- if slide_index < 0 or slide_index >= len(pres.slides):
118
- return {
119
- "error": f"Invalid slide index: {slide_index}. Available slides: 0-{len(pres.slides) - 1}"
120
- }
121
-
122
- slide = pres.slides[slide_index]
123
-
124
- try:
125
- result = ppt_utils.extract_slide_text_content(slide)
126
- result["slide_index"] = slide_index
127
- return result
128
- except Exception as e:
129
- return {
130
- "error": f"Failed to extract slide text: {str(e)}"
131
- }
132
-
133
- @app.tool()
134
- def extract_presentation_text(presentation_id: Optional[str] = None, include_slide_info: bool = True) -> Dict:
135
- """Extract all text content from all slides in the presentation."""
136
- pres_id = presentation_id if presentation_id is not None else get_current_presentation_id()
137
-
138
- if pres_id is None or pres_id not in presentations:
139
- return {
140
- "error": "No presentation is currently loaded or the specified ID is invalid"
141
- }
142
-
143
- pres = presentations[pres_id]
144
-
145
- try:
146
- slides_text = []
147
- total_text_shapes = 0
148
- slides_with_tables = 0
149
- slides_with_titles = 0
150
- all_presentation_text = []
151
-
152
- for slide_index, slide in enumerate(pres.slides):
153
- slide_text_result = ppt_utils.extract_slide_text_content(slide)
154
-
155
- if slide_text_result["success"]:
156
- slide_data = {
157
- "slide_index": slide_index,
158
- "text_content": slide_text_result["text_content"]
159
- }
160
-
161
- if include_slide_info:
162
- # Add basic slide info
163
- slide_data["layout_name"] = slide.slide_layout.name
164
- slide_data["total_text_shapes"] = slide_text_result["total_text_shapes"]
165
- slide_data["has_title"] = slide_text_result["has_title"]
166
- slide_data["has_tables"] = slide_text_result["has_tables"]
167
-
168
- slides_text.append(slide_data)
169
-
170
- # Accumulate statistics
171
- total_text_shapes += slide_text_result["total_text_shapes"]
172
- if slide_text_result["has_tables"]:
173
- slides_with_tables += 1
174
- if slide_text_result["has_title"]:
175
- slides_with_titles += 1
176
-
177
- # Collect all text for combined output
178
- if slide_text_result["text_content"]["all_text_combined"]:
179
- all_presentation_text.append(f"=== SLIDE {slide_index + 1} ===")
180
- all_presentation_text.append(slide_text_result["text_content"]["all_text_combined"])
181
- all_presentation_text.append("") # Empty line separator
182
- else:
183
- slides_text.append({
184
- "slide_index": slide_index,
185
- "error": slide_text_result.get("error", "Unknown error"),
186
- "text_content": None
187
- })
188
-
189
- return {
190
- "success": True,
191
- "presentation_id": pres_id,
192
- "total_slides": len(pres.slides),
193
- "slides_with_text": len([s for s in slides_text if s.get("text_content") is not None]),
194
- "total_text_shapes": total_text_shapes,
195
- "slides_with_titles": slides_with_titles,
196
- "slides_with_tables": slides_with_tables,
197
- "slides_text": slides_text,
198
- "all_presentation_text_combined": "\n".join(all_presentation_text)
199
- }
200
-
201
- except Exception as e:
202
- return {
203
- "error": f"Failed to extract presentation text: {str(e)}"
204
- }
205
-
206
- @app.tool()
207
- def populate_placeholder(
208
- slide_index: int,
209
- placeholder_idx: int,
210
- text: str,
211
- presentation_id: Optional[str] = None
212
- ) -> Dict:
213
- """Populate a placeholder with text."""
214
- pres_id = presentation_id if presentation_id is not None else get_current_presentation_id()
215
-
216
- if pres_id is None or pres_id not in presentations:
217
- return {
218
- "error": "No presentation is currently loaded or the specified ID is invalid"
219
- }
220
-
221
- pres = presentations[pres_id]
222
-
223
- if slide_index < 0 or slide_index >= len(pres.slides):
224
- return {
225
- "error": f"Invalid slide index: {slide_index}. Available slides: 0-{len(pres.slides) - 1}"
226
- }
227
-
228
- slide = pres.slides[slide_index]
229
-
230
- try:
231
- ppt_utils.populate_placeholder(slide, placeholder_idx, text)
232
- return {
233
- "message": f"Populated placeholder {placeholder_idx} on slide {slide_index}"
234
- }
235
- except Exception as e:
236
- return {
237
- "error": f"Failed to populate placeholder: {str(e)}"
238
- }
239
-
240
- @app.tool()
241
- def add_bullet_points(
242
- slide_index: int,
243
- placeholder_idx: int,
244
- bullet_points: List[str],
245
- presentation_id: Optional[str] = None
246
- ) -> Dict:
247
- """Add bullet points to a placeholder."""
248
- pres_id = presentation_id if presentation_id is not None else get_current_presentation_id()
249
-
250
- if pres_id is None or pres_id not in presentations:
251
- return {
252
- "error": "No presentation is currently loaded or the specified ID is invalid"
253
- }
254
-
255
- pres = presentations[pres_id]
256
-
257
- if slide_index < 0 or slide_index >= len(pres.slides):
258
- return {
259
- "error": f"Invalid slide index: {slide_index}. Available slides: 0-{len(pres.slides) - 1}"
260
- }
261
-
262
- slide = pres.slides[slide_index]
263
-
264
- try:
265
- placeholder = slide.placeholders[placeholder_idx]
266
- ppt_utils.add_bullet_points(placeholder, bullet_points)
267
- return {
268
- "message": f"Added {len(bullet_points)} bullet points to placeholder {placeholder_idx} on slide {slide_index}"
269
- }
270
- except Exception as e:
271
- return {
272
- "error": f"Failed to add bullet points: {str(e)}"
273
- }
274
-
275
- @app.tool()
276
- def manage_text(
277
- slide_index: int,
278
- operation: str, # "add", "format", "validate", "format_runs"
279
- left: float = 1.0,
280
- top: float = 1.0,
281
- width: float = 4.0,
282
- height: float = 2.0,
283
- text: str = "",
284
- shape_index: Optional[int] = None, # For format/validate operations
285
- text_runs: Optional[List[Dict]] = None, # For format_runs operation
286
- # Formatting options
287
- font_size: Optional[int] = None,
288
- font_name: Optional[str] = None,
289
- bold: Optional[bool] = None,
290
- italic: Optional[bool] = None,
291
- underline: Optional[bool] = None,
292
- color: Optional[List[int]] = None,
293
- bg_color: Optional[List[int]] = None,
294
- alignment: Optional[str] = None,
295
- vertical_alignment: Optional[str] = None,
296
- # Advanced options
297
- auto_fit: bool = True,
298
- validation_only: bool = False,
299
- min_font_size: int = 8,
300
- max_font_size: int = 72,
301
- presentation_id: Optional[str] = None
302
- ) -> Dict:
303
- """Unified text management tool for adding, formatting, validating text, and formatting multiple text runs."""
304
- pres_id = presentation_id if presentation_id is not None else get_current_presentation_id()
305
-
306
- if pres_id is None or pres_id not in presentations:
307
- return {
308
- "error": "No presentation is currently loaded or the specified ID is invalid"
309
- }
310
-
311
- pres = presentations[pres_id]
312
-
313
- if slide_index < 0 or slide_index >= len(pres.slides):
314
- return {
315
- "error": f"Invalid slide index: {slide_index}. Available slides: 0-{len(pres.slides) - 1}"
316
- }
317
-
318
- slide = pres.slides[slide_index]
319
-
320
- # Validate parameters
321
- validations = {}
322
- if font_size is not None:
323
- validations["font_size"] = (font_size, [(is_positive, "must be a positive integer")])
324
- if color is not None:
325
- validations["color"] = (color, [(is_valid_rgb, "must be a valid RGB list [R, G, B] with values 0-255")])
326
- if bg_color is not None:
327
- validations["bg_color"] = (bg_color, [(is_valid_rgb, "must be a valid RGB list [R, G, B] with values 0-255")])
328
-
329
- if validations:
330
- valid, error = validate_parameters(validations)
331
- if not valid:
332
- return {"error": error}
333
-
334
- try:
335
- if operation == "add":
336
- # Auto-detect URL even if source_type is not explicitly "url"
337
- if isinstance(image_source, str) and (image_source.startswith("http://") or image_source.startswith("https://")):
338
- source_type = "url"
339
- # Add new textbox
340
- shape = ppt_utils.add_textbox(
341
- slide, left, top, width, height, text,
342
- font_size=font_size,
343
- font_name=font_name,
344
- bold=bold,
345
- italic=italic,
346
- underline=underline,
347
- color=tuple(color) if color else None,
348
- bg_color=tuple(bg_color) if bg_color else None,
349
- alignment=alignment,
350
- vertical_alignment=vertical_alignment,
351
- auto_fit=auto_fit
352
- )
353
- return {
354
- "message": f"Added text box to slide {slide_index}",
355
- "shape_index": len(slide.shapes) - 1,
356
- "text": text
357
- }
358
-
359
- elif operation == "format":
360
- # Format existing text shape
361
- if shape_index is None or shape_index < 0 or shape_index >= len(slide.shapes):
362
- return {
363
- "error": f"Invalid shape index for formatting: {shape_index}. Available shapes: 0-{len(slide.shapes) - 1}"
364
- }
365
-
366
- shape = slide.shapes[shape_index]
367
- ppt_utils.format_text_advanced(
368
- shape,
369
- font_size=font_size,
370
- font_name=font_name,
371
- bold=bold,
372
- italic=italic,
373
- underline=underline,
374
- color=tuple(color) if color else None,
375
- bg_color=tuple(bg_color) if bg_color else None,
376
- alignment=alignment,
377
- vertical_alignment=vertical_alignment
378
- )
379
- return {
380
- "message": f"Formatted text shape {shape_index} on slide {slide_index}"
381
- }
382
-
383
- elif operation == "validate":
384
- # Validate text fit
385
- if shape_index is None or shape_index < 0 or shape_index >= len(slide.shapes):
386
- return {
387
- "error": f"Invalid shape index for validation: {shape_index}. Available shapes: 0-{len(slide.shapes) - 1}"
388
- }
389
-
390
- validation_result = ppt_utils.validate_text_fit(
391
- slide.shapes[shape_index],
392
- text_content=text or None,
393
- font_size=font_size or 12
394
- )
395
-
396
- if not validation_only and validation_result.get("needs_optimization"):
397
- # Apply automatic fixes
398
- fix_result = ppt_utils.validate_and_fix_slide(
399
- slide,
400
- auto_fix=True,
401
- min_font_size=min_font_size,
402
- max_font_size=max_font_size
403
- )
404
- validation_result.update(fix_result)
405
-
406
- return validation_result
407
-
408
- elif operation == "format_runs":
409
- # Format multiple text runs with different formatting
410
- if shape_index is None or shape_index < 0 or shape_index >= len(slide.shapes):
411
- return {
412
- "error": f"Invalid shape index for format_runs: {shape_index}. Available shapes: 0-{len(slide.shapes) - 1}"
413
- }
414
-
415
- if not text_runs:
416
- return {"error": "text_runs parameter is required for format_runs operation"}
417
-
418
- shape = slide.shapes[shape_index]
419
-
420
- # Check if shape has text
421
- if not hasattr(shape, 'text_frame') or not shape.text_frame:
422
- return {"error": "Shape does not contain text"}
423
-
424
- # Clear existing text and rebuild with formatted runs
425
- text_frame = shape.text_frame
426
- text_frame.clear()
427
-
428
- formatted_runs = []
429
-
430
- for run_data in text_runs:
431
- if 'text' not in run_data:
432
- continue
433
-
434
- # Add paragraph if needed
435
- if not text_frame.paragraphs:
436
- paragraph = text_frame.paragraphs[0]
437
- else:
438
- paragraph = text_frame.add_paragraph()
439
-
440
- # Add run with text
441
- run = paragraph.add_run()
442
- run.text = run_data['text']
443
-
444
- # Apply formatting using pptx imports
445
- from pptx.util import Pt
446
- from pptx.dml.color import RGBColor
447
-
448
- if 'bold' in run_data:
449
- run.font.bold = run_data['bold']
450
- if 'italic' in run_data:
451
- run.font.italic = run_data['italic']
452
- if 'underline' in run_data:
453
- run.font.underline = run_data['underline']
454
- if 'font_size' in run_data:
455
- run.font.size = Pt(run_data['font_size'])
456
- if 'font_name' in run_data:
457
- run.font.name = run_data['font_name']
458
- if 'color' in run_data and is_valid_rgb(run_data['color']):
459
- run.font.color.rgb = RGBColor(*run_data['color'])
460
- if 'hyperlink' in run_data:
461
- run.hyperlink.address = run_data['hyperlink']
462
-
463
- formatted_runs.append({
464
- "text": run_data['text'],
465
- "formatting_applied": {k: v for k, v in run_data.items() if k != 'text'}
466
- })
467
-
468
- return {
469
- "message": f"Applied formatting to {len(formatted_runs)} text runs on shape {shape_index}",
470
- "slide_index": slide_index,
471
- "shape_index": shape_index,
472
- "formatted_runs": formatted_runs
473
- }
474
-
475
- else:
476
- return {
477
- "error": f"Invalid operation: {operation}. Must be 'add', 'format', 'validate', or 'format_runs'"
478
- }
479
-
480
- except Exception as e:
481
- return {
482
- "error": f"Failed to {operation} text: {str(e)}"
483
- }
484
-
485
- @app.tool()
486
- def manage_image(
487
- slide_index: int,
488
- operation: str, # "add", "enhance"
489
- image_source: str, # file path or base64 string
490
- source_type: str = "file", # "file" or "base64"
491
- left: float = 1.0,
492
- top: float = 1.0,
493
- width: Optional[float] = None,
494
- height: Optional[float] = None,
495
- # Enhancement options
496
- enhancement_style: Optional[str] = None, # "presentation", "custom"
497
- brightness: float = 1.0,
498
- contrast: float = 1.0,
499
- saturation: float = 1.0,
500
- sharpness: float = 1.0,
501
- blur_radius: float = 0,
502
- filter_type: Optional[str] = None,
503
- output_path: Optional[str] = None,
504
- presentation_id: Optional[str] = None
505
- ) -> Dict:
506
- """
507
- 统一的图片处理工具(添加/增强)。
508
-
509
- 功能
510
- - operation="add":将图片插入到指定幻灯片位置,支持本地文件或 Base64 图片源或图片地址。
511
- - operation="enhance":对已有图片文件进行画质增强与风格化处理,输出增强后的图片路径。
512
-
513
- 参数
514
- - slide_index: int 目标幻灯片索引(从 0 开始)。
515
- - operation: str "add" "enhance"。
516
- - image_source: str —
517
- * 当 source_type="file":本地图片文件路径。
518
- * source_type="base64":图片的 Base64 字符串。
519
- * source_type="url":图片的 http/https 地址。
520
- - source_type: str — "file"、"base64" 或 "url"。
521
- * add 支持 "file"、"base64"、"url"(仅允许 http/https)。
522
- * enhance 仅支持 "file"(不接受 base64 或 url)。
523
- - left, top: float — 插入位置(英寸)。
524
- - width, height: Optional[float] 插入尺寸(英寸)。可只提供一项以按比例缩放;都不提供则按图片原始尺寸。
525
- - enhancement_style: Optional[str] "presentation" "custom"。当 operation="add" 且需要自动增强时可用;"presentation" 走预设的专业增强流程。
526
- - brightness, contrast, saturation, sharpness: float — 亮度/对比度/饱和度/锐度(默认 1.0,>1 增强,<1 减弱)。
527
- - blur_radius: float — 模糊半径(默认 0)。
528
- - filter_type: Optional[str] — 过滤器类型(如 "DETAIL"、"SMOOTH" 等,取决于 Pillow 支持)。
529
- - output_path: Optional[str] 增强后图片的输出路径(不传则生成临时文件)。
530
- - presentation_id: Optional[str] — 指定演示文稿 ID;不传则使用当前打开的演示文稿。
531
- 注:在某些部署环境(如你当前环境)中,必须显式传入 presentation_id 才会对目标文档生效。
532
-
533
- 返回
534
- - operation="add":
535
- * message: str
536
- * shape_index: int 新增形状索引
537
- * image_path: str(当 source_type="file" 时返回)
538
- - operation="enhance":
539
- * message: str
540
- * enhanced_path: str — 增强后图片文件路径
541
- - 失败时返回 {"error": str}
542
-
543
- 注意事项
544
- - 现在支持通过 URL 插入图片(仅 operation="add"):source_type="url",仅允许 http/https 协议。
545
- 会在内部下载到临时文件后插入并自动清理。若返回的 Content-Type 非 image/* 将返回错误。
546
- enhance 仍然仅支持本地文件路径。
547
- - 在某些部署环境(如你当前环境)中,需显式提供 presentation_id 参数,否则可能插入到非预期文档或不生效。
548
- - operation="enhance" 不接受 Base64,必须提供可访问的本地文件路径。
549
- - 插入 Base64 图片时,内部会写入临时文件后再插入,操作完成后临时文件会被清理。
550
- - slide_index 必须在当前演示文稿的有效范围内,否则将返回错误。
551
-
552
- 示例
553
- - 通过 URL 插入图片(仅 add):
554
- manage_image(slide_index=0, operation="add",
555
- image_source="https://example.com/logo.png", source_type="url",
556
- left=1.0, top=1.0, width=3.0, presentation_id="YOUR_PRESENTATION_ID")
557
- - 插入本地图片:
558
- manage_image(slide_index=0, operation="add",
559
- image_source="D:/images/logo.png", source_type="file",
560
- left=1.0, top=1.0, width=3.0, presentation_id="YOUR_PRESENTATION_ID")
561
-
562
- - 插入 Base64 图片:
563
- manage_image(slide_index=1, operation="add",
564
- image_source="<BASE64字符串>", source_type="base64",
565
- left=2.0, top=1.5, width=4.0, height=2.5, presentation_id="YOUR_PRESENTATION_ID")
566
-
567
- - 插入并应用专业增强(演示风格):
568
- manage_image(slide_index=2, operation="add",
569
- image_source="assets/photo.jpg", source_type="file",
570
- enhancement_style="presentation", left=1.0, top=2.0, presentation_id="YOUR_PRESENTATION_ID")
571
-
572
- - 增强已有图片文件(自定义参数):
573
- manage_image(slide_index=0, operation="enhance",
574
- image_source="assets/photo.jpg", source_type="file",
575
- brightness=1.2, contrast=1.1, saturation=1.3,
576
- sharpness=1.1, blur_radius=0, filter_type=None,
577
- output_path="assets/photo_enhanced.jpg", presentation_id="YOUR_PRESENTATION_ID")
578
- """
579
- pres_id = presentation_id if presentation_id is not None else get_current_presentation_id()
580
-
581
- if pres_id is None or pres_id not in presentations:
582
- return {
583
- "error": "No presentation is currently loaded or the specified ID is invalid"
584
- }
585
-
586
- pres = presentations[pres_id]
587
-
588
- if slide_index < 0 or slide_index >= len(pres.slides):
589
- return {
590
- "error": f"Invalid slide index: {slide_index}. Available slides: 0-{len(pres.slides) - 1}"
591
- }
592
-
593
- slide = pres.slides[slide_index]
594
-
595
- try:
596
- if operation == "add":
597
- if source_type == "base64":
598
- # Handle base64 image
599
- try:
600
- image_data = base64.b64decode(image_source)
601
- with tempfile.NamedTemporaryFile(delete=False, suffix='.png') as temp_file:
602
- temp_file.write(image_data)
603
- temp_path = temp_file.name
604
-
605
- # Add image from temporary file
606
- shape = ppt_utils.add_image(slide, temp_path, left, top, width, height)
607
-
608
- # Clean up temporary file
609
- os.unlink(temp_path)
610
-
611
- return {
612
- "message": f"Added image from base64 to slide {slide_index}",
613
- "shape_index": len(slide.shapes) - 1
614
- }
615
- except Exception as e:
616
- return {
617
- "error": f"Failed to process base64 image: {str(e)}"
618
- }
619
- elif source_type == "url":
620
- # Handle image URL (http/https)
621
- try:
622
- # Normalize and percent-encode URL path/query to support spaces and non-ASCII characters
623
- parsed = urllib.parse.urlsplit(image_source)
624
- if parsed.scheme not in ("http", "https"):
625
- return {"error": f"Unsupported URL scheme: {parsed.scheme}. Only http/https allowed."}
626
- encoded_path = urllib.parse.quote(parsed.path or "", safe="/%")
627
- # Re-encode query preserving keys and multiple values
628
- qsl = urllib.parse.parse_qsl(parsed.query or "", keep_blank_values=True)
629
- encoded_query = urllib.parse.urlencode(qsl, doseq=True)
630
- encoded_url = urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, encoded_path, encoded_query, parsed.fragment))
631
-
632
- # Download helper using requests if available, else urllib
633
- content_type = None
634
- temp_path = None
635
- image_exts = (".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tif", ".tiff")
636
-
637
- if requests is not None:
638
- with requests.get(encoded_url, stream=True) as resp:
639
- if resp.status_code != 200:
640
- return {"error": f"Failed to download image. HTTP {resp.status_code}"}
641
- content_type = resp.headers.get("Content-Type", "") or ""
642
-
643
- # Determine suffix and allow fallback by URL extension if Content-Type missing or not image/*
644
- suffix = ".png"
645
- is_image = content_type.startswith("image/")
646
- try:
647
- main_type = (content_type.split(";")[0].strip() if content_type else "")
648
- if "/" in main_type:
649
- ext = main_type.split("/")[1].lower()
650
- if ext in ("jpeg", "pjpeg"):
651
- suffix = ".jpg"
652
- elif ext in ("png", "gif", "bmp", "webp", "tiff"):
653
- suffix = f".{ext}"
654
- except Exception:
655
- pass
656
- if suffix == ".png":
657
- path_ext = os.path.splitext(parsed.path or "")[1].lower()
658
- if path_ext in image_exts:
659
- suffix = path_ext
660
-
661
- if not is_image and suffix not in image_exts:
662
- return {"error": f"URL content is not an image (Content-Type: {content_type or 'unknown'})"}
663
-
664
- with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
665
- temp_path = temp_file.name
666
- for chunk in resp.iter_content(chunk_size=8192):
667
- if not chunk:
668
- continue
669
- temp_file.write(chunk)
670
- else:
671
- req = urllib.request.Request(encoded_url, headers={"User-Agent": "Mozilla/5.0"})
672
- with urllib.request.urlopen(req) as resp:
673
- content_type = resp.headers.get("Content-Type", "") or ""
674
-
675
- suffix = ".png"
676
- is_image = content_type.startswith("image/")
677
- try:
678
- main_type = (content_type.split(";")[0].strip() if content_type else "")
679
- if "/" in main_type:
680
- ext = main_type.split("/")[1].lower()
681
- if ext in ("jpeg", "pjpeg"):
682
- suffix = ".jpg"
683
- elif ext in ("png", "gif", "bmp", "webp", "tiff"):
684
- suffix = f".{ext}"
685
- except Exception:
686
- pass
687
- if suffix == ".png":
688
- path_ext = os.path.splitext(parsed.path or "")[1].lower()
689
- if path_ext in image_exts:
690
- suffix = path_ext
691
-
692
- if not is_image and suffix not in image_exts:
693
- return {"error": f"URL content is not an image (Content-Type: {content_type or 'unknown'})"}
694
-
695
- with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
696
- temp_path = temp_file.name
697
- while True:
698
- chunk = resp.read(8192)
699
- if not chunk:
700
- break
701
- temp_file.write(chunk)
702
-
703
- # Add image from temporary file
704
- shape = ppt_utils.add_image(slide, temp_path, left, top, width, height)
705
-
706
- # Clean up temporary file
707
- if temp_path and os.path.exists(temp_path):
708
- os.unlink(temp_path)
709
-
710
- return {
711
- "message": f"Added image from URL to slide {slide_index}",
712
- "shape_index": len(slide.shapes) - 1
713
- }
714
- except Exception as e:
715
- # Best-effort cleanup if temp_path was created
716
- try:
717
- if temp_path and os.path.exists(temp_path):
718
- os.unlink(temp_path)
719
- except Exception:
720
- pass
721
- return {"error": f"Failed to process image URL: {str(e)}"}
722
- else:
723
- # Handle file path
724
- if not os.path.exists(image_source):
725
- return {
726
- "error": f"Image file not found: {image_source}"
727
- }
728
-
729
- shape = ppt_utils.add_image(slide, image_source, left, top, width, height)
730
- return {
731
- "message": f"Added image to slide {slide_index}",
732
- "shape_index": len(slide.shapes) - 1,
733
- "image_path": image_source
734
- }
735
-
736
- elif operation == "enhance":
737
- # Enhance existing image file
738
- if source_type == "base64":
739
- return {
740
- "error": "Enhancement operation requires file path, not base64 data"
741
- }
742
-
743
- if not os.path.exists(image_source):
744
- return {
745
- "error": f"Image file not found: {image_source}"
746
- }
747
-
748
- if enhancement_style == "presentation":
749
- # Apply professional enhancement
750
- enhanced_path = ppt_utils.apply_professional_image_enhancement(
751
- image_source, style="presentation", output_path=output_path
752
- )
753
- else:
754
- # Apply custom enhancement
755
- enhanced_path = ppt_utils.enhance_image_with_pillow(
756
- image_source,
757
- brightness=brightness,
758
- contrast=contrast,
759
- saturation=saturation,
760
- sharpness=sharpness,
761
- blur_radius=blur_radius,
762
- filter_type=filter_type,
763
- output_path=output_path
764
- )
765
-
766
- return {
767
- "message": f"Enhanced image: {image_source}",
768
- "enhanced_path": enhanced_path
769
- }
770
-
771
- else:
772
- return {
773
- "error": f"Invalid operation: {operation}. Must be 'add' or 'enhance'"
774
- }
775
-
776
- except Exception as e:
777
- return {
778
- "error": f"Failed to {operation} image: {str(e)}"
1
+ """
2
+ Content management tools for PowerPoint MCP Server.
3
+ Handles slides, text, images, and content manipulation.
4
+ """
5
+ from typing import Dict, List, Optional, Any, Union
6
+ from mcp.server.fastmcp import FastMCP
7
+ import utils as ppt_utils
8
+ import tempfile
9
+ import base64
10
+ import os
11
+ import urllib.request
12
+ import urllib.parse
13
+
14
+ # Optional: requests for better HTTP handling
15
+ try:
16
+ import requests # type: ignore
17
+ except Exception:
18
+ requests = None
19
+
20
+
21
+ def register_content_tools(app: FastMCP, presentations: Dict, get_current_presentation_id, validate_parameters, is_positive, is_non_negative, is_in_range, is_valid_rgb):
22
+ """Register content management tools with the FastMCP app"""
23
+
24
+ @app.tool()
25
+ def add_slide(
26
+ layout_index: int = 1,
27
+ title: Optional[str] = None,
28
+ background_type: Optional[str] = None, # "solid", "gradient", "professional_gradient"
29
+ background_colors: Optional[List[List[int]]] = None, # For gradient: [[start_rgb], [end_rgb]]
30
+ gradient_direction: str = "horizontal",
31
+ color_scheme: str = "modern_blue",
32
+ presentation_id: Optional[str] = None
33
+ ) -> Dict:
34
+ """Add a new slide to the presentation with optional background styling."""
35
+ pres_id = presentation_id if presentation_id is not None else get_current_presentation_id()
36
+
37
+ if pres_id is None or pres_id not in presentations:
38
+ return {
39
+ "error": "No presentation is currently loaded or the specified ID is invalid"
40
+ }
41
+
42
+ pres = presentations[pres_id]
43
+
44
+ # Validate layout index
45
+ if layout_index < 0 or layout_index >= len(pres.slide_layouts):
46
+ return {
47
+ "error": f"Invalid layout index: {layout_index}. Available layouts: 0-{len(pres.slide_layouts) - 1}"
48
+ }
49
+
50
+ try:
51
+ # Add the slide
52
+ slide, layout = ppt_utils.add_slide(pres, layout_index)
53
+ slide_index = len(pres.slides) - 1
54
+
55
+ # Set title if provided
56
+ if title:
57
+ ppt_utils.set_title(slide, title)
58
+
59
+ # Apply background if specified
60
+ if background_type == "gradient" and background_colors and len(background_colors) >= 2:
61
+ ppt_utils.set_slide_gradient_background(
62
+ slide, background_colors[0], background_colors[1], gradient_direction
63
+ )
64
+ elif background_type == "professional_gradient":
65
+ ppt_utils.create_professional_gradient_background(
66
+ slide, color_scheme, "subtle", gradient_direction
67
+ )
68
+
69
+ return {
70
+ "message": f"Added slide {slide_index} with layout {layout_index}",
71
+ "slide_index": slide_index,
72
+ "layout_name": layout.name if hasattr(layout, 'name') else f"Layout {layout_index}"
73
+ }
74
+ except Exception as e:
75
+ return {
76
+ "error": f"Failed to add slide: {str(e)}"
77
+ }
78
+
79
+ @app.tool()
80
+ def get_slide_info(slide_index: int, presentation_id: Optional[str] = None) -> Dict:
81
+ """Get information about a specific slide."""
82
+ pres_id = presentation_id if presentation_id is not None else get_current_presentation_id()
83
+
84
+ if pres_id is None or pres_id not in presentations:
85
+ return {
86
+ "error": "No presentation is currently loaded or the specified ID is invalid"
87
+ }
88
+
89
+ pres = presentations[pres_id]
90
+
91
+ if slide_index < 0 or slide_index >= len(pres.slides):
92
+ return {
93
+ "error": f"Invalid slide index: {slide_index}. Available slides: 0-{len(pres.slides) - 1}"
94
+ }
95
+
96
+ slide = pres.slides[slide_index]
97
+
98
+ try:
99
+ return ppt_utils.get_slide_info(slide, slide_index)
100
+ except Exception as e:
101
+ return {
102
+ "error": f"Failed to get slide info: {str(e)}"
103
+ }
104
+
105
+ @app.tool()
106
+ def move_slide(
107
+ from_page: int,
108
+ to_page: Optional[int] = None,
109
+ to_position: Optional[str] = None,
110
+ file_path: Optional[str] = None,
111
+ save_path: Optional[str] = None,
112
+ presentation_id: Optional[str] = None
113
+ ) -> Dict:
114
+ """
115
+ 移动幻灯片到新位置。
116
+
117
+ 将指定页码的幻灯片移动到目标位置。
118
+
119
+ 【重要】页码从 1 开始计数,与用户日常习惯一致:
120
+ - 第1页 = from_page 填 1
121
+ - 第2页 = from_page 填 2
122
+ - 以此类推...
123
+
124
+ 目标位置有两种指定方式(二选一):
125
+ 1. to_page: 具体页码数字(如:移动到第5页,填 5)
126
+ 2. to_position: 相对位置关键字
127
+ - "first" 或 "首页" — 移动到第一页
128
+ - "last" "末页" 或 "最后" — 移动到最后一页
129
+
130
+ 参数:
131
+ - from_page: int — 要移动的幻灯片页码(从1开始)。例如:用户说"第2页",则传入 2
132
+ - to_page: Optional[int] — 目标位置页码(从1开始)。例如:用户说"移动到第5页",则传入 5
133
+ - to_position: Optional[str] — 目标位置关键字。可选值:"first"/"首页" 或 "last"/"末页"/"最后"
134
+ - file_path: Optional[str] PPT 文件的完整路径。传入后会自动打开、操作并保存
135
+ - save_path: Optional[str] 保存路径。不传则保存到原文件
136
+ - presentation_id: Optional[str] 已打开的演示文稿 ID
137
+
138
+ 注意:to_page to_position 二选一,如果都传入,优先使用 to_position
139
+
140
+ 返回:
141
+ - success: bool — 操作是否成功
142
+ - message: str — 操作结果消息
143
+ - from_page: int — 原页码
144
+ - to_page: int — 新页码
145
+ - total_slides: int — 幻灯片总数
146
+ - saved_to: str — 文件保存路径(仅 file_path 模式)
147
+
148
+ 示例:
149
+ - 将第2页移动到第5页:
150
+ move_slide(from_page=2, to_page=5, file_path="D:/文档/演示文稿.pptx")
151
+ - 将第2页移动到最后一页:
152
+ move_slide(from_page=2, to_position="last", file_path="D:/文档/演示文稿.pptx")
153
+ - 将第5页移动到首页:
154
+ move_slide(from_page=5, to_position="first", file_path="D:/文档/演示文稿.pptx")
155
+ """
156
+
157
+ def resolve_target_page(slide_count: int) -> int:
158
+ """Resolve to_position keyword or to_page to actual page number"""
159
+ if to_position is not None:
160
+ pos_lower = to_position.lower().strip()
161
+ if pos_lower in ("first", "首页", "第一页", "开头"):
162
+ return 1
163
+ elif pos_lower in ("last", "末页", "最后", "最后一页", "结尾", "末尾"):
164
+ return slide_count
165
+ else:
166
+ raise ValueError(f"无效的 to_position: {to_position}。可选值: 'first'/'首页' 或 'last'/'末页'/'最后'")
167
+ elif to_page is not None:
168
+ return to_page
169
+ else:
170
+ raise ValueError("必须提供 to_page(目标页码)或 to_position(目标位置如 'first'/'last')")
171
+
172
+ # Mode 1: Direct file operation
173
+ if file_path is not None:
174
+ if not os.path.exists(file_path):
175
+ return {
176
+ "error": f"文件不存在: {file_path}"
177
+ }
178
+
179
+ try:
180
+ # Open the presentation
181
+ pres = ppt_utils.open_presentation(file_path)
182
+ slide_count = len(pres.slides)
183
+
184
+ # Validate from_page
185
+ if from_page < 1 or from_page > slide_count:
186
+ return {
187
+ "error": f"无效的 from_page: {from_page}。有效范围: 1-{slide_count}(共 {slide_count} 张幻灯片)"
188
+ }
189
+
190
+ # Resolve target page
191
+ try:
192
+ actual_to_page = resolve_target_page(slide_count)
193
+ except ValueError as e:
194
+ return {"error": str(e)}
195
+
196
+ # Validate resolved to_page
197
+ if actual_to_page < 1 or actual_to_page > slide_count:
198
+ return {
199
+ "error": f"无效的目标页码: {actual_to_page}。有效范围: 1-{slide_count}(共 {slide_count} 张幻灯片)"
200
+ }
201
+
202
+ # Convert to 0-based indices
203
+ from_index = from_page - 1
204
+ to_index = actual_to_page - 1
205
+
206
+ # Move the slide
207
+ result = ppt_utils.move_slide(pres, from_index, to_index)
208
+
209
+ # Save the presentation
210
+ final_save_path = save_path if save_path else file_path
211
+ ppt_utils.save_presentation(pres, final_save_path)
212
+
213
+ # Build descriptive message
214
+ position_desc = f"第 {actual_to_page} 页"
215
+ if to_position:
216
+ pos_lower = to_position.lower().strip()
217
+ if pos_lower in ("first", "首页", "第一页", "开头"):
218
+ position_desc = "首页"
219
+ elif pos_lower in ("last", "末页", "最后", "最后一页", "结尾", "末尾"):
220
+ position_desc = f"末页(第 {actual_to_page} 页)"
221
+
222
+ return {
223
+ "success": True,
224
+ "message": f"成功将第 {from_page} 页移动到{position_desc}的位置,并已保存到 {final_save_path}",
225
+ "from_page": from_page,
226
+ "to_page": actual_to_page,
227
+ "total_slides": slide_count,
228
+ "saved_to": final_save_path
229
+ }
230
+
231
+ except Exception as e:
232
+ return {
233
+ "error": f"操作失败: {str(e)}"
234
+ }
235
+
236
+ # Mode 2: Use existing presentation_id
237
+ pres_id = presentation_id if presentation_id is not None else get_current_presentation_id()
238
+
239
+ if pres_id is None or pres_id not in presentations:
240
+ return {
241
+ "error": "请提供 file_path(文件路径)或有效的 presentation_id。如果没有打开的演示文稿,请使用 file_path 参数直接指定文件路径。"
242
+ }
243
+
244
+ pres = presentations[pres_id]
245
+ slide_count = len(pres.slides)
246
+
247
+ # Validate from_page
248
+ if from_page < 1 or from_page > slide_count:
249
+ return {
250
+ "error": f"无效的 from_page: {from_page}。有效范围: 1-{slide_count}(共 {slide_count} 张幻灯片)"
251
+ }
252
+
253
+ # Resolve target page
254
+ try:
255
+ actual_to_page = resolve_target_page(slide_count)
256
+ except ValueError as e:
257
+ return {"error": str(e)}
258
+
259
+ # Validate resolved to_page
260
+ if actual_to_page < 1 or actual_to_page > slide_count:
261
+ return {
262
+ "error": f"无效的目标页码: {actual_to_page}。有效范围: 1-{slide_count}(共 {slide_count} 张幻灯片)"
263
+ }
264
+
265
+ # Convert to 0-based indices
266
+ from_index = from_page - 1
267
+ to_index = actual_to_page - 1
268
+
269
+ try:
270
+ result = ppt_utils.move_slide(pres, from_index, to_index)
271
+
272
+ # Build descriptive message
273
+ position_desc = f"第 {actual_to_page} 页"
274
+ if to_position:
275
+ pos_lower = to_position.lower().strip()
276
+ if pos_lower in ("first", "首页", "第一页", "开头"):
277
+ position_desc = "首页"
278
+ elif pos_lower in ("last", "末页", "最后", "最后一页", "结尾", "末尾"):
279
+ position_desc = f"末页(第 {actual_to_page} 页)"
280
+
281
+ return {
282
+ "success": True,
283
+ "message": f"成功将第 {from_page} 页移动到{position_desc}的位置。请记得使用 save_presentation 保存文件。",
284
+ "from_page": from_page,
285
+ "to_page": actual_to_page,
286
+ "total_slides": slide_count
287
+ }
288
+ except Exception as e:
289
+ return {
290
+ "error": f"移动幻灯片失败: {str(e)}"
291
+ }
292
+
293
+ @app.tool()
294
+ def extract_slide_text(slide_index: int, presentation_id: Optional[str] = None) -> Dict:
295
+ """Extract all text content from a specific slide."""
296
+ pres_id = presentation_id if presentation_id is not None else get_current_presentation_id()
297
+
298
+ if pres_id is None or pres_id not in presentations:
299
+ return {
300
+ "error": "No presentation is currently loaded or the specified ID is invalid"
301
+ }
302
+
303
+ pres = presentations[pres_id]
304
+
305
+ if slide_index < 0 or slide_index >= len(pres.slides):
306
+ return {
307
+ "error": f"Invalid slide index: {slide_index}. Available slides: 0-{len(pres.slides) - 1}"
308
+ }
309
+
310
+ slide = pres.slides[slide_index]
311
+
312
+ try:
313
+ result = ppt_utils.extract_slide_text_content(slide)
314
+ result["slide_index"] = slide_index
315
+ return result
316
+ except Exception as e:
317
+ return {
318
+ "error": f"Failed to extract slide text: {str(e)}"
319
+ }
320
+
321
+ @app.tool()
322
+ def extract_presentation_text(presentation_id: Optional[str] = None, include_slide_info: bool = True) -> Dict:
323
+ """Extract all text content from all slides in the presentation."""
324
+ pres_id = presentation_id if presentation_id is not None else get_current_presentation_id()
325
+
326
+ if pres_id is None or pres_id not in presentations:
327
+ return {
328
+ "error": "No presentation is currently loaded or the specified ID is invalid"
329
+ }
330
+
331
+ pres = presentations[pres_id]
332
+
333
+ try:
334
+ slides_text = []
335
+ total_text_shapes = 0
336
+ slides_with_tables = 0
337
+ slides_with_titles = 0
338
+ all_presentation_text = []
339
+
340
+ for slide_index, slide in enumerate(pres.slides):
341
+ slide_text_result = ppt_utils.extract_slide_text_content(slide)
342
+
343
+ if slide_text_result["success"]:
344
+ slide_data = {
345
+ "slide_index": slide_index,
346
+ "text_content": slide_text_result["text_content"]
347
+ }
348
+
349
+ if include_slide_info:
350
+ # Add basic slide info
351
+ slide_data["layout_name"] = slide.slide_layout.name
352
+ slide_data["total_text_shapes"] = slide_text_result["total_text_shapes"]
353
+ slide_data["has_title"] = slide_text_result["has_title"]
354
+ slide_data["has_tables"] = slide_text_result["has_tables"]
355
+
356
+ slides_text.append(slide_data)
357
+
358
+ # Accumulate statistics
359
+ total_text_shapes += slide_text_result["total_text_shapes"]
360
+ if slide_text_result["has_tables"]:
361
+ slides_with_tables += 1
362
+ if slide_text_result["has_title"]:
363
+ slides_with_titles += 1
364
+
365
+ # Collect all text for combined output
366
+ if slide_text_result["text_content"]["all_text_combined"]:
367
+ all_presentation_text.append(f"=== SLIDE {slide_index + 1} ===")
368
+ all_presentation_text.append(slide_text_result["text_content"]["all_text_combined"])
369
+ all_presentation_text.append("") # Empty line separator
370
+ else:
371
+ slides_text.append({
372
+ "slide_index": slide_index,
373
+ "error": slide_text_result.get("error", "Unknown error"),
374
+ "text_content": None
375
+ })
376
+
377
+ return {
378
+ "success": True,
379
+ "presentation_id": pres_id,
380
+ "total_slides": len(pres.slides),
381
+ "slides_with_text": len([s for s in slides_text if s.get("text_content") is not None]),
382
+ "total_text_shapes": total_text_shapes,
383
+ "slides_with_titles": slides_with_titles,
384
+ "slides_with_tables": slides_with_tables,
385
+ "slides_text": slides_text,
386
+ "all_presentation_text_combined": "\n".join(all_presentation_text)
387
+ }
388
+
389
+ except Exception as e:
390
+ return {
391
+ "error": f"Failed to extract presentation text: {str(e)}"
392
+ }
393
+
394
+ @app.tool()
395
+ def populate_placeholder(
396
+ slide_index: int,
397
+ placeholder_idx: int,
398
+ text: str,
399
+ presentation_id: Optional[str] = None
400
+ ) -> Dict:
401
+ """Populate a placeholder with text."""
402
+ pres_id = presentation_id if presentation_id is not None else get_current_presentation_id()
403
+
404
+ if pres_id is None or pres_id not in presentations:
405
+ return {
406
+ "error": "No presentation is currently loaded or the specified ID is invalid"
407
+ }
408
+
409
+ pres = presentations[pres_id]
410
+
411
+ if slide_index < 0 or slide_index >= len(pres.slides):
412
+ return {
413
+ "error": f"Invalid slide index: {slide_index}. Available slides: 0-{len(pres.slides) - 1}"
414
+ }
415
+
416
+ slide = pres.slides[slide_index]
417
+
418
+ try:
419
+ ppt_utils.populate_placeholder(slide, placeholder_idx, text)
420
+ return {
421
+ "message": f"Populated placeholder {placeholder_idx} on slide {slide_index}"
422
+ }
423
+ except Exception as e:
424
+ return {
425
+ "error": f"Failed to populate placeholder: {str(e)}"
426
+ }
427
+
428
+ @app.tool()
429
+ def add_bullet_points(
430
+ slide_index: int,
431
+ placeholder_idx: int,
432
+ bullet_points: List[str],
433
+ presentation_id: Optional[str] = None
434
+ ) -> Dict:
435
+ """Add bullet points to a placeholder."""
436
+ pres_id = presentation_id if presentation_id is not None else get_current_presentation_id()
437
+
438
+ if pres_id is None or pres_id not in presentations:
439
+ return {
440
+ "error": "No presentation is currently loaded or the specified ID is invalid"
441
+ }
442
+
443
+ pres = presentations[pres_id]
444
+
445
+ if slide_index < 0 or slide_index >= len(pres.slides):
446
+ return {
447
+ "error": f"Invalid slide index: {slide_index}. Available slides: 0-{len(pres.slides) - 1}"
448
+ }
449
+
450
+ slide = pres.slides[slide_index]
451
+
452
+ try:
453
+ placeholder = slide.placeholders[placeholder_idx]
454
+ ppt_utils.add_bullet_points(placeholder, bullet_points)
455
+ return {
456
+ "message": f"Added {len(bullet_points)} bullet points to placeholder {placeholder_idx} on slide {slide_index}"
457
+ }
458
+ except Exception as e:
459
+ return {
460
+ "error": f"Failed to add bullet points: {str(e)}"
461
+ }
462
+
463
+ @app.tool()
464
+ def manage_text(
465
+ slide_index: int,
466
+ operation: str, # "add", "format", "validate", "format_runs"
467
+ left: float = 1.0,
468
+ top: float = 1.0,
469
+ width: float = 4.0,
470
+ height: float = 2.0,
471
+ text: str = "",
472
+ shape_index: Optional[int] = None, # For format/validate operations
473
+ text_runs: Optional[List[Dict]] = None, # For format_runs operation
474
+ # Formatting options
475
+ font_size: Optional[int] = None,
476
+ font_name: Optional[str] = None,
477
+ bold: Optional[bool] = None,
478
+ italic: Optional[bool] = None,
479
+ underline: Optional[bool] = None,
480
+ color: Optional[List[int]] = None,
481
+ bg_color: Optional[List[int]] = None,
482
+ alignment: Optional[str] = None,
483
+ vertical_alignment: Optional[str] = None,
484
+ # Advanced options
485
+ auto_fit: bool = True,
486
+ validation_only: bool = False,
487
+ min_font_size: int = 8,
488
+ max_font_size: int = 72,
489
+ presentation_id: Optional[str] = None
490
+ ) -> Dict:
491
+ """Unified text management tool for adding, formatting, validating text, and formatting multiple text runs."""
492
+ pres_id = presentation_id if presentation_id is not None else get_current_presentation_id()
493
+
494
+ if pres_id is None or pres_id not in presentations:
495
+ return {
496
+ "error": "No presentation is currently loaded or the specified ID is invalid"
497
+ }
498
+
499
+ pres = presentations[pres_id]
500
+
501
+ if slide_index < 0 or slide_index >= len(pres.slides):
502
+ return {
503
+ "error": f"Invalid slide index: {slide_index}. Available slides: 0-{len(pres.slides) - 1}"
504
+ }
505
+
506
+ slide = pres.slides[slide_index]
507
+
508
+ # Validate parameters
509
+ validations = {}
510
+ if font_size is not None:
511
+ validations["font_size"] = (font_size, [(is_positive, "must be a positive integer")])
512
+ if color is not None:
513
+ validations["color"] = (color, [(is_valid_rgb, "must be a valid RGB list [R, G, B] with values 0-255")])
514
+ if bg_color is not None:
515
+ validations["bg_color"] = (bg_color, [(is_valid_rgb, "must be a valid RGB list [R, G, B] with values 0-255")])
516
+
517
+ if validations:
518
+ valid, error = validate_parameters(validations)
519
+ if not valid:
520
+ return {"error": error}
521
+
522
+ try:
523
+ if operation == "add":
524
+ # Auto-detect URL even if source_type is not explicitly "url"
525
+ if isinstance(image_source, str) and (image_source.startswith("http://") or image_source.startswith("https://")):
526
+ source_type = "url"
527
+ # Add new textbox
528
+ shape = ppt_utils.add_textbox(
529
+ slide, left, top, width, height, text,
530
+ font_size=font_size,
531
+ font_name=font_name,
532
+ bold=bold,
533
+ italic=italic,
534
+ underline=underline,
535
+ color=tuple(color) if color else None,
536
+ bg_color=tuple(bg_color) if bg_color else None,
537
+ alignment=alignment,
538
+ vertical_alignment=vertical_alignment,
539
+ auto_fit=auto_fit
540
+ )
541
+ return {
542
+ "message": f"Added text box to slide {slide_index}",
543
+ "shape_index": len(slide.shapes) - 1,
544
+ "text": text
545
+ }
546
+
547
+ elif operation == "format":
548
+ # Format existing text shape
549
+ if shape_index is None or shape_index < 0 or shape_index >= len(slide.shapes):
550
+ return {
551
+ "error": f"Invalid shape index for formatting: {shape_index}. Available shapes: 0-{len(slide.shapes) - 1}"
552
+ }
553
+
554
+ shape = slide.shapes[shape_index]
555
+ ppt_utils.format_text_advanced(
556
+ shape,
557
+ font_size=font_size,
558
+ font_name=font_name,
559
+ bold=bold,
560
+ italic=italic,
561
+ underline=underline,
562
+ color=tuple(color) if color else None,
563
+ bg_color=tuple(bg_color) if bg_color else None,
564
+ alignment=alignment,
565
+ vertical_alignment=vertical_alignment
566
+ )
567
+ return {
568
+ "message": f"Formatted text shape {shape_index} on slide {slide_index}"
569
+ }
570
+
571
+ elif operation == "validate":
572
+ # Validate text fit
573
+ if shape_index is None or shape_index < 0 or shape_index >= len(slide.shapes):
574
+ return {
575
+ "error": f"Invalid shape index for validation: {shape_index}. Available shapes: 0-{len(slide.shapes) - 1}"
576
+ }
577
+
578
+ validation_result = ppt_utils.validate_text_fit(
579
+ slide.shapes[shape_index],
580
+ text_content=text or None,
581
+ font_size=font_size or 12
582
+ )
583
+
584
+ if not validation_only and validation_result.get("needs_optimization"):
585
+ # Apply automatic fixes
586
+ fix_result = ppt_utils.validate_and_fix_slide(
587
+ slide,
588
+ auto_fix=True,
589
+ min_font_size=min_font_size,
590
+ max_font_size=max_font_size
591
+ )
592
+ validation_result.update(fix_result)
593
+
594
+ return validation_result
595
+
596
+ elif operation == "format_runs":
597
+ # Format multiple text runs with different formatting
598
+ if shape_index is None or shape_index < 0 or shape_index >= len(slide.shapes):
599
+ return {
600
+ "error": f"Invalid shape index for format_runs: {shape_index}. Available shapes: 0-{len(slide.shapes) - 1}"
601
+ }
602
+
603
+ if not text_runs:
604
+ return {"error": "text_runs parameter is required for format_runs operation"}
605
+
606
+ shape = slide.shapes[shape_index]
607
+
608
+ # Check if shape has text
609
+ if not hasattr(shape, 'text_frame') or not shape.text_frame:
610
+ return {"error": "Shape does not contain text"}
611
+
612
+ # Clear existing text and rebuild with formatted runs
613
+ text_frame = shape.text_frame
614
+ text_frame.clear()
615
+
616
+ formatted_runs = []
617
+
618
+ for run_data in text_runs:
619
+ if 'text' not in run_data:
620
+ continue
621
+
622
+ # Add paragraph if needed
623
+ if not text_frame.paragraphs:
624
+ paragraph = text_frame.paragraphs[0]
625
+ else:
626
+ paragraph = text_frame.add_paragraph()
627
+
628
+ # Add run with text
629
+ run = paragraph.add_run()
630
+ run.text = run_data['text']
631
+
632
+ # Apply formatting using pptx imports
633
+ from pptx.util import Pt
634
+ from pptx.dml.color import RGBColor
635
+
636
+ if 'bold' in run_data:
637
+ run.font.bold = run_data['bold']
638
+ if 'italic' in run_data:
639
+ run.font.italic = run_data['italic']
640
+ if 'underline' in run_data:
641
+ run.font.underline = run_data['underline']
642
+ if 'font_size' in run_data:
643
+ run.font.size = Pt(run_data['font_size'])
644
+ if 'font_name' in run_data:
645
+ run.font.name = run_data['font_name']
646
+ if 'color' in run_data and is_valid_rgb(run_data['color']):
647
+ run.font.color.rgb = RGBColor(*run_data['color'])
648
+ if 'hyperlink' in run_data:
649
+ run.hyperlink.address = run_data['hyperlink']
650
+
651
+ formatted_runs.append({
652
+ "text": run_data['text'],
653
+ "formatting_applied": {k: v for k, v in run_data.items() if k != 'text'}
654
+ })
655
+
656
+ return {
657
+ "message": f"Applied formatting to {len(formatted_runs)} text runs on shape {shape_index}",
658
+ "slide_index": slide_index,
659
+ "shape_index": shape_index,
660
+ "formatted_runs": formatted_runs
661
+ }
662
+
663
+ else:
664
+ return {
665
+ "error": f"Invalid operation: {operation}. Must be 'add', 'format', 'validate', or 'format_runs'"
666
+ }
667
+
668
+ except Exception as e:
669
+ return {
670
+ "error": f"Failed to {operation} text: {str(e)}"
671
+ }
672
+
673
+ @app.tool()
674
+ def manage_image(
675
+ slide_index: int,
676
+ operation: str, # "add", "enhance"
677
+ image_source: str, # file path or base64 string
678
+ source_type: str = "file", # "file" or "base64"
679
+ left: float = 1.0,
680
+ top: float = 1.0,
681
+ width: Optional[float] = None,
682
+ height: Optional[float] = None,
683
+ # Enhancement options
684
+ enhancement_style: Optional[str] = None, # "presentation", "custom"
685
+ brightness: float = 1.0,
686
+ contrast: float = 1.0,
687
+ saturation: float = 1.0,
688
+ sharpness: float = 1.0,
689
+ blur_radius: float = 0,
690
+ filter_type: Optional[str] = None,
691
+ output_path: Optional[str] = None,
692
+ presentation_id: Optional[str] = None
693
+ ) -> Dict:
694
+ """
695
+ 统一的图片处理工具(添加/增强)。
696
+
697
+ 功能
698
+ - operation="add":将图片插入到指定幻灯片位置,支持本地文件或 Base64 图片源或图片地址。
699
+ - operation="enhance":对已有图片文件进行画质增强与风格化处理,输出增强后的图片路径。
700
+
701
+ 参数
702
+ - slide_index: int — 目标幻灯片索引(从 0 开始)。
703
+ - operation: str "add" 或 "enhance"。
704
+ - image_source: str
705
+ * 当 source_type="file":本地图片文件路径。
706
+ * source_type="base64":图片的 Base64 字符串。
707
+ * source_type="url":图片的 http/https 地址。
708
+ - source_type: str — "file"、"base64" 或 "url"。
709
+ * add 支持 "file"、"base64"、"url"(仅允许 http/https)。
710
+ * enhance 仅支持 "file"(不接受 base64 或 url)。
711
+ - left, top: float 插入位置(英寸)。
712
+ - width, height: Optional[float] 插入尺寸(英寸)。可只提供一项以按比例缩放;都不提供则按图片原始尺寸。
713
+ - enhancement_style: Optional[str] — "presentation" 或 "custom"。当 operation="add" 且需要自动增强时可用;"presentation" 走预设的专业增强流程。
714
+ - brightness, contrast, saturation, sharpness: float — 亮度/对比度/饱和度/锐度(默认 1.0,>1 增强,<1 减弱)。
715
+ - blur_radius: float 模糊半径(默认 0)。
716
+ - filter_type: Optional[str] — 过滤器类型(如 "DETAIL"、"SMOOTH" 等,取决于 Pillow 支持)。
717
+ - output_path: Optional[str] — 增强后图片的输出路径(不传则生成临时文件)。
718
+ - presentation_id: Optional[str] — 指定演示文稿 ID;不传则使用当前打开的演示文稿。
719
+ 注:在某些部署环境(如你当前环境)中,必须显式传入 presentation_id 才会对目标文档生效。
720
+
721
+ 返回
722
+ - operation="add":
723
+ * message: str
724
+ * shape_index: int — 新增形状索引
725
+ * image_path: str(当 source_type="file" 时返回)
726
+ - operation="enhance"
727
+ * message: str
728
+ * enhanced_path: str — 增强后图片文件路径
729
+ - 失败时返回 {"error": str}
730
+
731
+ 注意事项
732
+ - 现在支持通过 URL 插入图片(仅 operation="add"):source_type="url",仅允许 http/https 协议。
733
+ 会在内部下载到临时文件后插入并自动清理。若返回的 Content-Type 非 image/* 将返回错误。
734
+ enhance 仍然仅支持本地文件路径。
735
+ - 在某些部署环境(如你当前环境)中,需显式提供 presentation_id 参数,否则可能插入到非预期文档或不生效。
736
+ - operation="enhance" 不接受 Base64,必须提供可访问的本地文件路径。
737
+ - 插入 Base64 图片时,内部会写入临时文件后再插入,操作完成后临时文件会被清理。
738
+ - slide_index 必须在当前演示文稿的有效范围内,否则将返回错误。
739
+
740
+ 示例
741
+ - 通过 URL 插入图片(仅 add):
742
+ manage_image(slide_index=0, operation="add",
743
+ image_source="https://example.com/logo.png", source_type="url",
744
+ left=1.0, top=1.0, width=3.0, presentation_id="YOUR_PRESENTATION_ID")
745
+ - 插入本地图片:
746
+ manage_image(slide_index=0, operation="add",
747
+ image_source="D:/images/logo.png", source_type="file",
748
+ left=1.0, top=1.0, width=3.0, presentation_id="YOUR_PRESENTATION_ID")
749
+
750
+ - 插入 Base64 图片:
751
+ manage_image(slide_index=1, operation="add",
752
+ image_source="<BASE64字符串>", source_type="base64",
753
+ left=2.0, top=1.5, width=4.0, height=2.5, presentation_id="YOUR_PRESENTATION_ID")
754
+
755
+ - 插入并应用专业增强(演示风格):
756
+ manage_image(slide_index=2, operation="add",
757
+ image_source="assets/photo.jpg", source_type="file",
758
+ enhancement_style="presentation", left=1.0, top=2.0, presentation_id="YOUR_PRESENTATION_ID")
759
+
760
+ - 增强已有图片文件(自定义参数):
761
+ manage_image(slide_index=0, operation="enhance",
762
+ image_source="assets/photo.jpg", source_type="file",
763
+ brightness=1.2, contrast=1.1, saturation=1.3,
764
+ sharpness=1.1, blur_radius=0, filter_type=None,
765
+ output_path="assets/photo_enhanced.jpg", presentation_id="YOUR_PRESENTATION_ID")
766
+ """
767
+ pres_id = presentation_id if presentation_id is not None else get_current_presentation_id()
768
+
769
+ if pres_id is None or pres_id not in presentations:
770
+ return {
771
+ "error": "No presentation is currently loaded or the specified ID is invalid"
772
+ }
773
+
774
+ pres = presentations[pres_id]
775
+
776
+ if slide_index < 0 or slide_index >= len(pres.slides):
777
+ return {
778
+ "error": f"Invalid slide index: {slide_index}. Available slides: 0-{len(pres.slides) - 1}"
779
+ }
780
+
781
+ slide = pres.slides[slide_index]
782
+
783
+ try:
784
+ if operation == "add":
785
+ if source_type == "base64":
786
+ # Handle base64 image
787
+ try:
788
+ image_data = base64.b64decode(image_source)
789
+ with tempfile.NamedTemporaryFile(delete=False, suffix='.png') as temp_file:
790
+ temp_file.write(image_data)
791
+ temp_path = temp_file.name
792
+
793
+ # Add image from temporary file
794
+ shape = ppt_utils.add_image(slide, temp_path, left, top, width, height)
795
+
796
+ # Clean up temporary file
797
+ os.unlink(temp_path)
798
+
799
+ return {
800
+ "message": f"Added image from base64 to slide {slide_index}",
801
+ "shape_index": len(slide.shapes) - 1
802
+ }
803
+ except Exception as e:
804
+ return {
805
+ "error": f"Failed to process base64 image: {str(e)}"
806
+ }
807
+ elif source_type == "url":
808
+ # Handle image URL (http/https)
809
+ try:
810
+ # Normalize and percent-encode URL path/query to support spaces and non-ASCII characters
811
+ parsed = urllib.parse.urlsplit(image_source)
812
+ if parsed.scheme not in ("http", "https"):
813
+ return {"error": f"Unsupported URL scheme: {parsed.scheme}. Only http/https allowed."}
814
+ encoded_path = urllib.parse.quote(parsed.path or "", safe="/%")
815
+ # Re-encode query preserving keys and multiple values
816
+ qsl = urllib.parse.parse_qsl(parsed.query or "", keep_blank_values=True)
817
+ encoded_query = urllib.parse.urlencode(qsl, doseq=True)
818
+ encoded_url = urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, encoded_path, encoded_query, parsed.fragment))
819
+
820
+ # Download helper using requests if available, else urllib
821
+ content_type = None
822
+ temp_path = None
823
+ image_exts = (".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tif", ".tiff")
824
+
825
+ if requests is not None:
826
+ with requests.get(encoded_url, stream=True) as resp:
827
+ if resp.status_code != 200:
828
+ return {"error": f"Failed to download image. HTTP {resp.status_code}"}
829
+ content_type = resp.headers.get("Content-Type", "") or ""
830
+
831
+ # Determine suffix and allow fallback by URL extension if Content-Type missing or not image/*
832
+ suffix = ".png"
833
+ is_image = content_type.startswith("image/")
834
+ try:
835
+ main_type = (content_type.split(";")[0].strip() if content_type else "")
836
+ if "/" in main_type:
837
+ ext = main_type.split("/")[1].lower()
838
+ if ext in ("jpeg", "pjpeg"):
839
+ suffix = ".jpg"
840
+ elif ext in ("png", "gif", "bmp", "webp", "tiff"):
841
+ suffix = f".{ext}"
842
+ except Exception:
843
+ pass
844
+ if suffix == ".png":
845
+ path_ext = os.path.splitext(parsed.path or "")[1].lower()
846
+ if path_ext in image_exts:
847
+ suffix = path_ext
848
+
849
+ if not is_image and suffix not in image_exts:
850
+ return {"error": f"URL content is not an image (Content-Type: {content_type or 'unknown'})"}
851
+
852
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
853
+ temp_path = temp_file.name
854
+ for chunk in resp.iter_content(chunk_size=8192):
855
+ if not chunk:
856
+ continue
857
+ temp_file.write(chunk)
858
+ else:
859
+ req = urllib.request.Request(encoded_url, headers={"User-Agent": "Mozilla/5.0"})
860
+ with urllib.request.urlopen(req) as resp:
861
+ content_type = resp.headers.get("Content-Type", "") or ""
862
+
863
+ suffix = ".png"
864
+ is_image = content_type.startswith("image/")
865
+ try:
866
+ main_type = (content_type.split(";")[0].strip() if content_type else "")
867
+ if "/" in main_type:
868
+ ext = main_type.split("/")[1].lower()
869
+ if ext in ("jpeg", "pjpeg"):
870
+ suffix = ".jpg"
871
+ elif ext in ("png", "gif", "bmp", "webp", "tiff"):
872
+ suffix = f".{ext}"
873
+ except Exception:
874
+ pass
875
+ if suffix == ".png":
876
+ path_ext = os.path.splitext(parsed.path or "")[1].lower()
877
+ if path_ext in image_exts:
878
+ suffix = path_ext
879
+
880
+ if not is_image and suffix not in image_exts:
881
+ return {"error": f"URL content is not an image (Content-Type: {content_type or 'unknown'})"}
882
+
883
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
884
+ temp_path = temp_file.name
885
+ while True:
886
+ chunk = resp.read(8192)
887
+ if not chunk:
888
+ break
889
+ temp_file.write(chunk)
890
+
891
+ # Add image from temporary file
892
+ shape = ppt_utils.add_image(slide, temp_path, left, top, width, height)
893
+
894
+ # Clean up temporary file
895
+ if temp_path and os.path.exists(temp_path):
896
+ os.unlink(temp_path)
897
+
898
+ return {
899
+ "message": f"Added image from URL to slide {slide_index}",
900
+ "shape_index": len(slide.shapes) - 1
901
+ }
902
+ except Exception as e:
903
+ # Best-effort cleanup if temp_path was created
904
+ try:
905
+ if temp_path and os.path.exists(temp_path):
906
+ os.unlink(temp_path)
907
+ except Exception:
908
+ pass
909
+ return {"error": f"Failed to process image URL: {str(e)}"}
910
+ else:
911
+ # Handle file path
912
+ if not os.path.exists(image_source):
913
+ return {
914
+ "error": f"Image file not found: {image_source}"
915
+ }
916
+
917
+ shape = ppt_utils.add_image(slide, image_source, left, top, width, height)
918
+ return {
919
+ "message": f"Added image to slide {slide_index}",
920
+ "shape_index": len(slide.shapes) - 1,
921
+ "image_path": image_source
922
+ }
923
+
924
+ elif operation == "enhance":
925
+ # Enhance existing image file
926
+ if source_type == "base64":
927
+ return {
928
+ "error": "Enhancement operation requires file path, not base64 data"
929
+ }
930
+
931
+ if not os.path.exists(image_source):
932
+ return {
933
+ "error": f"Image file not found: {image_source}"
934
+ }
935
+
936
+ if enhancement_style == "presentation":
937
+ # Apply professional enhancement
938
+ enhanced_path = ppt_utils.apply_professional_image_enhancement(
939
+ image_source, style="presentation", output_path=output_path
940
+ )
941
+ else:
942
+ # Apply custom enhancement
943
+ enhanced_path = ppt_utils.enhance_image_with_pillow(
944
+ image_source,
945
+ brightness=brightness,
946
+ contrast=contrast,
947
+ saturation=saturation,
948
+ sharpness=sharpness,
949
+ blur_radius=blur_radius,
950
+ filter_type=filter_type,
951
+ output_path=output_path
952
+ )
953
+
954
+ return {
955
+ "message": f"Enhanced image: {image_source}",
956
+ "enhanced_path": enhanced_path
957
+ }
958
+
959
+ else:
960
+ return {
961
+ "error": f"Invalid operation: {operation}. Must be 'add' or 'enhance'"
962
+ }
963
+
964
+ except Exception as e:
965
+ return {
966
+ "error": f"Failed to {operation} image: {str(e)}"
779
967
  }