From 5b7daf0e1f563a42817e85d0c6e602cadbdfd678 Mon Sep 17 00:00:00 2001 From: ChengFa <166748424+indexGin@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:47:39 +0800 Subject: [PATCH] fix(skills): preserve EXIF orientation in generated presentations (#7193) --- README.md | 4 + skills/public/ppt-generation/SKILL.md | 2 + .../public/ppt-generation/scripts/generate.py | 3 +- tests/skills/test_ppt_exif_orientation.py | 76 +++++++++++++++++++ 4 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 tests/skills/test_ppt_exif_orientation.py diff --git a/README.md b/README.md index 0c92a818b3..6b825816d4 100644 --- a/README.md +++ b/README.md @@ -1578,6 +1578,10 @@ 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 applies photo EXIF orientation before fitting and +embedding slide images, so rotated or mirrored photos retain their displayed +orientation. + 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 diff --git a/skills/public/ppt-generation/SKILL.md b/skills/public/ppt-generation/SKILL.md index b6f6b04470..0b3930426a 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: +Photos with EXIF orientation metadata are rotated or mirrored before sizing and embedding, preserving their displayed orientation in the presentation. + Transparent slide images retain their alpha channel in the presentation. Opaque images are embedded as JPEG as before. ```bash diff --git a/skills/public/ppt-generation/scripts/generate.py b/skills/public/ppt-generation/scripts/generate.py index 05c58bd0d7..8e7c09e729 100644 --- a/skills/public/ppt-generation/scripts/generate.py +++ b/skills/public/ppt-generation/scripts/generate.py @@ -2,7 +2,7 @@ import json import os from io import BytesIO -from PIL import Image +from PIL import Image, ImageOps from pptx import Presentation from pptx.util import Inches @@ -60,6 +60,7 @@ def generate_ppt( # Load and process image with Image.open(image_path) as img: + img = ImageOps.exif_transpose(img) image_format = "JPEG" if "A" in img.getbands() or "transparency" in img.info: img = img.convert("RGBA") diff --git a/tests/skills/test_ppt_exif_orientation.py b/tests/skills/test_ppt_exif_orientation.py new file mode 100644 index 0000000000..1282992f6c --- /dev/null +++ b/tests/skills/test_ppt_exif_orientation.py @@ -0,0 +1,76 @@ +import json +import runpy +from io import BytesIO +from pathlib import Path + +import pytest +from PIL import Image, ImageDraw +from pptx import Presentation + +SCRIPT = ( + Path(__file__).resolve().parents[2] + / "skills/public/ppt-generation/scripts/generate.py" +) +generate_ppt = runpy.run_path(str(SCRIPT))["generate_ppt"] +COLORS = {"R": (240, 0, 0), "G": (0, 220, 0), "B": (0, 0, 240), "Y": (240, 220, 0)} + + +@pytest.mark.parametrize("aspect_ratio", ["16:9", "4:3"]) +@pytest.mark.parametrize( + ("orientation", "corners"), + [ + (0, "RGBY"), + (1, "RGBY"), + (2, "GRYB"), + (3, "YBGR"), + (4, "BYRG"), + (5, "RBGY"), + (6, "BRYG"), + (7, "YGBR"), + (8, "GYRB"), + ], +) +def test_ppt_preserves_display_orientation( + tmp_path, aspect_ratio, orientation, corners +): + image_path = tmp_path / "photo.jpg" + with Image.new("RGB", (96, 64)) as image: + draw = ImageDraw.Draw(image) + for box, color in zip( + [(0, 0, 47, 31), (48, 0, 95, 31), (0, 32, 47, 63), (48, 32, 95, 63)], + "RGBY", + strict=True, + ): + draw.rectangle(box, fill=COLORS[color]) + exif = image.getexif() + if orientation: + exif[274] = orientation + image.save(image_path, quality=100, subsampling=0, exif=exif) + plan_path = tmp_path / "plan.json" + plan_path.write_text( + json.dumps({"aspect_ratio": aspect_ratio, "slides": [{"title": "Photo"}]}), + encoding="utf-8", + ) + output_path = tmp_path / "presentation.pptx" + + generate_ppt(str(plan_path), [str(image_path)], str(output_path)) + + presentation = Presentation(output_path) + picture = presentation.slides[0].shapes[0] + expected_size = (64, 96) if orientation >= 5 else (96, 64) + with Image.open(BytesIO(picture.image.blob)) as embedded: + assert embedded.size == expected_size + assert 274 not in embedded.getexif() + width, height = embedded.size + points = [ + (width // 4, height // 4), + (3 * width // 4, height // 4), + (width // 4, 3 * height // 4), + (3 * width // 4, 3 * height // 4), + ] + for point, color in zip(points, corners, strict=True): + assert embedded.getpixel(point) == pytest.approx(COLORS[color], abs=10) + assert picture.width / picture.height == pytest.approx( + expected_size[0] / expected_size[1], rel=1e-5 + ) + assert "Title: Photo" in presentation.slides[0].notes_slide.notes_text_frame.text