eigent/backend/camel/toolkits/pptx_toolkit.py
2026-03-31 17:20:08 +08:00

790 lines
28 KiB
Python

# ========= Copyright 2023-2026 @ CAMEL-AI.org. All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2023-2026 @ CAMEL-AI.org. All Rights Reserved. =========
import os
import random
import re
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union
if TYPE_CHECKING:
from pptx import presentation
from pptx.slide import Slide
from pptx.text.text import TextFrame
from camel.logger import get_logger
from camel.toolkits.base import BaseToolkit
from camel.toolkits.function_tool import FunctionTool
from camel.utils import MCPServer, api_keys_required
logger = get_logger(__name__)
# Constants
EMU_TO_INCH_SCALING_FACTOR = 1.0 / 914400
STEP_BY_STEP_PROCESS_MARKER = '>> '
IMAGE_DISPLAY_PROBABILITY = 1 / 3.0
SLIDE_NUMBER_REGEX = re.compile(r"^slide[ ]+\d+:", re.IGNORECASE)
BOLD_ITALICS_PATTERN = re.compile(r'(\*\*(.*?)\*\*|\*(.*?)\*)')
@MCPServer()
class PPTXToolkit(BaseToolkit):
r"""A toolkit for creating and writing PowerPoint presentations (PPTX
files).
This class provides cross-platform support for creating PPTX files with
title slides, content slides, text formatting, and image embedding.
"""
def __init__(
self,
working_directory: Optional[str] = None,
timeout: Optional[float] = None,
) -> None:
r"""Initialize the PPTXToolkit.
Args:
working_directory (str, optional): The default directory for
output files. If not provided, it will be determined by the
`CAMEL_WORKDIR` environment variable (if set). If the
environment variable is not set, it defaults to
`camel_working_dir`.
timeout (Optional[float]): The timeout for the toolkit.
(default: :obj:`None`)
"""
super().__init__(timeout=timeout)
if working_directory:
self.working_directory = Path(working_directory).resolve()
else:
camel_workdir = os.environ.get("CAMEL_WORKDIR")
if camel_workdir:
self.working_directory = Path(camel_workdir).resolve()
else:
self.working_directory = Path("./camel_working_dir").resolve()
self.working_directory.mkdir(parents=True, exist_ok=True)
logger.info(
f"PPTXToolkit initialized with output directory: "
f"{self.working_directory}"
)
def _resolve_filepath(self, file_path: str) -> Path:
r"""Convert the given string path to a Path object.
If the provided path is not absolute, it is made relative to the
default output directory. The filename part is sanitized to replace
spaces and special characters with underscores, ensuring safe usage
in downstream processing.
Args:
file_path (str): The file path to resolve.
Returns:
Path: A fully resolved (absolute) and sanitized Path object.
"""
path_obj = Path(file_path)
if not path_obj.is_absolute():
path_obj = self.working_directory / path_obj
sanitized_filename = self._sanitize_filename(path_obj.name)
path_obj = path_obj.parent / sanitized_filename
return path_obj.resolve()
def _sanitize_filename(self, filename: str) -> str:
r"""Sanitize a filename by replacing special characters and spaces.
Args:
filename (str): The filename to sanitize.
Returns:
str: The sanitized filename.
"""
import re
# Replace spaces and special characters with underscores
sanitized = re.sub(r'[^\w\-_\.]', '_', filename)
# Remove multiple consecutive underscores
sanitized = re.sub(r'_+', '_', sanitized)
return sanitized
def _format_text(
self, frame_paragraph, text: str, set_color_to_white=False
) -> None:
r"""Apply bold and italic formatting while preserving the original
word order.
Args:
frame_paragraph: The paragraph to format.
text (str): The text to format.
set_color_to_white (bool): Whether to set the color to white.
(default: :obj:`False`)
"""
from pptx.dml.color import RGBColor
matches = list(BOLD_ITALICS_PATTERN.finditer(text))
last_index = 0
for match in matches:
start, end = match.span()
if start > last_index:
run = frame_paragraph.add_run()
run.text = text[last_index:start]
if set_color_to_white:
run.font.color.rgb = RGBColor(255, 255, 255)
if match.group(2): # Bold
run = frame_paragraph.add_run()
run.text = match.group(2)
run.font.bold = True
if set_color_to_white:
run.font.color.rgb = RGBColor(255, 255, 255)
elif match.group(3): # Italics
run = frame_paragraph.add_run()
run.text = match.group(3)
run.font.italic = True
if set_color_to_white:
run.font.color.rgb = RGBColor(255, 255, 255)
last_index = end
if last_index < len(text):
run = frame_paragraph.add_run()
run.text = text[last_index:]
if set_color_to_white:
run.font.color.rgb = RGBColor(255, 255, 255)
def _add_bulleted_items(
self,
text_frame: "TextFrame",
flat_items_list: List[Tuple[str, int]],
set_color_to_white: bool = False,
) -> None:
r"""Add a list of texts as bullet points and apply formatting.
Args:
text_frame (TextFrame): The text frame where text is to be
displayed.
flat_items_list (List[Tuple[str, int]]): The list of items to be
displayed.
set_color_to_white (bool): Whether to set the font color to white.
(default: :obj:`False`)
"""
if not flat_items_list:
logger.warning("Empty bullet point list provided")
return
for idx, item_content in enumerate(flat_items_list):
item_text, item_level = item_content
if idx == 0:
if not text_frame.paragraphs:
# Ensure a paragraph exists if the frame is empty or
# cleared
paragraph = text_frame.add_paragraph()
else:
# Use the first existing paragraph
paragraph = text_frame.paragraphs[0]
else:
paragraph = text_frame.add_paragraph()
paragraph.level = item_level
self._format_text(
paragraph,
item_text.removeprefix(STEP_BY_STEP_PROCESS_MARKER),
set_color_to_white=set_color_to_white,
)
def _get_flat_list_of_contents(
self, items: List[Union[str, List[Any]]], level: int
) -> List[Tuple[str, int]]:
r"""Flatten a hierarchical list of bullet points to a single list.
Args:
items (List[Union[str, List[Any]]]): A bullet point (string or
list).
level (int): The current level of hierarchy.
Returns:
List[Tuple[str, int]]: A list of (bullet item text, hierarchical
level) tuples.
"""
flat_list = []
for item in items:
if isinstance(item, str):
flat_list.append((item, level))
elif isinstance(item, list):
flat_list.extend(
self._get_flat_list_of_contents(item, level + 1)
)
return flat_list
def _get_slide_width_height_inches(
self, presentation: "presentation.Presentation"
) -> Tuple[float, float]:
r"""Get the dimensions of a slide in inches.
Args:
presentation (presentation.Presentation): The presentation object.
Returns:
Tuple[float, float]: The width and height in inches.
"""
slide_width_inch = EMU_TO_INCH_SCALING_FACTOR * (
presentation.slide_width or 0
)
slide_height_inch = EMU_TO_INCH_SCALING_FACTOR * (
presentation.slide_height or 0
)
return slide_width_inch, slide_height_inch
def _write_pptx_file(
self,
file_path: Path,
content: List[Dict[str, Any]],
template: Optional[str] = None,
) -> None:
r"""Write text content to a PPTX file with enhanced formatting.
Args:
file_path (Path): The target file path.
content (List[Dict[str, Any]]): The content to write to the PPTX
file. Must be a list of dictionaries where:
- First element: Title slide with keys 'title' and 'subtitle'
- Subsequent elements: Content slides with keys 'title', 'text'
template (Optional[str]): The name of the template to use. If not
provided, the default template will be used. (default: :obj:
`None`)
"""
from pptx import Presentation
# Use template if provided, otherwise create new presentation
if template is not None:
template_path = Path(template).resolve()
if not template_path.exists():
logger.warning(
f"Template file not found: {template_path}, using "
"default template"
)
presentation = Presentation()
else:
presentation = Presentation(str(template_path))
# Clear all existing slides by removing them from the slide
# list
while len(presentation.slides) > 0:
rId = presentation.slides._sldIdLst[-1].rId
presentation.part.drop_rel(rId)
del presentation.slides._sldIdLst[-1]
else:
presentation = Presentation()
slide_width_inch, slide_height_inch = (
self._get_slide_width_height_inches(presentation)
)
# Process slides
if content:
# Title slide (first element)
title_slide_data = content.pop(0) if content else {}
title_layout = presentation.slide_layouts[0]
title_slide = presentation.slides.add_slide(title_layout)
# Set title and subtitle
if title_slide.shapes.title:
title_slide.shapes.title.text_frame.clear()
self._format_text(
title_slide.shapes.title.text_frame.paragraphs[0],
title_slide_data.get("title", ""),
)
if len(title_slide.placeholders) > 1:
subtitle = title_slide.placeholders[1]
subtitle.text_frame.clear()
self._format_text(
subtitle.text_frame.paragraphs[0],
title_slide_data.get("subtitle", ""),
)
# Content slides
for slide_data in content:
if not isinstance(slide_data, dict):
continue
# Handle different slide types
if 'table' in slide_data:
self._handle_table(
presentation,
slide_data,
)
elif 'bullet_points' in slide_data:
if any(
step.startswith(STEP_BY_STEP_PROCESS_MARKER)
for step in slide_data['bullet_points']
):
self._handle_step_by_step_process(
presentation,
slide_data,
slide_width_inch,
slide_height_inch,
)
else:
self._handle_default_display(
presentation,
slide_data,
)
# Save the presentation
presentation.save(str(file_path))
logger.debug(f"Wrote PPTX to {file_path} with enhanced formatting")
def create_presentation(
self,
content: str,
filename: str,
template: Optional[str] = None,
) -> str:
r"""Create a PowerPoint presentation (PPTX) file.
Args:
content (str): The content to write to the PPTX file as a JSON
string. Must represent a list of dictionaries with the
following structure:
- First dict: title slide {"title": str, "subtitle": str}
- Other dicts: content slides, which can be one of:
* Bullet/step slides: {"heading": str, "bullet_points":
list of str or nested lists, "img_keywords": str
(optional)}
- If any bullet point starts with '>> ', it will be
rendered as a step-by-step process.
- "img_keywords" can be a URL or search keywords for
an image (optional).
* Table slides: {"heading": str, "table": {"headers": list
of str, "rows": list of list of str}}
filename (str): The name or path of the file. If a relative path is
supplied, it is resolved to self.working_directory.
template (Optional[str]): The path to the template PPTX file.
Initializes a presentation from a given template file Or PPTX
file. (default: :obj:`None`)
Returns:
str: A success message indicating the file was created.
Example:
[
{
"title": "Presentation Title",
"subtitle": "Presentation Subtitle"
},
{
"heading": "Slide Title",
"bullet_points": [
"**Bold text** for emphasis",
"*Italic text* for additional emphasis",
"Regular text for normal content"
],
"img_keywords": "relevant search terms for images"
},
{
"heading": "Step-by-Step Process",
"bullet_points": [
">> **Step 1:** First step description",
">> **Step 2:** Second step description",
">> **Step 3:** Third step description"
],
"img_keywords": "process workflow steps"
},
{
"heading": "Comparison Table",
"table": {
"headers": ["Column 1", "Column 2", "Column 3"],
"rows": [
["Row 1, Col 1", "Row 1, Col 2", "Row 1, Col 3"],
["Row 2, Col 1", "Row 2, Col 2", "Row 2, Col 3"]
]
},
"img_keywords": "comparison visualization"
}
]
"""
# Ensure filename has .pptx extension
if not filename.lower().endswith('.pptx'):
filename += '.pptx'
# Resolve file path
file_path = self._resolve_filepath(filename)
# Parse and validate content format
try:
import json
parsed_content = json.loads(content)
except json.JSONDecodeError as e:
logger.error(f"Content must be valid JSON: {e}")
return "Failed to parse content as JSON"
if not isinstance(parsed_content, list):
logger.error(
f"PPTX content must be a list of dictionaries, "
f"got {type(parsed_content).__name__}"
)
return "PPTX content must be a list of dictionaries"
try:
# Create the PPTX file
self._write_pptx_file(file_path, parsed_content.copy(), template)
success_msg = (
f"PowerPoint presentation successfully created: {file_path}"
)
logger.info(success_msg)
return success_msg
except Exception as e:
error_msg = f"Failed to create PPTX file {file_path}: {e!s}"
logger.error(error_msg)
return error_msg
def _handle_default_display(
self,
presentation: "presentation.Presentation",
slide_json: Dict[str, Any],
) -> None:
r"""Display a list of text in a slide.
Args:
presentation (presentation.Presentation): The presentation object.
slide_json (Dict[str, Any]): The content of the slide as JSON data.
"""
status = False
if 'img_keywords' in slide_json:
if random.random() < IMAGE_DISPLAY_PROBABILITY:
status = self._handle_display_image__in_foreground(
presentation,
slide_json,
)
if status:
return
# Image display failed, so display only text
bullet_slide_layout = presentation.slide_layouts[1]
slide = presentation.slides.add_slide(bullet_slide_layout)
shapes = slide.shapes
title_shape = shapes.title
try:
body_shape = shapes.placeholders[1]
except KeyError:
# Get placeholders from the slide without layout_number
placeholders = self._get_slide_placeholders(slide)
body_shape = shapes.placeholders[placeholders[0][0]]
title_shape.text = self._remove_slide_number_from_heading(
slide_json['heading']
)
text_frame = body_shape.text_frame
flat_items_list = self._get_flat_list_of_contents(
slide_json['bullet_points'], level=0
)
self._add_bulleted_items(text_frame, flat_items_list)
@api_keys_required(
[
("api_key", 'PEXELS_API_KEY'),
]
)
def _handle_display_image__in_foreground(
self,
presentation: "presentation.Presentation",
slide_json: Dict[str, Any],
) -> bool:
r"""Create a slide with text and image using a picture placeholder
layout.
Args:
presentation (presentation.Presentation): The presentation object.
slide_json (Dict[str, Any]): The content of the slide as JSON data.
Returns:
bool: True if the slide has been processed.
"""
from io import BytesIO
import requests
img_keywords = slide_json.get('img_keywords', '').strip()
slide = presentation.slide_layouts[8] # Picture with Caption
slide = presentation.slides.add_slide(slide)
placeholders = None
title_placeholder = slide.shapes.title # type: ignore[attr-defined]
title_placeholder.text = self._remove_slide_number_from_heading(
slide_json['heading']
)
try:
pic_col = slide.shapes.placeholders[1] # type: ignore[attr-defined]
except KeyError:
# Get placeholders from the slide without layout_number
placeholders = self._get_slide_placeholders(slide) # type: ignore[arg-type]
pic_col = None
for idx, name in placeholders:
if 'picture' in name:
pic_col = slide.shapes.placeholders[idx] # type: ignore[attr-defined]
try:
text_col = slide.shapes.placeholders[2] # type: ignore[attr-defined]
except KeyError:
text_col = None
if not placeholders:
placeholders = self._get_slide_placeholders(slide) # type: ignore[arg-type]
for idx, name in placeholders:
if 'content' in name:
text_col = slide.shapes.placeholders[idx] # type: ignore[attr-defined]
flat_items_list = self._get_flat_list_of_contents(
slide_json['bullet_points'], level=0
)
self._add_bulleted_items(text_col.text_frame, flat_items_list)
if not img_keywords:
return True
if isinstance(img_keywords, str) and img_keywords.startswith(
('http://', 'https://')
):
try:
img_response = requests.get(img_keywords, timeout=30)
img_response.raise_for_status()
image_data = BytesIO(img_response.content)
pic_col.insert_picture(image_data)
return True
except Exception as ex:
logger.error(
'Error while downloading image from URL: %s', str(ex)
)
try:
url = 'https://api.pexels.com/v1/search'
api_key = os.getenv('PEXELS_API_KEY')
headers = {
'Authorization': api_key,
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:10.0) '
'Gecko/20100101 Firefox/10.0',
}
params = {
'query': img_keywords,
'size': 'medium',
'page': 1,
'per_page': 3,
}
response = requests.get(
url, headers=headers, params=params, timeout=12
)
response.raise_for_status()
json_response = response.json()
if json_response.get('photos'):
photo = random.choice(json_response['photos'])
photo_url = photo.get('src', {}).get('large') or photo.get(
'src', {}
).get('original')
if photo_url:
# Download and insert the image
img_response = requests.get(
photo_url, headers=headers, stream=True, timeout=12
)
img_response.raise_for_status()
image_data = BytesIO(img_response.content)
pic_col.insert_picture(image_data)
except Exception as ex:
logger.error(
'Error occurred while adding image to slide: %s', str(ex)
)
return True
def _handle_table(
self,
presentation: "presentation.Presentation",
slide_json: Dict[str, Any],
) -> None:
r"""Add a table to a slide.
Args:
presentation (presentation.Presentation): The presentation object.
slide_json (Dict[str, Any]): The content of the slide as JSON data.
"""
headers = slide_json['table'].get('headers', [])
rows = slide_json['table'].get('rows', [])
bullet_slide_layout = presentation.slide_layouts[1]
slide = presentation.slides.add_slide(bullet_slide_layout)
shapes = slide.shapes
shapes.title.text = self._remove_slide_number_from_heading(
slide_json['heading']
)
left = slide.placeholders[1].left
top = slide.placeholders[1].top
width = slide.placeholders[1].width
height = slide.placeholders[1].height
table = slide.shapes.add_table(
len(rows) + 1, len(headers), left, top, width, height
).table
# Set headers
for col_idx, header_text in enumerate(headers):
table.cell(0, col_idx).text = header_text
table.cell(0, col_idx).text_frame.paragraphs[0].font.bold = True
# Fill in rows
for row_idx, row_data in enumerate(rows, start=1):
for col_idx, cell_text in enumerate(row_data):
table.cell(row_idx, col_idx).text = cell_text
def _handle_step_by_step_process(
self,
presentation: "presentation.Presentation",
slide_json: Dict[str, Any],
slide_width_inch: float,
slide_height_inch: float,
) -> None:
r"""Add shapes to display a step-by-step process in the slide.
Args:
presentation (presentation.Presentation): The presentation object.
slide_json (Dict[str, Any]): The content of the slide as JSON data.
slide_width_inch (float): The width of the slide in inches.
slide_height_inch (float): The height of the slide in inches.
"""
from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
from pptx.util import Inches, Pt
steps = slide_json['bullet_points']
n_steps = len(steps)
bullet_slide_layout = presentation.slide_layouts[1]
slide = presentation.slides.add_slide(bullet_slide_layout)
shapes = slide.shapes
shapes.title.text = self._remove_slide_number_from_heading(
slide_json['heading']
)
if 3 <= n_steps <= 4:
# Horizontal display
height = Inches(1.5)
width = Inches(slide_width_inch / n_steps - 0.01)
top = Inches(slide_height_inch / 2)
left = Inches(
(slide_width_inch - width.inches * n_steps) / 2 + 0.05
)
for step in steps:
shape = shapes.add_shape(
MSO_AUTO_SHAPE_TYPE.CHEVRON, left, top, width, height
)
text_frame = shape.text_frame
text_frame.clear()
paragraph = text_frame.paragraphs[0]
paragraph.alignment = PP_ALIGN.CENTER
text_frame.vertical_anchor = MSO_ANCHOR.MIDDLE
self._format_text(
paragraph, step.removeprefix(STEP_BY_STEP_PROCESS_MARKER)
)
for run in paragraph.runs:
run.font.size = Pt(14)
left = Inches(left.inches + width.inches - Inches(0.4).inches)
elif 4 < n_steps <= 6:
# Vertical display
height = Inches(0.65)
top = Inches(slide_height_inch / 4)
left = Inches(1)
width = Inches(slide_width_inch * 2 / 3)
for step in steps:
shape = shapes.add_shape(
MSO_AUTO_SHAPE_TYPE.PENTAGON, left, top, width, height
)
text_frame = shape.text_frame
text_frame.clear()
paragraph = text_frame.paragraphs[0]
paragraph.alignment = PP_ALIGN.CENTER
text_frame.vertical_anchor = MSO_ANCHOR.MIDDLE
self._format_text(
paragraph, step.removeprefix(STEP_BY_STEP_PROCESS_MARKER)
)
for run in paragraph.runs:
run.font.size = Pt(14)
top = Inches(top.inches + height.inches + Inches(0.3).inches)
left = Inches(left.inches + Inches(0.5).inches)
def _remove_slide_number_from_heading(self, header: str) -> str:
r"""Remove the slide number from a given slide header.
Args:
header (str): The header of a slide.
Returns:
str: The header without slide number.
"""
if SLIDE_NUMBER_REGEX.match(header):
idx = header.find(':')
header = header[idx + 1 :]
return header
def _get_slide_placeholders(
self,
slide: "Slide",
) -> List[Tuple[int, str]]:
r"""Return the index and name of all placeholders present in a slide.
Args:
slide (Slide): The slide.
Returns:
List[Tuple[int, str]]: A list containing placeholders (idx, name)
tuples.
"""
if hasattr(slide.shapes, 'placeholders'):
placeholders = [
(shape.placeholder_format.idx, shape.name.lower())
for shape in slide.shapes.placeholders
]
if placeholders and len(placeholders) > 0:
placeholders.pop(0) # Remove the title placeholder
return placeholders
return []
def get_tools(self) -> List[FunctionTool]:
r"""Returns a list of FunctionTool objects representing the
functions in the toolkit.
Returns:
List[FunctionTool]: A list of FunctionTool objects
representing the functions in the toolkit.
"""
return [FunctionTool(self.create_presentation)]