diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a4c9825fb0..bfad721d2b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -338,6 +338,10 @@ before review. ## Testing ```bash +# Public skill scripts (from the repository root) +python -m pip install -r tests/skills/requirements.txt +python -m pytest tests/skills -q + # Default backend tests (four parallel shards; excludes live and blocking-I/O tests) cd backend make test diff --git a/README.md b/README.md index 6b7ef25c41..0a75d3c7f2 100644 --- a/README.md +++ b/README.md @@ -1551,6 +1551,8 @@ view. Built-in and user-created/imported skills are listed separately. The Community tab supports importing `.skill` archives into My skills. General preferences remain in Settings. +The **ppt-generation** skill preserves transparent slide images when composing a presentation, including grayscale and palette PNG transparency. + Administrators can see **Could not load** warnings in Capability Center > Skills when their own custom skill's `SKILL.md` contains invalid YAML. Warnings show the package-relative file and, when available, its line and column, with a quoting diff --git a/skills/public/ppt-generation/SKILL.md b/skills/public/ppt-generation/SKILL.md index 9583057ebf..b6f6b04470 100644 --- a/skills/public/ppt-generation/SKILL.md +++ b/skills/public/ppt-generation/SKILL.md @@ -146,6 +146,8 @@ python /mnt/skills/public/image-generation/scripts/generate.py \ After all slide images are generated, call the composition script: +Transparent slide images retain their alpha channel in the presentation. Opaque images are embedded as JPEG as before. + ```bash python /mnt/skills/public/ppt-generation/scripts/generate.py \ --plan-file /mnt/user-data/workspace/presentation-plan.json \ diff --git a/skills/public/ppt-generation/scripts/generate.py b/skills/public/ppt-generation/scripts/generate.py index a80676d8f5..05c58bd0d7 100644 --- a/skills/public/ppt-generation/scripts/generate.py +++ b/skills/public/ppt-generation/scripts/generate.py @@ -60,8 +60,11 @@ def generate_ppt( # Load and process image with Image.open(image_path) as img: - # Convert to RGB if necessary (for PNG with transparency) - if img.mode in ("RGBA", "P"): + image_format = "JPEG" + if "A" in img.getbands() or "transparency" in img.info: + img = img.convert("RGBA") + image_format = "PNG" + elif img.mode == "P": img = img.convert("RGB") # Calculate dimensions to fill slide while maintaining aspect ratio @@ -88,12 +91,16 @@ def generate_ppt( # Save processed image to bytes img_bytes = BytesIO() - img.save(img_bytes, format="JPEG", quality=95) + img.save(img_bytes, format=image_format, quality=95) img_bytes.seek(0) # Add image to slide slide.shapes.add_picture( - img_bytes, left, top, Inches(new_width_emu / 914400), Inches(new_height_emu / 914400) + img_bytes, + left, + top, + Inches(new_width_emu / 914400), + Inches(new_height_emu / 914400), ) # Add speaker notes if available in plan diff --git a/tests/skills/requirements.txt b/tests/skills/requirements.txt new file mode 100644 index 0000000000..7f3d7d3e53 --- /dev/null +++ b/tests/skills/requirements.txt @@ -0,0 +1,7 @@ +# Standalone public skill tests; no backend or model credentials required. +duckdb==2.0.0 +openpyxl==3.1.5 +Pillow==12.3.0 +pytest==9.1.1 +python-pptx==1.0.2 +requests==2.34.2 diff --git a/tests/skills/test_ppt_transparency.py b/tests/skills/test_ppt_transparency.py new file mode 100644 index 0000000000..36cb8bf256 --- /dev/null +++ b/tests/skills/test_ppt_transparency.py @@ -0,0 +1,72 @@ +"""Slide composition preserves transparency in the embedded image.""" + +import json +import runpy +from io import BytesIO +from pathlib import Path + +import pytest +from PIL import Image +from pptx import Presentation + +SCRIPT = ( + Path(__file__).resolve().parents[2] + / "skills/public/ppt-generation/scripts/generate.py" +) + + +@pytest.mark.parametrize("mode", ["RGBA", "LA", "P", "RGB", "L"]) +def test_ppt_preserves_png_transparency(tmp_path, mode): + source = tmp_path / "slide.png" + if mode == "P": + image = Image.new("P", (64, 64), 0) + image.putpalette([0, 0, 0, 240, 80, 40] + [0] * 762) + image.putpixel((48, 32), 1) + image.save(source, transparency=0) + elif mode in {"RGB", "L"}: + image = Image.new(mode, (64, 64), 0) + image.putpixel((48, 32), (240, 80, 40) if mode == "RGB" else 160) + image.save(source, transparency=(0, 0, 0) if mode == "RGB" else 0) + else: + image = Image.new(mode, (64, 64), (0, 0, 0, 0) if mode == "RGBA" else (0, 0)) + image.putpixel((48, 32), (240, 80, 40, 128) if mode == "RGBA" else (160, 128)) + image.save(source) + image.close() + with Image.open(source) as original: + expected = original.convert("RGBA") + + plan = tmp_path / "plan.json" + plan.write_text( + json.dumps({"slides": [{"title": "Transparent slide"}]}), encoding="utf-8" + ) + output = tmp_path / "slides.pptx" + generate_ppt = runpy.run_path(str(SCRIPT))["generate_ppt"] + + generate_ppt(str(plan), [str(source)], str(output)) + + presentation = Presentation(output) + picture = presentation.slides[0].shapes[0] + with Image.open(BytesIO(picture.image.blob)) as embedded: + assert embedded.format == "PNG" + assert embedded.convert("RGBA").tobytes() == expected.tobytes() + assert ( + "Title: Transparent slide" + in presentation.slides[0].notes_slide.notes_text_frame.text + ) + + +@pytest.mark.parametrize("mode", ["RGB", "L", "P"]) +def test_ppt_keeps_opaque_images_as_jpeg(tmp_path, mode): + source = tmp_path / "slide.png" + with Image.new(mode, (64, 64), 100) as image: + image.save(source) + plan = tmp_path / "plan.json" + plan.write_text("{}", encoding="utf-8") + output = tmp_path / "slides.pptx" + + runpy.run_path(str(SCRIPT))["generate_ppt"](str(plan), [str(source)], str(output)) + + picture = Presentation(output).slides[0].shapes[0] + with Image.open(BytesIO(picture.image.blob)) as embedded: + assert embedded.format == "JPEG" + assert embedded.size == (64, 64)