atlas-chat 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.
- atlas/__init__.py +40 -0
- atlas/application/__init__.py +7 -0
- atlas/application/chat/__init__.py +7 -0
- atlas/application/chat/agent/__init__.py +10 -0
- atlas/application/chat/agent/act_loop.py +179 -0
- atlas/application/chat/agent/factory.py +142 -0
- atlas/application/chat/agent/protocols.py +46 -0
- atlas/application/chat/agent/react_loop.py +338 -0
- atlas/application/chat/agent/think_act_loop.py +171 -0
- atlas/application/chat/approval_manager.py +151 -0
- atlas/application/chat/elicitation_manager.py +191 -0
- atlas/application/chat/events/__init__.py +1 -0
- atlas/application/chat/events/agent_event_relay.py +112 -0
- atlas/application/chat/modes/__init__.py +1 -0
- atlas/application/chat/modes/agent.py +125 -0
- atlas/application/chat/modes/plain.py +74 -0
- atlas/application/chat/modes/rag.py +81 -0
- atlas/application/chat/modes/tools.py +179 -0
- atlas/application/chat/orchestrator.py +213 -0
- atlas/application/chat/policies/__init__.py +1 -0
- atlas/application/chat/policies/tool_authorization.py +99 -0
- atlas/application/chat/preprocessors/__init__.py +1 -0
- atlas/application/chat/preprocessors/message_builder.py +92 -0
- atlas/application/chat/preprocessors/prompt_override_service.py +104 -0
- atlas/application/chat/service.py +454 -0
- atlas/application/chat/utilities/__init__.py +6 -0
- atlas/application/chat/utilities/error_handler.py +367 -0
- atlas/application/chat/utilities/event_notifier.py +546 -0
- atlas/application/chat/utilities/file_processor.py +613 -0
- atlas/application/chat/utilities/tool_executor.py +789 -0
- atlas/atlas_chat_cli.py +347 -0
- atlas/atlas_client.py +238 -0
- atlas/core/__init__.py +0 -0
- atlas/core/auth.py +205 -0
- atlas/core/authorization_manager.py +27 -0
- atlas/core/capabilities.py +123 -0
- atlas/core/compliance.py +215 -0
- atlas/core/domain_whitelist.py +147 -0
- atlas/core/domain_whitelist_middleware.py +82 -0
- atlas/core/http_client.py +28 -0
- atlas/core/log_sanitizer.py +102 -0
- atlas/core/metrics_logger.py +59 -0
- atlas/core/middleware.py +131 -0
- atlas/core/otel_config.py +242 -0
- atlas/core/prompt_risk.py +200 -0
- atlas/core/rate_limit.py +0 -0
- atlas/core/rate_limit_middleware.py +64 -0
- atlas/core/security_headers_middleware.py +51 -0
- atlas/domain/__init__.py +37 -0
- atlas/domain/chat/__init__.py +1 -0
- atlas/domain/chat/dtos.py +85 -0
- atlas/domain/errors.py +96 -0
- atlas/domain/messages/__init__.py +12 -0
- atlas/domain/messages/models.py +160 -0
- atlas/domain/rag_mcp_service.py +664 -0
- atlas/domain/sessions/__init__.py +7 -0
- atlas/domain/sessions/models.py +36 -0
- atlas/domain/unified_rag_service.py +371 -0
- atlas/infrastructure/__init__.py +10 -0
- atlas/infrastructure/app_factory.py +135 -0
- atlas/infrastructure/events/__init__.py +1 -0
- atlas/infrastructure/events/cli_event_publisher.py +140 -0
- atlas/infrastructure/events/websocket_publisher.py +140 -0
- atlas/infrastructure/sessions/in_memory_repository.py +56 -0
- atlas/infrastructure/transport/__init__.py +7 -0
- atlas/infrastructure/transport/websocket_connection_adapter.py +33 -0
- atlas/init_cli.py +226 -0
- atlas/interfaces/__init__.py +15 -0
- atlas/interfaces/events.py +134 -0
- atlas/interfaces/llm.py +54 -0
- atlas/interfaces/rag.py +40 -0
- atlas/interfaces/sessions.py +75 -0
- atlas/interfaces/tools.py +57 -0
- atlas/interfaces/transport.py +24 -0
- atlas/main.py +564 -0
- atlas/mcp/api_key_demo/README.md +76 -0
- atlas/mcp/api_key_demo/main.py +172 -0
- atlas/mcp/api_key_demo/run.sh +56 -0
- atlas/mcp/basictable/main.py +147 -0
- atlas/mcp/calculator/main.py +149 -0
- atlas/mcp/code-executor/execution_engine.py +98 -0
- atlas/mcp/code-executor/execution_environment.py +95 -0
- atlas/mcp/code-executor/main.py +528 -0
- atlas/mcp/code-executor/result_processing.py +276 -0
- atlas/mcp/code-executor/script_generation.py +195 -0
- atlas/mcp/code-executor/security_checker.py +140 -0
- atlas/mcp/corporate_cars/main.py +437 -0
- atlas/mcp/csv_reporter/main.py +545 -0
- atlas/mcp/duckduckgo/main.py +182 -0
- atlas/mcp/elicitation_demo/README.md +171 -0
- atlas/mcp/elicitation_demo/main.py +262 -0
- atlas/mcp/env-demo/README.md +158 -0
- atlas/mcp/env-demo/main.py +199 -0
- atlas/mcp/file_size_test/main.py +284 -0
- atlas/mcp/filesystem/main.py +348 -0
- atlas/mcp/image_demo/main.py +113 -0
- atlas/mcp/image_demo/requirements.txt +4 -0
- atlas/mcp/logging_demo/README.md +72 -0
- atlas/mcp/logging_demo/main.py +103 -0
- atlas/mcp/many_tools_demo/main.py +50 -0
- atlas/mcp/order_database/__init__.py +0 -0
- atlas/mcp/order_database/main.py +369 -0
- atlas/mcp/order_database/signal_data.csv +1001 -0
- atlas/mcp/pdfbasic/main.py +394 -0
- atlas/mcp/pptx_generator/main.py +760 -0
- atlas/mcp/pptx_generator/requirements.txt +13 -0
- atlas/mcp/pptx_generator/run_test.sh +1 -0
- atlas/mcp/pptx_generator/test_pptx_generator_security.py +169 -0
- atlas/mcp/progress_demo/main.py +167 -0
- atlas/mcp/progress_updates_demo/QUICKSTART.md +273 -0
- atlas/mcp/progress_updates_demo/README.md +120 -0
- atlas/mcp/progress_updates_demo/main.py +497 -0
- atlas/mcp/prompts/main.py +222 -0
- atlas/mcp/public_demo/main.py +189 -0
- atlas/mcp/sampling_demo/README.md +169 -0
- atlas/mcp/sampling_demo/main.py +234 -0
- atlas/mcp/thinking/main.py +77 -0
- atlas/mcp/tool_planner/main.py +240 -0
- atlas/mcp/ui-demo/badmesh.png +0 -0
- atlas/mcp/ui-demo/main.py +383 -0
- atlas/mcp/ui-demo/templates/button_demo.html +32 -0
- atlas/mcp/ui-demo/templates/data_visualization.html +32 -0
- atlas/mcp/ui-demo/templates/form_demo.html +28 -0
- atlas/mcp/username-override-demo/README.md +320 -0
- atlas/mcp/username-override-demo/main.py +308 -0
- atlas/modules/__init__.py +0 -0
- atlas/modules/config/__init__.py +34 -0
- atlas/modules/config/cli.py +231 -0
- atlas/modules/config/config_manager.py +1096 -0
- atlas/modules/file_storage/__init__.py +22 -0
- atlas/modules/file_storage/cli.py +330 -0
- atlas/modules/file_storage/content_extractor.py +290 -0
- atlas/modules/file_storage/manager.py +295 -0
- atlas/modules/file_storage/mock_s3_client.py +402 -0
- atlas/modules/file_storage/s3_client.py +417 -0
- atlas/modules/llm/__init__.py +19 -0
- atlas/modules/llm/caller.py +287 -0
- atlas/modules/llm/litellm_caller.py +675 -0
- atlas/modules/llm/models.py +19 -0
- atlas/modules/mcp_tools/__init__.py +17 -0
- atlas/modules/mcp_tools/client.py +2123 -0
- atlas/modules/mcp_tools/token_storage.py +556 -0
- atlas/modules/prompts/prompt_provider.py +130 -0
- atlas/modules/rag/__init__.py +24 -0
- atlas/modules/rag/atlas_rag_client.py +336 -0
- atlas/modules/rag/client.py +129 -0
- atlas/routes/admin_routes.py +865 -0
- atlas/routes/config_routes.py +484 -0
- atlas/routes/feedback_routes.py +361 -0
- atlas/routes/files_routes.py +274 -0
- atlas/routes/health_routes.py +40 -0
- atlas/routes/mcp_auth_routes.py +223 -0
- atlas/server_cli.py +164 -0
- atlas/tests/conftest.py +20 -0
- atlas/tests/integration/test_mcp_auth_integration.py +152 -0
- atlas/tests/manual_test_sampling.py +87 -0
- atlas/tests/modules/mcp_tools/test_client_auth.py +226 -0
- atlas/tests/modules/mcp_tools/test_client_env.py +191 -0
- atlas/tests/test_admin_mcp_server_management_routes.py +141 -0
- atlas/tests/test_agent_roa.py +135 -0
- atlas/tests/test_app_factory_smoke.py +47 -0
- atlas/tests/test_approval_manager.py +439 -0
- atlas/tests/test_atlas_client.py +188 -0
- atlas/tests/test_atlas_rag_client.py +447 -0
- atlas/tests/test_atlas_rag_integration.py +224 -0
- atlas/tests/test_attach_file_flow.py +287 -0
- atlas/tests/test_auth_utils.py +165 -0
- atlas/tests/test_backend_public_url.py +185 -0
- atlas/tests/test_banner_logging.py +287 -0
- atlas/tests/test_capability_tokens_and_injection.py +203 -0
- atlas/tests/test_compliance_level.py +54 -0
- atlas/tests/test_compliance_manager.py +253 -0
- atlas/tests/test_config_manager.py +617 -0
- atlas/tests/test_config_manager_paths.py +12 -0
- atlas/tests/test_core_auth.py +18 -0
- atlas/tests/test_core_utils.py +190 -0
- atlas/tests/test_docker_env_sync.py +202 -0
- atlas/tests/test_domain_errors.py +329 -0
- atlas/tests/test_domain_whitelist.py +359 -0
- atlas/tests/test_elicitation_manager.py +408 -0
- atlas/tests/test_elicitation_routing.py +296 -0
- atlas/tests/test_env_demo_server.py +88 -0
- atlas/tests/test_error_classification.py +113 -0
- atlas/tests/test_error_flow_integration.py +116 -0
- atlas/tests/test_feedback_routes.py +333 -0
- atlas/tests/test_file_content_extraction.py +1134 -0
- atlas/tests/test_file_extraction_routes.py +158 -0
- atlas/tests/test_file_library.py +107 -0
- atlas/tests/test_file_manager_unit.py +18 -0
- atlas/tests/test_health_route.py +49 -0
- atlas/tests/test_http_client_stub.py +8 -0
- atlas/tests/test_imports_smoke.py +30 -0
- atlas/tests/test_interfaces_llm_response.py +9 -0
- atlas/tests/test_issue_access_denied_fix.py +136 -0
- atlas/tests/test_llm_env_expansion.py +836 -0
- atlas/tests/test_log_level_sensitive_data.py +285 -0
- atlas/tests/test_mcp_auth_routes.py +341 -0
- atlas/tests/test_mcp_client_auth.py +331 -0
- atlas/tests/test_mcp_data_injection.py +270 -0
- atlas/tests/test_mcp_get_authorized_servers.py +95 -0
- atlas/tests/test_mcp_hot_reload.py +512 -0
- atlas/tests/test_mcp_image_content.py +424 -0
- atlas/tests/test_mcp_logging.py +172 -0
- atlas/tests/test_mcp_progress_updates.py +313 -0
- atlas/tests/test_mcp_prompt_override_system_prompt.py +102 -0
- atlas/tests/test_mcp_prompts_server.py +39 -0
- atlas/tests/test_mcp_tool_result_parsing.py +296 -0
- atlas/tests/test_metrics_logger.py +56 -0
- atlas/tests/test_middleware_auth.py +379 -0
- atlas/tests/test_prompt_risk_and_acl.py +141 -0
- atlas/tests/test_rag_mcp_aggregator.py +204 -0
- atlas/tests/test_rag_mcp_service.py +224 -0
- atlas/tests/test_rate_limit_middleware.py +45 -0
- atlas/tests/test_routes_config_smoke.py +60 -0
- atlas/tests/test_routes_files_download_token.py +41 -0
- atlas/tests/test_routes_files_health.py +18 -0
- atlas/tests/test_runtime_imports.py +53 -0
- atlas/tests/test_sampling_integration.py +482 -0
- atlas/tests/test_security_admin_routes.py +61 -0
- atlas/tests/test_security_capability_tokens.py +65 -0
- atlas/tests/test_security_file_stats_scope.py +21 -0
- atlas/tests/test_security_header_injection.py +191 -0
- atlas/tests/test_security_headers_and_filename.py +63 -0
- atlas/tests/test_shared_session_repository.py +101 -0
- atlas/tests/test_system_prompt_loading.py +181 -0
- atlas/tests/test_token_storage.py +505 -0
- atlas/tests/test_tool_approval_config.py +93 -0
- atlas/tests/test_tool_approval_utils.py +356 -0
- atlas/tests/test_tool_authorization_group_filtering.py +223 -0
- atlas/tests/test_tool_details_in_config.py +108 -0
- atlas/tests/test_tool_planner.py +300 -0
- atlas/tests/test_unified_rag_service.py +398 -0
- atlas/tests/test_username_override_in_approval.py +258 -0
- atlas/tests/test_websocket_auth_header.py +168 -0
- atlas/version.py +6 -0
- atlas_chat-0.1.0.data/data/.env.example +253 -0
- atlas_chat-0.1.0.data/data/config/defaults/compliance-levels.json +44 -0
- atlas_chat-0.1.0.data/data/config/defaults/domain-whitelist.json +123 -0
- atlas_chat-0.1.0.data/data/config/defaults/file-extractors.json +74 -0
- atlas_chat-0.1.0.data/data/config/defaults/help-config.json +198 -0
- atlas_chat-0.1.0.data/data/config/defaults/llmconfig-buggy.yml +11 -0
- atlas_chat-0.1.0.data/data/config/defaults/llmconfig.yml +19 -0
- atlas_chat-0.1.0.data/data/config/defaults/mcp.json +138 -0
- atlas_chat-0.1.0.data/data/config/defaults/rag-sources.json +17 -0
- atlas_chat-0.1.0.data/data/config/defaults/splash-config.json +16 -0
- atlas_chat-0.1.0.dist-info/METADATA +236 -0
- atlas_chat-0.1.0.dist-info/RECORD +250 -0
- atlas_chat-0.1.0.dist-info/WHEEL +5 -0
- atlas_chat-0.1.0.dist-info/entry_points.txt +4 -0
- atlas_chat-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PowerPoint Generator MCP Server using FastMCP.
|
|
3
|
+
|
|
4
|
+
Converts markdown content into a professional PowerPoint presentation with 16:9 aspect ratio.
|
|
5
|
+
Markdown headers (# or ##) become slide titles and content below each header becomes slide content.
|
|
6
|
+
Supports bullet point lists and optional image integration.
|
|
7
|
+
|
|
8
|
+
Tools:
|
|
9
|
+
- markdown_to_pptx: Converts markdown content to PowerPoint presentation
|
|
10
|
+
|
|
11
|
+
Demonstrates: Markdown parsing, file output with base64 encoding, and professional templating.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import html
|
|
18
|
+
import io
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import tempfile
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Annotated, Any, Dict, List, Optional
|
|
25
|
+
|
|
26
|
+
import requests
|
|
27
|
+
from fastmcp import FastMCP
|
|
28
|
+
from PIL import Image
|
|
29
|
+
from pptx import Presentation
|
|
30
|
+
from pptx.dml.color import RGBColor
|
|
31
|
+
from pptx.enum.shapes import MSO_SHAPE
|
|
32
|
+
from pptx.enum.text import PP_ALIGN
|
|
33
|
+
from pptx.util import Inches, Pt
|
|
34
|
+
|
|
35
|
+
# Configuration
|
|
36
|
+
VERBOSE = True
|
|
37
|
+
|
|
38
|
+
# Configure logging first
|
|
39
|
+
logging.basicConfig(
|
|
40
|
+
level=logging.INFO,
|
|
41
|
+
format='%(asctime)s - PPTX_GENERATOR - %(name)s - %(levelname)s - %(message)s',
|
|
42
|
+
handlers=[
|
|
43
|
+
logging.StreamHandler()
|
|
44
|
+
]
|
|
45
|
+
)
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
# Configure paths and add file handler if available
|
|
49
|
+
current_dir = Path(__file__).parent
|
|
50
|
+
logger.info(f"Current dir: {current_dir.absolute()}")
|
|
51
|
+
backend_dir = current_dir.parent.parent
|
|
52
|
+
logger.info(f"Backend dir: {backend_dir.absolute()}")
|
|
53
|
+
project_root = backend_dir.parent
|
|
54
|
+
logger.info(f"Project root: {project_root.absolute()}")
|
|
55
|
+
logs_dir = project_root / 'logs'
|
|
56
|
+
logger.info(f"Logs dir: {logs_dir.absolute()}")
|
|
57
|
+
main_log_path = logs_dir / 'app.jsonl'
|
|
58
|
+
logger.info(f"Log path: {main_log_path.absolute()}")
|
|
59
|
+
logger.info(f"Log path exists: {main_log_path.exists()}")
|
|
60
|
+
logger.info(f"Logs dir exists: {logs_dir.exists()}")
|
|
61
|
+
|
|
62
|
+
# Add file handler if log path exists
|
|
63
|
+
if main_log_path.exists():
|
|
64
|
+
file_handler = logging.FileHandler(main_log_path)
|
|
65
|
+
file_handler.setFormatter(logging.Formatter('%(asctime)s - PPTX_GENERATOR - %(name)s - %(levelname)s - %(message)s'))
|
|
66
|
+
logger.addHandler(file_handler)
|
|
67
|
+
|
|
68
|
+
mcp = FastMCP("pptx_generator")
|
|
69
|
+
|
|
70
|
+
# Sandia National Laboratories color scheme
|
|
71
|
+
SANDIA_BLUE = RGBColor(0, 51, 102) # Dark blue - primary brand color
|
|
72
|
+
SANDIA_LIGHT_BLUE = RGBColor(0, 102, 153) # Lighter blue for accents
|
|
73
|
+
SANDIA_RED = RGBColor(153, 0, 0) # Red accent
|
|
74
|
+
SANDIA_GRAY = RGBColor(102, 102, 102) # Gray for secondary text
|
|
75
|
+
SANDIA_WHITE = RGBColor(255, 255, 255)
|
|
76
|
+
|
|
77
|
+
# 16:9 slide dimensions (standard widescreen)
|
|
78
|
+
# Height is 7.5 inches, width is calculated for exact 16:9 ratio
|
|
79
|
+
SLIDE_HEIGHT = Inches(7.5)
|
|
80
|
+
SLIDE_WIDTH = Inches(7.5 * 16 / 9) # 16:9 aspect ratio = 13.333... inches
|
|
81
|
+
|
|
82
|
+
# Allowed base paths for local file access (security constraint)
|
|
83
|
+
# Only allow files relative to the current working directory
|
|
84
|
+
ALLOWED_BASE_PATH = Path(".").resolve()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _escape_html(text: str) -> str:
|
|
88
|
+
"""Escape HTML special characters to prevent XSS attacks."""
|
|
89
|
+
return html.escape(text, quote=True)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _is_safe_local_path(filepath: str) -> bool:
|
|
93
|
+
"""Check if a local file path is safe (within allowed base directory).
|
|
94
|
+
|
|
95
|
+
Prevents path traversal attacks by ensuring the resolved path
|
|
96
|
+
is within the allowed base directory.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
filepath: The file path to validate
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
True if the path is safe to access, False otherwise
|
|
103
|
+
"""
|
|
104
|
+
if not filepath:
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
requested_path = Path(filepath)
|
|
109
|
+
if requested_path.is_absolute():
|
|
110
|
+
resolved_path = requested_path.resolve()
|
|
111
|
+
else:
|
|
112
|
+
resolved_path = (ALLOWED_BASE_PATH / requested_path).resolve()
|
|
113
|
+
|
|
114
|
+
# Ensure the path is within the allowed base directory
|
|
115
|
+
resolved_path.relative_to(ALLOWED_BASE_PATH)
|
|
116
|
+
return True
|
|
117
|
+
except (ValueError, OSError):
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _calculate_indent_level(leading_spaces: int) -> int:
|
|
122
|
+
"""Calculate bullet indent level from leading whitespace."""
|
|
123
|
+
return leading_spaces // 2 if leading_spaces >= 2 else 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _clean_markdown_text(text: str) -> str:
|
|
127
|
+
"""Clean markdown formatting from text while preserving content."""
|
|
128
|
+
if not text:
|
|
129
|
+
return ""
|
|
130
|
+
|
|
131
|
+
# Remove bold markers (**text** or __text__)
|
|
132
|
+
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
|
|
133
|
+
text = re.sub(r'__(.+?)__', r'\1', text)
|
|
134
|
+
|
|
135
|
+
# Remove italic markers (*text* or _text_) - be careful not to match bullet points
|
|
136
|
+
text = re.sub(r'(?<!\*)\*([^*\n]+?)\*(?!\*)', r'\1', text)
|
|
137
|
+
text = re.sub(r'(?<!_)_([^_\n]+?)_(?!_)', r'\1', text)
|
|
138
|
+
|
|
139
|
+
# Remove inline code markers (`text`)
|
|
140
|
+
text = re.sub(r'`([^`]+?)`', r'\1', text)
|
|
141
|
+
|
|
142
|
+
# Remove image syntax  - must come before link syntax
|
|
143
|
+
text = re.sub(r'!\[([^\]]*?)\]\([^)]+?\)', r'\1', text)
|
|
144
|
+
|
|
145
|
+
# Remove link syntax [text](url) - keep the text
|
|
146
|
+
text = re.sub(r'\[([^\]]+?)\]\([^)]+?\)', r'\1', text)
|
|
147
|
+
|
|
148
|
+
# Clean up any remaining markdown artifacts
|
|
149
|
+
text = re.sub(r'^\s*#{1,6}\s*', '', text) # Remove header markers at start of line
|
|
150
|
+
|
|
151
|
+
return text.strip()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _apply_sandia_template(prs: Presentation) -> None:
|
|
155
|
+
"""Apply Sandia-style template settings to the presentation."""
|
|
156
|
+
# Set 16:9 aspect ratio
|
|
157
|
+
prs.slide_width = SLIDE_WIDTH
|
|
158
|
+
prs.slide_height = SLIDE_HEIGHT
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _add_footer_bar(slide_obj, slide_num: int, total_slides: int) -> None:
|
|
162
|
+
"""Add a professional footer bar to the slide."""
|
|
163
|
+
# Add footer bar at bottom
|
|
164
|
+
footer_height = Inches(0.4)
|
|
165
|
+
footer_top = SLIDE_HEIGHT - footer_height
|
|
166
|
+
|
|
167
|
+
# Add footer rectangle
|
|
168
|
+
footer_shape = slide_obj.shapes.add_shape(
|
|
169
|
+
MSO_SHAPE.RECTANGLE,
|
|
170
|
+
Inches(0), footer_top,
|
|
171
|
+
SLIDE_WIDTH, footer_height
|
|
172
|
+
)
|
|
173
|
+
footer_shape.fill.solid()
|
|
174
|
+
footer_shape.fill.fore_color.rgb = SANDIA_BLUE
|
|
175
|
+
footer_shape.line.fill.background()
|
|
176
|
+
|
|
177
|
+
# Add slide number text
|
|
178
|
+
slide_num_box = slide_obj.shapes.add_textbox(
|
|
179
|
+
SLIDE_WIDTH - Inches(1), footer_top + Inches(0.05),
|
|
180
|
+
Inches(0.9), Inches(0.3)
|
|
181
|
+
)
|
|
182
|
+
tf = slide_num_box.text_frame
|
|
183
|
+
tf.paragraphs[0].text = f"{slide_num}/{total_slides}"
|
|
184
|
+
tf.paragraphs[0].font.size = Pt(10)
|
|
185
|
+
tf.paragraphs[0].font.color.rgb = SANDIA_WHITE
|
|
186
|
+
tf.paragraphs[0].alignment = PP_ALIGN.RIGHT
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _add_header_bar(slide_obj) -> None:
|
|
190
|
+
"""Add a professional header accent bar to the slide."""
|
|
191
|
+
# Add thin accent bar at top
|
|
192
|
+
header_height = Inches(0.1)
|
|
193
|
+
header_shape = slide_obj.shapes.add_shape(
|
|
194
|
+
MSO_SHAPE.RECTANGLE,
|
|
195
|
+
Inches(0), Inches(0),
|
|
196
|
+
SLIDE_WIDTH, header_height
|
|
197
|
+
)
|
|
198
|
+
header_shape.fill.solid()
|
|
199
|
+
header_shape.fill.fore_color.rgb = SANDIA_LIGHT_BLUE
|
|
200
|
+
header_shape.line.fill.background()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _style_title(title_shape) -> None:
|
|
204
|
+
"""Apply Sandia styling to title text."""
|
|
205
|
+
if title_shape and title_shape.has_text_frame:
|
|
206
|
+
for paragraph in title_shape.text_frame.paragraphs:
|
|
207
|
+
paragraph.font.color.rgb = SANDIA_BLUE
|
|
208
|
+
paragraph.font.bold = True
|
|
209
|
+
paragraph.font.size = Pt(36)
|
|
210
|
+
|
|
211
|
+
def _sanitize_filename(filename: str, max_length: int = 50) -> str:
|
|
212
|
+
"""Sanitize filename by removing bad characters and truncating."""
|
|
213
|
+
# Remove bad characters (anything not alphanumeric, underscore, or dash)
|
|
214
|
+
cleaned_filename = re.sub(r'[^\w\-]', '', filename)
|
|
215
|
+
# Remove newlines and extra spaces
|
|
216
|
+
cleaned_filename = re.sub(r'\s+', '', cleaned_filename)
|
|
217
|
+
# Truncate to max length
|
|
218
|
+
return cleaned_filename[:max_length] if cleaned_filename else "presentation"
|
|
219
|
+
|
|
220
|
+
def _is_backend_download_path(s: str) -> bool:
|
|
221
|
+
"""Detect backend-relative download paths like /api/files/download/...."""
|
|
222
|
+
return isinstance(s, str) and s.startswith("/api/files/download/")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _backend_base_url() -> str:
|
|
226
|
+
"""Resolve backend base URL from environment variable."""
|
|
227
|
+
return os.environ.get("CHATUI_BACKEND_BASE_URL", "http://127.0.0.1:8000")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _load_image_bytes(filename: str, file_data_base64: str = "") -> Optional[bytes]:
|
|
231
|
+
"""Load image data from filename or base64 data."""
|
|
232
|
+
if file_data_base64:
|
|
233
|
+
try:
|
|
234
|
+
return base64.b64decode(file_data_base64)
|
|
235
|
+
except Exception as e:
|
|
236
|
+
if VERBOSE:
|
|
237
|
+
logger.info(f"Error decoding base64 image data: {e}")
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
if _is_backend_download_path(filename):
|
|
241
|
+
# Backend provided a download path
|
|
242
|
+
full_url = _backend_base_url() + filename
|
|
243
|
+
try:
|
|
244
|
+
if VERBOSE:
|
|
245
|
+
logger.info(f"Fetching image from {full_url}")
|
|
246
|
+
response = requests.get(full_url, timeout=30)
|
|
247
|
+
response.raise_for_status()
|
|
248
|
+
return response.content
|
|
249
|
+
except Exception as e:
|
|
250
|
+
if VERBOSE:
|
|
251
|
+
logger.info(f"Error fetching image from {full_url}: {e}")
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
# Try as local file path - with path traversal protection
|
|
255
|
+
if _is_safe_local_path(filename) and os.path.isfile(filename):
|
|
256
|
+
try:
|
|
257
|
+
with open(filename, "rb") as f:
|
|
258
|
+
return f.read()
|
|
259
|
+
except Exception as e:
|
|
260
|
+
if VERBOSE:
|
|
261
|
+
logger.info(f"Error reading local image file {filename}: {e}")
|
|
262
|
+
return None
|
|
263
|
+
elif not _is_safe_local_path(filename):
|
|
264
|
+
if VERBOSE:
|
|
265
|
+
logger.warning(f"Blocked access to unsafe path: {filename}")
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
if VERBOSE:
|
|
269
|
+
logger.info(f"Image file not found: {filename}")
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _parse_markdown_slides(markdown_content: str) -> List[Dict[str, str]]:
|
|
274
|
+
"""Parse markdown content into slides with improved bullet point handling."""
|
|
275
|
+
slides = []
|
|
276
|
+
|
|
277
|
+
# Split by headers (# or ##)
|
|
278
|
+
sections = re.split(r'^#{1,2}\s+(.+)$', markdown_content, flags=re.MULTILINE)
|
|
279
|
+
|
|
280
|
+
# Remove empty first element if exists
|
|
281
|
+
if sections and not sections[0].strip():
|
|
282
|
+
sections = sections[1:]
|
|
283
|
+
|
|
284
|
+
# Group into title/content pairs
|
|
285
|
+
for i in range(0, len(sections), 2):
|
|
286
|
+
if i + 1 < len(sections):
|
|
287
|
+
title = _clean_markdown_text(sections[i].strip())
|
|
288
|
+
content = sections[i + 1].strip() if i + 1 < len(sections) else ""
|
|
289
|
+
slides.append({"title": title, "content": content})
|
|
290
|
+
elif sections[i].strip():
|
|
291
|
+
# Handle case where there's a title but no content
|
|
292
|
+
slides.append({"title": _clean_markdown_text(sections[i].strip()), "content": ""})
|
|
293
|
+
|
|
294
|
+
# If no headers found, treat entire content as one slide
|
|
295
|
+
if not slides and markdown_content.strip():
|
|
296
|
+
slides.append({"title": "Slide 1", "content": markdown_content.strip()})
|
|
297
|
+
|
|
298
|
+
return slides
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _add_image_to_slide(slide_obj, image_bytes: bytes,
|
|
302
|
+
left: Inches = Inches(1), top: Inches = Inches(2),
|
|
303
|
+
width: Inches = Inches(10), height: Inches = Inches(5)):
|
|
304
|
+
"""Add image to a slide with 16:9 optimized positioning."""
|
|
305
|
+
try:
|
|
306
|
+
# Create a temporary file for the image
|
|
307
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix='.png') as tmp_file:
|
|
308
|
+
tmp_file.write(image_bytes)
|
|
309
|
+
tmp_file.flush()
|
|
310
|
+
|
|
311
|
+
# Add image to slide
|
|
312
|
+
pic = slide_obj.shapes.add_picture(tmp_file.name, left, top, width, height)
|
|
313
|
+
|
|
314
|
+
# Clean up
|
|
315
|
+
os.unlink(tmp_file.name)
|
|
316
|
+
|
|
317
|
+
return pic
|
|
318
|
+
except Exception as e:
|
|
319
|
+
logger.error(f"Error adding image to slide: {e}")
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@mcp.tool
|
|
325
|
+
def markdown_to_pptx(
|
|
326
|
+
markdown_content: Annotated[str, "Markdown content with headers (# or ##) as slide titles and content below each header"],
|
|
327
|
+
file_name: Annotated[Optional[str], "Output file name (base name for generated files without extension)"] = None,
|
|
328
|
+
image_filename: Annotated[Optional[str], "Optional image filename to integrate into the presentation"] = "",
|
|
329
|
+
image_data_base64: Annotated[Optional[str], "Framework may supply Base64 image content as fallback"] = ""
|
|
330
|
+
) -> Dict[str, Any]:
|
|
331
|
+
"""
|
|
332
|
+
Converts markdown content to a professional PowerPoint presentation with 16:9 aspect ratio.
|
|
333
|
+
|
|
334
|
+
Creates polished presentations with Sandia-style professional templating, proper bullet
|
|
335
|
+
point formatting, and clean markdown text handling.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
markdown_content: Markdown content where headers (# or ##) become slide titles and content below becomes slide content
|
|
339
|
+
file_name: Output file name (base name for generated files without extension)
|
|
340
|
+
image_filename: Optional image filename to integrate into the presentation
|
|
341
|
+
image_data_base64: Framework may supply Base64 image content as fallback
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
Dictionary with 'results' and 'artifacts' keys:
|
|
345
|
+
- 'results': Success message or error message
|
|
346
|
+
- 'artifacts': List of artifact dictionaries with 'name', 'b64', and 'mime' keys
|
|
347
|
+
"""
|
|
348
|
+
if VERBOSE:
|
|
349
|
+
logger.info("Starting markdown_to_pptx execution...")
|
|
350
|
+
try:
|
|
351
|
+
# Handle None values and sanitize the output filename
|
|
352
|
+
image_filename = image_filename or ""
|
|
353
|
+
image_data_base64 = image_data_base64 or ""
|
|
354
|
+
# Use file_name if provided, otherwise use default "presentation"
|
|
355
|
+
output_filename = _sanitize_filename(file_name or "presentation")
|
|
356
|
+
|
|
357
|
+
# Parse markdown into slides
|
|
358
|
+
slides = _parse_markdown_slides(markdown_content)
|
|
359
|
+
if VERBOSE:
|
|
360
|
+
logger.info(f"Parsed {len(slides)} slides from markdown")
|
|
361
|
+
|
|
362
|
+
if not slides:
|
|
363
|
+
return {"results": {"error": "No slides could be parsed from markdown content"}}
|
|
364
|
+
|
|
365
|
+
total_slides = len(slides)
|
|
366
|
+
|
|
367
|
+
# Load image if provided
|
|
368
|
+
image_bytes = None
|
|
369
|
+
if image_filename:
|
|
370
|
+
image_bytes = _load_image_bytes(image_filename, image_data_base64)
|
|
371
|
+
if image_bytes:
|
|
372
|
+
if VERBOSE:
|
|
373
|
+
logger.info(f"Loaded image: {image_filename}")
|
|
374
|
+
else:
|
|
375
|
+
if VERBOSE:
|
|
376
|
+
logger.info(f"Failed to load image: {image_filename}")
|
|
377
|
+
|
|
378
|
+
# Create presentation with 16:9 aspect ratio
|
|
379
|
+
prs = Presentation()
|
|
380
|
+
_apply_sandia_template(prs)
|
|
381
|
+
if VERBOSE:
|
|
382
|
+
logger.info("Created PowerPoint presentation with 16:9 aspect ratio")
|
|
383
|
+
|
|
384
|
+
for i, slide_data in enumerate(slides):
|
|
385
|
+
title = slide_data.get('title', 'Untitled Slide')
|
|
386
|
+
content = slide_data.get('content', '')
|
|
387
|
+
|
|
388
|
+
# Add slide using blank layout for full control
|
|
389
|
+
slide_layout = prs.slide_layouts[6] # Blank layout
|
|
390
|
+
slide_obj = prs.slides.add_slide(slide_layout)
|
|
391
|
+
|
|
392
|
+
# Add header accent bar
|
|
393
|
+
_add_header_bar(slide_obj)
|
|
394
|
+
|
|
395
|
+
# Add title text box
|
|
396
|
+
title_box = slide_obj.shapes.add_textbox(
|
|
397
|
+
Inches(0.5), Inches(0.3),
|
|
398
|
+
SLIDE_WIDTH - Inches(1), Inches(0.8)
|
|
399
|
+
)
|
|
400
|
+
title_tf = title_box.text_frame
|
|
401
|
+
title_tf.word_wrap = True
|
|
402
|
+
title_p = title_tf.paragraphs[0]
|
|
403
|
+
title_p.text = title
|
|
404
|
+
title_p.font.size = Pt(36)
|
|
405
|
+
title_p.font.bold = True
|
|
406
|
+
title_p.font.color.rgb = SANDIA_BLUE
|
|
407
|
+
title_p.alignment = PP_ALIGN.LEFT
|
|
408
|
+
|
|
409
|
+
if VERBOSE:
|
|
410
|
+
logger.info(f"Added slide {i+1}: {title}")
|
|
411
|
+
|
|
412
|
+
# Add content text box
|
|
413
|
+
content_box = slide_obj.shapes.add_textbox(
|
|
414
|
+
Inches(0.5), Inches(1.3),
|
|
415
|
+
SLIDE_WIDTH - Inches(1), SLIDE_HEIGHT - Inches(2.0)
|
|
416
|
+
)
|
|
417
|
+
tf = content_box.text_frame
|
|
418
|
+
tf.word_wrap = True
|
|
419
|
+
|
|
420
|
+
# Process content - handle bullet points and regular text with improved cleanup
|
|
421
|
+
if content.strip():
|
|
422
|
+
lines = content.split('\n')
|
|
423
|
+
first_paragraph = True
|
|
424
|
+
|
|
425
|
+
for line in lines:
|
|
426
|
+
line = line.rstrip()
|
|
427
|
+
|
|
428
|
+
if not line.strip():
|
|
429
|
+
continue
|
|
430
|
+
|
|
431
|
+
# Calculate indentation level
|
|
432
|
+
indent_level = 0
|
|
433
|
+
stripped_line = line.lstrip()
|
|
434
|
+
leading_spaces = len(line) - len(stripped_line)
|
|
435
|
+
|
|
436
|
+
# Check for various bullet point formats
|
|
437
|
+
is_bullet = False
|
|
438
|
+
bullet_text = stripped_line
|
|
439
|
+
|
|
440
|
+
# Handle numbered lists (1. 2. etc.)
|
|
441
|
+
numbered_match = re.match(r'^(\d+)\.\s+(.+)$', stripped_line)
|
|
442
|
+
if numbered_match:
|
|
443
|
+
is_bullet = True
|
|
444
|
+
bullet_text = numbered_match.group(2)
|
|
445
|
+
indent_level = _calculate_indent_level(leading_spaces)
|
|
446
|
+
# Handle bullet points (-, *, +) with regex for proper text extraction
|
|
447
|
+
else:
|
|
448
|
+
bullet_match = re.match(r'^[-*+]\s+(.+)$', stripped_line)
|
|
449
|
+
if bullet_match:
|
|
450
|
+
is_bullet = True
|
|
451
|
+
bullet_text = bullet_match.group(1)
|
|
452
|
+
indent_level = _calculate_indent_level(leading_spaces)
|
|
453
|
+
|
|
454
|
+
# Clean the bullet text from markdown formatting
|
|
455
|
+
bullet_text = _clean_markdown_text(bullet_text.strip())
|
|
456
|
+
|
|
457
|
+
if not bullet_text:
|
|
458
|
+
continue
|
|
459
|
+
|
|
460
|
+
if first_paragraph:
|
|
461
|
+
p = tf.paragraphs[0]
|
|
462
|
+
first_paragraph = False
|
|
463
|
+
else:
|
|
464
|
+
p = tf.add_paragraph()
|
|
465
|
+
|
|
466
|
+
p.text = bullet_text
|
|
467
|
+
p.level = min(indent_level, 4) # Cap at level 4
|
|
468
|
+
p.font.size = Pt(20)
|
|
469
|
+
p.font.color.rgb = SANDIA_GRAY if indent_level > 0 else RGBColor(51, 51, 51)
|
|
470
|
+
p.space_after = Pt(8)
|
|
471
|
+
p.alignment = PP_ALIGN.LEFT
|
|
472
|
+
|
|
473
|
+
# Add footer bar with slide number
|
|
474
|
+
_add_footer_bar(slide_obj, i + 1, total_slides)
|
|
475
|
+
|
|
476
|
+
# Add image to first slide if provided
|
|
477
|
+
if i == 0 and image_bytes:
|
|
478
|
+
_add_image_to_slide(slide_obj, image_bytes,
|
|
479
|
+
left=Inches(8), top=Inches(2),
|
|
480
|
+
width=Inches(4.5), height=Inches(4))
|
|
481
|
+
|
|
482
|
+
# Write outputs to a temporary directory and clean up after encoding
|
|
483
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
484
|
+
# Save presentation
|
|
485
|
+
pptx_output_path = os.path.join(tmpdir, f"output_{output_filename}.pptx")
|
|
486
|
+
prs.save(pptx_output_path)
|
|
487
|
+
if VERBOSE:
|
|
488
|
+
logger.info(f"Saved PowerPoint presentation to {pptx_output_path}")
|
|
489
|
+
|
|
490
|
+
# Create HTML file instead of PDF
|
|
491
|
+
html_output_path = os.path.join(tmpdir, f"output_{output_filename}.html")
|
|
492
|
+
if VERBOSE:
|
|
493
|
+
logger.info(f"Starting HTML creation to {html_output_path}")
|
|
494
|
+
|
|
495
|
+
# Create HTML representation of the presentation with Sandia styling
|
|
496
|
+
html_content = """<!DOCTYPE html>
|
|
497
|
+
<html lang="en">
|
|
498
|
+
<head>
|
|
499
|
+
<meta charset="UTF-8">
|
|
500
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
501
|
+
<title>PowerPoint Presentation</title>
|
|
502
|
+
<style>
|
|
503
|
+
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background-color: #f0f0f0; }
|
|
504
|
+
.slide {
|
|
505
|
+
background: white;
|
|
506
|
+
margin: 20px auto;
|
|
507
|
+
padding: 0;
|
|
508
|
+
max-width: 960px;
|
|
509
|
+
aspect-ratio: 16/9;
|
|
510
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
511
|
+
border-radius: 4px;
|
|
512
|
+
page-break-after: always;
|
|
513
|
+
position: relative;
|
|
514
|
+
overflow: hidden;
|
|
515
|
+
}
|
|
516
|
+
.header-bar {
|
|
517
|
+
background: #006699;
|
|
518
|
+
height: 8px;
|
|
519
|
+
width: 100%;
|
|
520
|
+
}
|
|
521
|
+
.footer-bar {
|
|
522
|
+
background: #003366;
|
|
523
|
+
height: 32px;
|
|
524
|
+
width: 100%;
|
|
525
|
+
position: absolute;
|
|
526
|
+
bottom: 0;
|
|
527
|
+
display: flex;
|
|
528
|
+
align-items: center;
|
|
529
|
+
justify-content: flex-end;
|
|
530
|
+
padding-right: 20px;
|
|
531
|
+
box-sizing: border-box;
|
|
532
|
+
}
|
|
533
|
+
.slide-number {
|
|
534
|
+
color: white;
|
|
535
|
+
font-size: 12px;
|
|
536
|
+
}
|
|
537
|
+
.slide-content-wrapper {
|
|
538
|
+
padding: 20px 40px;
|
|
539
|
+
}
|
|
540
|
+
.slide-title {
|
|
541
|
+
color: #003366;
|
|
542
|
+
font-size: 28px;
|
|
543
|
+
font-weight: bold;
|
|
544
|
+
margin-bottom: 20px;
|
|
545
|
+
}
|
|
546
|
+
.slide-content {
|
|
547
|
+
font-size: 16px;
|
|
548
|
+
line-height: 1.6;
|
|
549
|
+
color: #333;
|
|
550
|
+
}
|
|
551
|
+
.slide-content ul {
|
|
552
|
+
margin: 0;
|
|
553
|
+
padding-left: 30px;
|
|
554
|
+
list-style-type: disc;
|
|
555
|
+
}
|
|
556
|
+
.slide-content ul ul {
|
|
557
|
+
list-style-type: circle;
|
|
558
|
+
color: #666;
|
|
559
|
+
}
|
|
560
|
+
.slide-content li {
|
|
561
|
+
margin-bottom: 8px;
|
|
562
|
+
}
|
|
563
|
+
.slide-image {
|
|
564
|
+
max-width: 350px;
|
|
565
|
+
max-height: 250px;
|
|
566
|
+
display: block;
|
|
567
|
+
margin: 20px auto;
|
|
568
|
+
border-radius: 4px;
|
|
569
|
+
}
|
|
570
|
+
</style>
|
|
571
|
+
</head>
|
|
572
|
+
<body>"""
|
|
573
|
+
|
|
574
|
+
for i, slide_data in enumerate(slides):
|
|
575
|
+
title = slide_data.get('title', 'Untitled Slide')
|
|
576
|
+
content = slide_data.get('content', '')
|
|
577
|
+
|
|
578
|
+
# Escape HTML to prevent XSS attacks
|
|
579
|
+
safe_title = _escape_html(_clean_markdown_text(title))
|
|
580
|
+
|
|
581
|
+
html_content += f"""
|
|
582
|
+
<div class="slide">
|
|
583
|
+
<div class="header-bar"></div>
|
|
584
|
+
<div class="slide-content-wrapper">
|
|
585
|
+
<div class="slide-title">{safe_title}</div>
|
|
586
|
+
<div class="slide-content">"""
|
|
587
|
+
|
|
588
|
+
# Add image to first slide if provided
|
|
589
|
+
if i == 0 and image_bytes:
|
|
590
|
+
try:
|
|
591
|
+
img = Image.open(io.BytesIO(image_bytes))
|
|
592
|
+
mime_type = Image.MIME.get(img.format)
|
|
593
|
+
if mime_type:
|
|
594
|
+
img_b64 = base64.b64encode(image_bytes).decode('utf-8')
|
|
595
|
+
html_content += f'<img src="data:{mime_type};base64,{img_b64}" class="slide-image" />'
|
|
596
|
+
except Exception as e:
|
|
597
|
+
if VERBOSE:
|
|
598
|
+
logger.warning(f"Could not process image for HTML conversion: {e}")
|
|
599
|
+
|
|
600
|
+
if content.strip():
|
|
601
|
+
lines = content.split('\n')
|
|
602
|
+
in_list = False
|
|
603
|
+
current_indent = 0
|
|
604
|
+
|
|
605
|
+
for line in lines:
|
|
606
|
+
stripped = line.strip()
|
|
607
|
+
if not stripped:
|
|
608
|
+
continue
|
|
609
|
+
|
|
610
|
+
# Detect bullet points with various formats
|
|
611
|
+
is_bullet = False
|
|
612
|
+
bullet_text = stripped
|
|
613
|
+
indent = _calculate_indent_level(len(line) - len(line.lstrip()))
|
|
614
|
+
|
|
615
|
+
# Numbered list
|
|
616
|
+
numbered_match = re.match(r'^(\d+)\.\s+(.+)$', stripped)
|
|
617
|
+
if numbered_match:
|
|
618
|
+
is_bullet = True
|
|
619
|
+
bullet_text = numbered_match.group(2)
|
|
620
|
+
else:
|
|
621
|
+
# Handle bullet points (-, *, +) with regex
|
|
622
|
+
bullet_match = re.match(r'^[-*+]\s+(.+)$', stripped)
|
|
623
|
+
if bullet_match:
|
|
624
|
+
is_bullet = True
|
|
625
|
+
bullet_text = bullet_match.group(1)
|
|
626
|
+
|
|
627
|
+
# Clean markdown from text and escape HTML
|
|
628
|
+
bullet_text = _escape_html(_clean_markdown_text(bullet_text))
|
|
629
|
+
|
|
630
|
+
if is_bullet:
|
|
631
|
+
if not in_list:
|
|
632
|
+
html_content += "<ul>"
|
|
633
|
+
in_list = True
|
|
634
|
+
current_indent = indent
|
|
635
|
+
|
|
636
|
+
# Handle indent changes
|
|
637
|
+
while current_indent < indent:
|
|
638
|
+
html_content += "<ul>"
|
|
639
|
+
current_indent += 1
|
|
640
|
+
while current_indent > indent:
|
|
641
|
+
html_content += "</ul>"
|
|
642
|
+
current_indent -= 1
|
|
643
|
+
|
|
644
|
+
html_content += f"<li>{bullet_text}</li>"
|
|
645
|
+
else:
|
|
646
|
+
# Close all open lists
|
|
647
|
+
while current_indent > 0:
|
|
648
|
+
html_content += "</ul>"
|
|
649
|
+
current_indent -= 1
|
|
650
|
+
if in_list:
|
|
651
|
+
html_content += "</ul>"
|
|
652
|
+
in_list = False
|
|
653
|
+
# Escape HTML in paragraph text to prevent XSS
|
|
654
|
+
safe_paragraph = _escape_html(_clean_markdown_text(stripped))
|
|
655
|
+
html_content += f"<p>{safe_paragraph}</p>"
|
|
656
|
+
|
|
657
|
+
# Close any remaining open lists
|
|
658
|
+
while current_indent > 0:
|
|
659
|
+
html_content += "</ul>"
|
|
660
|
+
current_indent -= 1
|
|
661
|
+
if in_list:
|
|
662
|
+
html_content += "</ul>"
|
|
663
|
+
|
|
664
|
+
html_content += f"""
|
|
665
|
+
</div>
|
|
666
|
+
</div>
|
|
667
|
+
<div class="footer-bar">
|
|
668
|
+
<span class="slide-number">{i+1}/{total_slides}</span>
|
|
669
|
+
</div>
|
|
670
|
+
</div>"""
|
|
671
|
+
|
|
672
|
+
html_content += """
|
|
673
|
+
</body>
|
|
674
|
+
</html>"""
|
|
675
|
+
|
|
676
|
+
# Save HTML file
|
|
677
|
+
try:
|
|
678
|
+
if VERBOSE:
|
|
679
|
+
logger.info("Saving HTML file...")
|
|
680
|
+
with open(html_output_path, "w", encoding="utf-8") as f:
|
|
681
|
+
f.write(html_content)
|
|
682
|
+
if VERBOSE:
|
|
683
|
+
logger.info(f"HTML successfully created at {html_output_path}")
|
|
684
|
+
except Exception as e:
|
|
685
|
+
# If HTML save fails, continue with just PPTX
|
|
686
|
+
if VERBOSE:
|
|
687
|
+
logger.warning(f"HTML creation failed: {str(e)}")
|
|
688
|
+
# Remove the HTML file if it exists
|
|
689
|
+
if os.path.exists(html_output_path):
|
|
690
|
+
os.remove(html_output_path)
|
|
691
|
+
if VERBOSE:
|
|
692
|
+
logger.info("HTML file removed due to creation error")
|
|
693
|
+
|
|
694
|
+
# Read PPTX file as bytes
|
|
695
|
+
with open(pptx_output_path, "rb") as f:
|
|
696
|
+
pptx_bytes = f.read()
|
|
697
|
+
|
|
698
|
+
# Encode PPTX as base64
|
|
699
|
+
pptx_b64 = base64.b64encode(pptx_bytes).decode('utf-8')
|
|
700
|
+
if VERBOSE:
|
|
701
|
+
logger.info("PPTX file successfully encoded to base64")
|
|
702
|
+
|
|
703
|
+
# Read HTML file as bytes if it exists
|
|
704
|
+
html_b64 = None
|
|
705
|
+
if os.path.exists(html_output_path):
|
|
706
|
+
with open(html_output_path, "r", encoding="utf-8") as f:
|
|
707
|
+
html_content_file = f.read()
|
|
708
|
+
html_b64 = base64.b64encode(html_content_file.encode('utf-8')).decode('utf-8')
|
|
709
|
+
if VERBOSE:
|
|
710
|
+
logger.info("HTML file successfully encoded to base64")
|
|
711
|
+
|
|
712
|
+
# Prepare artifacts
|
|
713
|
+
artifacts = [
|
|
714
|
+
{
|
|
715
|
+
"name": f"{output_filename}.pptx",
|
|
716
|
+
"b64": pptx_b64,
|
|
717
|
+
"mime": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
718
|
+
}
|
|
719
|
+
]
|
|
720
|
+
|
|
721
|
+
# Add HTML if creation was successful
|
|
722
|
+
if html_b64:
|
|
723
|
+
artifacts.append({
|
|
724
|
+
"name": f"{output_filename}.html",
|
|
725
|
+
"b64": html_b64,
|
|
726
|
+
"mime": "text/html",
|
|
727
|
+
})
|
|
728
|
+
if VERBOSE:
|
|
729
|
+
logger.info(f"Added {len(artifacts)} artifacts to response")
|
|
730
|
+
else:
|
|
731
|
+
if VERBOSE:
|
|
732
|
+
logger.info("No HTML artifact added due to creation failure")
|
|
733
|
+
|
|
734
|
+
return {
|
|
735
|
+
"results": {
|
|
736
|
+
"operation": "markdown_to_pptx",
|
|
737
|
+
"message": "PowerPoint presentation and HTML file generated successfully from markdown.",
|
|
738
|
+
"html_generated": html_b64 is not None,
|
|
739
|
+
"image_included": image_bytes is not None,
|
|
740
|
+
},
|
|
741
|
+
"artifacts": artifacts,
|
|
742
|
+
"display": {
|
|
743
|
+
"open_canvas": True,
|
|
744
|
+
"primary_file": f"{output_filename}.pptx",
|
|
745
|
+
"mode": "replace",
|
|
746
|
+
"viewer_hint": "powerpoint",
|
|
747
|
+
},
|
|
748
|
+
"meta_data": {
|
|
749
|
+
"generated_slides": len(slides),
|
|
750
|
+
"output_files": [f"{output_filename}.pptx", f"{output_filename}.html"] if html_b64 else [f"{output_filename}.pptx"],
|
|
751
|
+
"output_file_paths": [f"temp:output_{output_filename}.pptx", f"temp:output_{output_filename}.html"] if html_b64 else [f"temp:output_{output_filename}.pptx"],
|
|
752
|
+
},
|
|
753
|
+
}
|
|
754
|
+
except Exception as e:
|
|
755
|
+
logger.error(f"Error in markdown_to_pptx: {str(e)}")
|
|
756
|
+
return {"results": {"error": f"Error creating PowerPoint from markdown: {str(e)}"}}
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
if __name__ == "__main__":
|
|
760
|
+
mcp.run()
|