Skill: zicheng354-tech/ppt-generate-and-edit (version 1.0.0)

--- name: ppt-generate-and-edit description: Generate and edit PowerPoint files with python-pptx, including slide creation, text replacement, and image replacement while preserving layout. Use when the user asks to create PPTs from structured content, update existing PPT content, or perform country/theme replacement in slides. --- # PPT Generate And Edit ## Purpose Provide a reusable Python skill for AI agents to: - Create PPT files from structured input. - Update existing PPT files with text/image replacement. - Keep image layout stable (same position and size) during replacement. ## Inputs ### create_ppt - `title`: presentation title. - `slides`: array of slide objects: - `title`: slide title. - `content`: string or list of paragraphs. - `image_prompt` (optional): prompt for image generation. ### update_ppt - `ppt_path`: source `.pptx` path. - `replace_rules`: - `text_replace`: dictionary for text replacement. - `image_replace`: dictionary for image replacement strategy. ## Outputs ### create_ppt - Returns generated `.pptx` file path. ### update_ppt - Returns: - `new_ppt_path`: new `.pptx` output path. - `replace_log`: replacement details grouped by slide. ## Module Structure - `main.py`: external API for agent calls and demo entry. - `ppt_generator.py`: PPT creation. - `ppt_editor.py`: PPT text/image replacement. - `image_service.py`: image generation abstraction (mock + extension points). - `utils.py`: logging and path helpers. ## Usage ```python from ppt_skill.main import create_ppt, update_ppt slides = [ { "title": "Brazil Overview", "content": ["Brazil is the largest country in South America."], "image_prompt": "Brazil skyline and national flag", } ] ppt_path = create_ppt(title="Brazil Briefing", slides=slides) result = update_ppt( ppt_path=ppt_path, replace_rules={ "text_replace": {"Brazil": "Chile"}, "image_replace": {"__all__": {"prompt": "Chile skyline and national flag"}}, }, ) print(result["new_ppt_path"]) ``` ## Notes - Current `generate_image()` uses `mock` backend by default. - DALL·E and Stable Diffusion integration points are reserved as TODO. - Keep API fields stable for downstream agent compatibility.

path: LICENSE.md
content: MIT-0 License (MIT No Attribution)

Copyright 2026

Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


path: requirements.txt
content: python-pptx


path: ppt_editor.py
content: """PPT editing logic for text and image replacement."""

from __future__ import annotations

from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple

from pptx import Presentation
from pptx.enum.shapes import MSO_SHAPE_TYPE

from .image_service import generate_image
from .utils import build_non_overwrite_path, get_logger

logger = get_logger(__name__)


def _replace_text_in_shape(shape, text_replace: Dict[str, str]) -> List[Tuple[str, str]]:
    """Replace matching text in one shape and return applied replacements."""
    replaced_pairs: List[Tuple[str, str]] = []
    if not shape.has_text_frame:
        return replaced_pairs

    for paragraph in shape.text_frame.paragraphs:
        for run in paragraph.runs:
            original = run.text
            updated = original
            for old_text, new_text in text_replace.items():
                if old_text in updated:
                    updated = updated.replace(old_text, new_text)
                    replaced_pairs.append((old_text, new_text))
            if updated != original:
                run.text = updated

    return replaced_pairs


def _should_replace_picture(shape, old_keyword: str) -> bool:
    """Check if a picture should be replaced based on current lightweight rules."""
    name_text = f"{getattr(shape, 'name', '')} {getattr(shape, 'alt_text', '')}".lower()
    return old_keyword.lower() in name_text


def _replace_picture_keep_layout(slide, picture_shape, new_image_path: str) -> None:
    """Replace one picture while preserving exact position and size."""
    left = picture_shape.left
    top = picture_shape.top
    width = picture_shape.width
    height = picture_shape.height
    element = picture_shape._element
    element.getparent().remove(element)
    slide.shapes.add_picture(new_image_path, left, top, width=width, height=height)


def _resolve_image_prompt(old_keyword: str, rule_value: Any) -> str:
    """Resolve prompt text from image replacement rule value."""
    if isinstance(rule_value, dict):
        explicit_prompt = rule_value.get("prompt")
        if explicit_prompt:
            return str(explicit_prompt)
        target = str(rule_value.get("target", "")).strip()
        if target:
            return f"High quality image of {target}"
    if isinstance(rule_value, str):
        target = rule_value.strip()
        if target:
            return f"High quality image of {target}"
    # TODO: business-specific prompt strategy should be confirmed by product owner.
    return f"Image related to {old_keyword}"


def update_ppt(
    ppt_path: str | Path,
    replace_rules: Dict[str, Any],
    image_generator: Optional[Callable[[str], str]] = None,
) -> Dict[str, Any]:
    """Update PPT text/images and save as a new file with replacement logs."""
    ppt_file = Path(ppt_path)
    logger.info("Start update_ppt source=%s", ppt_file)
    prs = Presentation(str(ppt_file))
    image_generator = image_generator or generate_image

    text_rules: Dict[str, str] = replace_rules.get("text_replace", {})
    image_rules: Dict[str, Any] = replace_rules.get("image_replace", {})

    replace_log: Dict[str, Any] = {"source": str(ppt_file), "slides": []}

    for idx, slide in enumerate(prs.slides, start=1):
        slide_log: Dict[str, Any] = {"slide_index": idx, "text_changes": [], "image_changes": []}
        logger.info("Processing slide index=%d", idx)

        for shape in list(slide.shapes):
            if text_rules:
                text_changes = _replace_text_in_shape(shape, text_rules)
                for old_text, new_text in text_changes:
                    slide_log["text_changes"].append({"from": old_text, "to": new_text})

            if shape.shape_type == MSO_SHAPE_TYPE.PICTURE and image_rules:
                for old_keyword, rule_value in image_rules.items():
                    if old_keyword == "__all__":
                        prompt = _resolve_image_prompt(old_keyword, rule_value)
                        new_image_path = image_generator(prompt)
                        _replace_picture_keep_layout(slide, shape, new_image_path)
                        slide_log["image_changes"].append(
                            {"match": old_keyword, "prompt": prompt, "new_image": new_image_path}
                        )
                        break

                    if _should_replace_picture(shape, old_keyword):
                        prompt = _resolve_image_prompt(old_keyword, rule_value)
                        new_image_path = image_generator(prompt)
                        _replace_picture_keep_layout(slide, shape, new_image_path)
                        slide_log["image_changes"].append(
                            {"match": old_keyword, "prompt": prompt, "new_image": new_image_path}
                        )
                        break

        if slide_log["text_changes"] or slide_log["image_changes"]:
            replace_log["slides"].append(slide_log)

    output_path = build_non_overwrite_path(ppt_file, suffix="_replaced")
    prs.save(str(output_path))
    replace_log["output"] = str(output_path)
    logger.info("update_ppt completed output=%s changed_slides=%d", output_path, len(replace_log["slides"]))
    return {"new_ppt_path": str(output_path), "replace_log": replace_log}


path: __init__.py
content: """Reusable PPT skill package for agent integration."""

from .ppt_editor import update_ppt
from .ppt_generator import create_ppt

__all__ = ["create_ppt", "update_ppt"]


path: README.md
content: # ppt_skill

Reusable Python skill module for AI agents to generate and modify PowerPoint files.

## Features

- `create_ppt`: create PPT slides from structured content.
- `update_ppt`: replace text and replace images in existing PPT files.
- `generate_image`: image generation interface (mock by default, extensible to DALL·E or Stable Diffusion).
- Stable layout behavior for image replacement (keeps original position and size).

## Project Structure

```text
ppt_skill/
  __init__.py
  main.py
  ppt_generator.py
  ppt_editor.py
  image_service.py
  utils.py
  SKILL.md
  README.md
  requirements.txt
  LICENSE
```

## Install

```bash
pip install -r requirements.txt
```

## Quick Start

```python
from ppt_skill.main import create_ppt, update_ppt

slides = [
    {
        "title": "Brazil Overview",
        "content": [
            "Brazil is the largest country in South America.",
            "Its economy is driven by agriculture, mining, and services.",
        ],
        "image_prompt": "Brazil flag and skyline of Sao Paulo",
    }
]

source_ppt = create_ppt("Brazil Trade Briefing", slides)

result = update_ppt(
    ppt_path=source_ppt,
    replace_rules={
        "text_replace": {"Brazil": "Chile", "Sao Paulo": "Santiago"},
        "image_replace": {"__all__": {"prompt": "Chile flag and Chilean city skyline"}},
    },
)

print(result["new_ppt_path"])
print(result["replace_log"])
```

## Demo

Run the built-in demo (Brazil -> Chile):

```bash
python -m ppt_skill.main
```

## Notes

- This package logs key steps for debug and traceability.
- `update_ppt` always saves to a new output file to avoid overriding source files.
- `image_replace` currently uses rule-based matching; visual semantic matching is left as future extension.


path: utils.py
content: """Shared utility helpers for the ppt_skill package."""

from __future__ import annotations

import json
import logging
from datetime import datetime
from pathlib import Path
from typing import Any, Dict


def get_logger(name: str) -> logging.Logger:
    """Create and return a consistent logger for all skill modules."""
    logger = logging.getLogger(name)
    if logger.handlers:
        return logger

    logger.setLevel(logging.INFO)
    handler = logging.StreamHandler()
    formatter = logging.Formatter(
        fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S",
    )
    handler.setFormatter(formatter)
    logger.addHandler(handler)
    logger.propagate = False
    return logger


def timestamp_str() -> str:
    """Return a compact timestamp used in generated output filenames."""
    return datetime.now().strftime("%Y%m%d_%H%M%S")


def ensure_directory(path: str | Path) -> Path:
    """Ensure a directory exists and return it as a Path object."""
    directory = Path(path)
    directory.mkdir(parents=True, exist_ok=True)
    return directory


def build_non_overwrite_path(path: str | Path, suffix: str = "_updated") -> Path:
    """Return a non-overwriting path by appending a suffix and timestamp."""
    src = Path(path)
    target_name = f"{src.stem}{suffix}_{timestamp_str()}{src.suffix}"
    return src.with_name(target_name)


def save_json(data: Dict[str, Any], output_path: str | Path) -> Path:
    """Persist dictionary content as UTF-8 JSON for debug logs."""
    out = Path(output_path)
    ensure_directory(out.parent)
    out.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
    return out


path: image_service.py
content: """Image generation service abstraction used by PPT modules."""

from __future__ import annotations

import base64
from pathlib import Path

from .utils import ensure_directory, get_logger, timestamp_str

logger = get_logger(__name__)

# 1x1 transparent PNG, used as deterministic mock output.
_MOCK_PNG_BASE64 = (
    [REDACTED_SECRET]
    [REDACTED_SECRET]
)


def generate_image(
    prompt: str,
    output_dir: str | Path = "ppt_skill/generated_images",
    backend: str = "mock",
) -> str:
    """Generate an image and return the local image file path.

    The default implementation is a mock backend to keep this skill runnable
    without external dependencies. Future backends can be added for DALL·E
    and local Stable Diffusion services.
    """
    safe_dir = ensure_directory(output_dir)
    output_path = safe_dir / f"img_{timestamp_str()}.png"

    logger.info("generate_image called with backend=%s prompt=%s", backend, prompt)
    if backend == "mock":
        output_path.write_bytes(base64.b64decode(_MOCK_PNG_BASE64))
        logger.info("mock image generated at %s", output_path)
        return str(output_path)

    if backend == "dalle":
        raise NotImplementedError("TODO: integrate with DALL·E API backend.")
    if backend == "stable_diffusion":
        raise NotImplementedError(
            "TODO: integrate with local Stable Diffusion service."
        )
    raise ValueError(f"Unsupported backend: {backend}")


path: ppt_generator.py
content: """PPT creation logic for the reusable skill module."""

from __future__ import annotations

from pathlib import Path
from typing import Callable, Dict, List, Optional

from pptx import Presentation
from pptx.util import Inches, Pt

from .image_service import generate_image
from .utils import ensure_directory, get_logger, timestamp_str

logger = get_logger(__name__)


def _normalize_content(content: str | List[str]) -> str:
    """Normalize content input into a single text block."""
    if isinstance(content, list):
        return "\n".join(part.strip() for part in content if part and part.strip())
    return content.strip()


def _add_title_and_body(slide, slide_title: str, content: str) -> None:
    """Add title and content text boxes with basic layout."""
    title_box = slide.shapes.add_textbox(Inches(0.6), Inches(0.3), Inches(8.8), Inches(0.8))
    title_frame = title_box.text_frame
    title_frame.text = slide_title
    title_frame.paragraphs[0].font.size = Pt(30)
    title_frame.paragraphs[0].font.bold = True

    body_box = slide.shapes.add_textbox(Inches(0.8), Inches(1.4), Inches(5.2), Inches(4.8))
    body_frame = body_box.text_frame
    body_frame.word_wrap = True
    body_frame.clear()

    for idx, line in enumerate(content.split("\n")):
        paragraph = body_frame.paragraphs[0] if idx == 0 else body_frame.add_paragraph()
        paragraph.text = line.strip()
        paragraph.level = 0
        paragraph.font.size = Pt(18)


def create_ppt(
    title: str,
    slides: List[Dict[str, object]],
    output_dir: str | Path = "ppt_skill/output",
    image_generator: Optional[Callable[[str], str]] = None,
) -> str:
    """Create a PPT based on slide descriptors and return output path.

    Expected slide schema:
    {
      "title": str,
      "content": str | list[str],
      "image_prompt": Optional[str]
    }
    """
    logger.info("Start create_ppt title=%s slide_count=%d", title, len(slides))
    prs = Presentation()
    image_generator = image_generator or generate_image

    # Title page
    title_slide = prs.slides.add_slide(prs.slide_layouts[6])
    title_box = title_slide.shapes.add_textbox(Inches(1.0), Inches(2.3), Inches(8.0), Inches(1.6))
    title_tf = title_box.text_frame
    title_tf.text = title
    title_tf.paragraphs[0].font.size = Pt(44)
    title_tf.paragraphs[0].font.bold = True

    for index, slide_data in enumerate(slides, start=1):
        logger.info("Rendering slide index=%d data=%s", index, slide_data.get("title", ""))
        slide = prs.slides.add_slide(prs.slide_layouts[6])
        slide_title = str(slide_data.get("title", f"Slide {index}"))
        content = _normalize_content(slide_data.get("content", ""))
        _add_title_and_body(slide, slide_title, content)

        image_prompt = slide_data.get("image_prompt")
        if image_prompt:
            image_path = image_generator(str(image_prompt))
            logger.info("Insert generated image for slide=%d path=%s", index, image_path)
            slide.shapes.add_picture(
                image_path,
                Inches(6.1),
                Inches(1.5),
                width=Inches(3.1),
                height=Inches(3.6),
            )

    output_directory = ensure_directory(output_dir)
    output_path = output_directory / f"{title.replace(' ', '_')}_{timestamp_str()}.pptx"
    prs.save(str(output_path))
    logger.info("create_ppt completed output=%s", output_path)
    return str(output_path)


path: main.py
content: """Public entrypoints for the PPT skill module."""

from __future__ import annotations

from pathlib import Path
from typing import Any, Dict, List

from .ppt_editor import update_ppt as _update_ppt
from .ppt_generator import create_ppt as _create_ppt
from .utils import get_logger, save_json

logger = get_logger(__name__)


def create_ppt(title: str, slides: List[Dict[str, object]]) -> str:
    """Agent-facing create API that delegates to the generator module."""
    logger.info("main.create_ppt called title=%s", title)
    return _create_ppt(title=title, slides=slides)


def update_ppt(ppt_path: str, replace_rules: Dict[str, Any]) -> Dict[str, Any]:
    """Agent-facing update API that returns new PPT path and replace log."""
    logger.info("main.update_ppt called path=%s", ppt_path)
    return _update_ppt(ppt_path=ppt_path, replace_rules=replace_rules)


def run_brazil_to_chile_demo() -> Dict[str, Any]:
    """Run a complete demo flow: create Brazil PPT then produce Chile version."""
    logger.info("Running Brazil -> Chile demo")
    brazil_slides = [
        {
            "title": "Brazil Overview",
            "content": [
                "Brazil is the largest country in South America.",
                "Its economy is driven by agriculture, mining, and services.",
            ],
            "image_prompt": "Brazil flag and skyline of Sao Paulo",
        },
        {
            "title": "Brazil Ports",
            "content": [
                "Santos is one of the busiest ports in Latin America.",
                "Brazil has strong Atlantic shipping connections.",
            ],
            "image_prompt": "Port of Santos containers and cargo ships",
        },
    ]
    brazil_ppt = _create_ppt(title="Brazil Trade Briefing", slides=brazil_slides)

    replace_rules = {
        "text_replace": {
            "Brazil": "Chile",
            "Sao Paulo": "Santiago",
            "Santos": "Valparaiso",
            "Atlantic": "Pacific",
        },
        "image_replace": {
            "__all__": {"prompt": "Chile flag and Chilean port logistics scene"}
        },
    }
    result = _update_ppt(ppt_path=brazil_ppt, replace_rules=replace_rules)

    # Persist logs for debugging and agent traceability.
    log_path = Path(result["new_ppt_path"]).with_suffix(".replace_log.json")
    save_json(result["replace_log"], log_path)
    logger.info("Demo finished. Brazil PPT=%s Chile PPT=%s", brazil_ppt, result["new_ppt_path"])

    return {
        "brazil_ppt_path": brazil_ppt,
        "chile_ppt_path": result["new_ppt_path"],
        "replace_log_path": str(log_path),
        "replace_log": result["replace_log"],
    }


if __name__ == "__main__":
    demo_result = run_brazil_to_chile_demo()
    print("Demo result:")
    for key, value in demo_result.items():
        if key == "replace_log":
            print(f"- {key}: <omitted in stdout, check log file>")
            continue
        print(f"- {key}: {value}")


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)