From c6285e3fedcf6dba14559a49686cd95749d95dba Mon Sep 17 00:00:00 2001 From: auyidi Date: Tue, 28 Apr 2026 02:47:06 +0000 Subject: [PATCH 01/22] feat(skills): add 6 PowerPoint skill enhancements - Add validate_geometry.py for structural margin, gap, and overflow checks - Add style["colors"] population from themes for content-extra.py portability - Add generate_themes.py for YAML-driven theme variant generation - Add --dry-run pre-flight check to build_deck.py - Add embed_audio.py for WAV embedding into PPTX slides - Add export_svg.py for PPTX-to-SVG export via LibreOffice and PyMuPDF - Wire geometric validation into Invoke-PptxPipeline.ps1 Validate action - Update SKILL.md with new script docs and architecture table entries - Update pptx.instructions.md with geometric validation and color palette docs --- .../experimental/pptx.instructions.md | 8 +- .../skills/experimental/powerpoint/SKILL.md | 60 +++ .../scripts/Invoke-PptxPipeline.ps1 | 28 + .../powerpoint/scripts/build_deck.py | 54 ++ .../powerpoint/scripts/embed_audio.py | 221 ++++++++ .../powerpoint/scripts/export_svg.py | 253 +++++++++ .../powerpoint/scripts/generate_themes.py | 272 ++++++++++ .../powerpoint/scripts/validate_geometry.py | 494 ++++++++++++++++++ 8 files changed, 1389 insertions(+), 1 deletion(-) create mode 100644 .github/skills/experimental/powerpoint/scripts/embed_audio.py create mode 100644 .github/skills/experimental/powerpoint/scripts/export_svg.py create mode 100644 .github/skills/experimental/powerpoint/scripts/generate_themes.py create mode 100644 .github/skills/experimental/powerpoint/scripts/validate_geometry.py diff --git a/.github/instructions/experimental/pptx.instructions.md b/.github/instructions/experimental/pptx.instructions.md index 39a5850d3..76e2c4cc7 100644 --- a/.github/instructions/experimental/pptx.instructions.md +++ b/.github/instructions/experimental/pptx.instructions.md @@ -79,7 +79,9 @@ For partial rebuild workflows (update a few slides in an existing deck): ## Validation Criteria -These criteria define the quality standards agents verify after building or updating slides. Visual checks use `validate_slides.py` and PPTX-only checks use `validate_deck.py`. +These criteria define the quality standards agents verify after building or updating slides. The Validate pipeline runs three checks in sequence: PPTX property validation (`validate_deck.py`), geometric validation (`validate_geometry.py`), and optionally vision-based validation (`validate_slides.py`). + +Geometric validation runs automatically during the Validate action and checks the element positioning rules below programmatically. It catches margin violations, boundary overflow, and insufficient gaps without requiring vision model access. ### Element Positioning @@ -116,6 +118,10 @@ These criteria define the quality standards agents verify after building or upda Use `#RRGGBB` hex values or `@theme_name` references for all colors. See the Color Syntax section in `content-yaml-template.md` for the full specification including theme brightness adjustments and dict syntax. +### Theme Colors in content-extra.py + +When `style.yaml` defines a `themes` section, the build script populates `style["colors"]` with the first theme's color map. Use `style.get("colors", {}).get("accent_blue", "#0078D4")` in `content-extra.py` to reference theme-aware colors. This enables theme portability — the same script produces correct colors across all theme variants without regex replacement. + ## Contextual Styling Slide decks often contain multiple visual themes (title slides, content slides, section dividers, dark vs. light themes). Rather than enforcing a single global style, derive colors, fonts, and layout patterns from context: diff --git a/.github/skills/experimental/powerpoint/SKILL.md b/.github/skills/experimental/powerpoint/SKILL.md index 8d53a9e11..43ab2a8d1 100644 --- a/.github/skills/experimental/powerpoint/SKILL.md +++ b/.github/skills/experimental/powerpoint/SKILL.md @@ -404,6 +404,62 @@ python scripts/render_pdf_images.py \ **Dependencies**: Requires LibreOffice for PPTX-to-PDF conversion and either `pdftoppm` (from `poppler`) or `pymupdf` (pip) for PDF-to-JPG rendering. +### Dry-Run Validation + +```bash +python scripts/build_deck.py \ + --content-dir content/ \ + --style content/global/style.yaml \ + --output /dev/null \ + --dry-run +``` + +Validates content files without producing a PPTX. Parses all `content.yaml` files, checks for speaker notes, runs AST validation on `content-extra.py` scripts, and counts image assets. Reports per-slide status and exits with code 1 if any errors are found. + +### Generate Theme Variants + +```bash +python scripts/generate_themes.py \ + --content-dir content/ \ + --themes themes.yaml \ + --output-dir ../ +``` + +Generates themed content directories from a base content directory using a color mapping YAML file. The themes YAML defines color replacement tables: + +```yaml +themes: + fluent: + label: "Microsoft Fluent" + colors: + "1b1b1f": "FFFFFF" + "f8f8fc": "242424" +``` + +Each theme gets its own output directory with remapped `content.yaml`, `style.yaml`, and `content-extra.py` files. Images are copied as-is. Run `build_deck.py` on each themed directory to produce the PPTX. + +### Embed Audio + +```bash +python scripts/embed_audio.py \ + --input slide-deck/presentation.pptx \ + --audio-dir voice-over/ \ + --output slide-deck/presentation-narrated.pptx +``` + +Embeds WAV audio files into PPTX slides. Audio files are matched to slides by naming convention (`slide-001.wav`, `slide-002.wav`, etc.). The audio icon is placed off-screen to keep it hidden during presentation. Pass `--slides` to embed audio on specific slides only. + +### Export Slides to SVG + +```bash +python scripts/export_svg.py \ + --input slide-deck/presentation.pptx \ + --output-dir slide-deck/svg/ \ + --slides 3,5,10 +``` + +Exports slides to SVG format via LibreOffice (PPTX → PDF) and PyMuPDF (PDF → SVG). Output files are named `slide-NNN.svg`. Pass `--slides` to export specific slides. **Dependencies**: Requires LibreOffice and `pymupdf`. + ## Script Architecture The build and extraction scripts use shared modules in the `scripts/` directory: @@ -419,8 +475,12 @@ The build and extraction scripts use shared modules in the `scripts/` directory: | `pptx_tables.py` | Table element creation and extraction with cell merging, banding, and per-cell styling | | `pptx_charts.py` | Chart element creation and extraction for 12 chart types (column, bar, line, pie, scatter, bubble, etc.) | | `validate_deck.py` | PPTX-only validation for speaker notes and slide count | +| `validate_geometry.py` | Structural validation for element edge margins, adjacent gaps, boundary overflow, and title clearance | | `validate_slides.py` | Vision-based slide issue detection and quality validation via Copilot SDK with built-in checks and plain-text per-slide output | | `render_pdf_images.py` | PDF-to-JPG rendering via PyMuPDF with optional slide-number-based naming | +| `generate_themes.py` | Theme variant generation from a base content directory using a color mapping YAML file | +| `embed_audio.py` | WAV audio embedding into PPTX slides with per-slide file matching and off-screen audio icon placement | +| `export_svg.py` | PPTX-to-SVG export via LibreOffice PDF conversion and PyMuPDF SVG rendering | ## python-pptx Constraints diff --git a/.github/skills/experimental/powerpoint/scripts/Invoke-PptxPipeline.ps1 b/.github/skills/experimental/powerpoint/scripts/Invoke-PptxPipeline.ps1 index 7133cf841..497c52381 100644 --- a/.github/skills/experimental/powerpoint/scripts/Invoke-PptxPipeline.ps1 +++ b/.github/skills/experimental/powerpoint/scripts/Invoke-PptxPipeline.ps1 @@ -429,6 +429,34 @@ function Invoke-ValidateDeck { Write-Host "PPTX property checks found warnings — see $deckReportPath" } + # Step 2b: Run geometric validation (margin, gap, overflow checks) + Write-Host "Step 2b: Running geometric validation..." + $geomScript = Join-Path $ScriptDir 'validate_geometry.py' + $geomArgs = @( + $geomScript, + '--input', $InputPath + ) + if ($Slides) { + $geomArgs += '--slides' + $geomArgs += $Slides + } + $geomOutputPath = Join-Path $ImageOutputDir 'geometry-validation-results.json' + $geomArgs += '--output' + $geomArgs += $geomOutputPath + $geomReportPath = Join-Path $ImageOutputDir 'geometry-validation-report.md' + $geomArgs += '--report' + $geomArgs += $geomReportPath + $geomArgs += '--per-slide-dir' + $geomArgs += $ImageOutputDir + + & $python @geomArgs + if ($LASTEXITCODE -eq 2) { + throw "validate_geometry.py encountered an error (exit code $LASTEXITCODE)." + } + if ($LASTEXITCODE -eq 1) { + Write-Host "Geometric validation found warnings — see $geomReportPath" + } + # Step 3: Run Copilot SDK vision validation (when prompt provided) if ($hasVisionPrompt) { Write-Host "Step 3/$totalSteps`: Running Copilot SDK vision validation..." diff --git a/.github/skills/experimental/powerpoint/scripts/build_deck.py b/.github/skills/experimental/powerpoint/scripts/build_deck.py index 704e79124..ce27d5917 100644 --- a/.github/skills/experimental/powerpoint/scripts/build_deck.py +++ b/.github/skills/experimental/powerpoint/scripts/build_deck.py @@ -959,6 +959,15 @@ def build_slide( colors = {} typography = {} + # Populate colors from the first theme's color map in style.yaml so + # content-extra.py scripts can reference theme colors programmatically + # via style["colors"]["accent_blue"] instead of hardcoding hex values. + themes = style.get("themes", []) + if themes and isinstance(themes, list) and isinstance(themes[0], dict): + style_colors = themes[0].get("colors", {}) + if style_colors: + style["colors"] = style_colors + if existing_slide is not None: slide = existing_slide clear_slide_shapes(slide) @@ -1092,10 +1101,55 @@ def main(): action="store_true", help="Skip AST validation of content-extra.py (trusted content only)", ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Validate content without building PPTX (parse YAML, check images, validate scripts)", + ) args = parser.parse_args() content_dir = Path(args.content_dir) style = load_yaml(Path(args.style)) + + # Dry-run mode: validate content files without producing a PPTX + if args.dry_run: + slides_data = discover_slides(content_dir) + if not slides_data: + print("No slide content found in", content_dir) + sys.exit(1) + errors = 0 + for num, slide_dir in slides_data: + content_yaml = slide_dir / "content.yaml" + try: + slide_content = load_yaml(content_yaml) + title = slide_content.get("title", "Untitled") + # Check for speaker notes + notes = slide_content.get("speaker_notes") + notes_status = "✅" if notes else "⚠️ no notes" + # Validate content-extra.py if present + extra = slide_dir / "content-extra.py" + extra_status = "" + if extra.exists(): + if not args.allow_scripts: + try: + _validate_content_extra(extra) + extra_status = " | extra: ✅" + except ContentExtraError as exc: + extra_status = f" | extra: ❌ {exc}" + errors += 1 + else: + extra_status = " | extra: skipped" + # Check image references + images = slide_dir / "images" + img_count = len(list(images.glob("*.png"))) + len(list(images.glob("*.jpg"))) if images.exists() else 0 + img_status = f" | {img_count} images" if img_count else "" + print(f" Slide {num:03d}: {title} [{notes_status}{extra_status}{img_status}]") + except Exception as exc: + print(f" Slide {num:03d}: ❌ YAML parse error: {exc}") + errors += 1 + print(f"\nDry-run complete: {len(slides_data)} slides, {errors} error(s)") + sys.exit(1 if errors else 0) + output_path = Path(args.output) output_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/.github/skills/experimental/powerpoint/scripts/embed_audio.py b/.github/skills/experimental/powerpoint/scripts/embed_audio.py new file mode 100644 index 000000000..1ec4c60b6 --- /dev/null +++ b/.github/skills/experimental/powerpoint/scripts/embed_audio.py @@ -0,0 +1,221 @@ +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT +"""Embed WAV audio files into a PowerPoint deck, one per slide. + +Matches audio files to slides by naming convention (slide-001.wav → slide 1) +and embeds each as an audio shape using python-pptx's add_movie API. + +Usage: + python embed_audio.py --input deck.pptx --audio-dir voice-over/ --output deck-narrated.pptx + python embed_audio.py --input deck.pptx --audio-dir voice-over/ --output deck-narrated.pptx --slides "1,3,5" + python embed_audio.py --input deck.pptx --audio-dir voice-over/ --output deck-narrated.pptx -v +""" + +import argparse +import io +import logging +import re +import sys +import tempfile +from pathlib import Path + +from PIL import Image +from pptx import Presentation +from pptx.util import Inches +from pptx_utils import ( + EXIT_ERROR, + EXIT_FAILURE, + EXIT_SUCCESS, + configure_logging, + parse_slide_filter, +) + +logger = logging.getLogger(__name__) + +AUDIO_PATTERN = re.compile(r"^slide-(\d+)\.wav$", re.IGNORECASE) + +AUDIO_LEFT = Inches(0.1) +AUDIO_TOP = Inches(7.0) +AUDIO_WIDTH = Inches(0.3) +AUDIO_HEIGHT = Inches(0.3) + + +def create_parser() -> argparse.ArgumentParser: + """Create and configure argument parser.""" + parser = argparse.ArgumentParser( + description="Embed WAV audio files into a PowerPoint deck" + ) + parser.add_argument( + "--input", required=True, type=Path, help="Source PPTX file path" + ) + parser.add_argument( + "--audio-dir", required=True, type=Path, help="Directory containing WAV files" + ) + parser.add_argument( + "--output", required=True, type=Path, help="Output PPTX file path" + ) + parser.add_argument( + "--slides", + help="Comma-separated slide numbers to embed audio on (1-based, default: all)", + ) + parser.add_argument( + "-v", "--verbose", action="store_true", help="Enable verbose output" + ) + return parser + + +def discover_audio_files(audio_dir: Path) -> dict[int, Path]: + """Map slide numbers to WAV file paths found in the audio directory. + + Scans for files matching the ``slide-NNN.wav`` naming convention. + + Args: + audio_dir: Directory to scan for WAV files. + + Returns: + Dictionary mapping 1-based slide numbers to their WAV file paths. + """ + mapping: dict[int, Path] = {} + for entry in sorted(audio_dir.iterdir()): + if not entry.is_file(): + continue + match = AUDIO_PATTERN.match(entry.name) + if match: + slide_num = int(match.group(1)) + mapping[slide_num] = entry + logger.debug("Found audio for slide %d: %s", slide_num, entry.name) + return mapping + + +def create_poster_frame() -> Path: + """Create a minimal 1x1 transparent PNG for the audio poster frame. + + python-pptx's ``add_movie`` requires a poster frame image. This creates + a temporary transparent PNG so the audio shape has no visible thumbnail. + + Returns: + Path to the temporary PNG file. + """ + img = Image.new("RGBA", (1, 1), (0, 0, 0, 0)) + buf = io.BytesIO() + img.save(buf, format="PNG") + buf.seek(0) + tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False) + tmp.write(buf.getvalue()) + tmp.close() + return Path(tmp.name) + + +def embed_audio( + prs: Presentation, + audio_map: dict[int, Path], + slide_filter: set[int] | None, + poster_frame: Path, +) -> int: + """Embed WAV files into matching slides. + + Args: + prs: Loaded Presentation object (modified in place). + audio_map: Mapping of 1-based slide numbers to WAV file paths. + slide_filter: Optional set of slide numbers to restrict embedding. + poster_frame: Path to the poster frame image for add_movie. + + Returns: + Count of slides that received embedded audio. + """ + embedded_count = 0 + for slide_num, slide in enumerate(prs.slides, start=1): + if slide_filter and slide_num not in slide_filter: + continue + wav_path = audio_map.get(slide_num) + if not wav_path: + logger.debug("Slide %d: no audio file found, skipping", slide_num) + continue + + slide.shapes.add_movie( + movie_file=str(wav_path), + left=AUDIO_LEFT, + top=AUDIO_TOP, + width=AUDIO_WIDTH, + height=AUDIO_HEIGHT, + poster_frame_image=str(poster_frame), + mime_type="audio/wav", + ) + embedded_count += 1 + logger.info("Slide %d: embedded %s", slide_num, wav_path.name) + + return embedded_count + + +def run(args: argparse.Namespace) -> int: + """Execute the audio embedding workflow. + + Args: + args: Parsed command-line arguments. + + Returns: + Exit code indicating success or failure. + """ + input_path: Path = args.input + audio_dir: Path = args.audio_dir + output_path: Path = args.output + + if not input_path.is_file(): + logger.error("Input file not found: %s", input_path) + return EXIT_ERROR + + if not audio_dir.is_dir(): + logger.error("Audio directory not found: %s", audio_dir) + return EXIT_ERROR + + slide_filter = parse_slide_filter(args.slides) + + audio_map = discover_audio_files(audio_dir) + if not audio_map: + logger.warning("No slide-NNN.wav files found in %s", audio_dir) + return EXIT_FAILURE + + logger.info( + "Discovered %d audio file(s) in %s", len(audio_map), audio_dir + ) + + prs = Presentation(str(input_path)) + total_slides = len(prs.slides) + logger.info("Opened %s (%d slides)", input_path.name, total_slides) + + poster_frame = create_poster_frame() + try: + embedded = embed_audio(prs, audio_map, slide_filter, poster_frame) + finally: + poster_frame.unlink(missing_ok=True) + + if embedded == 0: + logger.warning("No audio files matched any target slides") + return EXIT_FAILURE + + output_path.parent.mkdir(parents=True, exist_ok=True) + prs.save(str(output_path)) + logger.info("Saved %s with %d embedded audio track(s)", output_path, embedded) + return EXIT_SUCCESS + + +def main() -> int: + """Main entry point for the script.""" + parser = create_parser() + args = parser.parse_args() + configure_logging(args.verbose) + try: + return run(args) + except KeyboardInterrupt: + print("\nInterrupted by user", file=sys.stderr) + return 130 + except BrokenPipeError: + sys.stderr.close() + return EXIT_FAILURE + except Exception as e: + logger.error("Unexpected error: %s", e) + return EXIT_FAILURE + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/skills/experimental/powerpoint/scripts/export_svg.py b/.github/skills/experimental/powerpoint/scripts/export_svg.py new file mode 100644 index 000000000..9a3b4be1c --- /dev/null +++ b/.github/skills/experimental/powerpoint/scripts/export_svg.py @@ -0,0 +1,253 @@ +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT +"""Export PowerPoint slides to SVG with optional slide filtering. + +Converts a PPTX file to individual SVG images via an intermediate PDF +generated by LibreOffice headless mode. Each slide is rendered to SVG +using PyMuPDF's vector export. + +Usage: + python export_svg.py --input presentation.pptx --output-dir svg/ + python export_svg.py --input presentation.pptx --output-dir svg/ --slides 1,3,5 +""" + +import argparse +import logging +import os +import platform +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +EXIT_SUCCESS = 0 +EXIT_FAILURE = 1 +EXIT_ERROR = 2 + +logger = logging.getLogger(__name__) + + +def configure_logging(verbose: bool = False) -> None: + """Configure logging based on verbosity level.""" + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig(level=level, format="%(levelname)s: %(message)s") + + +def create_parser() -> argparse.ArgumentParser: + """Create and configure argument parser.""" + parser = argparse.ArgumentParser( + description="Export PowerPoint slides to SVG images" + ) + parser.add_argument( + "--input", required=True, type=Path, help="Input PPTX file path" + ) + parser.add_argument( + "--output-dir", + required=True, + type=Path, + help="Output directory for SVG files", + ) + parser.add_argument( + "--slides", + help="Comma-separated slide numbers to export (1-based, default: all)", + ) + parser.add_argument( + "-v", "--verbose", action="store_true", help="Enable verbose output" + ) + return parser + + +def find_libreoffice() -> str | None: + """Locate the LibreOffice/soffice executable across platforms.""" + for cmd in ("libreoffice", "soffice"): + path = shutil.which(cmd) + if path: + return path + + system = platform.system() + if system == "Darwin": + candidates = [ + "/Applications/LibreOffice.app/Contents/MacOS/soffice", + ] + elif system == "Windows": + candidates = [ + r"C:\Program Files\LibreOffice\program\soffice.exe", + r"C:\Program Files (x86)\LibreOffice\program\soffice.exe", + ] + else: + candidates = [ + "/usr/bin/libreoffice", + "/usr/bin/soffice", + "/snap/bin/libreoffice", + ] + + for candidate in candidates: + if os.path.isfile(candidate): + return candidate + + return None + + +def convert_pptx_to_pdf(pptx_path: Path, output_dir: Path) -> Path: + """Convert a PPTX file to PDF using LibreOffice headless mode. + + Args: + pptx_path: Path to the input PPTX file. + output_dir: Directory where the PDF will be written. + + Returns: + Path to the generated PDF file. + """ + soffice = find_libreoffice() + if not soffice: + logger.error("LibreOffice is required for PPTX-to-PDF conversion.") + logger.error("Install via:") + logger.error(" macOS: brew install --cask libreoffice") + logger.error(" Linux: sudo apt-get install libreoffice") + logger.error(" Windows: winget install TheDocumentFoundation.LibreOffice") + sys.exit(EXIT_FAILURE) + + output_dir.mkdir(parents=True, exist_ok=True) + logger.info("Converting %s to PDF via LibreOffice", pptx_path.name) + + try: + result = subprocess.run( + [ + soffice, + "--headless", + "--convert-to", + "pdf", + "--outdir", + str(output_dir), + str(pptx_path), + ], + capture_output=True, + text=True, + check=True, + ) + logger.debug("LibreOffice stdout: %s", result.stdout) + except subprocess.CalledProcessError as e: + logger.error("LibreOffice conversion failed: %s", e.stderr) + sys.exit(EXIT_FAILURE) + except FileNotFoundError: + logger.error("LibreOffice executable not found: %s", soffice) + sys.exit(EXIT_FAILURE) + + pdf_name = pptx_path.stem + ".pdf" + pdf_path = output_dir / pdf_name + if not pdf_path.exists(): + logger.error("Expected PDF not found: %s", pdf_path) + sys.exit(EXIT_FAILURE) + + return pdf_path + + +def parse_slide_numbers(slides_str: str) -> list[int]: + """Parse comma-separated slide numbers into a sorted list of integers.""" + numbers = [] + for part in slides_str.split(","): + part = part.strip() + if part: + numbers.append(int(part)) + return sorted(set(numbers)) + + +def export_pdf_to_svg( + pdf_path: Path, + output_dir: Path, + slides: list[int] | None = None, +) -> list[Path]: + """Render PDF pages to individual SVG files using PyMuPDF. + + Args: + pdf_path: Path to the intermediate PDF. + output_dir: Directory where SVG files will be written. + slides: Optional 1-based slide numbers to export. Exports all when None. + + Returns: + List of paths to the generated SVG files. + """ + try: + import fitz # noqa: PLC0415 — PyMuPDF + except ImportError: + logger.error( + "PyMuPDF is required for SVG export. Install via: pip install pymupdf" + ) + sys.exit(EXIT_FAILURE) + + doc = fitz.open(str(pdf_path)) + total_pages = len(doc) + output_dir.mkdir(parents=True, exist_ok=True) + + if slides: + page_numbers = [n for n in slides if 1 <= n <= total_pages] + skipped = [n for n in slides if n < 1 or n > total_pages] + for num in skipped: + logger.warning( + "Slide %d out of range (1-%d), skipping", num, total_pages + ) + else: + page_numbers = list(range(1, total_pages + 1)) + + exported: list[Path] = [] + for page_num in page_numbers: + page = doc[page_num - 1] + svg_text = page.get_svg_image() + svg_path = output_dir / f"slide-{page_num:03d}.svg" + svg_path.write_text(svg_text, encoding="utf-8") + logger.info("Exported slide %d → %s", page_num, svg_path.name) + exported.append(svg_path) + + doc.close() + return exported + + +def run(args: argparse.Namespace) -> int: + """Execute the SVG export pipeline.""" + pptx_path = args.input.resolve() + output_dir = args.output_dir.resolve() + + if not pptx_path.exists(): + logger.error("Input file not found: %s", pptx_path) + return EXIT_ERROR + + if not pptx_path.suffix.lower() == ".pptx": + logger.error("Input file must be a .pptx file: %s", pptx_path) + return EXIT_ERROR + + slides: list[int] | None = None + if args.slides: + slides = parse_slide_numbers(args.slides) + logger.info("Filtering to slides: %s", slides) + + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + pdf_path = convert_pptx_to_pdf(pptx_path, tmp_path) + exported = export_pdf_to_svg(pdf_path, output_dir, slides) + + logger.info("SVG export complete: %d slide(s) → %s", len(exported), output_dir) + return EXIT_SUCCESS + + +def main() -> int: + """Main entry point with error handling.""" + parser = create_parser() + args = parser.parse_args() + configure_logging(args.verbose) + + try: + return run(args) + except KeyboardInterrupt: + print("\nInterrupted by user", file=sys.stderr) + return 130 + except BrokenPipeError: + sys.stderr.close() + return EXIT_FAILURE + except Exception as e: + logger.error("Unexpected error: %s", e) + return EXIT_FAILURE + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/skills/experimental/powerpoint/scripts/generate_themes.py b/.github/skills/experimental/powerpoint/scripts/generate_themes.py new file mode 100644 index 000000000..3d5ef970b --- /dev/null +++ b/.github/skills/experimental/powerpoint/scripts/generate_themes.py @@ -0,0 +1,272 @@ +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT +"""Generate themed content directory variants from a base deck's content. + +Reads a themes.yaml color mapping file and produces a complete content +directory copy for each theme with all hex colors remapped in YAML and +Python files while copying images as-is. + +Usage:: + + python generate_themes.py --content-dir content/ \ + --themes themes.yaml --output-dir ../ +""" + +import argparse +import logging +import re +import shutil +import sys +from pathlib import Path + +import yaml + +EXIT_SUCCESS = 0 +EXIT_FAILURE = 1 +EXIT_ERROR = 2 + +logger = logging.getLogger(__name__) + + +def configure_logging(verbose: bool = False) -> None: + """Configure logging based on verbosity level.""" + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig(level=level, format="%(levelname)s: %(message)s") + + +def create_parser() -> argparse.ArgumentParser: + """Create and configure argument parser.""" + parser = argparse.ArgumentParser( + description="Generate themed content directory variants from a base deck." + ) + parser.add_argument( + "--content-dir", + type=Path, + required=True, + help="Path to the base theme's content directory.", + ) + parser.add_argument( + "--themes", + type=Path, + required=True, + help="Path to a YAML file defining theme color mappings.", + ) + parser.add_argument( + "--output-dir", + type=Path, + required=True, + help="Parent directory where themed content directories are created.", + ) + parser.add_argument("-v", "--verbose", action="store_true") + return parser + + +def load_themes(themes_path: Path) -> dict: + """Load and validate the themes YAML file. + + Returns the ``themes`` mapping keyed by theme-id. + """ + text = themes_path.read_text(encoding="utf-8") + data = yaml.safe_load(text) + if not isinstance(data, dict) or "themes" not in data: + raise ValueError("themes YAML must contain a top-level 'themes' key") + themes = data["themes"] + for theme_id, cfg in themes.items(): + if "colors" not in cfg or not isinstance(cfg["colors"], dict): + raise ValueError(f"Theme '{theme_id}' must contain a 'colors' mapping") + return themes + + +def remap_hex_in_text(text: str, color_map: dict[str, str]) -> str: + """Replace ``#RRGGBB`` hex color values using *color_map*. + + Keys and values in *color_map* must include the leading ``#``. + Matching is case-insensitive. + """ + result = text + for old_hex, new_hex in color_map.items(): + old_bare = old_hex.lstrip("#") + new_bare = new_hex.lstrip("#") + result = re.sub( + rf"#{re.escape(old_bare)}", + f"#{new_bare}", + result, + flags=re.IGNORECASE, + ) + return result + + +def remap_rgb_in_python(text: str, color_map: dict[str, str]) -> str: + """Replace ``RGBColor(0xRR, 0xGG, 0xBB)`` and ``"#RRGGBB"`` patterns. + + Keys and values in *color_map* must include the leading ``#``. + """ + result = text + for old_hex, new_hex in color_map.items(): + old_bare = old_hex.lstrip("#") + new_bare = new_hex.lstrip("#") + + old_r = int(old_bare[0:2], 16) + old_g = int(old_bare[2:4], 16) + old_b = int(old_bare[4:6], 16) + new_r = int(new_bare[0:2], 16) + new_g = int(new_bare[2:4], 16) + new_b = int(new_bare[4:6], 16) + + # RGBColor(0xRR, 0xGG, 0xBB) + old_pattern = ( + rf"RGBColor\(\s*0x{old_r:02X}\s*,\s*0x{old_g:02X}\s*," + rf"\s*0x{old_b:02X}\s*\)" + ) + new_value = f"RGBColor(0x{new_r:02X}, 0x{new_g:02X}, 0x{new_b:02X})" + result = re.sub(old_pattern, new_value, result, flags=re.IGNORECASE) + + # "#RRGGBB" string literals + result = re.sub( + rf'"#{re.escape(old_bare)}"', + f'"#{new_bare}"', + result, + flags=re.IGNORECASE, + ) + return result + + +def process_file(src: Path, dest: Path, color_map: dict[str, str]) -> None: + """Copy *src* to *dest*, remapping colors for YAML and Python files.""" + if src.suffix == ".yaml": + text = src.read_text(encoding="utf-8") + text = remap_hex_in_text(text, color_map) + dest.write_text(text, encoding="utf-8") + elif src.suffix == ".py": + text = src.read_text(encoding="utf-8") + text = remap_rgb_in_python(text, color_map) + text = remap_hex_in_text(text, color_map) + dest.write_text(text, encoding="utf-8") + else: + shutil.copy2(src, dest) + + +def process_directory(src_dir: Path, dest_dir: Path, color_map: dict[str, str]) -> None: + """Recursively process *src_dir* into *dest_dir*, remapping colors.""" + dest_dir.mkdir(parents=True, exist_ok=True) + for entry in sorted(src_dir.iterdir()): + dest_entry = dest_dir / entry.name + if entry.is_dir(): + process_directory(entry, dest_entry, color_map) + elif entry.is_file(): + process_file(entry, dest_entry, color_map) + + +def update_style_metadata( + style_path: Path, theme_id: str, label: str +) -> None: + """Patch theme name and append label to title in style.yaml.""" + if not style_path.exists(): + return + text = style_path.read_text(encoding="utf-8") + # Update theme name field + text = re.sub( + r'(name:\s*")[^"]*(")', + rf'\g<1>{theme_id}\2', + text, + count=1, + ) + # Append theme label to title when not already present + def _append_label(m: re.Match) -> str: + prefix, title, suffix = m.group(1), m.group(2), m.group(3) + if label in title: + return m.group(0) + return f'{prefix}{title} ({label}){suffix}' + + text = re.sub( + r'(title:\s*")([^"]*?)(")', + _append_label, + text, + count=1, + ) + style_path.write_text(text, encoding="utf-8") + + +def generate_theme( + content_dir: Path, + output_dir: Path, + deck_name: str, + theme_id: str, + theme_config: dict, +) -> Path: + """Generate a complete themed copy of *content_dir*.""" + color_map = theme_config["colors"] + label = theme_config.get("label", theme_id) + + output_base = output_dir / f"{deck_name}-{theme_id}" + output_content = output_base / "content" + output_deck = output_base / "slide-deck" + + if output_content.exists(): + shutil.rmtree(output_content) + + process_directory(content_dir, output_content, color_map) + + output_deck.mkdir(parents=True, exist_ok=True) + (output_deck / ".gitkeep").touch() + + # Patch style.yaml metadata inside the themed content + style_candidates = [ + output_content / "global" / "style.yaml", + output_content / "style.yaml", + ] + for style_path in style_candidates: + update_style_metadata(style_path, theme_id, label) + + logger.info("Generated: %s/", output_base.name) + return output_base + + +def run(args: argparse.Namespace) -> int: + """Execute theme generation.""" + content_dir = args.content_dir.resolve() + themes_path = args.themes.resolve() + output_dir = args.output_dir.resolve() + + if not content_dir.is_dir(): + logger.error("Content directory does not exist: %s", content_dir) + return EXIT_ERROR + if not themes_path.is_file(): + logger.error("Themes file does not exist: %s", themes_path) + return EXIT_ERROR + + themes = load_themes(themes_path) + deck_name = content_dir.parent.name + output_dir.mkdir(parents=True, exist_ok=True) + + logger.info( + "Generating %d themed variant(s) for '%s' ...", len(themes), deck_name + ) + + for theme_id, theme_config in themes.items(): + generate_theme(content_dir, output_dir, deck_name, theme_id, theme_config) + + logger.info("All themes generated successfully.") + return EXIT_SUCCESS + + +def main() -> int: + """Main entry point.""" + parser = create_parser() + args = parser.parse_args() + configure_logging(args.verbose) + try: + return run(args) + except KeyboardInterrupt: + print("\nInterrupted by user", file=sys.stderr) + return 130 + except BrokenPipeError: + sys.stderr.close() + return EXIT_FAILURE + except Exception as e: + logger.error("%s", e) + return EXIT_FAILURE + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/skills/experimental/powerpoint/scripts/validate_geometry.py b/.github/skills/experimental/powerpoint/scripts/validate_geometry.py new file mode 100644 index 000000000..f783f57e6 --- /dev/null +++ b/.github/skills/experimental/powerpoint/scripts/validate_geometry.py @@ -0,0 +1,494 @@ +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT +"""Validate PPTX element geometry against spacing and margin rules. + +Checks edge margins, adjacent element gaps, boundary overflow, and +title-subtitle clearance. Decorative accent bars (full-width shapes at +top with height ≤ 0.12") are exempted from margin rules. + +Usage: + python validate_geometry.py --input slide-deck/presentation.pptx + python validate_geometry.py --input deck.pptx --output results.json --report report.md + python validate_geometry.py --input deck.pptx --slides "1,3" --margin 0.6 --gap 0.4 +""" + +import argparse +import json +import logging +import sys +from datetime import datetime, timezone +from pathlib import Path + +from pptx import Presentation +from pptx_utils import ( + EXIT_ERROR, + EXIT_FAILURE, + EXIT_SUCCESS, + configure_logging, + emu_to_inches, + parse_slide_filter, +) + +logger = logging.getLogger(__name__) + +SEVERITY_ICON = {"error": "❌", "warning": "⚠️", "info": "ℹ️"} +QUALITY_ICON = {"good": "✅", "needs-attention": "⚠️"} + +ACCENT_BAR_MAX_HEIGHT = 0.12 + + +def _is_accent_bar(shape, slide_width_in: float) -> bool: + """Return True when shape is a full-width decorative accent bar at top.""" + top_in = emu_to_inches(shape.top) + height_in = emu_to_inches(shape.height) + width_in = emu_to_inches(shape.width) + left_in = emu_to_inches(shape.left) + return ( + top_in == 0.0 + and left_in <= 0.01 + and height_in <= ACCENT_BAR_MAX_HEIGHT + and abs(width_in - slide_width_in) < 0.01 + ) + + +def _shape_label(shape) -> str: + """Return a human-readable label for a shape.""" + name = shape.name or "unnamed" + if hasattr(shape, "text") and shape.text: + preview = shape.text[:40].replace("\n", " ") + return f"{name} (\"{preview}\")" + return name + + +def check_boundary_overflow( + shape, slide_w_in: float, slide_h_in: float, +) -> list[dict]: + """Check whether a shape extends beyond slide boundaries.""" + issues: list[dict] = [] + left = emu_to_inches(shape.left) + top = emu_to_inches(shape.top) + width = emu_to_inches(shape.width) + height = emu_to_inches(shape.height) + right = left + width + bottom = top + height + label = _shape_label(shape) + + if right > slide_w_in + 0.01: + issues.append({ + "check_type": "boundary_overflow", + "severity": "error", + "description": ( + f"Shape '{label}' right edge ({right:.2f}\") exceeds " + f"slide width ({slide_w_in:.2f}\")" + ), + "location": shape.name or "shape", + }) + if bottom > slide_h_in + 0.01: + issues.append({ + "check_type": "boundary_overflow", + "severity": "error", + "description": ( + f"Shape '{label}' bottom edge ({bottom:.2f}\") exceeds " + f"slide height ({slide_h_in:.2f}\")" + ), + "location": shape.name or "shape", + }) + return issues + + +def check_edge_margins( + shape, slide_w_in: float, slide_h_in: float, margin: float, +) -> list[dict]: + """Check whether a shape maintains minimum edge margins.""" + issues: list[dict] = [] + left = emu_to_inches(shape.left) + top = emu_to_inches(shape.top) + width = emu_to_inches(shape.width) + height = emu_to_inches(shape.height) + right = left + width + bottom = top + height + label = _shape_label(shape) + + if left < margin - 0.01: + issues.append({ + "check_type": "edge_margin", + "severity": "warning", + "description": ( + f"Shape '{label}' left ({left:.2f}\") < " + f"minimum margin ({margin}\")" + ), + "location": shape.name or "shape", + }) + if top < margin - 0.01: + issues.append({ + "check_type": "edge_margin", + "severity": "warning", + "description": ( + f"Shape '{label}' top ({top:.2f}\") < " + f"minimum margin ({margin}\")" + ), + "location": shape.name or "shape", + }) + if right > slide_w_in - margin + 0.01: + issues.append({ + "check_type": "edge_margin", + "severity": "warning", + "description": ( + f"Shape '{label}' right edge ({right:.2f}\") > " + f"slide width - margin ({slide_w_in - margin:.2f}\")" + ), + "location": shape.name or "shape", + }) + if bottom > slide_h_in - margin + 0.01: + issues.append({ + "check_type": "edge_margin", + "severity": "warning", + "description": ( + f"Shape '{label}' bottom edge ({bottom:.2f}\") > " + f"slide height - margin ({slide_h_in - margin:.2f}\")" + ), + "location": shape.name or "shape", + }) + return issues + + +def check_adjacent_gaps(shapes, gap: float) -> list[dict]: + """Check vertical gaps between adjacent elements. + + Sorts shapes by top position and checks consecutive pairs for minimum + vertical clearance. + """ + issues: list[dict] = [] + rects = [] + for s in shapes: + top = emu_to_inches(s.top) + height = emu_to_inches(s.height) + rects.append((top, top + height, s)) + rects.sort(key=lambda r: r[0]) + + for i in range(len(rects) - 1): + _, bottom_a, shape_a = rects[i] + top_b, _, shape_b = rects[i + 1] + vertical_gap = top_b - bottom_a + if vertical_gap < gap - 0.01 and vertical_gap >= 0: + label_a = _shape_label(shape_a) + label_b = _shape_label(shape_b) + issues.append({ + "check_type": "adjacent_gap", + "severity": "warning", + "description": ( + f"Gap between '{label_a}' and '{label_b}' " + f"is {vertical_gap:.2f}\" (minimum {gap}\")" + ), + "location": f"{shape_a.name or 'shape'}→{shape_b.name or 'shape'}", + }) + return issues + + +def check_title_clearance(shapes, clearance: float) -> list[dict]: + """Check title-to-next-element vertical clearance. + + Identifies positions where a shape name contains 'title' (but not + 'subtitle') and verifies the next element below has sufficient clearance. + """ + issues: list[dict] = [] + rects = [] + for s in shapes: + top = emu_to_inches(s.top) + height = emu_to_inches(s.height) + rects.append((top, top + height, s)) + rects.sort(key=lambda r: r[0]) + + for i, (_, bottom, shape) in enumerate(rects): + name_lower = (shape.name or "").lower() + if "title" not in name_lower or "subtitle" in name_lower: + continue + if i + 1 >= len(rects): + continue + next_top = rects[i + 1][0] + title_clearance = next_top - bottom + if title_clearance < clearance - 0.01 and title_clearance >= 0: + label = _shape_label(shape) + next_label = _shape_label(rects[i + 1][2]) + issues.append({ + "check_type": "title_clearance", + "severity": "info", + "description": ( + f"Title '{label}' to '{next_label}' clearance " + f"is {title_clearance:.2f}\" (recommended {clearance}\")" + ), + "location": ( + f"{shape.name or 'title'}→" + f"{rects[i + 1][2].name or 'shape'}" + ), + }) + return issues + + +def validate_slide_geometry( + slide, + slide_num: int, + slide_w_in: float, + slide_h_in: float, + *, + margin: float, + gap: float, + clearance: float, +) -> dict: + """Run all geometry checks for a single slide.""" + issues: list[dict] = [] + non_accent_shapes = [] + + for shape in slide.shapes: + # Boundary overflow applies to all shapes + issues.extend(check_boundary_overflow(shape, slide_w_in, slide_h_in)) + + if _is_accent_bar(shape, slide_w_in): + logger.debug( + "Slide %d: exempting accent bar '%s'", slide_num, shape.name, + ) + continue + + non_accent_shapes.append(shape) + issues.extend(check_edge_margins(shape, slide_w_in, slide_h_in, margin)) + + # Adjacent gaps and title clearance use non-accent shapes only + issues.extend(check_adjacent_gaps(non_accent_shapes, gap)) + issues.extend(check_title_clearance(non_accent_shapes, clearance)) + + quality = "good" if not issues else "needs-attention" + return { + "slide_number": slide_num, + "issues": issues, + "overall_quality": quality, + } + + +def validate_geometry( + pptx_path: Path, + slide_filter: set[int] | None = None, + *, + margin: float = 0.5, + gap: float = 0.3, + clearance: float = 0.2, +) -> dict: + """Run geometry validation across all slides in a presentation. + + Returns: + Dict with source, slide_count, and per-slide issues. + """ + prs = Presentation(str(pptx_path)) + slide_w_in = emu_to_inches(prs.slide_width) + slide_h_in = emu_to_inches(prs.slide_height) + total_slides = len(prs.slides) + slides = [] + + for i, slide in enumerate(prs.slides): + slide_num = i + 1 + if slide_filter and slide_num not in slide_filter: + continue + slide_result = validate_slide_geometry( + slide, slide_num, slide_w_in, slide_h_in, + margin=margin, gap=gap, clearance=clearance, + ) + slides.append(slide_result) + + return { + "source": "geometry-validation", + "slide_count": total_slides, + "slides": slides, + } + + +def generate_report(results: dict) -> str: + """Generate a Markdown validation report from results.""" + lines = ["# Geometry Validation Report", ""] + ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + lines.append(f"**Generated**: {ts} ") + lines.append(f"**Source**: {results['source']} ") + lines.append(f"**Slides**: {results['slide_count']}") + lines.append("") + + error_count = 0 + warning_count = 0 + info_count = 0 + for slide in results["slides"]: + for issue in slide.get("issues", []): + sev = issue.get("severity", "info") + if sev == "error": + error_count += 1 + elif sev == "warning": + warning_count += 1 + else: + info_count += 1 + + lines.append("## Summary") + lines.append("") + lines.append("| Severity | Count |") + lines.append("|-|-|") + lines.append(f"| ❌ Errors | {error_count} |") + lines.append(f"| ⚠️ Warnings | {warning_count} |") + lines.append(f"| ℹ️ Info | {info_count} |") + lines.append("") + + lines.append("## Per-Slide Findings") + lines.append("") + for slide in results["slides"]: + num = slide.get("slide_number", "?") + quality = slide.get("overall_quality", "unknown") + icon = QUALITY_ICON.get(quality, "❓") + lines.append(f"### Slide {num} {icon} {quality}") + lines.append("") + + issues = slide.get("issues", []) + if not issues: + lines.append("No issues found.") + lines.append("") + continue + + lines.append("| Severity | Check | Location | Description |") + lines.append("|-|-|-|-|") + for issue in issues: + sev = issue.get("severity", "info") + sev_icon = SEVERITY_ICON.get(sev, "") + check = issue.get("check_type", "") + loc = issue.get("location", "") + desc = issue.get("description", "") + lines.append(f"| {sev_icon} {sev} | {check} | {loc} | {desc} |") + lines.append("") + + return "\n".join(lines) + + +def max_severity(results: dict) -> str: + """Return the highest severity found across all issues.""" + severities = set() + for slide in results["slides"]: + for issue in slide.get("issues", []): + severities.add(issue.get("severity", "info")) + if "error" in severities: + return "error" + if "warning" in severities: + return "warning" + if "info" in severities: + return "info" + return "none" + + +def create_parser() -> argparse.ArgumentParser: + """Create and configure argument parser.""" + parser = argparse.ArgumentParser( + description=( + "Validate PPTX element geometry: edge margins, adjacent gaps, " + "boundary overflow, and title-subtitle clearance" + ) + ) + parser.add_argument( + "--input", required=True, type=Path, help="Input PPTX file path", + ) + parser.add_argument( + "--slides", + help="Comma-separated slide numbers to validate (default: all)", + ) + parser.add_argument( + "--output", type=Path, help="Output JSON file path (default: stdout)", + ) + parser.add_argument( + "--report", type=Path, help="Output Markdown report file path", + ) + parser.add_argument( + "--per-slide-dir", + type=Path, + help="Directory for per-slide JSON files (slide-NNN-geometry.json)", + ) + parser.add_argument( + "--margin", + type=float, + default=0.5, + help="Minimum edge margin in inches (default: 0.5)", + ) + parser.add_argument( + "--gap", + type=float, + default=0.3, + help="Minimum adjacent element gap in inches (default: 0.3)", + ) + parser.add_argument( + "--clearance", + type=float, + default=0.2, + help="Minimum title-subtitle clearance in inches (default: 0.2)", + ) + parser.add_argument( + "-v", "--verbose", action="store_true", help="Enable verbose logging", + ) + return parser + + +def main() -> int: + """Main entry point.""" + parser = create_parser() + args = parser.parse_args() + configure_logging(getattr(args, "verbose", False)) + + pptx_path = args.input + if not pptx_path.exists(): + logger.error("File not found: %s", pptx_path) + return EXIT_ERROR + + slide_filter = parse_slide_filter(args.slides) + + logger.info("Validating geometry: %s", pptx_path) + results = validate_geometry( + pptx_path, + slide_filter=slide_filter, + margin=args.margin, + gap=args.gap, + clearance=args.clearance, + ) + + # Write per-slide geometry JSON files + if args.per_slide_dir: + args.per_slide_dir.mkdir(parents=True, exist_ok=True) + for slide_result in results["slides"]: + slide_num = slide_result.get("slide_number", 0) + per_slide_path = ( + args.per_slide_dir / f"slide-{slide_num:03d}-geometry.json" + ) + per_slide_json = json.dumps(slide_result, indent=2) + per_slide_path.write_text(per_slide_json, encoding="utf-8") + logger.debug("Per-slide geometry results written to %s", per_slide_path) + + # Output JSON + output_json = json.dumps(results, indent=2) + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(output_json, encoding="utf-8") + logger.info("Results written to %s", args.output) + else: + print(output_json) + + # Generate Markdown report + if args.report: + report_md = generate_report(results) + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(report_md, encoding="utf-8") + logger.info("Report written to %s", args.report) + + # Report summary + total_issues = sum(len(s.get("issues", [])) for s in results["slides"]) + severity = max_severity(results) + slide_count = results["slide_count"] + logger.info( + "Validation complete: %d issue(s) across %d slide(s)", + total_issues, + slide_count, + ) + + if severity in ("error", "warning"): + return EXIT_FAILURE + return EXIT_SUCCESS + + +if __name__ == "__main__": + sys.exit(main()) From 8d5365725a5351f590118d0cc0dc7485260b01b2 Mon Sep 17 00:00:00 2001 From: auyidi Date: Tue, 28 Apr 2026 03:03:37 +0000 Subject: [PATCH 02/22] fix(skills): resolve ruff lint and format violations - Fix E501 line-length violations in build_deck.py, embed_audio.py, and validate_geometry.py - Apply ruff format to all 5 new/modified scripts --- .../powerpoint/scripts/build_deck.py | 17 +- .../powerpoint/scripts/embed_audio.py | 17 +- .../powerpoint/scripts/export_svg.py | 4 +- .../powerpoint/scripts/generate_themes.py | 13 +- .../powerpoint/scripts/validate_geometry.py | 220 ++++++++++-------- 5 files changed, 158 insertions(+), 113 deletions(-) diff --git a/.github/skills/experimental/powerpoint/scripts/build_deck.py b/.github/skills/experimental/powerpoint/scripts/build_deck.py index ce27d5917..4c7e08528 100644 --- a/.github/skills/experimental/powerpoint/scripts/build_deck.py +++ b/.github/skills/experimental/powerpoint/scripts/build_deck.py @@ -1104,7 +1104,10 @@ def main(): parser.add_argument( "--dry-run", action="store_true", - help="Validate content without building PPTX (parse YAML, check images, validate scripts)", + help=( + "Validate content without building PPTX" + " (parse YAML, check images, validate scripts)" + ), ) args = parser.parse_args() @@ -1141,9 +1144,17 @@ def main(): extra_status = " | extra: skipped" # Check image references images = slide_dir / "images" - img_count = len(list(images.glob("*.png"))) + len(list(images.glob("*.jpg"))) if images.exists() else 0 + img_count = ( + len(list(images.glob("*.png"))) + len(list(images.glob("*.jpg"))) + if images.exists() + else 0 + ) img_status = f" | {img_count} images" if img_count else "" - print(f" Slide {num:03d}: {title} [{notes_status}{extra_status}{img_status}]") + status_line = ( + f" Slide {num:03d}: {title}" + f" [{notes_status}{extra_status}{img_status}]" + ) + print(status_line) except Exception as exc: print(f" Slide {num:03d}: ❌ YAML parse error: {exc}") errors += 1 diff --git a/.github/skills/experimental/powerpoint/scripts/embed_audio.py b/.github/skills/experimental/powerpoint/scripts/embed_audio.py index 1ec4c60b6..414094cb8 100644 --- a/.github/skills/experimental/powerpoint/scripts/embed_audio.py +++ b/.github/skills/experimental/powerpoint/scripts/embed_audio.py @@ -5,10 +5,15 @@ Matches audio files to slides by naming convention (slide-001.wav → slide 1) and embeds each as an audio shape using python-pptx's add_movie API. -Usage: - python embed_audio.py --input deck.pptx --audio-dir voice-over/ --output deck-narrated.pptx - python embed_audio.py --input deck.pptx --audio-dir voice-over/ --output deck-narrated.pptx --slides "1,3,5" - python embed_audio.py --input deck.pptx --audio-dir voice-over/ --output deck-narrated.pptx -v +Usage:: + + python embed_audio.py --input deck.pptx \ + --audio-dir voice-over/ --output out.pptx + python embed_audio.py --input deck.pptx \ + --audio-dir voice-over/ --output out.pptx \ + --slides "1,3,5" + python embed_audio.py --input deck.pptx \ + --audio-dir voice-over/ --output out.pptx -v """ import argparse @@ -175,9 +180,7 @@ def run(args: argparse.Namespace) -> int: logger.warning("No slide-NNN.wav files found in %s", audio_dir) return EXIT_FAILURE - logger.info( - "Discovered %d audio file(s) in %s", len(audio_map), audio_dir - ) + logger.info("Discovered %d audio file(s) in %s", len(audio_map), audio_dir) prs = Presentation(str(input_path)) total_slides = len(prs.slides) diff --git a/.github/skills/experimental/powerpoint/scripts/export_svg.py b/.github/skills/experimental/powerpoint/scripts/export_svg.py index 9a3b4be1c..7921dcdb3 100644 --- a/.github/skills/experimental/powerpoint/scripts/export_svg.py +++ b/.github/skills/experimental/powerpoint/scripts/export_svg.py @@ -184,9 +184,7 @@ def export_pdf_to_svg( page_numbers = [n for n in slides if 1 <= n <= total_pages] skipped = [n for n in slides if n < 1 or n > total_pages] for num in skipped: - logger.warning( - "Slide %d out of range (1-%d), skipping", num, total_pages - ) + logger.warning("Slide %d out of range (1-%d), skipping", num, total_pages) else: page_numbers = list(range(1, total_pages + 1)) diff --git a/.github/skills/experimental/powerpoint/scripts/generate_themes.py b/.github/skills/experimental/powerpoint/scripts/generate_themes.py index 3d5ef970b..20df63eb1 100644 --- a/.github/skills/experimental/powerpoint/scripts/generate_themes.py +++ b/.github/skills/experimental/powerpoint/scripts/generate_themes.py @@ -157,9 +157,7 @@ def process_directory(src_dir: Path, dest_dir: Path, color_map: dict[str, str]) process_file(entry, dest_entry, color_map) -def update_style_metadata( - style_path: Path, theme_id: str, label: str -) -> None: +def update_style_metadata(style_path: Path, theme_id: str, label: str) -> None: """Patch theme name and append label to title in style.yaml.""" if not style_path.exists(): return @@ -167,16 +165,17 @@ def update_style_metadata( # Update theme name field text = re.sub( r'(name:\s*")[^"]*(")', - rf'\g<1>{theme_id}\2', + rf"\g<1>{theme_id}\2", text, count=1, ) + # Append theme label to title when not already present def _append_label(m: re.Match) -> str: prefix, title, suffix = m.group(1), m.group(2), m.group(3) if label in title: return m.group(0) - return f'{prefix}{title} ({label}){suffix}' + return f"{prefix}{title} ({label}){suffix}" text = re.sub( r'(title:\s*")([^"]*?)(")', @@ -239,9 +238,7 @@ def run(args: argparse.Namespace) -> int: deck_name = content_dir.parent.name output_dir.mkdir(parents=True, exist_ok=True) - logger.info( - "Generating %d themed variant(s) for '%s' ...", len(themes), deck_name - ) + logger.info("Generating %d themed variant(s) for '%s' ...", len(themes), deck_name) for theme_id, theme_config in themes.items(): generate_theme(content_dir, output_dir, deck_name, theme_id, theme_config) diff --git a/.github/skills/experimental/powerpoint/scripts/validate_geometry.py b/.github/skills/experimental/powerpoint/scripts/validate_geometry.py index f783f57e6..4871d8a7e 100644 --- a/.github/skills/experimental/powerpoint/scripts/validate_geometry.py +++ b/.github/skills/experimental/powerpoint/scripts/validate_geometry.py @@ -6,10 +6,13 @@ title-subtitle clearance. Decorative accent bars (full-width shapes at top with height ≤ 0.12") are exempted from margin rules. -Usage: - python validate_geometry.py --input slide-deck/presentation.pptx - python validate_geometry.py --input deck.pptx --output results.json --report report.md - python validate_geometry.py --input deck.pptx --slides "1,3" --margin 0.6 --gap 0.4 +Usage:: + + python validate_geometry.py --input deck.pptx + python validate_geometry.py --input deck.pptx \ + --output results.json --report report.md + python validate_geometry.py --input deck.pptx \ + --slides "1,3" --margin 0.6 --gap 0.4 """ import argparse @@ -56,12 +59,14 @@ def _shape_label(shape) -> str: name = shape.name or "unnamed" if hasattr(shape, "text") and shape.text: preview = shape.text[:40].replace("\n", " ") - return f"{name} (\"{preview}\")" + return f'{name} ("{preview}")' return name def check_boundary_overflow( - shape, slide_w_in: float, slide_h_in: float, + shape, + slide_w_in: float, + slide_h_in: float, ) -> list[dict]: """Check whether a shape extends beyond slide boundaries.""" issues: list[dict] = [] @@ -74,30 +79,37 @@ def check_boundary_overflow( label = _shape_label(shape) if right > slide_w_in + 0.01: - issues.append({ - "check_type": "boundary_overflow", - "severity": "error", - "description": ( - f"Shape '{label}' right edge ({right:.2f}\") exceeds " - f"slide width ({slide_w_in:.2f}\")" - ), - "location": shape.name or "shape", - }) + issues.append( + { + "check_type": "boundary_overflow", + "severity": "error", + "description": ( + f"Shape '{label}' right edge ({right:.2f}\") exceeds " + f'slide width ({slide_w_in:.2f}")' + ), + "location": shape.name or "shape", + } + ) if bottom > slide_h_in + 0.01: - issues.append({ - "check_type": "boundary_overflow", - "severity": "error", - "description": ( - f"Shape '{label}' bottom edge ({bottom:.2f}\") exceeds " - f"slide height ({slide_h_in:.2f}\")" - ), - "location": shape.name or "shape", - }) + issues.append( + { + "check_type": "boundary_overflow", + "severity": "error", + "description": ( + f"Shape '{label}' bottom edge ({bottom:.2f}\") exceeds " + f'slide height ({slide_h_in:.2f}")' + ), + "location": shape.name or "shape", + } + ) return issues def check_edge_margins( - shape, slide_w_in: float, slide_h_in: float, margin: float, + shape, + slide_w_in: float, + slide_h_in: float, + margin: float, ) -> list[dict]: """Check whether a shape maintains minimum edge margins.""" issues: list[dict] = [] @@ -110,45 +122,51 @@ def check_edge_margins( label = _shape_label(shape) if left < margin - 0.01: - issues.append({ - "check_type": "edge_margin", - "severity": "warning", - "description": ( - f"Shape '{label}' left ({left:.2f}\") < " - f"minimum margin ({margin}\")" - ), - "location": shape.name or "shape", - }) + issues.append( + { + "check_type": "edge_margin", + "severity": "warning", + "description": ( + f"Shape '{label}' left ({left:.2f}\") < minimum margin ({margin}\")" + ), + "location": shape.name or "shape", + } + ) if top < margin - 0.01: - issues.append({ - "check_type": "edge_margin", - "severity": "warning", - "description": ( - f"Shape '{label}' top ({top:.2f}\") < " - f"minimum margin ({margin}\")" - ), - "location": shape.name or "shape", - }) + issues.append( + { + "check_type": "edge_margin", + "severity": "warning", + "description": ( + f"Shape '{label}' top ({top:.2f}\") < minimum margin ({margin}\")" + ), + "location": shape.name or "shape", + } + ) if right > slide_w_in - margin + 0.01: - issues.append({ - "check_type": "edge_margin", - "severity": "warning", - "description": ( - f"Shape '{label}' right edge ({right:.2f}\") > " - f"slide width - margin ({slide_w_in - margin:.2f}\")" - ), - "location": shape.name or "shape", - }) + issues.append( + { + "check_type": "edge_margin", + "severity": "warning", + "description": ( + f"Shape '{label}' right edge ({right:.2f}\") > " + f'slide width - margin ({slide_w_in - margin:.2f}")' + ), + "location": shape.name or "shape", + } + ) if bottom > slide_h_in - margin + 0.01: - issues.append({ - "check_type": "edge_margin", - "severity": "warning", - "description": ( - f"Shape '{label}' bottom edge ({bottom:.2f}\") > " - f"slide height - margin ({slide_h_in - margin:.2f}\")" - ), - "location": shape.name or "shape", - }) + issues.append( + { + "check_type": "edge_margin", + "severity": "warning", + "description": ( + f"Shape '{label}' bottom edge ({bottom:.2f}\") > " + f'slide height - margin ({slide_h_in - margin:.2f}")' + ), + "location": shape.name or "shape", + } + ) return issues @@ -173,15 +191,17 @@ def check_adjacent_gaps(shapes, gap: float) -> list[dict]: if vertical_gap < gap - 0.01 and vertical_gap >= 0: label_a = _shape_label(shape_a) label_b = _shape_label(shape_b) - issues.append({ - "check_type": "adjacent_gap", - "severity": "warning", - "description": ( - f"Gap between '{label_a}' and '{label_b}' " - f"is {vertical_gap:.2f}\" (minimum {gap}\")" - ), - "location": f"{shape_a.name or 'shape'}→{shape_b.name or 'shape'}", - }) + issues.append( + { + "check_type": "adjacent_gap", + "severity": "warning", + "description": ( + f"Gap between '{label_a}' and '{label_b}' " + f'is {vertical_gap:.2f}" (minimum {gap}")' + ), + "location": f"{shape_a.name or 'shape'}→{shape_b.name or 'shape'}", + } + ) return issues @@ -210,18 +230,19 @@ def check_title_clearance(shapes, clearance: float) -> list[dict]: if title_clearance < clearance - 0.01 and title_clearance >= 0: label = _shape_label(shape) next_label = _shape_label(rects[i + 1][2]) - issues.append({ - "check_type": "title_clearance", - "severity": "info", - "description": ( - f"Title '{label}' to '{next_label}' clearance " - f"is {title_clearance:.2f}\" (recommended {clearance}\")" - ), - "location": ( - f"{shape.name or 'title'}→" - f"{rects[i + 1][2].name or 'shape'}" - ), - }) + issues.append( + { + "check_type": "title_clearance", + "severity": "info", + "description": ( + f"Title '{label}' to '{next_label}' clearance " + f'is {title_clearance:.2f}" (recommended {clearance}")' + ), + "location": ( + f"{shape.name or 'title'}→{rects[i + 1][2].name or 'shape'}" + ), + } + ) return issues @@ -245,7 +266,9 @@ def validate_slide_geometry( if _is_accent_bar(shape, slide_w_in): logger.debug( - "Slide %d: exempting accent bar '%s'", slide_num, shape.name, + "Slide %d: exempting accent bar '%s'", + slide_num, + shape.name, ) continue @@ -288,8 +311,13 @@ def validate_geometry( if slide_filter and slide_num not in slide_filter: continue slide_result = validate_slide_geometry( - slide, slide_num, slide_w_in, slide_h_in, - margin=margin, gap=gap, clearance=clearance, + slide, + slide_num, + slide_w_in, + slide_h_in, + margin=margin, + gap=gap, + clearance=clearance, ) slides.append(slide_result) @@ -384,17 +412,24 @@ def create_parser() -> argparse.ArgumentParser: ) ) parser.add_argument( - "--input", required=True, type=Path, help="Input PPTX file path", + "--input", + required=True, + type=Path, + help="Input PPTX file path", ) parser.add_argument( "--slides", help="Comma-separated slide numbers to validate (default: all)", ) parser.add_argument( - "--output", type=Path, help="Output JSON file path (default: stdout)", + "--output", + type=Path, + help="Output JSON file path (default: stdout)", ) parser.add_argument( - "--report", type=Path, help="Output Markdown report file path", + "--report", + type=Path, + help="Output Markdown report file path", ) parser.add_argument( "--per-slide-dir", @@ -420,7 +455,10 @@ def create_parser() -> argparse.ArgumentParser: help="Minimum title-subtitle clearance in inches (default: 0.2)", ) parser.add_argument( - "-v", "--verbose", action="store_true", help="Enable verbose logging", + "-v", + "--verbose", + action="store_true", + help="Enable verbose logging", ) return parser @@ -452,9 +490,7 @@ def main() -> int: args.per_slide_dir.mkdir(parents=True, exist_ok=True) for slide_result in results["slides"]: slide_num = slide_result.get("slide_number", 0) - per_slide_path = ( - args.per_slide_dir / f"slide-{slide_num:03d}-geometry.json" - ) + per_slide_path = args.per_slide_dir / f"slide-{slide_num:03d}-geometry.json" per_slide_json = json.dumps(slide_result, indent=2) per_slide_path.write_text(per_slide_json, encoding="utf-8") logger.debug("Per-slide geometry results written to %s", per_slide_path) From 333416fc75565dd8f897fb0a69d8745814d6f1c0 Mon Sep 17 00:00:00 2001 From: auyidi Date: Tue, 28 Apr 2026 03:20:18 +0000 Subject: [PATCH 03/22] test(skills): add tests for 4 new PowerPoint skill scripts - test_validate_geometry.py: 35 tests covering geometry checks, parser, report generation, and main entry point - test_generate_themes.py: 18 tests covering hex/RGB remap, theme loading, directory processing, and full theme generation - test_embed_audio.py: 18 tests covering audio discovery, embedding, poster frame creation, and run function - test_export_svg.py: 11 tests covering parser, LibreOffice detection, slide number parsing, and error paths Coverage: 89% (above 85% threshold) --- .../powerpoint/tests/test_embed_audio.py | 276 ++++++++++ .../powerpoint/tests/test_export_svg.py | 117 ++++ .../powerpoint/tests/test_generate_themes.py | 265 ++++++++++ .../tests/test_validate_geometry.py | 499 ++++++++++++++++++ 4 files changed, 1157 insertions(+) create mode 100644 .github/skills/experimental/powerpoint/tests/test_embed_audio.py create mode 100644 .github/skills/experimental/powerpoint/tests/test_export_svg.py create mode 100644 .github/skills/experimental/powerpoint/tests/test_generate_themes.py create mode 100644 .github/skills/experimental/powerpoint/tests/test_validate_geometry.py diff --git a/.github/skills/experimental/powerpoint/tests/test_embed_audio.py b/.github/skills/experimental/powerpoint/tests/test_embed_audio.py new file mode 100644 index 000000000..8de6d8edf --- /dev/null +++ b/.github/skills/experimental/powerpoint/tests/test_embed_audio.py @@ -0,0 +1,276 @@ +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT +"""Tests for embed_audio module.""" + +import pytest +from embed_audio import ( + AUDIO_PATTERN, + create_parser, + create_poster_frame, + discover_audio_files, + embed_audio, + main, + run, +) +from pptx import Presentation +from pptx.util import Inches + + +def _make_wav_bytes(duration_ms: int = 100) -> bytes: + """Create minimal valid WAV file bytes.""" + import struct + + sample_rate = 16000 + num_samples = int(sample_rate * duration_ms / 1000) + data = b"\x00\x00" * num_samples + data_size = len(data) + fmt_size = 16 + file_size = 4 + (8 + fmt_size) + (8 + data_size) + header = struct.pack( + "<4sI4s4sIHHIIHH4sI", + b"RIFF", + file_size, + b"WAVE", + b"fmt ", + fmt_size, + 1, # PCM + 1, # mono + sample_rate, + sample_rate * 2, # byte rate + 2, # block align + 16, # bits per sample + b"data", + data_size, + ) + return header + data + + +@pytest.fixture() +def simple_deck(tmp_path): + """Create a minimal 3-slide PPTX.""" + prs = Presentation() + prs.slide_width = Inches(13.333) + prs.slide_height = Inches(7.5) + layout = prs.slide_layouts[6] + for _ in range(3): + prs.slides.add_slide(layout) + path = tmp_path / "deck.pptx" + prs.save(str(path)) + return path + + +@pytest.fixture() +def audio_dir(tmp_path): + """Create a directory with 3 WAV files.""" + d = tmp_path / "audio" + d.mkdir() + wav = _make_wav_bytes() + for i in range(1, 4): + (d / f"slide-{i:03d}.wav").write_bytes(wav) + return d + + +class TestAudioPattern: + """Tests for the AUDIO_PATTERN regex.""" + + def test_matches_standard(self): + m = AUDIO_PATTERN.match("slide-001.wav") + assert m is not None + assert m.group(1) == "001" + + def test_matches_large_number(self): + m = AUDIO_PATTERN.match("slide-123.wav") + assert m is not None + assert m.group(1) == "123" + + def test_rejects_wrong_prefix(self): + assert AUDIO_PATTERN.match("audio-001.wav") is None + + def test_rejects_wrong_extension(self): + assert AUDIO_PATTERN.match("slide-001.mp3") is None + + +class TestDiscoverAudioFiles: + """Tests for discover_audio_files.""" + + def test_finds_wav_files(self, audio_dir): + mapping = discover_audio_files(audio_dir) + assert len(mapping) == 3 + assert 1 in mapping + assert 2 in mapping + assert 3 in mapping + + def test_empty_dir(self, tmp_path): + d = tmp_path / "empty" + d.mkdir() + assert discover_audio_files(d) == {} + + def test_ignores_non_wav(self, tmp_path): + d = tmp_path / "mixed" + d.mkdir() + (d / "slide-001.wav").write_bytes(_make_wav_bytes()) + (d / "slide-002.mp3").write_bytes(b"\x00") + (d / "README.md").write_text("hi") + mapping = discover_audio_files(d) + assert len(mapping) == 1 + assert 1 in mapping + + +class TestCreatePosterFrame: + """Tests for create_poster_frame.""" + + def test_creates_png_file(self): + path = create_poster_frame() + assert path.exists() + assert path.suffix == ".png" + data = path.read_bytes() + assert data[:4] == b"\x89PNG" + # Clean up temp file + path.unlink(missing_ok=True) + + +class TestEmbedAudio: + """Tests for embed_audio function.""" + + def test_embeds_audio_on_slide(self, simple_deck, audio_dir, tmp_path): + from pptx import Presentation as Prs + + output = tmp_path / "out.pptx" + prs = Prs(str(simple_deck)) + poster = create_poster_frame() + try: + embedded = embed_audio(prs, {1: audio_dir / "slide-001.wav"}, None, poster) + finally: + poster.unlink(missing_ok=True) + assert embedded == 1 + prs.save(str(output)) + assert output.exists() + + def test_slide_filter(self, simple_deck, audio_dir, tmp_path): + from pptx import Presentation as Prs + + prs = Prs(str(simple_deck)) + audio_map = discover_audio_files(audio_dir) + poster = create_poster_frame() + try: + embedded = embed_audio(prs, audio_map, {2}, poster) + finally: + poster.unlink(missing_ok=True) + assert embedded == 1 + + +class TestCreateParser: + """Tests for create_parser.""" + + def test_required_args(self): + parser = create_parser() + args = parser.parse_args( + ["--input", "d.pptx", "--audio-dir", "a/", "--output", "o.pptx"] + ) + assert str(args.input) == "d.pptx" + + def test_optional_slides(self): + parser = create_parser() + args = parser.parse_args( + [ + "--input", + "d.pptx", + "--audio-dir", + "a/", + "--output", + "o.pptx", + "--slides", + "1,3", + ] + ) + assert args.slides == "1,3" + + +class TestRun: + """Tests for run function.""" + + def test_full_embed(self, simple_deck, audio_dir, tmp_path): + parser = create_parser() + output = tmp_path / "narrated.pptx" + args = parser.parse_args( + [ + "--input", + str(simple_deck), + "--audio-dir", + str(audio_dir), + "--output", + str(output), + ] + ) + rc = run(args) + assert rc == 0 + assert output.exists() + + def test_missing_input(self, audio_dir, tmp_path): + parser = create_parser() + args = parser.parse_args( + [ + "--input", + str(tmp_path / "missing.pptx"), + "--audio-dir", + str(audio_dir), + "--output", + str(tmp_path / "out.pptx"), + ] + ) + rc = run(args) + assert rc == 2 + + def test_missing_audio_dir(self, simple_deck, tmp_path): + parser = create_parser() + args = parser.parse_args( + [ + "--input", + str(simple_deck), + "--audio-dir", + str(tmp_path / "no-audio"), + "--output", + str(tmp_path / "out.pptx"), + ] + ) + rc = run(args) + assert rc == 2 + + def test_no_matching_audio(self, simple_deck, tmp_path): + empty_audio = tmp_path / "empty-audio" + empty_audio.mkdir() + parser = create_parser() + args = parser.parse_args( + [ + "--input", + str(simple_deck), + "--audio-dir", + str(empty_audio), + "--output", + str(tmp_path / "out.pptx"), + ] + ) + rc = run(args) + assert rc == 1 + + +class TestMain: + """Tests for main entry point.""" + + def test_success(self, simple_deck, audio_dir, tmp_path, monkeypatch): + output = tmp_path / "main-out.pptx" + monkeypatch.setattr( + "sys.argv", + [ + "embed_audio", + "--input", + str(simple_deck), + "--audio-dir", + str(audio_dir), + "--output", + str(output), + ], + ) + rc = main() + assert rc == 0 + assert output.exists() diff --git a/.github/skills/experimental/powerpoint/tests/test_export_svg.py b/.github/skills/experimental/powerpoint/tests/test_export_svg.py new file mode 100644 index 000000000..122f17f8b --- /dev/null +++ b/.github/skills/experimental/powerpoint/tests/test_export_svg.py @@ -0,0 +1,117 @@ +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT +"""Tests for export_svg module.""" + +from unittest.mock import patch + +import pytest +from export_svg import ( + create_parser, + find_libreoffice, + main, + parse_slide_numbers, + run, +) + + +class TestCreateParser: + """Tests for create_parser.""" + + def test_required_args(self): + parser = create_parser() + args = parser.parse_args(["--input", "deck.pptx", "--output-dir", "svg"]) + assert str(args.input) == "deck.pptx" + assert str(args.output_dir) == "svg" + + def test_optional_slides(self): + parser = create_parser() + args = parser.parse_args( + ["--input", "d.pptx", "--output-dir", "o/", "--slides", "1,3,5"] + ) + assert args.slides == "1,3,5" + + def test_verbose(self): + parser = create_parser() + args = parser.parse_args(["--input", "d.pptx", "--output-dir", "o/", "-v"]) + assert args.verbose is True + + +class TestParseSlideNumbers: + """Tests for parse_slide_numbers.""" + + def test_simple(self): + assert parse_slide_numbers("1,3,5") == [1, 3, 5] + + def test_whitespace(self): + assert parse_slide_numbers(" 2 , 4 , 6 ") == [2, 4, 6] + + def test_single(self): + assert parse_slide_numbers("7") == [7] + + +class TestFindLibreoffice: + """Tests for find_libreoffice.""" + + def test_returns_string_or_none(self): + result = find_libreoffice() + assert result is None or isinstance(result, str) + + @patch("shutil.which", return_value="/usr/bin/libreoffice") + def test_finds_on_path(self, mock_which): + assert find_libreoffice() == "/usr/bin/libreoffice" + + @patch("shutil.which", return_value=None) + @patch("os.path.isfile", return_value=False) + def test_returns_none_when_missing(self, mock_isfile, mock_which): + assert find_libreoffice() is None + + +class TestRun: + """Tests for run function.""" + + def test_missing_input_file(self, tmp_path): + parser = create_parser() + args = parser.parse_args( + [ + "--input", + str(tmp_path / "missing.pptx"), + "--output-dir", + str(tmp_path / "out"), + ] + ) + rc = run(args) + assert rc == 2 + + @patch("export_svg.find_libreoffice", return_value=None) + def test_missing_libreoffice(self, mock_lo, tmp_path): + deck = tmp_path / "test.pptx" + deck.write_bytes(b"PK") # minimal zip header + parser = create_parser() + args = parser.parse_args( + [ + "--input", + str(deck), + "--output-dir", + str(tmp_path / "out"), + ] + ) + with pytest.raises(SystemExit): + run(args) + + +class TestMain: + """Tests for main entry point.""" + + def test_missing_input(self, tmp_path, monkeypatch): + monkeypatch.setattr( + "sys.argv", + [ + "export_svg", + "--input", + str(tmp_path / "missing.pptx"), + "--output-dir", + str(tmp_path), + ], + ) + rc = main() + assert rc == 2 diff --git a/.github/skills/experimental/powerpoint/tests/test_generate_themes.py b/.github/skills/experimental/powerpoint/tests/test_generate_themes.py new file mode 100644 index 000000000..85ba359d9 --- /dev/null +++ b/.github/skills/experimental/powerpoint/tests/test_generate_themes.py @@ -0,0 +1,265 @@ +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT +"""Tests for generate_themes module.""" + +import pytest +import yaml +from generate_themes import ( + create_parser, + generate_theme, + load_themes, + process_directory, + process_file, + remap_hex_in_text, + remap_rgb_in_python, + run, + update_style_metadata, +) + + +@pytest.fixture() +def themes_yaml(tmp_path): + """Create a minimal themes YAML file.""" + themes = { + "themes": { + "light": { + "label": "Light Theme", + "colors": { + "#1B1B1F": "#FFFFFF", + "#F8F8FC": "#242424", + "#0078D4": "#0F6CBD", + }, + }, + }, + } + path = tmp_path / "themes.yaml" + path.write_text(yaml.dump(themes), encoding="utf-8") + return path + + +@pytest.fixture() +def base_content(tmp_path): + """Create a minimal content directory structure.""" + content = tmp_path / "content" + global_dir = content / "global" + global_dir.mkdir(parents=True) + + style = { + "dimensions": {"width_inches": 13.333, "height_inches": 7.5}, + "metadata": {"title": "Test Deck"}, + "themes": [ + { + "name": "dark", + "slides": [1], + "colors": {"bg_dark": "#1B1B1F", "accent_blue": "#0078D4"}, + } + ], + } + (global_dir / "style.yaml").write_text(yaml.dump(style), encoding="utf-8") + + slide_dir = content / "slide-001" + slide_dir.mkdir() + slide_yaml = ( + 'slide: 1\ntitle: "Hello"\n' + 'background:\n fill: "#1B1B1F"\n' + 'speaker_notes: "Test notes"\n' + ) + (slide_dir / "content.yaml").write_text(slide_yaml, encoding="utf-8") + + # Image directory with a dummy file + images = slide_dir / "images" + images.mkdir() + (images / "badge.png").write_bytes(b"\x89PNG\r\n") + + return content + + +class TestRemapHexInText: + """Tests for remap_hex_in_text.""" + + def test_simple_replacement(self): + text = 'fill: "#1B1B1F"' + result = remap_hex_in_text(text, {"#1B1B1F": "#FFFFFF"}) + assert "#FFFFFF" in result + assert "#1B1B1F" not in result + + def test_case_insensitive(self): + text = "color: #1b1b1f" + result = remap_hex_in_text(text, {"#1B1B1F": "#FFFFFF"}) + assert "#FFFFFF" in result + + def test_no_match(self): + text = "color: #AABBCC" + result = remap_hex_in_text(text, {"#1B1B1F": "#FFFFFF"}) + assert result == text + + def test_multiple_values(self): + text = "#1B1B1F and #0078D4" + result = remap_hex_in_text(text, {"#1B1B1F": "#FFFFFF", "#0078D4": "#0F6CBD"}) + assert "#FFFFFF" in result + assert "#0F6CBD" in result + + +class TestRemapRgbInPython: + """Tests for remap_rgb_in_python.""" + + def test_rgb_color_replacement(self): + text = "RGBColor(0x1B, 0x1B, 0x1F)" + result = remap_rgb_in_python(text, {"#1B1B1F": "#FFFFFF"}) + assert "RGBColor(0xFF, 0xFF, 0xFF)" in result + + def test_hex_string_in_python(self): + text = 'shape.fill = "#1B1B1F"' + result = remap_rgb_in_python(text, {"#1B1B1F": "#FFFFFF"}) + assert '"#FFFFFF"' in result + + def test_preserves_other_code(self): + text = "x = 42\ny = RGBColor(0x1B, 0x1B, 0x1F)\nz = 99" + result = remap_rgb_in_python(text, {"#1B1B1F": "#FFFFFF"}) + assert "x = 42" in result + assert "z = 99" in result + + +class TestLoadThemes: + """Tests for load_themes.""" + + def test_valid_themes(self, themes_yaml): + themes = load_themes(themes_yaml) + assert "light" in themes + assert "colors" in themes["light"] + + def test_missing_themes_key(self, tmp_path): + bad = tmp_path / "bad.yaml" + bad.write_text("not_themes: {}", encoding="utf-8") + with pytest.raises(ValueError, match="top-level"): + load_themes(bad) + + def test_missing_colors(self, tmp_path): + bad = tmp_path / "bad.yaml" + bad.write_text( + yaml.dump({"themes": {"t1": {"label": "Test"}}}), + encoding="utf-8", + ) + with pytest.raises(ValueError, match="colors"): + load_themes(bad) + + +class TestProcessFile: + """Tests for process_file.""" + + def test_yaml_color_remap(self, tmp_path): + src = tmp_path / "in.yaml" + dst = tmp_path / "out.yaml" + src.write_text('fill: "#1B1B1F"', encoding="utf-8") + process_file(src, dst, {"#1B1B1F": "#FFFFFF"}) + assert "#FFFFFF" in dst.read_text() + + def test_py_color_remap(self, tmp_path): + src = tmp_path / "in.py" + dst = tmp_path / "out.py" + src.write_text('color = "#1B1B1F"', encoding="utf-8") + process_file(src, dst, {"#1B1B1F": "#FFFFFF"}) + assert "#FFFFFF" in dst.read_text() + + def test_other_file_copied(self, tmp_path): + src = tmp_path / "image.png" + dst = tmp_path / "copy.png" + src.write_bytes(b"\x89PNG") + process_file(src, dst, {"#1B1B1F": "#FFFFFF"}) + assert dst.read_bytes() == b"\x89PNG" + + +class TestProcessDirectory: + """Tests for process_directory.""" + + def test_recursive_copy(self, base_content, tmp_path): + dest = tmp_path / "output" + process_directory(base_content, dest, {"#1B1B1F": "#FFFFFF"}) + assert (dest / "global" / "style.yaml").exists() + assert (dest / "slide-001" / "content.yaml").exists() + assert (dest / "slide-001" / "images" / "badge.png").exists() + + +class TestUpdateStyleMetadata: + """Tests for update_style_metadata.""" + + def test_updates_theme_name(self, tmp_path): + style = tmp_path / "style.yaml" + style.write_text( + 'metadata:\n title: "My Deck"\nthemes:\n - name: "dark"\n', + encoding="utf-8", + ) + update_style_metadata(style, "light", "Light Theme") + text = style.read_text() + assert "light" in text + assert "Light Theme" in text + + def test_missing_file_noop(self, tmp_path): + update_style_metadata(tmp_path / "missing.yaml", "t", "T") + + +class TestGenerateTheme: + """Tests for generate_theme.""" + + def test_generates_themed_dir(self, base_content, tmp_path): + config = { + "label": "Light Theme", + "colors": {"#1B1B1F": "#FFFFFF", "#0078D4": "#0F6CBD"}, + } + result = generate_theme(base_content, tmp_path, "deck", "light", config) + assert result.exists() + assert (result / "content" / "global" / "style.yaml").exists() + assert (result / "content" / "slide-001" / "content.yaml").exists() + # Verify colors were remapped + content = (result / "content" / "slide-001" / "content.yaml").read_text() + assert "#FFFFFF" in content + + +class TestCreateParser: + """Tests for create_parser.""" + + def test_required_args(self): + parser = create_parser() + args = parser.parse_args( + ["--content-dir", "c", "--themes", "t.yaml", "--output-dir", "o"] + ) + assert str(args.content_dir) == "c" + assert str(args.themes) == "t.yaml" + + +class TestRun: + """Tests for run function.""" + + def test_full_run(self, base_content, themes_yaml, tmp_path): + parser = create_parser() + args = parser.parse_args( + [ + "--content-dir", + str(base_content), + "--themes", + str(themes_yaml), + "--output-dir", + str(tmp_path), + ] + ) + rc = run(args) + assert rc == 0 + # Output dir name is derived from parent dir name + theme ID + themed_dirs = list(tmp_path.glob("*-light")) + assert len(themed_dirs) == 1 + assert (themed_dirs[0] / "content").exists() + + def test_missing_content_dir(self, themes_yaml, tmp_path): + parser = create_parser() + args = parser.parse_args( + [ + "--content-dir", + str(tmp_path / "missing"), + "--themes", + str(themes_yaml), + "--output-dir", + str(tmp_path), + ] + ) + rc = run(args) + assert rc == 2 diff --git a/.github/skills/experimental/powerpoint/tests/test_validate_geometry.py b/.github/skills/experimental/powerpoint/tests/test_validate_geometry.py new file mode 100644 index 000000000..849356657 --- /dev/null +++ b/.github/skills/experimental/powerpoint/tests/test_validate_geometry.py @@ -0,0 +1,499 @@ +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT +"""Tests for validate_geometry module.""" + +import json + +import pytest +from pptx import Presentation +from pptx.enum.shapes import MSO_SHAPE +from pptx.util import Inches +from validate_geometry import ( + _is_accent_bar, + _shape_label, + check_adjacent_gaps, + check_boundary_overflow, + check_edge_margins, + check_title_clearance, + create_parser, + generate_report, + main, + max_severity, + validate_geometry, + validate_slide_geometry, +) + + +@pytest.fixture() +def simple_deck(tmp_path): + """Create a minimal PPTX with 2 slides.""" + prs = Presentation() + prs.slide_width = Inches(13.333) + prs.slide_height = Inches(7.5) + layout = prs.slide_layouts[6] + prs.slides.add_slide(layout) + prs.slides.add_slide(layout) + path = tmp_path / "test.pptx" + prs.save(str(path)) + return path + + +@pytest.fixture() +def deck_with_shapes(tmp_path): + """PPTX with shapes at known positions.""" + prs = Presentation() + prs.slide_width = Inches(13.333) + prs.slide_height = Inches(7.5) + layout = prs.slide_layouts[6] + slide = prs.slides.add_slide(layout) + + # Accent bar at top (should be exempted) + bar = slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, + Inches(0), + Inches(0), + Inches(13.333), + Inches(0.05), + ) + bar.name = "accent_bar" + + # Title at proper position + title = slide.shapes.add_textbox( + Inches(0.8), Inches(0.5), Inches(11.0), Inches(0.7) + ) + title.name = "title" + title.text_frame.text = "Slide Title" + + # Content below title + content = slide.shapes.add_textbox( + Inches(0.8), Inches(1.4), Inches(11.0), Inches(4.0) + ) + content.name = "content" + content.text_frame.text = "Content area" + + path = tmp_path / "shapes.pptx" + prs.save(str(path)) + return path + + +@pytest.fixture() +def deck_with_violations(tmp_path): + """PPTX with deliberate margin and overflow violations.""" + prs = Presentation() + prs.slide_width = Inches(13.333) + prs.slide_height = Inches(7.5) + layout = prs.slide_layouts[6] + slide = prs.slides.add_slide(layout) + + # Shape too close to left edge (0.2" < 0.5") + tight = slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, + Inches(0.2), + Inches(0.5), + Inches(2.0), + Inches(1.0), + ) + tight.name = "tight_left" + + # Shape overflowing right boundary + overflow = slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, + Inches(12.0), + Inches(1.0), + Inches(2.0), + Inches(1.0), + ) + overflow.name = "overflow_right" + + path = tmp_path / "violations.pptx" + prs.save(str(path)) + return path + + +class TestIsAccentBar: + """Tests for _is_accent_bar.""" + + def test_full_width_thin_bar(self, blank_slide): + shape = blank_slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, + Inches(0), + Inches(0), + Inches(13.333), + Inches(0.05), + ) + assert _is_accent_bar(shape, 13.333) is True + + def test_regular_shape_not_bar(self, blank_slide): + shape = blank_slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, + Inches(1), + Inches(1), + Inches(4), + Inches(2), + ) + assert _is_accent_bar(shape, 13.333) is False + + def test_tall_shape_not_bar(self, blank_slide): + shape = blank_slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, + Inches(0), + Inches(0), + Inches(13.333), + Inches(0.5), + ) + assert _is_accent_bar(shape, 13.333) is False + + +class TestShapeLabel: + """Tests for _shape_label.""" + + def test_named_shape_with_text(self, blank_slide): + tb = blank_slide.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1)) + tb.name = "Title 1" + tb.text_frame.text = "Hello World" + label = _shape_label(tb) + assert "Title 1" in label + assert "Hello World" in label + + def test_named_shape_without_text(self, blank_slide): + shape = blank_slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, + Inches(1), + Inches(1), + Inches(2), + Inches(1), + ) + shape.name = "rect1" + assert _shape_label(shape) == "rect1" + + +class TestCheckBoundaryOverflow: + """Tests for check_boundary_overflow.""" + + def test_no_overflow(self, blank_slide): + shape = blank_slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, + Inches(1), + Inches(1), + Inches(4), + Inches(2), + ) + issues = check_boundary_overflow(shape, 13.333, 7.5) + assert len(issues) == 0 + + def test_right_overflow(self, blank_slide): + shape = blank_slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, + Inches(12), + Inches(1), + Inches(2), + Inches(1), + ) + issues = check_boundary_overflow(shape, 13.333, 7.5) + assert len(issues) == 1 + assert issues[0]["check_type"] == "boundary_overflow" + assert issues[0]["severity"] == "error" + + def test_bottom_overflow(self, blank_slide): + shape = blank_slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, + Inches(1), + Inches(7), + Inches(2), + Inches(1), + ) + issues = check_boundary_overflow(shape, 13.333, 7.5) + assert len(issues) == 1 + assert "bottom" in issues[0]["description"].lower() + + +class TestCheckEdgeMargins: + """Tests for check_edge_margins.""" + + def test_within_margins(self, blank_slide): + shape = blank_slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, + Inches(0.8), + Inches(0.8), + Inches(4), + Inches(2), + ) + issues = check_edge_margins(shape, 13.333, 7.5, 0.5) + assert len(issues) == 0 + + def test_too_close_to_left(self, blank_slide): + shape = blank_slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, + Inches(0.2), + Inches(1), + Inches(2), + Inches(1), + ) + issues = check_edge_margins(shape, 13.333, 7.5, 0.5) + assert any(i["check_type"] == "edge_margin" for i in issues) + + def test_too_close_to_top(self, blank_slide): + shape = blank_slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, + Inches(1), + Inches(0.2), + Inches(2), + Inches(1), + ) + issues = check_edge_margins(shape, 13.333, 7.5, 0.5) + assert any("top" in i["description"].lower() for i in issues) + + +class TestCheckAdjacentGaps: + """Tests for check_adjacent_gaps.""" + + def test_sufficient_gap(self, blank_slide): + s1 = blank_slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, + Inches(1), + Inches(1), + Inches(4), + Inches(1), + ) + s2 = blank_slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, + Inches(1), + Inches(2.5), + Inches(4), + Inches(1), + ) + issues = check_adjacent_gaps([s1, s2], 0.3) + assert len(issues) == 0 + + def test_insufficient_gap(self, blank_slide): + s1 = blank_slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, + Inches(1), + Inches(1), + Inches(4), + Inches(1), + ) + s2 = blank_slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, + Inches(1), + Inches(2.1), + Inches(4), + Inches(1), + ) + issues = check_adjacent_gaps([s1, s2], 0.3) + assert len(issues) == 1 + assert issues[0]["check_type"] == "adjacent_gap" + + +class TestCheckTitleClearance: + """Tests for check_title_clearance.""" + + def test_sufficient_clearance(self, blank_slide): + title = blank_slide.shapes.add_textbox( + Inches(1), Inches(0.5), Inches(10), Inches(0.7) + ) + title.name = "title" + content = blank_slide.shapes.add_textbox( + Inches(1), Inches(1.5), Inches(10), Inches(4) + ) + content.name = "content" + issues = check_title_clearance([title, content], 0.2) + assert len(issues) == 0 + + def test_tight_clearance(self, blank_slide): + title = blank_slide.shapes.add_textbox( + Inches(1), Inches(0.5), Inches(10), Inches(0.7) + ) + title.name = "title" + content = blank_slide.shapes.add_textbox( + Inches(1), Inches(1.25), Inches(10), Inches(4) + ) + content.name = "content" + issues = check_title_clearance([title, content], 0.2) + assert len(issues) == 1 + assert issues[0]["check_type"] == "title_clearance" + + +class TestValidateSlideGeometry: + """Tests for validate_slide_geometry.""" + + def test_clean_slide(self, blank_slide): + result = validate_slide_geometry( + blank_slide, 1, 13.333, 7.5, margin=0.5, gap=0.3, clearance=0.2 + ) + assert result["slide_number"] == 1 + assert result["overall_quality"] == "good" + + def test_slide_with_issues(self, blank_slide): + blank_slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, + Inches(0.1), + Inches(0.1), + Inches(2), + Inches(1), + ) + result = validate_slide_geometry( + blank_slide, 1, 13.333, 7.5, margin=0.5, gap=0.3, clearance=0.2 + ) + assert result["overall_quality"] == "needs-attention" + assert len(result["issues"]) > 0 + + +class TestValidateGeometry: + """Tests for validate_geometry.""" + + def test_full_validation(self, simple_deck): + results = validate_geometry(simple_deck) + assert results["source"] == "geometry-validation" + assert results["slide_count"] == 2 + assert len(results["slides"]) == 2 + + def test_slide_filter(self, simple_deck): + results = validate_geometry(simple_deck, slide_filter={1}) + assert len(results["slides"]) == 1 + assert results["slides"][0]["slide_number"] == 1 + + def test_clean_deck(self, deck_with_shapes): + results = validate_geometry(deck_with_shapes) + # Deck has shapes well within boundaries; may have minor + # warnings from right-edge proximity of 11" wide content + sev = max_severity(results) + assert sev in ("none", "info", "warning") + + +class TestGenerateReport: + """Tests for generate_report.""" + + def test_report_structure(self, simple_deck): + results = validate_geometry(simple_deck) + report = generate_report(results) + assert "# Geometry Validation Report" in report + assert "## Summary" in report + assert "## Per-Slide Findings" in report + + def test_report_with_issues(self, deck_with_violations): + results = validate_geometry(deck_with_violations) + report = generate_report(results) + assert "warning" in report.lower() or "error" in report.lower() + + +class TestMaxSeverity: + """Tests for max_severity.""" + + def test_no_issues(self): + results = {"slides": [{"issues": []}]} + assert max_severity(results) == "none" + + def test_error_dominates(self): + results = { + "slides": [ + { + "issues": [ + {"severity": "info"}, + {"severity": "error"}, + {"severity": "warning"}, + ] + } + ] + } + assert max_severity(results) == "error" + + def test_warning_over_info(self): + results = { + "slides": [{"issues": [{"severity": "info"}, {"severity": "warning"}]}] + } + assert max_severity(results) == "warning" + + +class TestCreateParser: + """Tests for create_parser.""" + + def test_required_input(self): + parser = create_parser() + args = parser.parse_args(["--input", "test.pptx"]) + assert str(args.input) == "test.pptx" + + def test_defaults(self): + parser = create_parser() + args = parser.parse_args(["--input", "test.pptx"]) + assert args.margin == 0.5 + assert args.gap == 0.3 + assert args.clearance == 0.2 + + def test_custom_thresholds(self): + parser = create_parser() + args = parser.parse_args( + ["--input", "t.pptx", "--margin", "0.6", "--gap", "0.4"] + ) + assert args.margin == 0.6 + assert args.gap == 0.4 + + +class TestMain: + """Tests for main entry point.""" + + def test_valid_deck(self, simple_deck, monkeypatch): + monkeypatch.setattr( + "sys.argv", + ["validate_geometry", "--input", str(simple_deck)], + ) + rc = main() + assert rc == 0 + + def test_missing_file(self, tmp_path, monkeypatch): + monkeypatch.setattr( + "sys.argv", + ["validate_geometry", "--input", str(tmp_path / "missing.pptx")], + ) + rc = main() + assert rc == 2 + + def test_json_output(self, simple_deck, tmp_path, monkeypatch): + out = tmp_path / "results.json" + monkeypatch.setattr( + "sys.argv", + [ + "validate_geometry", + "--input", + str(simple_deck), + "--output", + str(out), + ], + ) + main() + assert out.exists() + data = json.loads(out.read_text()) + assert "slides" in data + + def test_report_output(self, simple_deck, tmp_path, monkeypatch): + report = tmp_path / "report.md" + monkeypatch.setattr( + "sys.argv", + [ + "validate_geometry", + "--input", + str(simple_deck), + "--report", + str(report), + ], + ) + main() + assert report.exists() + assert "# Geometry Validation Report" in report.read_text() + + def test_per_slide_dir(self, simple_deck, tmp_path, monkeypatch): + per_slide = tmp_path / "per-slide" + monkeypatch.setattr( + "sys.argv", + [ + "validate_geometry", + "--input", + str(simple_deck), + "--per-slide-dir", + str(per_slide), + ], + ) + main() + assert per_slide.exists() + geom_files = list(per_slide.glob("slide-*-geometry.json")) + assert len(geom_files) == 2 From 705b9db31d18982fd845df3fbc0ba4c10822786d Mon Sep 17 00:00:00 2001 From: auyidi Date: Tue, 28 Apr 2026 15:39:09 +0000 Subject: [PATCH 04/22] fix(skills): address PR review feedback from katriendg and CI - export_svg.py: replace sys.exit() with LibreOfficeError exception, import constants from pptx_utils, remove duplicate configure_logging - generate_themes.py: single-pass remap_hex_in_text to prevent chain remapping, yaml.safe_load round-trip in update_style_metadata, import constants from pptx_utils, remove duplicate configure_logging - build_deck.py: replace print() with logger in dry-run, replace sys.exit() with return EXIT_*, import EXIT_*/configure_logging/logger - test_export_svg.py: assert return code instead of SystemExit - test_generate_themes.py: add chain remapping and empty map tests --- .../powerpoint/scripts/build_deck.py | 35 +++++--- .../powerpoint/scripts/export_svg.py | 57 ++++++------ .../powerpoint/scripts/generate_themes.py | 86 +++++++++---------- .../powerpoint/tests/test_export_svg.py | 5 +- .../powerpoint/tests/test_generate_themes.py | 12 +++ 5 files changed, 113 insertions(+), 82 deletions(-) diff --git a/.github/skills/experimental/powerpoint/scripts/build_deck.py b/.github/skills/experimental/powerpoint/scripts/build_deck.py index 4c7e08528..79f305544 100644 --- a/.github/skills/experimental/powerpoint/scripts/build_deck.py +++ b/.github/skills/experimental/powerpoint/scripts/build_deck.py @@ -18,6 +18,7 @@ import ast import builtins import importlib.util +import logging import re import sys from pathlib import Path @@ -40,7 +41,14 @@ apply_text_properties, populate_text_frame, ) -from pptx_utils import load_yaml +from pptx_utils import ( + EXIT_ERROR, + EXIT_FAILURE, + EXIT_SUCCESS, + load_yaml, +) + +logger = logging.getLogger(__name__) CONNECTOR_TYPE_MAP = { "straight": MSO_CONNECTOR_TYPE.STRAIGHT, @@ -1118,8 +1126,8 @@ def main(): if args.dry_run: slides_data = discover_slides(content_dir) if not slides_data: - print("No slide content found in", content_dir) - sys.exit(1) + logger.error("No slide content found in %s", content_dir) + return EXIT_ERROR errors = 0 for num, slide_dir in slides_data: content_yaml = slide_dir / "content.yaml" @@ -1150,16 +1158,23 @@ def main(): else 0 ) img_status = f" | {img_count} images" if img_count else "" - status_line = ( - f" Slide {num:03d}: {title}" - f" [{notes_status}{extra_status}{img_status}]" + logger.info( + " Slide %03d: %s [%s%s%s]", + num, + title, + notes_status, + extra_status, + img_status, ) - print(status_line) except Exception as exc: - print(f" Slide {num:03d}: ❌ YAML parse error: {exc}") + logger.error(" Slide %03d: ❌ YAML parse error: %s", num, exc) errors += 1 - print(f"\nDry-run complete: {len(slides_data)} slides, {errors} error(s)") - sys.exit(1 if errors else 0) + logger.info( + "Dry-run complete: %d slides, %d error(s)", + len(slides_data), + errors, + ) + return EXIT_FAILURE if errors else EXIT_SUCCESS output_path = Path(args.output) output_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/.github/skills/experimental/powerpoint/scripts/export_svg.py b/.github/skills/experimental/powerpoint/scripts/export_svg.py index 7921dcdb3..e8631f99e 100644 --- a/.github/skills/experimental/powerpoint/scripts/export_svg.py +++ b/.github/skills/experimental/powerpoint/scripts/export_svg.py @@ -21,17 +21,18 @@ import tempfile from pathlib import Path -EXIT_SUCCESS = 0 -EXIT_FAILURE = 1 -EXIT_ERROR = 2 +from pptx_utils import ( + EXIT_ERROR, + EXIT_FAILURE, + EXIT_SUCCESS, + configure_logging, +) logger = logging.getLogger(__name__) -def configure_logging(verbose: bool = False) -> None: - """Configure logging based on verbosity level.""" - level = logging.DEBUG if verbose else logging.INFO - logging.basicConfig(level=level, format="%(levelname)s: %(message)s") +class LibreOfficeError(RuntimeError): + """Raised when LibreOffice is missing or conversion fails.""" def create_parser() -> argparse.ArgumentParser: @@ -101,12 +102,12 @@ def convert_pptx_to_pdf(pptx_path: Path, output_dir: Path) -> Path: """ soffice = find_libreoffice() if not soffice: - logger.error("LibreOffice is required for PPTX-to-PDF conversion.") - logger.error("Install via:") - logger.error(" macOS: brew install --cask libreoffice") - logger.error(" Linux: sudo apt-get install libreoffice") - logger.error(" Windows: winget install TheDocumentFoundation.LibreOffice") - sys.exit(EXIT_FAILURE) + raise LibreOfficeError( + "LibreOffice is required for PPTX-to-PDF conversion. " + "Install via: brew install --cask libreoffice (macOS), " + "sudo apt-get install libreoffice (Linux), " + "winget install TheDocumentFoundation.LibreOffice (Windows)" + ) output_dir.mkdir(parents=True, exist_ok=True) logger.info("Converting %s to PDF via LibreOffice", pptx_path.name) @@ -128,17 +129,18 @@ def convert_pptx_to_pdf(pptx_path: Path, output_dir: Path) -> Path: ) logger.debug("LibreOffice stdout: %s", result.stdout) except subprocess.CalledProcessError as e: - logger.error("LibreOffice conversion failed: %s", e.stderr) - sys.exit(EXIT_FAILURE) - except FileNotFoundError: - logger.error("LibreOffice executable not found: %s", soffice) - sys.exit(EXIT_FAILURE) + raise LibreOfficeError( + f"LibreOffice conversion failed: {e.stderr}" + ) from e + except FileNotFoundError as e: + raise LibreOfficeError( + f"LibreOffice executable not found: {soffice}" + ) from e pdf_name = pptx_path.stem + ".pdf" pdf_path = output_dir / pdf_name if not pdf_path.exists(): - logger.error("Expected PDF not found: %s", pdf_path) - sys.exit(EXIT_FAILURE) + raise LibreOfficeError(f"Expected PDF not found: {pdf_path}") return pdf_path @@ -170,11 +172,10 @@ def export_pdf_to_svg( """ try: import fitz # noqa: PLC0415 — PyMuPDF - except ImportError: - logger.error( + except ImportError as e: + raise LibreOfficeError( "PyMuPDF is required for SVG export. Install via: pip install pymupdf" - ) - sys.exit(EXIT_FAILURE) + ) from e doc = fitz.open(str(pdf_path)) total_pages = len(doc) @@ -221,8 +222,12 @@ def run(args: argparse.Namespace) -> int: with tempfile.TemporaryDirectory() as tmp_dir: tmp_path = Path(tmp_dir) - pdf_path = convert_pptx_to_pdf(pptx_path, tmp_path) - exported = export_pdf_to_svg(pdf_path, output_dir, slides) + try: + pdf_path = convert_pptx_to_pdf(pptx_path, tmp_path) + exported = export_pdf_to_svg(pdf_path, output_dir, slides) + except LibreOfficeError as e: + logger.error("%s", e) + return EXIT_FAILURE logger.info("SVG export complete: %d slide(s) → %s", len(exported), output_dir) return EXIT_SUCCESS diff --git a/.github/skills/experimental/powerpoint/scripts/generate_themes.py b/.github/skills/experimental/powerpoint/scripts/generate_themes.py index 20df63eb1..91b7cb0f7 100644 --- a/.github/skills/experimental/powerpoint/scripts/generate_themes.py +++ b/.github/skills/experimental/powerpoint/scripts/generate_themes.py @@ -20,20 +20,16 @@ from pathlib import Path import yaml - -EXIT_SUCCESS = 0 -EXIT_FAILURE = 1 -EXIT_ERROR = 2 +from pptx_utils import ( + EXIT_ERROR, + EXIT_FAILURE, + EXIT_SUCCESS, + configure_logging, +) logger = logging.getLogger(__name__) -def configure_logging(verbose: bool = False) -> None: - """Configure logging based on verbosity level.""" - level = logging.DEBUG if verbose else logging.INFO - logging.basicConfig(level=level, format="%(levelname)s: %(message)s") - - def create_parser() -> argparse.ArgumentParser: """Create and configure argument parser.""" parser = argparse.ArgumentParser( @@ -80,20 +76,21 @@ def load_themes(themes_path: Path) -> dict: def remap_hex_in_text(text: str, color_map: dict[str, str]) -> str: """Replace ``#RRGGBB`` hex color values using *color_map*. + Uses a single-pass regex callback to avoid chain remapping where + one substitution's output feeds the next (e.g., A→B then B→C + would incorrectly produce C instead of the intended B). + Keys and values in *color_map* must include the leading ``#``. Matching is case-insensitive. """ - result = text - for old_hex, new_hex in color_map.items(): - old_bare = old_hex.lstrip("#") - new_bare = new_hex.lstrip("#") - result = re.sub( - rf"#{re.escape(old_bare)}", - f"#{new_bare}", - result, - flags=re.IGNORECASE, - ) - return result + bare_map = {k.lstrip("#").lower(): v.lstrip("#") for k, v in color_map.items()} + if not bare_map: + return text + pattern = re.compile( + r"#(" + "|".join(re.escape(k) for k in bare_map) + r")", + re.IGNORECASE, + ) + return pattern.sub(lambda m: f"#{bare_map[m.group(1).lower()]}", text) def remap_rgb_in_python(text: str, color_map: dict[str, str]) -> str: @@ -158,32 +155,35 @@ def process_directory(src_dir: Path, dest_dir: Path, color_map: dict[str, str]) def update_style_metadata(style_path: Path, theme_id: str, label: str) -> None: - """Patch theme name and append label to title in style.yaml.""" + """Patch theme name and append label to title in style.yaml. + + Uses yaml.safe_load round-trip to avoid brittle regex patching. + Note: this normalizes YAML formatting (key ordering, quoting style). + """ if not style_path.exists(): return - text = style_path.read_text(encoding="utf-8") - # Update theme name field - text = re.sub( - r'(name:\s*")[^"]*(")', - rf"\g<1>{theme_id}\2", - text, - count=1, - ) + data = yaml.safe_load(style_path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + return - # Append theme label to title when not already present - def _append_label(m: re.Match) -> str: - prefix, title, suffix = m.group(1), m.group(2), m.group(3) - if label in title: - return m.group(0) - return f"{prefix}{title} ({label}){suffix}" - - text = re.sub( - r'(title:\s*")([^"]*?)(")', - _append_label, - text, - count=1, + # Update theme name in the themes list + themes = data.get("themes", []) + if isinstance(themes, list) and themes: + first = themes[0] + if isinstance(first, dict): + first["name"] = theme_id + + # Append theme label to metadata title + metadata = data.get("metadata", {}) + if isinstance(metadata, dict): + title = metadata.get("title", "") + if label not in title: + metadata["title"] = f"{title} ({label})" if title else label + + style_path.write_text( + yaml.dump(data, allow_unicode=True, default_flow_style=False), + encoding="utf-8", ) - style_path.write_text(text, encoding="utf-8") def generate_theme( diff --git a/.github/skills/experimental/powerpoint/tests/test_export_svg.py b/.github/skills/experimental/powerpoint/tests/test_export_svg.py index 122f17f8b..9da354850 100644 --- a/.github/skills/experimental/powerpoint/tests/test_export_svg.py +++ b/.github/skills/experimental/powerpoint/tests/test_export_svg.py @@ -4,7 +4,6 @@ from unittest.mock import patch -import pytest from export_svg import ( create_parser, find_libreoffice, @@ -95,8 +94,8 @@ def test_missing_libreoffice(self, mock_lo, tmp_path): str(tmp_path / "out"), ] ) - with pytest.raises(SystemExit): - run(args) + rc = run(args) + assert rc == 1 class TestMain: diff --git a/.github/skills/experimental/powerpoint/tests/test_generate_themes.py b/.github/skills/experimental/powerpoint/tests/test_generate_themes.py index 85ba359d9..e8bd05214 100644 --- a/.github/skills/experimental/powerpoint/tests/test_generate_themes.py +++ b/.github/skills/experimental/powerpoint/tests/test_generate_themes.py @@ -99,6 +99,18 @@ def test_multiple_values(self): assert "#FFFFFF" in result assert "#0F6CBD" in result + def test_chain_remapping_avoided(self): + """Ensure A->B and B->C produces B, not C (single-pass).""" + text = "#AAAAAA" + result = remap_hex_in_text( + text, {"#AAAAAA": "#BBBBBB", "#BBBBBB": "#CCCCCC"} + ) + assert result == "#BBBBBB" + + def test_empty_map(self): + text = "#1B1B1F" + assert remap_hex_in_text(text, {}) == text + class TestRemapRgbInPython: """Tests for remap_rgb_in_python.""" From 11b1472bedc4a97650362ced82c46a55dd1f3066 Mon Sep 17 00:00:00 2001 From: auyidi Date: Tue, 28 Apr 2026 15:58:45 +0000 Subject: [PATCH 05/22] style(skills): fix ruff format on export_svg.py and test_generate_themes.py --- .../skills/experimental/powerpoint/scripts/export_svg.py | 8 ++------ .../experimental/powerpoint/tests/test_generate_themes.py | 4 +--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/.github/skills/experimental/powerpoint/scripts/export_svg.py b/.github/skills/experimental/powerpoint/scripts/export_svg.py index e8631f99e..227090bed 100644 --- a/.github/skills/experimental/powerpoint/scripts/export_svg.py +++ b/.github/skills/experimental/powerpoint/scripts/export_svg.py @@ -129,13 +129,9 @@ def convert_pptx_to_pdf(pptx_path: Path, output_dir: Path) -> Path: ) logger.debug("LibreOffice stdout: %s", result.stdout) except subprocess.CalledProcessError as e: - raise LibreOfficeError( - f"LibreOffice conversion failed: {e.stderr}" - ) from e + raise LibreOfficeError(f"LibreOffice conversion failed: {e.stderr}") from e except FileNotFoundError as e: - raise LibreOfficeError( - f"LibreOffice executable not found: {soffice}" - ) from e + raise LibreOfficeError(f"LibreOffice executable not found: {soffice}") from e pdf_name = pptx_path.stem + ".pdf" pdf_path = output_dir / pdf_name diff --git a/.github/skills/experimental/powerpoint/tests/test_generate_themes.py b/.github/skills/experimental/powerpoint/tests/test_generate_themes.py index e8bd05214..7cf11001f 100644 --- a/.github/skills/experimental/powerpoint/tests/test_generate_themes.py +++ b/.github/skills/experimental/powerpoint/tests/test_generate_themes.py @@ -102,9 +102,7 @@ def test_multiple_values(self): def test_chain_remapping_avoided(self): """Ensure A->B and B->C produces B, not C (single-pass).""" text = "#AAAAAA" - result = remap_hex_in_text( - text, {"#AAAAAA": "#BBBBBB", "#BBBBBB": "#CCCCCC"} - ) + result = remap_hex_in_text(text, {"#AAAAAA": "#BBBBBB", "#BBBBBB": "#CCCCCC"}) assert result == "#BBBBBB" def test_empty_map(self): From dae25d3f522e0cdb06f92a5c69212d3244cc722d Mon Sep 17 00:00:00 2001 From: auyidi Date: Tue, 28 Apr 2026 18:00:02 +0000 Subject: [PATCH 06/22] fix(skills): address second round PR review feedback - generate_themes.py: single-pass remap_rgb_in_python to prevent chain remapping (matches remap_hex_in_text approach) - build_deck.py: add configure_logging() call and --verbose flag so dry-run output is visible; per-slide theme color lookup using themes[].slides instead of always using themes[0]; use shallow copy to avoid mutating shared style dict - validate_geometry.py: add left/top boundary overflow detection for shapes extending off-screen with negative positions - test_generate_themes.py: add chain remap and empty map tests for remap_rgb_in_python - test_validate_geometry.py: add left/top overflow detection tests --- .../powerpoint/scripts/build_deck.py | 30 ++++++-- .../powerpoint/scripts/generate_themes.py | 68 +++++++++++-------- .../powerpoint/scripts/validate_geometry.py | 25 ++++++- .../powerpoint/tests/test_generate_themes.py | 10 +++ .../tests/test_validate_geometry.py | 28 ++++++++ 5 files changed, 128 insertions(+), 33 deletions(-) diff --git a/.github/skills/experimental/powerpoint/scripts/build_deck.py b/.github/skills/experimental/powerpoint/scripts/build_deck.py index 79f305544..24442f571 100644 --- a/.github/skills/experimental/powerpoint/scripts/build_deck.py +++ b/.github/skills/experimental/powerpoint/scripts/build_deck.py @@ -45,6 +45,7 @@ EXIT_ERROR, EXIT_FAILURE, EXIT_SUCCESS, + configure_logging, load_yaml, ) @@ -967,14 +968,26 @@ def build_slide( colors = {} typography = {} - # Populate colors from the first theme's color map in style.yaml so + # Populate colors from the matching theme's color map in style.yaml so # content-extra.py scripts can reference theme colors programmatically # via style["colors"]["accent_blue"] instead of hardcoding hex values. + # Uses a per-slide lookup based on the themes[].slides list and falls + # back to themes[0] when no explicit assignment exists. + slide_num = slide_content.get("slide", 0) themes = style.get("themes", []) - if themes and isinstance(themes, list) and isinstance(themes[0], dict): - style_colors = themes[0].get("colors", {}) - if style_colors: - style["colors"] = style_colors + if themes and isinstance(themes, list): + matched_theme = next( + ( + t + for t in themes + if isinstance(t, dict) and slide_num in t.get("slides", []) + ), + themes[0] if isinstance(themes[0], dict) else None, + ) + if matched_theme: + style_colors = matched_theme.get("colors", {}) + if style_colors: + style = {**style, "colors": style_colors} if existing_slide is not None: slide = existing_slide @@ -1117,7 +1130,14 @@ def main(): " (parse YAML, check images, validate scripts)" ), ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Enable verbose logging output", + ) args = parser.parse_args() + configure_logging(getattr(args, "verbose", False)) content_dir = Path(args.content_dir) style = load_yaml(Path(args.style)) diff --git a/.github/skills/experimental/powerpoint/scripts/generate_themes.py b/.github/skills/experimental/powerpoint/scripts/generate_themes.py index 91b7cb0f7..3c9ce2448 100644 --- a/.github/skills/experimental/powerpoint/scripts/generate_themes.py +++ b/.github/skills/experimental/powerpoint/scripts/generate_themes.py @@ -96,36 +96,50 @@ def remap_hex_in_text(text: str, color_map: dict[str, str]) -> str: def remap_rgb_in_python(text: str, color_map: dict[str, str]) -> str: """Replace ``RGBColor(0xRR, 0xGG, 0xBB)`` and ``"#RRGGBB"`` patterns. + Uses a single-pass regex callback to avoid chain remapping where + one substitution's output feeds the next. + Keys and values in *color_map* must include the leading ``#``. """ - result = text + bare_map: dict[str, str] = {} for old_hex, new_hex in color_map.items(): - old_bare = old_hex.lstrip("#") - new_bare = new_hex.lstrip("#") - - old_r = int(old_bare[0:2], 16) - old_g = int(old_bare[2:4], 16) - old_b = int(old_bare[4:6], 16) - new_r = int(new_bare[0:2], 16) - new_g = int(new_bare[2:4], 16) - new_b = int(new_bare[4:6], 16) - - # RGBColor(0xRR, 0xGG, 0xBB) - old_pattern = ( - rf"RGBColor\(\s*0x{old_r:02X}\s*,\s*0x{old_g:02X}\s*," - rf"\s*0x{old_b:02X}\s*\)" - ) - new_value = f"RGBColor(0x{new_r:02X}, 0x{new_g:02X}, 0x{new_b:02X})" - result = re.sub(old_pattern, new_value, result, flags=re.IGNORECASE) - - # "#RRGGBB" string literals - result = re.sub( - rf'"#{re.escape(old_bare)}"', - f'"#{new_bare}"', - result, - flags=re.IGNORECASE, - ) - return result + old_bare = old_hex.lstrip("#").upper() + bare_map[old_bare] = new_hex.lstrip("#").upper() + + if not bare_map: + return text + + def _rgb_pattern(hex6: str) -> str: + r = int(hex6[0:2], 16) + g = int(hex6[2:4], 16) + b = int(hex6[4:6], 16) + return rf"RGBColor\(\s*0x{r:02X}\s*,\s*0x{g:02X}\s*,\s*0x{b:02X}\s*\)" + + def _hex_pattern(hex6: str) -> str: + return rf'"#{re.escape(hex6)}"' + + # Build combined pattern matching all RGBColor(...) and "#RRGGBB" forms + rgb_parts = [f"({_rgb_pattern(k)})" for k in bare_map] + hex_parts = [f"({_hex_pattern(k)})" for k in bare_map] + combined = re.compile("|".join(rgb_parts + hex_parts), re.IGNORECASE) + + keys = list(bare_map.keys()) + n = len(keys) + + def _replace(m: re.Match) -> str: + for i, k in enumerate(keys): + # Groups 1..n are RGBColor patterns, n+1..2n are hex patterns + if m.group(i + 1) is not None: + v = bare_map[k] + r = int(v[0:2], 16) + g = int(v[2:4], 16) + b = int(v[4:6], 16) + return f"RGBColor(0x{r:02X}, 0x{g:02X}, 0x{b:02X})" + if m.group(n + i + 1) is not None: + return f'"#{bare_map[k]}"' + return m.group(0) + + return combined.sub(_replace, text) def process_file(src: Path, dest: Path, color_map: dict[str, str]) -> None: diff --git a/.github/skills/experimental/powerpoint/scripts/validate_geometry.py b/.github/skills/experimental/powerpoint/scripts/validate_geometry.py index 4871d8a7e..df90c5d37 100644 --- a/.github/skills/experimental/powerpoint/scripts/validate_geometry.py +++ b/.github/skills/experimental/powerpoint/scripts/validate_geometry.py @@ -77,7 +77,30 @@ def check_boundary_overflow( right = left + width bottom = top + height label = _shape_label(shape) - + if left < -0.01: + issues.append( + { + "check_type": "boundary_overflow", + "severity": "error", + "description": ( + f"Shape '{label}' left edge ({left:.2f}\") extends " + "off the left boundary of the slide" + ), + "location": shape.name or "shape", + } + ) + if top < -0.01: + issues.append( + { + "check_type": "boundary_overflow", + "severity": "error", + "description": ( + f"Shape '{label}' top edge ({top:.2f}\") extends " + "off the top boundary of the slide" + ), + "location": shape.name or "shape", + } + ) if right > slide_w_in + 0.01: issues.append( { diff --git a/.github/skills/experimental/powerpoint/tests/test_generate_themes.py b/.github/skills/experimental/powerpoint/tests/test_generate_themes.py index 7cf11001f..0524b2568 100644 --- a/.github/skills/experimental/powerpoint/tests/test_generate_themes.py +++ b/.github/skills/experimental/powerpoint/tests/test_generate_themes.py @@ -129,6 +129,16 @@ def test_preserves_other_code(self): assert "x = 42" in result assert "z = 99" in result + def test_chain_remapping_avoided(self): + """Ensure A->B and B->C produces B, not C (single-pass).""" + text = "RGBColor(0xAA, 0xAA, 0xAA)" + result = remap_rgb_in_python(text, {"#AAAAAA": "#BBBBBB", "#BBBBBB": "#CCCCCC"}) + assert "RGBColor(0xBB, 0xBB, 0xBB)" in result + + def test_empty_map(self): + text = "RGBColor(0x1B, 0x1B, 0x1F)" + assert remap_rgb_in_python(text, {}) == text + class TestLoadThemes: """Tests for load_themes.""" diff --git a/.github/skills/experimental/powerpoint/tests/test_validate_geometry.py b/.github/skills/experimental/powerpoint/tests/test_validate_geometry.py index 849356657..2293ad9d9 100644 --- a/.github/skills/experimental/powerpoint/tests/test_validate_geometry.py +++ b/.github/skills/experimental/powerpoint/tests/test_validate_geometry.py @@ -206,6 +206,34 @@ def test_bottom_overflow(self, blank_slide): assert len(issues) == 1 assert "bottom" in issues[0]["description"].lower() + def test_left_overflow(self, blank_slide): + """Shape with negative left position is detected.""" + from pptx.util import Emu + + shape = blank_slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, + Emu(-91440), # -0.1 inches + Inches(1), + Inches(2), + Inches(1), + ) + issues = check_boundary_overflow(shape, 13.333, 7.5) + assert any("left" in i["description"].lower() for i in issues) + + def test_top_overflow(self, blank_slide): + """Shape with negative top position is detected.""" + from pptx.util import Emu + + shape = blank_slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, + Inches(1), + Emu(-91440), # -0.1 inches + Inches(2), + Inches(1), + ) + issues = check_boundary_overflow(shape, 13.333, 7.5) + assert any("top" in i["description"].lower() for i in issues) + class TestCheckEdgeMargins: """Tests for check_edge_margins.""" From 5c404767cf36e013ef907ec9f4aa188c1552dd48 Mon Sep 17 00:00:00 2001 From: auyidi Date: Wed, 29 Apr 2026 22:41:11 +0000 Subject: [PATCH 07/22] fix(skills): address third round PR review feedback - test_embed_audio.py: move import struct to module top per Python coding standards (thread #11) - test_export_svg.py: replace @patch decorator with mocker.patch() per pytest-mock convention (thread #14) - export_svg.py: replace local parse_slide_numbers with shared parse_slide_filter from pptx_utils (thread #15) - test_export_svg.py: remove parse_slide_numbers tests (function removed), drop unused import --- .../powerpoint/scripts/export_svg.py | 14 ++------- .../powerpoint/tests/test_embed_audio.py | 4 +-- .../powerpoint/tests/test_export_svg.py | 30 +++++-------------- 3 files changed, 12 insertions(+), 36 deletions(-) diff --git a/.github/skills/experimental/powerpoint/scripts/export_svg.py b/.github/skills/experimental/powerpoint/scripts/export_svg.py index 227090bed..042c156a1 100644 --- a/.github/skills/experimental/powerpoint/scripts/export_svg.py +++ b/.github/skills/experimental/powerpoint/scripts/export_svg.py @@ -26,6 +26,7 @@ EXIT_FAILURE, EXIT_SUCCESS, configure_logging, + parse_slide_filter, ) logger = logging.getLogger(__name__) @@ -141,16 +142,6 @@ def convert_pptx_to_pdf(pptx_path: Path, output_dir: Path) -> Path: return pdf_path -def parse_slide_numbers(slides_str: str) -> list[int]: - """Parse comma-separated slide numbers into a sorted list of integers.""" - numbers = [] - for part in slides_str.split(","): - part = part.strip() - if part: - numbers.append(int(part)) - return sorted(set(numbers)) - - def export_pdf_to_svg( pdf_path: Path, output_dir: Path, @@ -213,7 +204,8 @@ def run(args: argparse.Namespace) -> int: slides: list[int] | None = None if args.slides: - slides = parse_slide_numbers(args.slides) + slide_set = parse_slide_filter(args.slides) + slides = sorted(slide_set) if slide_set else None logger.info("Filtering to slides: %s", slides) with tempfile.TemporaryDirectory() as tmp_dir: diff --git a/.github/skills/experimental/powerpoint/tests/test_embed_audio.py b/.github/skills/experimental/powerpoint/tests/test_embed_audio.py index 8de6d8edf..874163e70 100644 --- a/.github/skills/experimental/powerpoint/tests/test_embed_audio.py +++ b/.github/skills/experimental/powerpoint/tests/test_embed_audio.py @@ -2,6 +2,8 @@ # SPDX-License-Identifier: MIT """Tests for embed_audio module.""" +import struct + import pytest from embed_audio import ( AUDIO_PATTERN, @@ -18,8 +20,6 @@ def _make_wav_bytes(duration_ms: int = 100) -> bytes: """Create minimal valid WAV file bytes.""" - import struct - sample_rate = 16000 num_samples = int(sample_rate * duration_ms / 1000) data = b"\x00\x00" * num_samples diff --git a/.github/skills/experimental/powerpoint/tests/test_export_svg.py b/.github/skills/experimental/powerpoint/tests/test_export_svg.py index 9da354850..d78ac2baf 100644 --- a/.github/skills/experimental/powerpoint/tests/test_export_svg.py +++ b/.github/skills/experimental/powerpoint/tests/test_export_svg.py @@ -2,13 +2,10 @@ # SPDX-License-Identifier: MIT """Tests for export_svg module.""" -from unittest.mock import patch - from export_svg import ( create_parser, find_libreoffice, main, - parse_slide_numbers, run, ) @@ -35,19 +32,6 @@ def test_verbose(self): assert args.verbose is True -class TestParseSlideNumbers: - """Tests for parse_slide_numbers.""" - - def test_simple(self): - assert parse_slide_numbers("1,3,5") == [1, 3, 5] - - def test_whitespace(self): - assert parse_slide_numbers(" 2 , 4 , 6 ") == [2, 4, 6] - - def test_single(self): - assert parse_slide_numbers("7") == [7] - - class TestFindLibreoffice: """Tests for find_libreoffice.""" @@ -55,13 +39,13 @@ def test_returns_string_or_none(self): result = find_libreoffice() assert result is None or isinstance(result, str) - @patch("shutil.which", return_value="/usr/bin/libreoffice") - def test_finds_on_path(self, mock_which): + def test_finds_on_path(self, mocker): + mocker.patch("shutil.which", return_value="/usr/bin/libreoffice") assert find_libreoffice() == "/usr/bin/libreoffice" - @patch("shutil.which", return_value=None) - @patch("os.path.isfile", return_value=False) - def test_returns_none_when_missing(self, mock_isfile, mock_which): + def test_returns_none_when_missing(self, mocker): + mocker.patch("shutil.which", return_value=None) + mocker.patch("os.path.isfile", return_value=False) assert find_libreoffice() is None @@ -81,10 +65,10 @@ def test_missing_input_file(self, tmp_path): rc = run(args) assert rc == 2 - @patch("export_svg.find_libreoffice", return_value=None) - def test_missing_libreoffice(self, mock_lo, tmp_path): + def test_missing_libreoffice(self, mocker, tmp_path): deck = tmp_path / "test.pptx" deck.write_bytes(b"PK") # minimal zip header + mocker.patch("export_svg.find_libreoffice", return_value=None) parser = create_parser() args = parser.parse_args( [ From 7f72e4734ae08d8429b2fc704e27f4f163f47228 Mon Sep 17 00:00:00 2001 From: auyidi Date: Fri, 1 May 2026 15:41:07 +0000 Subject: [PATCH 08/22] fix(skills): address fourth round PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add shebangs to embed_audio, export_svg, validate_geometry, generate_themes - fix validate_geometry exit codes to distinguish errors (2) from warnings (1) - add horizontal overlap guard to prevent false positives in multi-column layouts - fix float equality in accent bar detection to use tolerance - move audio icon off-screen and document Pillow dependency in SKILL.md - fix negation style in export_svg, add verbose help and return type in generate_themes - correct docstrings about optional # prefix in color_map keys 🐛 - Generated by Copilot --- .github/skills/experimental/powerpoint/SKILL.md | 4 +++- .../powerpoint/scripts/embed_audio.py | 3 ++- .../experimental/powerpoint/scripts/export_svg.py | 3 ++- .../powerpoint/scripts/generate_themes.py | 15 ++++++++++----- .../powerpoint/scripts/validate_geometry.py | 15 ++++++++++++--- 5 files changed, 29 insertions(+), 11 deletions(-) diff --git a/.github/skills/experimental/powerpoint/SKILL.md b/.github/skills/experimental/powerpoint/SKILL.md index 43ab2a8d1..e6ad8459e 100644 --- a/.github/skills/experimental/powerpoint/SKILL.md +++ b/.github/skills/experimental/powerpoint/SKILL.md @@ -447,7 +447,9 @@ python scripts/embed_audio.py \ --output slide-deck/presentation-narrated.pptx ``` -Embeds WAV audio files into PPTX slides. Audio files are matched to slides by naming convention (`slide-001.wav`, `slide-002.wav`, etc.). The audio icon is placed off-screen to keep it hidden during presentation. Pass `--slides` to embed audio on specific slides only. +Embeds WAV audio files into PPTX slides. Audio files are matched to slides by naming convention (`slide-001.wav`, `slide-002.wav`, etc.). The audio icon is placed off-screen (below the slide boundary) to keep it hidden during presentation. Pass `--slides` to embed audio on specific slides only. + +**Dependencies**: Requires `pillow` (`pip install pillow`) for poster frame generation. ### Export Slides to SVG diff --git a/.github/skills/experimental/powerpoint/scripts/embed_audio.py b/.github/skills/experimental/powerpoint/scripts/embed_audio.py index 414094cb8..3ea651fe7 100644 --- a/.github/skills/experimental/powerpoint/scripts/embed_audio.py +++ b/.github/skills/experimental/powerpoint/scripts/embed_audio.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python3 # Copyright (c) Microsoft Corporation. # SPDX-License-Identifier: MIT """Embed WAV audio files into a PowerPoint deck, one per slide. @@ -40,7 +41,7 @@ AUDIO_PATTERN = re.compile(r"^slide-(\d+)\.wav$", re.IGNORECASE) AUDIO_LEFT = Inches(0.1) -AUDIO_TOP = Inches(7.0) +AUDIO_TOP = Inches(8.0) AUDIO_WIDTH = Inches(0.3) AUDIO_HEIGHT = Inches(0.3) diff --git a/.github/skills/experimental/powerpoint/scripts/export_svg.py b/.github/skills/experimental/powerpoint/scripts/export_svg.py index 042c156a1..1355d854b 100644 --- a/.github/skills/experimental/powerpoint/scripts/export_svg.py +++ b/.github/skills/experimental/powerpoint/scripts/export_svg.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python3 # Copyright (c) Microsoft Corporation. # SPDX-License-Identifier: MIT """Export PowerPoint slides to SVG with optional slide filtering. @@ -198,7 +199,7 @@ def run(args: argparse.Namespace) -> int: logger.error("Input file not found: %s", pptx_path) return EXIT_ERROR - if not pptx_path.suffix.lower() == ".pptx": + if pptx_path.suffix.lower() != ".pptx": logger.error("Input file must be a .pptx file: %s", pptx_path) return EXIT_ERROR diff --git a/.github/skills/experimental/powerpoint/scripts/generate_themes.py b/.github/skills/experimental/powerpoint/scripts/generate_themes.py index 3c9ce2448..dd28c5d9f 100644 --- a/.github/skills/experimental/powerpoint/scripts/generate_themes.py +++ b/.github/skills/experimental/powerpoint/scripts/generate_themes.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python3 # Copyright (c) Microsoft Corporation. # SPDX-License-Identifier: MIT """Generate themed content directory variants from a base deck's content. @@ -18,6 +19,7 @@ import shutil import sys from pathlib import Path +from typing import Any import yaml from pptx_utils import ( @@ -53,11 +55,13 @@ def create_parser() -> argparse.ArgumentParser: required=True, help="Parent directory where themed content directories are created.", ) - parser.add_argument("-v", "--verbose", action="store_true") + parser.add_argument( + "-v", "--verbose", action="store_true", help="Enable verbose output" + ) return parser -def load_themes(themes_path: Path) -> dict: +def load_themes(themes_path: Path) -> dict[str, Any]: """Load and validate the themes YAML file. Returns the ``themes`` mapping keyed by theme-id. @@ -80,8 +84,8 @@ def remap_hex_in_text(text: str, color_map: dict[str, str]) -> str: one substitution's output feeds the next (e.g., A→B then B→C would incorrectly produce C instead of the intended B). - Keys and values in *color_map* must include the leading ``#``. - Matching is case-insensitive. + Keys and values in *color_map* may optionally include the leading ``#``; + the prefix is stripped before matching. Matching is case-insensitive. """ bare_map = {k.lstrip("#").lower(): v.lstrip("#") for k, v in color_map.items()} if not bare_map: @@ -99,7 +103,8 @@ def remap_rgb_in_python(text: str, color_map: dict[str, str]) -> str: Uses a single-pass regex callback to avoid chain remapping where one substitution's output feeds the next. - Keys and values in *color_map* must include the leading ``#``. + Keys and values in *color_map* may optionally include the leading ``#``; + the prefix is stripped before matching. """ bare_map: dict[str, str] = {} for old_hex, new_hex in color_map.items(): diff --git a/.github/skills/experimental/powerpoint/scripts/validate_geometry.py b/.github/skills/experimental/powerpoint/scripts/validate_geometry.py index df90c5d37..cf22c8fac 100644 --- a/.github/skills/experimental/powerpoint/scripts/validate_geometry.py +++ b/.github/skills/experimental/powerpoint/scripts/validate_geometry.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python3 # Copyright (c) Microsoft Corporation. # SPDX-License-Identifier: MIT """Validate PPTX element geometry against spacing and margin rules. @@ -47,7 +48,7 @@ def _is_accent_bar(shape, slide_width_in: float) -> bool: width_in = emu_to_inches(shape.width) left_in = emu_to_inches(shape.left) return ( - top_in == 0.0 + top_in <= 0.01 and left_in <= 0.01 and height_in <= ACCENT_BAR_MAX_HEIGHT and abs(width_in - slide_width_in) < 0.01 @@ -211,7 +212,13 @@ def check_adjacent_gaps(shapes, gap: float) -> list[dict]: _, bottom_a, shape_a = rects[i] top_b, _, shape_b = rects[i + 1] vertical_gap = top_b - bottom_a - if vertical_gap < gap - 0.01 and vertical_gap >= 0: + # Verify shapes share horizontal extent before flagging + left_a = emu_to_inches(shape_a.left) + right_a = left_a + emu_to_inches(shape_a.width) + left_b = emu_to_inches(shape_b.left) + right_b = left_b + emu_to_inches(shape_b.width) + h_overlap = min(right_a, right_b) - max(left_a, left_b) + if vertical_gap < gap - 0.01 and vertical_gap >= 0 and h_overlap > 0.01: label_a = _shape_label(shape_a) label_b = _shape_label(shape_b) issues.append( @@ -544,7 +551,9 @@ def main() -> int: slide_count, ) - if severity in ("error", "warning"): + if severity == "error": + return EXIT_ERROR + if severity == "warning": return EXIT_FAILURE return EXIT_SUCCESS From f103c6b8e1106c85a8da8fd1fa89beaa21171c4d Mon Sep 17 00:00:00 2001 From: auyidi Date: Fri, 1 May 2026 16:17:23 +0000 Subject: [PATCH 09/22] fix(skills): address fifth round PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - compute audio_top dynamically from slide height for off-screen placement - add BaseShape type annotations to validate_geometry helper functions - remove chain-remap risk by dropping remap_hex_in_text call on .py files 🐛 - Generated by Copilot --- .../experimental/powerpoint/scripts/embed_audio.py | 5 +++-- .../powerpoint/scripts/generate_themes.py | 3 ++- .../powerpoint/scripts/validate_geometry.py | 13 +++++++------ 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/.github/skills/experimental/powerpoint/scripts/embed_audio.py b/.github/skills/experimental/powerpoint/scripts/embed_audio.py index 3ea651fe7..1ecc48a73 100644 --- a/.github/skills/experimental/powerpoint/scripts/embed_audio.py +++ b/.github/skills/experimental/powerpoint/scripts/embed_audio.py @@ -41,9 +41,9 @@ AUDIO_PATTERN = re.compile(r"^slide-(\d+)\.wav$", re.IGNORECASE) AUDIO_LEFT = Inches(0.1) -AUDIO_TOP = Inches(8.0) AUDIO_WIDTH = Inches(0.3) AUDIO_HEIGHT = Inches(0.3) +AUDIO_OFFSCREEN_OFFSET = Inches(0.5) def create_parser() -> argparse.ArgumentParser: @@ -130,6 +130,7 @@ def embed_audio( Count of slides that received embedded audio. """ embedded_count = 0 + audio_top = prs.slide_height + AUDIO_OFFSCREEN_OFFSET for slide_num, slide in enumerate(prs.slides, start=1): if slide_filter and slide_num not in slide_filter: continue @@ -141,7 +142,7 @@ def embed_audio( slide.shapes.add_movie( movie_file=str(wav_path), left=AUDIO_LEFT, - top=AUDIO_TOP, + top=audio_top, width=AUDIO_WIDTH, height=AUDIO_HEIGHT, poster_frame_image=str(poster_frame), diff --git a/.github/skills/experimental/powerpoint/scripts/generate_themes.py b/.github/skills/experimental/powerpoint/scripts/generate_themes.py index dd28c5d9f..d74d9b911 100644 --- a/.github/skills/experimental/powerpoint/scripts/generate_themes.py +++ b/.github/skills/experimental/powerpoint/scripts/generate_themes.py @@ -155,8 +155,9 @@ def process_file(src: Path, dest: Path, color_map: dict[str, str]) -> None: dest.write_text(text, encoding="utf-8") elif src.suffix == ".py": text = src.read_text(encoding="utf-8") + # remap_rgb_in_python handles both RGBColor(...) and "#RRGGBB" quoted + # forms in a single pass; skip remap_hex_in_text to avoid chain remap text = remap_rgb_in_python(text, color_map) - text = remap_hex_in_text(text, color_map) dest.write_text(text, encoding="utf-8") else: shutil.copy2(src, dest) diff --git a/.github/skills/experimental/powerpoint/scripts/validate_geometry.py b/.github/skills/experimental/powerpoint/scripts/validate_geometry.py index cf22c8fac..d543d7a1f 100644 --- a/.github/skills/experimental/powerpoint/scripts/validate_geometry.py +++ b/.github/skills/experimental/powerpoint/scripts/validate_geometry.py @@ -24,6 +24,7 @@ from pathlib import Path from pptx import Presentation +from pptx.shapes.base import BaseShape from pptx_utils import ( EXIT_ERROR, EXIT_FAILURE, @@ -41,7 +42,7 @@ ACCENT_BAR_MAX_HEIGHT = 0.12 -def _is_accent_bar(shape, slide_width_in: float) -> bool: +def _is_accent_bar(shape: BaseShape, slide_width_in: float) -> bool: """Return True when shape is a full-width decorative accent bar at top.""" top_in = emu_to_inches(shape.top) height_in = emu_to_inches(shape.height) @@ -55,7 +56,7 @@ def _is_accent_bar(shape, slide_width_in: float) -> bool: ) -def _shape_label(shape) -> str: +def _shape_label(shape: BaseShape) -> str: """Return a human-readable label for a shape.""" name = shape.name or "unnamed" if hasattr(shape, "text") and shape.text: @@ -65,7 +66,7 @@ def _shape_label(shape) -> str: def check_boundary_overflow( - shape, + shape: BaseShape, slide_w_in: float, slide_h_in: float, ) -> list[dict]: @@ -130,7 +131,7 @@ def check_boundary_overflow( def check_edge_margins( - shape, + shape: BaseShape, slide_w_in: float, slide_h_in: float, margin: float, @@ -194,7 +195,7 @@ def check_edge_margins( return issues -def check_adjacent_gaps(shapes, gap: float) -> list[dict]: +def check_adjacent_gaps(shapes: list[BaseShape], gap: float) -> list[dict]: """Check vertical gaps between adjacent elements. Sorts shapes by top position and checks consecutive pairs for minimum @@ -235,7 +236,7 @@ def check_adjacent_gaps(shapes, gap: float) -> list[dict]: return issues -def check_title_clearance(shapes, clearance: float) -> list[dict]: +def check_title_clearance(shapes: list[BaseShape], clearance: float) -> list[dict]: """Check title-to-next-element vertical clearance. Identifies positions where a shape name contains 'title' (but not From c825af3568094d2c1c5b3a43de11e28f5a4b5795 Mon Sep 17 00:00:00 2001 From: auyidi Date: Fri, 1 May 2026 16:58:15 +0000 Subject: [PATCH 10/22] fix(skills): address sixth round PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - switch update_style_metadata to ruamel.yaml for round-trip fidelity - add geometric validation Step 2b to invoke-pptx-pipeline.sh 🐛 - Generated by Copilot --- .../experimental/powerpoint/pyproject.toml | 1 + .../powerpoint/scripts/generate_themes.py | 15 ++++++----- .../scripts/invoke-pptx-pipeline.sh | 26 +++++++++++++++++-- .../skills/experimental/powerpoint/uv.lock | 11 ++++++++ 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/.github/skills/experimental/powerpoint/pyproject.toml b/.github/skills/experimental/powerpoint/pyproject.toml index cc6655777..4975d2d73 100644 --- a/.github/skills/experimental/powerpoint/pyproject.toml +++ b/.github/skills/experimental/powerpoint/pyproject.toml @@ -5,6 +5,7 @@ requires-python = ">=3.11" dependencies = [ "python-pptx", "pyyaml", + "ruamel.yaml", "cairosvg", "Pillow", "pymupdf", diff --git a/.github/skills/experimental/powerpoint/scripts/generate_themes.py b/.github/skills/experimental/powerpoint/scripts/generate_themes.py index d74d9b911..d65c3862b 100644 --- a/.github/skills/experimental/powerpoint/scripts/generate_themes.py +++ b/.github/skills/experimental/powerpoint/scripts/generate_themes.py @@ -28,6 +28,7 @@ EXIT_SUCCESS, configure_logging, ) +from ruamel.yaml import YAML logger = logging.getLogger(__name__) @@ -177,12 +178,14 @@ def process_directory(src_dir: Path, dest_dir: Path, color_map: dict[str, str]) def update_style_metadata(style_path: Path, theme_id: str, label: str) -> None: """Patch theme name and append label to title in style.yaml. - Uses yaml.safe_load round-trip to avoid brittle regex patching. - Note: this normalizes YAML formatting (key ordering, quoting style). + Uses ruamel.yaml for round-trip fidelity: preserves comments, + key ordering, and quoting style from the original file. """ if not style_path.exists(): return - data = yaml.safe_load(style_path.read_text(encoding="utf-8")) + ryaml = YAML() + ryaml.preserve_quotes = True + data = ryaml.load(style_path.read_text(encoding="utf-8")) if not isinstance(data, dict): return @@ -200,10 +203,8 @@ def update_style_metadata(style_path: Path, theme_id: str, label: str) -> None: if label not in title: metadata["title"] = f"{title} ({label})" if title else label - style_path.write_text( - yaml.dump(data, allow_unicode=True, default_flow_style=False), - encoding="utf-8", - ) + with style_path.open("w", encoding="utf-8") as f: + ryaml.dump(data, f) def generate_theme( diff --git a/.github/skills/experimental/powerpoint/scripts/invoke-pptx-pipeline.sh b/.github/skills/experimental/powerpoint/scripts/invoke-pptx-pipeline.sh index 01f0baff7..9669d212d 100755 --- a/.github/skills/experimental/powerpoint/scripts/invoke-pptx-pipeline.sh +++ b/.github/skills/experimental/powerpoint/scripts/invoke-pptx-pipeline.sh @@ -244,8 +244,8 @@ invoke_validate_deck() { local has_vision_prompt=false [[ -n "${VALIDATION_PROMPT:-}" || -n "${VALIDATION_PROMPT_FILE:-}" ]] && has_vision_prompt=true - local total_steps=2 - ${has_vision_prompt} && total_steps=3 + local total_steps=3 + ${has_vision_prompt} && total_steps=4 # Default image output directory if [[ -z "${IMAGE_OUTPUT_DIR:-}" ]]; then @@ -281,6 +281,28 @@ invoke_validate_deck() { echo "PPTX property checks found warnings — see ${deck_report}" fi + # Step 2b: Run geometric validation (margin, gap, overflow checks) + echo "Step 2b/${total_steps}: Running geometric validation..." + local geom_output="${IMAGE_OUTPUT_DIR}/geometry-validation-results.json" + local geom_report="${IMAGE_OUTPUT_DIR}/geometry-validation-report.md" + local -a geom_args=( + "${SCRIPT_DIR}/validate_geometry.py" + "--input" "${INPUT_PATH}" + "--output" "${geom_output}" + "--report" "${geom_report}" + "--per-slide-dir" "${IMAGE_OUTPUT_DIR}" + ) + [[ -n "${SLIDES:-}" ]] && geom_args+=("--slides" "${SLIDES}") + + local geom_exit=0 + "${python}" "${geom_args[@]}" || geom_exit=$? + if (( geom_exit == 2 )); then + err "validate_geometry.py encountered an error (exit code ${geom_exit})." + fi + if (( geom_exit == 1 )); then + echo "Geometric validation found warnings — see ${geom_report}" + fi + # Step 3: Vision validation (when prompt provided) if ${has_vision_prompt}; then echo "Step 3/${total_steps}: Running Copilot SDK vision validation..." diff --git a/.github/skills/experimental/powerpoint/uv.lock b/.github/skills/experimental/powerpoint/uv.lock index 2704a56fd..30580bd6e 100644 --- a/.github/skills/experimental/powerpoint/uv.lock +++ b/.github/skills/experimental/powerpoint/uv.lock @@ -511,6 +511,7 @@ dependencies = [ { name = "pymupdf" }, { name = "python-pptx" }, { name = "pyyaml" }, + { name = "ruamel-yaml" }, ] [package.dev-dependencies] @@ -533,6 +534,7 @@ requires-dist = [ { name = "pymupdf" }, { name = "python-pptx" }, { name = "pyyaml" }, + { name = "ruamel-yaml" }, ] [package.metadata.requires-dev] @@ -814,6 +816,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "ruamel-yaml" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" }, +] + [[package]] name = "ruff" version = "0.15.4" From a6657854e2d339bdf8baaedbdbb78a6c195a4745 Mon Sep 17 00:00:00 2001 From: auyidi Date: Fri, 1 May 2026 17:23:58 +0000 Subject: [PATCH 11/22] fix(skills): address seventh round PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - replace os.path.isfile with Path.is_file in export_svg.py - consolidate generate_themes.py to use only ruamel.yaml, drop pyyaml import - add dry-run tests for build_deck.py (success, no-slides, yaml-error) - add export_svg.py test coverage (non-pptx, slide filter, pdf-to-svg mock, pathlib) - fix em-dash in pptx.instructions.md 🐛 - Generated by Copilot --- .../experimental/pptx.instructions.md | 2 +- .../powerpoint/scripts/export_svg.py | 3 +- .../powerpoint/scripts/generate_themes.py | 5 +- .../powerpoint/tests/test_build_deck.py | 79 ++++++++++++ .../powerpoint/tests/test_export_svg.py | 121 +++++++++++++++++- 5 files changed, 203 insertions(+), 7 deletions(-) diff --git a/.github/instructions/experimental/pptx.instructions.md b/.github/instructions/experimental/pptx.instructions.md index 76e2c4cc7..e76389476 100644 --- a/.github/instructions/experimental/pptx.instructions.md +++ b/.github/instructions/experimental/pptx.instructions.md @@ -120,7 +120,7 @@ Use `#RRGGBB` hex values or `@theme_name` references for all colors. See the Col ### Theme Colors in content-extra.py -When `style.yaml` defines a `themes` section, the build script populates `style["colors"]` with the first theme's color map. Use `style.get("colors", {}).get("accent_blue", "#0078D4")` in `content-extra.py` to reference theme-aware colors. This enables theme portability — the same script produces correct colors across all theme variants without regex replacement. +When `style.yaml` defines a `themes` section, the build script populates `style["colors"]` with the first theme's color map. Use `style.get("colors", {}).get("accent_blue", "#0078D4")` in `content-extra.py` to reference theme-aware colors. This enables theme portability. The same script produces correct colors across all theme variants without regex replacement. ## Contextual Styling diff --git a/.github/skills/experimental/powerpoint/scripts/export_svg.py b/.github/skills/experimental/powerpoint/scripts/export_svg.py index 1355d854b..d618f511e 100644 --- a/.github/skills/experimental/powerpoint/scripts/export_svg.py +++ b/.github/skills/experimental/powerpoint/scripts/export_svg.py @@ -14,7 +14,6 @@ import argparse import logging -import os import platform import shutil import subprocess @@ -86,7 +85,7 @@ def find_libreoffice() -> str | None: ] for candidate in candidates: - if os.path.isfile(candidate): + if Path(candidate).is_file(): return candidate return None diff --git a/.github/skills/experimental/powerpoint/scripts/generate_themes.py b/.github/skills/experimental/powerpoint/scripts/generate_themes.py index d65c3862b..f3fcb4a29 100644 --- a/.github/skills/experimental/powerpoint/scripts/generate_themes.py +++ b/.github/skills/experimental/powerpoint/scripts/generate_themes.py @@ -21,7 +21,6 @@ from pathlib import Path from typing import Any -import yaml from pptx_utils import ( EXIT_ERROR, EXIT_FAILURE, @@ -67,8 +66,8 @@ def load_themes(themes_path: Path) -> dict[str, Any]: Returns the ``themes`` mapping keyed by theme-id. """ - text = themes_path.read_text(encoding="utf-8") - data = yaml.safe_load(text) + ryaml = YAML(typ="safe") + data = ryaml.load(themes_path.read_text(encoding="utf-8")) if not isinstance(data, dict) or "themes" not in data: raise ValueError("themes YAML must contain a top-level 'themes' key") themes = data["themes"] diff --git a/.github/skills/experimental/powerpoint/tests/test_build_deck.py b/.github/skills/experimental/powerpoint/tests/test_build_deck.py index 09daed4da..f830ece63 100644 --- a/.github/skills/experimental/powerpoint/tests/test_build_deck.py +++ b/.github/skills/experimental/powerpoint/tests/test_build_deck.py @@ -1989,3 +1989,82 @@ def test_no_allow_scripts_rejects_blocked_import(self, mocker, tmp_path): ) with pytest.raises(ContentExtraError): main() + + +class TestDryRun: + """Tests for the --dry-run validation mode.""" + + @staticmethod + def _make_content(tmp_path, slides=None): + """Create minimal content dir and style for dry-run tests.""" + content_dir = tmp_path / "content" + content_dir.mkdir() + style_file = tmp_path / "style.yaml" + style_file.write_text("dimensions:\n width_inches: 13.333\n") + if slides: + for num, yaml_text in slides.items(): + slide_dir = content_dir / f"slide-{num:03d}" + slide_dir.mkdir() + (slide_dir / "content.yaml").write_text(yaml_text) + return content_dir, style_file + + def test_dry_run_success(self, mocker, tmp_path): + """Dry-run with valid content returns EXIT_SUCCESS.""" + content_dir, style_file = self._make_content( + tmp_path, {1: "title: Hello\nspeaker_notes: Some notes\n"} + ) + mocker.patch( + "sys.argv", + [ + "build_deck.py", + "--content-dir", + str(content_dir), + "--style", + str(style_file), + "--output", + str(tmp_path / "out.pptx"), + "--dry-run", + ], + ) + rc = main() + assert rc == 0 + + def test_dry_run_no_slides(self, mocker, tmp_path): + """Dry-run with empty content dir returns EXIT_ERROR.""" + content_dir, style_file = self._make_content(tmp_path) + mocker.patch( + "sys.argv", + [ + "build_deck.py", + "--content-dir", + str(content_dir), + "--style", + str(style_file), + "--output", + str(tmp_path / "out.pptx"), + "--dry-run", + ], + ) + rc = main() + assert rc == 2 + + def test_dry_run_yaml_parse_error(self, mocker, tmp_path): + """Dry-run with invalid YAML returns EXIT_FAILURE.""" + content_dir, style_file = self._make_content( + tmp_path, {1: ": :\n - [invalid yaml\n"} + ) + mocker.patch( + "sys.argv", + [ + "build_deck.py", + "--content-dir", + str(content_dir), + "--style", + str(style_file), + "--output", + str(tmp_path / "out.pptx"), + "--dry-run", + ], + ) + rc = main() + assert rc == 1 diff --git a/.github/skills/experimental/powerpoint/tests/test_export_svg.py b/.github/skills/experimental/powerpoint/tests/test_export_svg.py index d78ac2baf..8d7cd418c 100644 --- a/.github/skills/experimental/powerpoint/tests/test_export_svg.py +++ b/.github/skills/experimental/powerpoint/tests/test_export_svg.py @@ -2,8 +2,12 @@ # SPDX-License-Identifier: MIT """Tests for export_svg module.""" +from pathlib import Path +from unittest.mock import MagicMock + from export_svg import ( create_parser, + export_pdf_to_svg, find_libreoffice, main, run, @@ -45,7 +49,7 @@ def test_finds_on_path(self, mocker): def test_returns_none_when_missing(self, mocker): mocker.patch("shutil.which", return_value=None) - mocker.patch("os.path.isfile", return_value=False) + mocker.patch.object(Path, "is_file", return_value=False) assert find_libreoffice() is None @@ -98,3 +102,118 @@ def test_missing_input(self, tmp_path, monkeypatch): ) rc = main() assert rc == 2 + + +class TestRunExtended: + """Extended tests for run function edge cases.""" + + def test_non_pptx_extension(self, tmp_path): + """Non-.pptx input file returns EXIT_ERROR.""" + bad_file = tmp_path / "test.pdf" + bad_file.write_bytes(b"fake") + parser = create_parser() + args = parser.parse_args( + ["--input", str(bad_file), "--output-dir", str(tmp_path / "out")] + ) + rc = run(args) + assert rc == 2 + + def test_with_slide_filter(self, mocker, tmp_path): + """Slide filter is parsed and passed through.""" + deck = tmp_path / "test.pptx" + deck.write_bytes(b"PK") + mocker.patch("export_svg.find_libreoffice", return_value=None) + parser = create_parser() + args = parser.parse_args( + [ + "--input", + str(deck), + "--output-dir", + str(tmp_path / "out"), + "--slides", + "1,3", + ] + ) + rc = run(args) + assert rc == 1 + + +class TestExportPdfToSvg: + """Tests for export_pdf_to_svg with mocked fitz.""" + + def test_exports_all_pages(self, mocker, tmp_path): + """All pages are exported when no slide filter is provided.""" + mock_page = MagicMock() + mock_page.get_svg_image.return_value = "" + + mock_doc = MagicMock() + mock_doc.__len__ = MagicMock(return_value=3) + mock_doc.__getitem__ = MagicMock(return_value=mock_page) + + mock_fitz = MagicMock() + mock_fitz.open.return_value = mock_doc + mocker.patch.dict("sys.modules", {"fitz": mock_fitz}) + + pdf = tmp_path / "slides.pdf" + pdf.write_bytes(b"fake") + out = tmp_path / "svg" + + result = export_pdf_to_svg(pdf, out) + assert len(result) == 3 + assert all(p.suffix == ".svg" for p in result) + + def test_exports_filtered_pages(self, mocker, tmp_path): + """Only specified slides are exported.""" + mock_page = MagicMock() + mock_page.get_svg_image.return_value = "" + + mock_doc = MagicMock() + mock_doc.__len__ = MagicMock(return_value=5) + mock_doc.__getitem__ = MagicMock(return_value=mock_page) + + mock_fitz = MagicMock() + mock_fitz.open.return_value = mock_doc + mocker.patch.dict("sys.modules", {"fitz": mock_fitz}) + + pdf = tmp_path / "slides.pdf" + pdf.write_bytes(b"fake") + out = tmp_path / "svg" + + result = export_pdf_to_svg(pdf, out, slides=[1, 3]) + assert len(result) == 2 + + def test_out_of_range_slides_skipped(self, mocker, tmp_path): + """Out-of-range slide numbers are skipped.""" + mock_page = MagicMock() + mock_page.get_svg_image.return_value = "" + + mock_doc = MagicMock() + mock_doc.__len__ = MagicMock(return_value=2) + mock_doc.__getitem__ = MagicMock(return_value=mock_page) + + mock_fitz = MagicMock() + mock_fitz.open.return_value = mock_doc + mocker.patch.dict("sys.modules", {"fitz": mock_fitz}) + + pdf = tmp_path / "slides.pdf" + pdf.write_bytes(b"fake") + out = tmp_path / "svg" + + result = export_pdf_to_svg(pdf, out, slides=[1, 5, 10]) + assert len(result) == 1 + + +class TestFindLibreofficePathlib: + """Tests for find_libreoffice using Path-based file checks.""" + + def test_finds_platform_candidate(self, mocker): + """Finds LibreOffice at a platform-specific path.""" + mocker.patch("shutil.which", return_value=None) + mocker.patch("platform.system", return_value="Linux") + mocker.patch.object( + Path, + "is_file", + lambda p: str(p) == "/usr/bin/soffice", + ) + result = find_libreoffice() + assert result == "/usr/bin/soffice" From 94edc90ab6f31bcc6189b1fcffc133afc8d7a8bd Mon Sep 17 00:00:00 2001 From: auyidi Date: Fri, 1 May 2026 17:58:09 +0000 Subject: [PATCH 12/22] fix(skills): address eighth round PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add hex-value length validation to remap_hex_in_text and remap_rgb_in_python - add cross-platform wrapper scripts for embed_audio, export_svg, generate_themes - fix PS1 step 2b label to include total steps suffix 🐛 - Generated by Copilot --- .../powerpoint/scripts/Invoke-EmbedAudio.ps1 | 70 ++++++++++++++++++ .../powerpoint/scripts/Invoke-ExportSvg.ps1 | 66 +++++++++++++++++ .../scripts/Invoke-GenerateThemes.ps1 | 65 +++++++++++++++++ .../scripts/Invoke-PptxPipeline.ps1 | 2 +- .../powerpoint/scripts/embed-audio.sh | 71 +++++++++++++++++++ .../powerpoint/scripts/export-svg.sh | 70 ++++++++++++++++++ .../powerpoint/scripts/generate-themes.sh | 70 ++++++++++++++++++ .../powerpoint/scripts/generate_themes.py | 11 +++ 8 files changed, 424 insertions(+), 1 deletion(-) create mode 100644 .github/skills/experimental/powerpoint/scripts/Invoke-EmbedAudio.ps1 create mode 100644 .github/skills/experimental/powerpoint/scripts/Invoke-ExportSvg.ps1 create mode 100644 .github/skills/experimental/powerpoint/scripts/Invoke-GenerateThemes.ps1 create mode 100755 .github/skills/experimental/powerpoint/scripts/embed-audio.sh create mode 100755 .github/skills/experimental/powerpoint/scripts/export-svg.sh create mode 100755 .github/skills/experimental/powerpoint/scripts/generate-themes.sh diff --git a/.github/skills/experimental/powerpoint/scripts/Invoke-EmbedAudio.ps1 b/.github/skills/experimental/powerpoint/scripts/Invoke-EmbedAudio.ps1 new file mode 100644 index 000000000..30aad94e5 --- /dev/null +++ b/.github/skills/experimental/powerpoint/scripts/Invoke-EmbedAudio.ps1 @@ -0,0 +1,70 @@ +#!/usr/bin/env pwsh +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT +#Requires -Version 7.0 + +<# +.SYNOPSIS + Embed WAV audio files into a PowerPoint deck. + +.DESCRIPTION + Wrapper script that manages the Python virtual environment and invokes + embed_audio.py to embed per-slide WAV files into a PPTX presentation. + +.PARAMETER InputPath + Input PPTX file path. + +.PARAMETER AudioDir + Directory containing slide-NNN.wav files. + +.PARAMETER OutputPath + Output PPTX file path. + +.PARAMETER Slides + Comma-separated slide numbers to embed audio on (optional). + +.PARAMETER SkipVenvSetup + Skip virtual environment setup. + +.PARAMETER Verbose + Enable verbose output. + +.EXAMPLE + ./Invoke-EmbedAudio.ps1 -InputPath deck.pptx -AudioDir voice-over/ -OutputPath out.pptx +#> + +param( + [Parameter(Mandatory)][string]$InputPath, + [Parameter(Mandatory)][string]$AudioDir, + [Parameter(Mandatory)][string]$OutputPath, + [string]$Slides, + [switch]$SkipVenvSetup +) + +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$SkillRoot = Split-Path -Parent $ScriptDir +$VenvDir = Join-Path $SkillRoot '.venv' + +if (-not $SkipVenvSetup) { + if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { + throw 'uv is required but was not found on PATH.' + } + uv sync --directory $SkillRoot +} + +$python = if (Test-Path (Join-Path $VenvDir 'Scripts/python.exe')) { + Join-Path $VenvDir 'Scripts/python.exe' +} elseif (Test-Path (Join-Path $VenvDir 'bin/python')) { + Join-Path $VenvDir 'bin/python' +} else { + throw "Python interpreter not found in venv. Run: uv sync --directory `"$SkillRoot`"" +} + +$script = Join-Path $ScriptDir 'embed_audio.py' +$args_ = @($script, '--input', $InputPath, '--audio-dir', $AudioDir, '--output', $OutputPath) +if ($Slides) { $args_ += '--slides'; $args_ += $Slides } +if ($VerbosePreference -eq 'Continue') { $args_ += '-v' } + +& $python @args_ diff --git a/.github/skills/experimental/powerpoint/scripts/Invoke-ExportSvg.ps1 b/.github/skills/experimental/powerpoint/scripts/Invoke-ExportSvg.ps1 new file mode 100644 index 000000000..8ae3efff4 --- /dev/null +++ b/.github/skills/experimental/powerpoint/scripts/Invoke-ExportSvg.ps1 @@ -0,0 +1,66 @@ +#!/usr/bin/env pwsh +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT +#Requires -Version 7.0 + +<# +.SYNOPSIS + Export PowerPoint slides to SVG images. + +.DESCRIPTION + Wrapper script that manages the Python virtual environment and invokes + export_svg.py to convert PPTX slides to SVG via LibreOffice and PyMuPDF. + +.PARAMETER InputPath + Input PPTX file path. + +.PARAMETER OutputDir + Output directory for SVG files. + +.PARAMETER Slides + Comma-separated slide numbers to export (optional). + +.PARAMETER SkipVenvSetup + Skip virtual environment setup. + +.PARAMETER Verbose + Enable verbose output. + +.EXAMPLE + ./Invoke-ExportSvg.ps1 -InputPath deck.pptx -OutputDir svg/ +#> + +param( + [Parameter(Mandatory)][string]$InputPath, + [Parameter(Mandatory)][string]$OutputDir, + [string]$Slides, + [switch]$SkipVenvSetup +) + +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$SkillRoot = Split-Path -Parent $ScriptDir +$VenvDir = Join-Path $SkillRoot '.venv' + +if (-not $SkipVenvSetup) { + if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { + throw 'uv is required but was not found on PATH.' + } + uv sync --directory $SkillRoot +} + +$python = if (Test-Path (Join-Path $VenvDir 'Scripts/python.exe')) { + Join-Path $VenvDir 'Scripts/python.exe' +} elseif (Test-Path (Join-Path $VenvDir 'bin/python')) { + Join-Path $VenvDir 'bin/python' +} else { + throw "Python interpreter not found in venv. Run: uv sync --directory `"$SkillRoot`"" +} + +$script = Join-Path $ScriptDir 'export_svg.py' +$args_ = @($script, '--input', $InputPath, '--output-dir', $OutputDir) +if ($Slides) { $args_ += '--slides'; $args_ += $Slides } +if ($VerbosePreference -eq 'Continue') { $args_ += '-v' } + +& $python @args_ diff --git a/.github/skills/experimental/powerpoint/scripts/Invoke-GenerateThemes.ps1 b/.github/skills/experimental/powerpoint/scripts/Invoke-GenerateThemes.ps1 new file mode 100644 index 000000000..4ba291a26 --- /dev/null +++ b/.github/skills/experimental/powerpoint/scripts/Invoke-GenerateThemes.ps1 @@ -0,0 +1,65 @@ +#!/usr/bin/env pwsh +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT +#Requires -Version 7.0 + +<# +.SYNOPSIS + Generate themed content directory variants from a base deck. + +.DESCRIPTION + Wrapper script that manages the Python virtual environment and invokes + generate_themes.py to produce themed content copies with remapped colors. + +.PARAMETER ContentDir + Path to the base theme's content directory. + +.PARAMETER ThemesPath + Path to a YAML file defining theme color mappings. + +.PARAMETER OutputDir + Parent directory where themed content directories are created. + +.PARAMETER SkipVenvSetup + Skip virtual environment setup. + +.PARAMETER Verbose + Enable verbose output. + +.EXAMPLE + ./Invoke-GenerateThemes.ps1 -ContentDir content/ -ThemesPath themes.yaml -OutputDir ../ +#> + +param( + [Parameter(Mandatory)][string]$ContentDir, + [Parameter(Mandatory)][string]$ThemesPath, + [Parameter(Mandatory)][string]$OutputDir, + [switch]$SkipVenvSetup +) + +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$SkillRoot = Split-Path -Parent $ScriptDir +$VenvDir = Join-Path $SkillRoot '.venv' + +if (-not $SkipVenvSetup) { + if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { + throw 'uv is required but was not found on PATH.' + } + uv sync --directory $SkillRoot +} + +$python = if (Test-Path (Join-Path $VenvDir 'Scripts/python.exe')) { + Join-Path $VenvDir 'Scripts/python.exe' +} elseif (Test-Path (Join-Path $VenvDir 'bin/python')) { + Join-Path $VenvDir 'bin/python' +} else { + throw "Python interpreter not found in venv. Run: uv sync --directory `"$SkillRoot`"" +} + +$script = Join-Path $ScriptDir 'generate_themes.py' +$args_ = @($script, '--content-dir', $ContentDir, '--themes', $ThemesPath, '--output-dir', $OutputDir) +if ($VerbosePreference -eq 'Continue') { $args_ += '-v' } + +& $python @args_ diff --git a/.github/skills/experimental/powerpoint/scripts/Invoke-PptxPipeline.ps1 b/.github/skills/experimental/powerpoint/scripts/Invoke-PptxPipeline.ps1 index 497c52381..23b0525fb 100644 --- a/.github/skills/experimental/powerpoint/scripts/Invoke-PptxPipeline.ps1 +++ b/.github/skills/experimental/powerpoint/scripts/Invoke-PptxPipeline.ps1 @@ -430,7 +430,7 @@ function Invoke-ValidateDeck { } # Step 2b: Run geometric validation (margin, gap, overflow checks) - Write-Host "Step 2b: Running geometric validation..." + Write-Host "Step 2b/$totalSteps`: Running geometric validation..." $geomScript = Join-Path $ScriptDir 'validate_geometry.py' $geomArgs = @( $geomScript, diff --git a/.github/skills/experimental/powerpoint/scripts/embed-audio.sh b/.github/skills/experimental/powerpoint/scripts/embed-audio.sh new file mode 100755 index 000000000..de46f6e1b --- /dev/null +++ b/.github/skills/experimental/powerpoint/scripts/embed-audio.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT +# +# embed-audio.sh +# Embed WAV audio files into a PowerPoint deck. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_ROOT="$(dirname "${SCRIPT_DIR}")" +VENV_DIR="${SKILL_ROOT}/.venv" + +SKIP_VENV_SETUP=false + +err() { + printf "ERROR: %s\n" "$1" >&2 + exit 1 +} + +usage() { + cat < Input PPTX file path (required) + --audio-dir Directory containing WAV files (required) + --output Output PPTX file path (required) + --slides Comma-separated slide numbers (optional) + --skip-venv-setup Skip virtual environment setup + -v, --verbose Enable verbose output + -h, --help Show this help message +EOF + exit 0 +} + +get_venv_python_path() { + if [[ -f "${VENV_DIR}/Scripts/python.exe" ]]; then + echo "${VENV_DIR}/Scripts/python.exe" + elif [[ -f "${VENV_DIR}/bin/python" ]]; then + echo "${VENV_DIR}/bin/python" + else + err "Python interpreter not found in venv. Run: uv sync --directory \"${SKILL_ROOT}\"" + fi +} + +main() { + local -a pass_through=() + + while (( $# > 0 )); do + case "$1" in + --skip-venv-setup) SKIP_VENV_SETUP=true; shift ;; + -h|--help) usage ;; + *) pass_through+=("$1"); shift ;; + esac + done + + if [[ "${SKIP_VENV_SETUP}" == "false" ]]; then + if ! command -v uv &>/dev/null; then + err "uv is required but was not found on PATH." + fi + uv sync --directory "${SKILL_ROOT}" + fi + + local python + python="$(get_venv_python_path)" + + "${python}" "${SCRIPT_DIR}/embed_audio.py" "${pass_through[@]}" +} + +main "$@" diff --git a/.github/skills/experimental/powerpoint/scripts/export-svg.sh b/.github/skills/experimental/powerpoint/scripts/export-svg.sh new file mode 100755 index 000000000..4a58ac473 --- /dev/null +++ b/.github/skills/experimental/powerpoint/scripts/export-svg.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT +# +# export-svg.sh +# Export PowerPoint slides to SVG images. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_ROOT="$(dirname "${SCRIPT_DIR}")" +VENV_DIR="${SKILL_ROOT}/.venv" + +SKIP_VENV_SETUP=false + +err() { + printf "ERROR: %s\n" "$1" >&2 + exit 1 +} + +usage() { + cat < Input PPTX file path (required) + --output-dir Output directory for SVG files (required) + --slides Comma-separated slide numbers (optional) + --skip-venv-setup Skip virtual environment setup + -v, --verbose Enable verbose output + -h, --help Show this help message +EOF + exit 0 +} + +get_venv_python_path() { + if [[ -f "${VENV_DIR}/Scripts/python.exe" ]]; then + echo "${VENV_DIR}/Scripts/python.exe" + elif [[ -f "${VENV_DIR}/bin/python" ]]; then + echo "${VENV_DIR}/bin/python" + else + err "Python interpreter not found in venv. Run: uv sync --directory \"${SKILL_ROOT}\"" + fi +} + +main() { + local -a pass_through=() + + while (( $# > 0 )); do + case "$1" in + --skip-venv-setup) SKIP_VENV_SETUP=true; shift ;; + -h|--help) usage ;; + *) pass_through+=("$1"); shift ;; + esac + done + + if [[ "${SKIP_VENV_SETUP}" == "false" ]]; then + if ! command -v uv &>/dev/null; then + err "uv is required but was not found on PATH." + fi + uv sync --directory "${SKILL_ROOT}" + fi + + local python + python="$(get_venv_python_path)" + + "${python}" "${SCRIPT_DIR}/export_svg.py" "${pass_through[@]}" +} + +main "$@" diff --git a/.github/skills/experimental/powerpoint/scripts/generate-themes.sh b/.github/skills/experimental/powerpoint/scripts/generate-themes.sh new file mode 100755 index 000000000..22a2d0ee8 --- /dev/null +++ b/.github/skills/experimental/powerpoint/scripts/generate-themes.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT +# +# generate-themes.sh +# Generate themed content directory variants from a base deck. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_ROOT="$(dirname "${SCRIPT_DIR}")" +VENV_DIR="${SKILL_ROOT}/.venv" + +SKIP_VENV_SETUP=false + +err() { + printf "ERROR: %s\n" "$1" >&2 + exit 1 +} + +usage() { + cat < Path to base theme content directory (required) + --themes Path to themes YAML file (required) + --output-dir Parent directory for themed outputs (required) + --skip-venv-setup Skip virtual environment setup + -v, --verbose Enable verbose output + -h, --help Show this help message +EOF + exit 0 +} + +get_venv_python_path() { + if [[ -f "${VENV_DIR}/Scripts/python.exe" ]]; then + echo "${VENV_DIR}/Scripts/python.exe" + elif [[ -f "${VENV_DIR}/bin/python" ]]; then + echo "${VENV_DIR}/bin/python" + else + err "Python interpreter not found in venv. Run: uv sync --directory \"${SKILL_ROOT}\"" + fi +} + +main() { + local -a pass_through=() + + while (( $# > 0 )); do + case "$1" in + --skip-venv-setup) SKIP_VENV_SETUP=true; shift ;; + -h|--help) usage ;; + *) pass_through+=("$1"); shift ;; + esac + done + + if [[ "${SKIP_VENV_SETUP}" == "false" ]]; then + if ! command -v uv &>/dev/null; then + err "uv is required but was not found on PATH." + fi + uv sync --directory "${SKILL_ROOT}" + fi + + local python + python="$(get_venv_python_path)" + + "${python}" "${SCRIPT_DIR}/generate_themes.py" "${pass_through[@]}" +} + +main "$@" diff --git a/.github/skills/experimental/powerpoint/scripts/generate_themes.py b/.github/skills/experimental/powerpoint/scripts/generate_themes.py index f3fcb4a29..717ebcacc 100644 --- a/.github/skills/experimental/powerpoint/scripts/generate_themes.py +++ b/.github/skills/experimental/powerpoint/scripts/generate_themes.py @@ -88,6 +88,11 @@ def remap_hex_in_text(text: str, color_map: dict[str, str]) -> str: the prefix is stripped before matching. Matching is case-insensitive. """ bare_map = {k.lstrip("#").lower(): v.lstrip("#") for k, v in color_map.items()} + invalid = {k: v for k, v in bare_map.items() if len(k) != 6 or len(v) != 6} + if invalid: + raise ValueError( + f"Color map entries must be 6-character hex strings; invalid: {invalid}" + ) if not bare_map: return text pattern = re.compile( @@ -111,6 +116,12 @@ def remap_rgb_in_python(text: str, color_map: dict[str, str]) -> str: old_bare = old_hex.lstrip("#").upper() bare_map[old_bare] = new_hex.lstrip("#").upper() + invalid = {k: v for k, v in bare_map.items() if len(k) != 6 or len(v) != 6} + if invalid: + raise ValueError( + f"Color map entries must be 6-character hex strings; invalid: {invalid}" + ) + if not bare_map: return text From d85988d9a72cd6f96488a5719f4cb0ff37559b73 Mon Sep 17 00:00:00 2001 From: auyidi Date: Fri, 1 May 2026 20:45:40 +0000 Subject: [PATCH 13/22] fix(skills): add CmdletBinding and explicit Mandatory to PS1 wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐛 - Generated by Copilot --- .../experimental/powerpoint/scripts/Invoke-EmbedAudio.ps1 | 7 ++++--- .../experimental/powerpoint/scripts/Invoke-ExportSvg.ps1 | 5 +++-- .../powerpoint/scripts/Invoke-GenerateThemes.ps1 | 7 ++++--- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/skills/experimental/powerpoint/scripts/Invoke-EmbedAudio.ps1 b/.github/skills/experimental/powerpoint/scripts/Invoke-EmbedAudio.ps1 index 30aad94e5..b86bee7f2 100644 --- a/.github/skills/experimental/powerpoint/scripts/Invoke-EmbedAudio.ps1 +++ b/.github/skills/experimental/powerpoint/scripts/Invoke-EmbedAudio.ps1 @@ -33,10 +33,11 @@ ./Invoke-EmbedAudio.ps1 -InputPath deck.pptx -AudioDir voice-over/ -OutputPath out.pptx #> +[CmdletBinding()] param( - [Parameter(Mandatory)][string]$InputPath, - [Parameter(Mandatory)][string]$AudioDir, - [Parameter(Mandatory)][string]$OutputPath, + [Parameter(Mandatory = $true)][string]$InputPath, + [Parameter(Mandatory = $true)][string]$AudioDir, + [Parameter(Mandatory = $true)][string]$OutputPath, [string]$Slides, [switch]$SkipVenvSetup ) diff --git a/.github/skills/experimental/powerpoint/scripts/Invoke-ExportSvg.ps1 b/.github/skills/experimental/powerpoint/scripts/Invoke-ExportSvg.ps1 index 8ae3efff4..9b64aa84d 100644 --- a/.github/skills/experimental/powerpoint/scripts/Invoke-ExportSvg.ps1 +++ b/.github/skills/experimental/powerpoint/scripts/Invoke-ExportSvg.ps1 @@ -30,9 +30,10 @@ ./Invoke-ExportSvg.ps1 -InputPath deck.pptx -OutputDir svg/ #> +[CmdletBinding()] param( - [Parameter(Mandatory)][string]$InputPath, - [Parameter(Mandatory)][string]$OutputDir, + [Parameter(Mandatory = $true)][string]$InputPath, + [Parameter(Mandatory = $true)][string]$OutputDir, [string]$Slides, [switch]$SkipVenvSetup ) diff --git a/.github/skills/experimental/powerpoint/scripts/Invoke-GenerateThemes.ps1 b/.github/skills/experimental/powerpoint/scripts/Invoke-GenerateThemes.ps1 index 4ba291a26..2a3f74186 100644 --- a/.github/skills/experimental/powerpoint/scripts/Invoke-GenerateThemes.ps1 +++ b/.github/skills/experimental/powerpoint/scripts/Invoke-GenerateThemes.ps1 @@ -30,10 +30,11 @@ ./Invoke-GenerateThemes.ps1 -ContentDir content/ -ThemesPath themes.yaml -OutputDir ../ #> +[CmdletBinding()] param( - [Parameter(Mandatory)][string]$ContentDir, - [Parameter(Mandatory)][string]$ThemesPath, - [Parameter(Mandatory)][string]$OutputDir, + [Parameter(Mandatory = $true)][string]$ContentDir, + [Parameter(Mandatory = $true)][string]$ThemesPath, + [Parameter(Mandatory = $true)][string]$OutputDir, [switch]$SkipVenvSetup ) From f47ee0ee0ceb3ee7f4144d8ad4d2c7289570b70c Mon Sep 17 00:00:00 2001 From: auyidi Date: Fri, 1 May 2026 21:48:19 +0000 Subject: [PATCH 14/22] fix(skills): address ninth round PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - renumber pipeline steps sequentially (1,2,3,4) in both bash and PS1 - propagate verbose flag to validate_geometry in both pipelines - escape pipe characters in markdown report table to prevent corruption - replace defensive getattr with direct args.verbose in build_deck and validate_geometry - switch generate_seeds.py from os.path to pathlib 🐛 - Generated by Copilot --- .../powerpoint/scripts/Invoke-PptxPipeline.ps1 | 13 ++++++++----- .../experimental/powerpoint/scripts/build_deck.py | 2 +- .../powerpoint/scripts/invoke-pptx-pipeline.sh | 13 +++++++------ .../powerpoint/scripts/validate_geometry.py | 6 +++--- .../powerpoint/tests/corpus/generate_seeds.py | 10 +++++----- 5 files changed, 24 insertions(+), 20 deletions(-) diff --git a/.github/skills/experimental/powerpoint/scripts/Invoke-PptxPipeline.ps1 b/.github/skills/experimental/powerpoint/scripts/Invoke-PptxPipeline.ps1 index 23b0525fb..1bfe4b04f 100644 --- a/.github/skills/experimental/powerpoint/scripts/Invoke-PptxPipeline.ps1 +++ b/.github/skills/experimental/powerpoint/scripts/Invoke-PptxPipeline.ps1 @@ -386,7 +386,7 @@ function Invoke-ValidateDeck { $python = Get-VenvPythonPath $hasVisionPrompt = $ValidationPrompt -or $ValidationPromptFile - $totalSteps = if ($hasVisionPrompt) { 3 } else { 2 } + $totalSteps = if ($hasVisionPrompt) { 5 } else { 4 } # Default image output directory when not specified if (-not $ImageOutputDir) { @@ -429,8 +429,8 @@ function Invoke-ValidateDeck { Write-Host "PPTX property checks found warnings — see $deckReportPath" } - # Step 2b: Run geometric validation (margin, gap, overflow checks) - Write-Host "Step 2b/$totalSteps`: Running geometric validation..." + # Step 3: Run geometric validation (margin, gap, overflow checks) + Write-Host "Step 3/$totalSteps`: Running geometric validation..." $geomScript = Join-Path $ScriptDir 'validate_geometry.py' $geomArgs = @( $geomScript, @@ -448,6 +448,9 @@ function Invoke-ValidateDeck { $geomArgs += $geomReportPath $geomArgs += '--per-slide-dir' $geomArgs += $ImageOutputDir + if ($VerbosePreference -eq 'Continue') { + $geomArgs += '-v' + } & $python @geomArgs if ($LASTEXITCODE -eq 2) { @@ -457,9 +460,9 @@ function Invoke-ValidateDeck { Write-Host "Geometric validation found warnings — see $geomReportPath" } - # Step 3: Run Copilot SDK vision validation (when prompt provided) + # Step 4: Run Copilot SDK vision validation (when prompt provided) if ($hasVisionPrompt) { - Write-Host "Step 3/$totalSteps`: Running Copilot SDK vision validation..." + Write-Host "Step 4/$totalSteps`: Running Copilot SDK vision validation..." $visionScript = Join-Path $ScriptDir 'validate_slides.py' $visionArgs = @( $visionScript, diff --git a/.github/skills/experimental/powerpoint/scripts/build_deck.py b/.github/skills/experimental/powerpoint/scripts/build_deck.py index 24442f571..40560125b 100644 --- a/.github/skills/experimental/powerpoint/scripts/build_deck.py +++ b/.github/skills/experimental/powerpoint/scripts/build_deck.py @@ -1137,7 +1137,7 @@ def main(): help="Enable verbose logging output", ) args = parser.parse_args() - configure_logging(getattr(args, "verbose", False)) + configure_logging(args.verbose) content_dir = Path(args.content_dir) style = load_yaml(Path(args.style)) diff --git a/.github/skills/experimental/powerpoint/scripts/invoke-pptx-pipeline.sh b/.github/skills/experimental/powerpoint/scripts/invoke-pptx-pipeline.sh index 9669d212d..8fd1d3e5e 100755 --- a/.github/skills/experimental/powerpoint/scripts/invoke-pptx-pipeline.sh +++ b/.github/skills/experimental/powerpoint/scripts/invoke-pptx-pipeline.sh @@ -244,8 +244,8 @@ invoke_validate_deck() { local has_vision_prompt=false [[ -n "${VALIDATION_PROMPT:-}" || -n "${VALIDATION_PROMPT_FILE:-}" ]] && has_vision_prompt=true - local total_steps=3 - ${has_vision_prompt} && total_steps=4 + local total_steps=4 + ${has_vision_prompt} && total_steps=5 # Default image output directory if [[ -z "${IMAGE_OUTPUT_DIR:-}" ]]; then @@ -281,8 +281,8 @@ invoke_validate_deck() { echo "PPTX property checks found warnings — see ${deck_report}" fi - # Step 2b: Run geometric validation (margin, gap, overflow checks) - echo "Step 2b/${total_steps}: Running geometric validation..." + # Step 3: Run geometric validation (margin, gap, overflow checks) + echo "Step 3/${total_steps}: Running geometric validation..." local geom_output="${IMAGE_OUTPUT_DIR}/geometry-validation-results.json" local geom_report="${IMAGE_OUTPUT_DIR}/geometry-validation-report.md" local -a geom_args=( @@ -293,6 +293,7 @@ invoke_validate_deck() { "--per-slide-dir" "${IMAGE_OUTPUT_DIR}" ) [[ -n "${SLIDES:-}" ]] && geom_args+=("--slides" "${SLIDES}") + [[ "${VERBOSE:-false}" == "true" ]] && geom_args+=("-v") local geom_exit=0 "${python}" "${geom_args[@]}" || geom_exit=$? @@ -303,9 +304,9 @@ invoke_validate_deck() { echo "Geometric validation found warnings — see ${geom_report}" fi - # Step 3: Vision validation (when prompt provided) + # Step 4: Vision validation (when prompt provided) if ${has_vision_prompt}; then - echo "Step 3/${total_steps}: Running Copilot SDK vision validation..." + echo "Step 4/${total_steps}: Running Copilot SDK vision validation..." local vision_script="${SCRIPT_DIR}/validate_slides.py" local -a vision_args=( "${vision_script}" diff --git a/.github/skills/experimental/powerpoint/scripts/validate_geometry.py b/.github/skills/experimental/powerpoint/scripts/validate_geometry.py index d543d7a1f..f6a1dc71a 100644 --- a/.github/skills/experimental/powerpoint/scripts/validate_geometry.py +++ b/.github/skills/experimental/powerpoint/scripts/validate_geometry.py @@ -411,8 +411,8 @@ def generate_report(results: dict) -> str: sev = issue.get("severity", "info") sev_icon = SEVERITY_ICON.get(sev, "") check = issue.get("check_type", "") - loc = issue.get("location", "") - desc = issue.get("description", "") + loc = issue.get("location", "").replace("|", "\\|") + desc = issue.get("description", "").replace("|", "\\|") lines.append(f"| {sev_icon} {sev} | {check} | {loc} | {desc} |") lines.append("") @@ -498,7 +498,7 @@ def main() -> int: """Main entry point.""" parser = create_parser() args = parser.parse_args() - configure_logging(getattr(args, "verbose", False)) + configure_logging(args.verbose) pptx_path = args.input if not pptx_path.exists(): diff --git a/.github/skills/experimental/powerpoint/tests/corpus/generate_seeds.py b/.github/skills/experimental/powerpoint/tests/corpus/generate_seeds.py index 7b9d92fc0..d3df803bb 100644 --- a/.github/skills/experimental/powerpoint/tests/corpus/generate_seeds.py +++ b/.github/skills/experimental/powerpoint/tests/corpus/generate_seeds.py @@ -2,15 +2,15 @@ # SPDX-License-Identifier: MIT """Generate corpus seeds for fuzz targets.""" -import os +from pathlib import Path -CORPUS_DIR = os.path.dirname(__file__) +CORPUS_DIR = Path(__file__).parent -def write_seed(name, data: bytes): +def write_seed(name: str, data: bytes) -> None: """Write raw bytes to corpus file.""" - with open(os.path.join(CORPUS_DIR, name), "wb") as f: - f.write(data) + path = CORPUS_DIR / name + path.write_bytes(data) print(f"Created: {name} ({len(data)} bytes)") From faba1e7898f9473b66b5bf3dc7f48bae06b6d0fe Mon Sep 17 00:00:00 2001 From: auyidi Date: Fri, 1 May 2026 22:34:44 +0000 Subject: [PATCH 15/22] fix(skills): address PR Review bot findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - propagate Python exit code via exit $LASTEXITCODE in all 3 PS1 wrappers - add 300s timeout to LibreOffice subprocess.run with TimeoutExpired handling - validate color map keys and values are strings in load_themes 🐛 - Generated by Copilot --- .../experimental/powerpoint/scripts/Invoke-EmbedAudio.ps1 | 1 + .../experimental/powerpoint/scripts/Invoke-ExportSvg.ps1 | 1 + .../powerpoint/scripts/Invoke-GenerateThemes.ps1 | 1 + .../skills/experimental/powerpoint/scripts/export_svg.py | 5 +++++ .../experimental/powerpoint/scripts/generate_themes.py | 6 ++++++ 5 files changed, 14 insertions(+) diff --git a/.github/skills/experimental/powerpoint/scripts/Invoke-EmbedAudio.ps1 b/.github/skills/experimental/powerpoint/scripts/Invoke-EmbedAudio.ps1 index b86bee7f2..f8fd285b4 100644 --- a/.github/skills/experimental/powerpoint/scripts/Invoke-EmbedAudio.ps1 +++ b/.github/skills/experimental/powerpoint/scripts/Invoke-EmbedAudio.ps1 @@ -69,3 +69,4 @@ if ($Slides) { $args_ += '--slides'; $args_ += $Slides } if ($VerbosePreference -eq 'Continue') { $args_ += '-v' } & $python @args_ +exit $LASTEXITCODE diff --git a/.github/skills/experimental/powerpoint/scripts/Invoke-ExportSvg.ps1 b/.github/skills/experimental/powerpoint/scripts/Invoke-ExportSvg.ps1 index 9b64aa84d..3ca13520b 100644 --- a/.github/skills/experimental/powerpoint/scripts/Invoke-ExportSvg.ps1 +++ b/.github/skills/experimental/powerpoint/scripts/Invoke-ExportSvg.ps1 @@ -65,3 +65,4 @@ if ($Slides) { $args_ += '--slides'; $args_ += $Slides } if ($VerbosePreference -eq 'Continue') { $args_ += '-v' } & $python @args_ +exit $LASTEXITCODE diff --git a/.github/skills/experimental/powerpoint/scripts/Invoke-GenerateThemes.ps1 b/.github/skills/experimental/powerpoint/scripts/Invoke-GenerateThemes.ps1 index 2a3f74186..611717ddb 100644 --- a/.github/skills/experimental/powerpoint/scripts/Invoke-GenerateThemes.ps1 +++ b/.github/skills/experimental/powerpoint/scripts/Invoke-GenerateThemes.ps1 @@ -64,3 +64,4 @@ $args_ = @($script, '--content-dir', $ContentDir, '--themes', $ThemesPath, '--ou if ($VerbosePreference -eq 'Continue') { $args_ += '-v' } & $python @args_ +exit $LASTEXITCODE diff --git a/.github/skills/experimental/powerpoint/scripts/export_svg.py b/.github/skills/experimental/powerpoint/scripts/export_svg.py index d618f511e..9e0b96715 100644 --- a/.github/skills/experimental/powerpoint/scripts/export_svg.py +++ b/.github/skills/experimental/powerpoint/scripts/export_svg.py @@ -127,8 +127,13 @@ def convert_pptx_to_pdf(pptx_path: Path, output_dir: Path) -> Path: capture_output=True, text=True, check=True, + timeout=300, ) logger.debug("LibreOffice stdout: %s", result.stdout) + except subprocess.TimeoutExpired as e: + raise LibreOfficeError( + f"LibreOffice conversion timed out after {e.timeout}s" + ) from e except subprocess.CalledProcessError as e: raise LibreOfficeError(f"LibreOffice conversion failed: {e.stderr}") from e except FileNotFoundError as e: diff --git a/.github/skills/experimental/powerpoint/scripts/generate_themes.py b/.github/skills/experimental/powerpoint/scripts/generate_themes.py index 717ebcacc..295a54167 100644 --- a/.github/skills/experimental/powerpoint/scripts/generate_themes.py +++ b/.github/skills/experimental/powerpoint/scripts/generate_themes.py @@ -74,6 +74,12 @@ def load_themes(themes_path: Path) -> dict[str, Any]: for theme_id, cfg in themes.items(): if "colors" not in cfg or not isinstance(cfg["colors"], dict): raise ValueError(f"Theme '{theme_id}' must contain a 'colors' mapping") + for k, v in cfg["colors"].items(): + if not isinstance(k, str) or not isinstance(v, str): + raise ValueError( + f"Theme '{theme_id}' color map keys and values must be " + f"strings; got {k!r}: {v!r}" + ) return themes From c5ae6d34d42b7d46dadfa147cdc57ddd2e04da65 Mon Sep 17 00:00:00 2001 From: auyidi Date: Fri, 1 May 2026 23:29:28 +0000 Subject: [PATCH 16/22] fix(skills): correct step counts and extract run() in validate_geometry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix total_steps to 3 (no vision) / 4 (with vision) in both pipelines - extract run() + try/except guard in validate_geometry.py main() 🐛 - Generated by Copilot --- .../scripts/Invoke-PptxPipeline.ps1 | 2 +- .../scripts/invoke-pptx-pipeline.sh | 4 ++-- .../powerpoint/scripts/validate_geometry.py | 22 ++++++++++++++----- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/.github/skills/experimental/powerpoint/scripts/Invoke-PptxPipeline.ps1 b/.github/skills/experimental/powerpoint/scripts/Invoke-PptxPipeline.ps1 index 1bfe4b04f..e030553f1 100644 --- a/.github/skills/experimental/powerpoint/scripts/Invoke-PptxPipeline.ps1 +++ b/.github/skills/experimental/powerpoint/scripts/Invoke-PptxPipeline.ps1 @@ -386,7 +386,7 @@ function Invoke-ValidateDeck { $python = Get-VenvPythonPath $hasVisionPrompt = $ValidationPrompt -or $ValidationPromptFile - $totalSteps = if ($hasVisionPrompt) { 5 } else { 4 } + $totalSteps = if ($hasVisionPrompt) { 4 } else { 3 } # Default image output directory when not specified if (-not $ImageOutputDir) { diff --git a/.github/skills/experimental/powerpoint/scripts/invoke-pptx-pipeline.sh b/.github/skills/experimental/powerpoint/scripts/invoke-pptx-pipeline.sh index 8fd1d3e5e..ff61bb257 100755 --- a/.github/skills/experimental/powerpoint/scripts/invoke-pptx-pipeline.sh +++ b/.github/skills/experimental/powerpoint/scripts/invoke-pptx-pipeline.sh @@ -244,8 +244,8 @@ invoke_validate_deck() { local has_vision_prompt=false [[ -n "${VALIDATION_PROMPT:-}" || -n "${VALIDATION_PROMPT_FILE:-}" ]] && has_vision_prompt=true - local total_steps=4 - ${has_vision_prompt} && total_steps=5 + local total_steps=3 + ${has_vision_prompt} && total_steps=4 # Default image output directory if [[ -z "${IMAGE_OUTPUT_DIR:-}" ]]; then diff --git a/.github/skills/experimental/powerpoint/scripts/validate_geometry.py b/.github/skills/experimental/powerpoint/scripts/validate_geometry.py index f6a1dc71a..f6eba22b4 100644 --- a/.github/skills/experimental/powerpoint/scripts/validate_geometry.py +++ b/.github/skills/experimental/powerpoint/scripts/validate_geometry.py @@ -494,12 +494,8 @@ def create_parser() -> argparse.ArgumentParser: return parser -def main() -> int: - """Main entry point.""" - parser = create_parser() - args = parser.parse_args() - configure_logging(args.verbose) - +def run(args: argparse.Namespace) -> int: + """Execute geometry validation logic.""" pptx_path = args.input if not pptx_path.exists(): logger.error("File not found: %s", pptx_path) @@ -559,5 +555,19 @@ def main() -> int: return EXIT_SUCCESS +def main() -> int: + """Main entry point.""" + parser = create_parser() + args = parser.parse_args() + configure_logging(args.verbose) + try: + return run(args) + except KeyboardInterrupt: + return 130 + except BrokenPipeError: + sys.stderr.close() + return EXIT_FAILURE + + if __name__ == "__main__": sys.exit(main()) From 6e54a0b76f06676cc5bb0df361b8bfaa1663ee3c Mon Sep 17 00:00:00 2001 From: auyidi Date: Fri, 1 May 2026 23:52:36 +0000 Subject: [PATCH 17/22] fix(skills): support single-quoted hex and fix theme docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - extend remap_rgb_in_python to match '#RRGGBB' single-quoted literals - correct pptx.instructions.md to describe per-slide theme color lookup 🐛 - Generated by Copilot --- .../experimental/pptx.instructions.md | 2 +- .../powerpoint/scripts/generate_themes.py | 21 +++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/instructions/experimental/pptx.instructions.md b/.github/instructions/experimental/pptx.instructions.md index e76389476..e06a365c5 100644 --- a/.github/instructions/experimental/pptx.instructions.md +++ b/.github/instructions/experimental/pptx.instructions.md @@ -120,7 +120,7 @@ Use `#RRGGBB` hex values or `@theme_name` references for all colors. See the Col ### Theme Colors in content-extra.py -When `style.yaml` defines a `themes` section, the build script populates `style["colors"]` with the first theme's color map. Use `style.get("colors", {}).get("accent_blue", "#0078D4")` in `content-extra.py` to reference theme-aware colors. This enables theme portability. The same script produces correct colors across all theme variants without regex replacement. +When `style.yaml` defines a `themes` section, the build script populates `style[\"colors\"]` with the color map for the theme assigned to each slide via `themes[].slides`. Slides not explicitly assigned fall back to the first theme in the list. Use `style.get(\"colors\", {}).get(\"accent_blue\", \"#0078D4\")` in `content-extra.py` to reference theme-aware colors. This enables theme portability. The same script produces correct colors across all theme variants without regex replacement. ## Contextual Styling diff --git a/.github/skills/experimental/powerpoint/scripts/generate_themes.py b/.github/skills/experimental/powerpoint/scripts/generate_themes.py index 295a54167..f1eb3d7bb 100644 --- a/.github/skills/experimental/powerpoint/scripts/generate_themes.py +++ b/.github/skills/experimental/powerpoint/scripts/generate_themes.py @@ -109,7 +109,8 @@ def remap_hex_in_text(text: str, color_map: dict[str, str]) -> str: def remap_rgb_in_python(text: str, color_map: dict[str, str]) -> str: - """Replace ``RGBColor(0xRR, 0xGG, 0xBB)`` and ``"#RRGGBB"`` patterns. + """Replace ``RGBColor(0xRR, 0xGG, 0xBB)``, ``"#RRGGBB"``, and + ``'#RRGGBB'`` patterns. Uses a single-pass regex callback to avoid chain remapping where one substitution's output feeds the next. @@ -137,20 +138,26 @@ def _rgb_pattern(hex6: str) -> str: b = int(hex6[4:6], 16) return rf"RGBColor\(\s*0x{r:02X}\s*,\s*0x{g:02X}\s*,\s*0x{b:02X}\s*\)" - def _hex_pattern(hex6: str) -> str: + def _hex_pattern_double(hex6: str) -> str: return rf'"#{re.escape(hex6)}"' - # Build combined pattern matching all RGBColor(...) and "#RRGGBB" forms + def _hex_pattern_single(hex6: str) -> str: + return rf"'#{re.escape(hex6)}'" + + # Build combined pattern matching RGBColor(...), "#RRGGBB", and '#RRGGBB' rgb_parts = [f"({_rgb_pattern(k)})" for k in bare_map] - hex_parts = [f"({_hex_pattern(k)})" for k in bare_map] - combined = re.compile("|".join(rgb_parts + hex_parts), re.IGNORECASE) + hex_dbl_parts = [f"({_hex_pattern_double(k)})" for k in bare_map] + hex_sgl_parts = [f"({_hex_pattern_single(k)})" for k in bare_map] + combined = re.compile( + "|".join(rgb_parts + hex_dbl_parts + hex_sgl_parts), re.IGNORECASE + ) keys = list(bare_map.keys()) n = len(keys) def _replace(m: re.Match) -> str: for i, k in enumerate(keys): - # Groups 1..n are RGBColor patterns, n+1..2n are hex patterns + # Groups 1..n are RGBColor, n+1..2n double-quoted, 2n+1..3n single-quoted if m.group(i + 1) is not None: v = bare_map[k] r = int(v[0:2], 16) @@ -159,6 +166,8 @@ def _replace(m: re.Match) -> str: return f"RGBColor(0x{r:02X}, 0x{g:02X}, 0x{b:02X})" if m.group(n + i + 1) is not None: return f'"#{bare_map[k]}"' + if m.group(2 * n + i + 1) is not None: + return f"'#{bare_map[k]}'" return m.group(0) return combined.sub(_replace, text) From 2daa49c0c08dd71914531ec7f9efba989d174b76 Mon Sep 17 00:00:00 2001 From: auyidi Date: Sat, 2 May 2026 00:23:27 +0000 Subject: [PATCH 18/22] fix(skills): add Exception handler in validate_geometry and use PascalCase ScriptArgs - Add catch-all except Exception handler in validate_geometry.py main() to match sibling scripts (embed_audio, export_svg, generate_themes) so corrupt PPTX errors surface as clean log messages instead of unhandled tracebacks. - Rename $args_ to $ScriptArgs in Invoke-EmbedAudio.ps1, Invoke-ExportSvg.ps1, and Invoke-GenerateThemes.ps1 to comply with PowerShell PascalCase convention. Generated by Copilot --- .../experimental/powerpoint/scripts/Invoke-EmbedAudio.ps1 | 8 ++++---- .../experimental/powerpoint/scripts/Invoke-ExportSvg.ps1 | 8 ++++---- .../powerpoint/scripts/Invoke-GenerateThemes.ps1 | 6 +++--- .../experimental/powerpoint/scripts/validate_geometry.py | 3 +++ 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/.github/skills/experimental/powerpoint/scripts/Invoke-EmbedAudio.ps1 b/.github/skills/experimental/powerpoint/scripts/Invoke-EmbedAudio.ps1 index f8fd285b4..def93b51a 100644 --- a/.github/skills/experimental/powerpoint/scripts/Invoke-EmbedAudio.ps1 +++ b/.github/skills/experimental/powerpoint/scripts/Invoke-EmbedAudio.ps1 @@ -64,9 +64,9 @@ $python = if (Test-Path (Join-Path $VenvDir 'Scripts/python.exe')) { } $script = Join-Path $ScriptDir 'embed_audio.py' -$args_ = @($script, '--input', $InputPath, '--audio-dir', $AudioDir, '--output', $OutputPath) -if ($Slides) { $args_ += '--slides'; $args_ += $Slides } -if ($VerbosePreference -eq 'Continue') { $args_ += '-v' } +$ScriptArgs = @($script, '--input', $InputPath, '--audio-dir', $AudioDir, '--output', $OutputPath) +if ($Slides) { $ScriptArgs += '--slides'; $ScriptArgs += $Slides } +if ($VerbosePreference -eq 'Continue') { $ScriptArgs += '-v' } -& $python @args_ +& $python @ScriptArgs exit $LASTEXITCODE diff --git a/.github/skills/experimental/powerpoint/scripts/Invoke-ExportSvg.ps1 b/.github/skills/experimental/powerpoint/scripts/Invoke-ExportSvg.ps1 index 3ca13520b..991a263a4 100644 --- a/.github/skills/experimental/powerpoint/scripts/Invoke-ExportSvg.ps1 +++ b/.github/skills/experimental/powerpoint/scripts/Invoke-ExportSvg.ps1 @@ -60,9 +60,9 @@ $python = if (Test-Path (Join-Path $VenvDir 'Scripts/python.exe')) { } $script = Join-Path $ScriptDir 'export_svg.py' -$args_ = @($script, '--input', $InputPath, '--output-dir', $OutputDir) -if ($Slides) { $args_ += '--slides'; $args_ += $Slides } -if ($VerbosePreference -eq 'Continue') { $args_ += '-v' } +$ScriptArgs = @($script, '--input', $InputPath, '--output-dir', $OutputDir) +if ($Slides) { $ScriptArgs += '--slides'; $ScriptArgs += $Slides } +if ($VerbosePreference -eq 'Continue') { $ScriptArgs += '-v' } -& $python @args_ +& $python @ScriptArgs exit $LASTEXITCODE diff --git a/.github/skills/experimental/powerpoint/scripts/Invoke-GenerateThemes.ps1 b/.github/skills/experimental/powerpoint/scripts/Invoke-GenerateThemes.ps1 index 611717ddb..6a1c93e66 100644 --- a/.github/skills/experimental/powerpoint/scripts/Invoke-GenerateThemes.ps1 +++ b/.github/skills/experimental/powerpoint/scripts/Invoke-GenerateThemes.ps1 @@ -60,8 +60,8 @@ $python = if (Test-Path (Join-Path $VenvDir 'Scripts/python.exe')) { } $script = Join-Path $ScriptDir 'generate_themes.py' -$args_ = @($script, '--content-dir', $ContentDir, '--themes', $ThemesPath, '--output-dir', $OutputDir) -if ($VerbosePreference -eq 'Continue') { $args_ += '-v' } +$ScriptArgs = @($script, '--content-dir', $ContentDir, '--themes', $ThemesPath, '--output-dir', $OutputDir) +if ($VerbosePreference -eq 'Continue') { $ScriptArgs += '-v' } -& $python @args_ +& $python @ScriptArgs exit $LASTEXITCODE diff --git a/.github/skills/experimental/powerpoint/scripts/validate_geometry.py b/.github/skills/experimental/powerpoint/scripts/validate_geometry.py index f6eba22b4..0a8acc7fb 100644 --- a/.github/skills/experimental/powerpoint/scripts/validate_geometry.py +++ b/.github/skills/experimental/powerpoint/scripts/validate_geometry.py @@ -567,6 +567,9 @@ def main() -> int: except BrokenPipeError: sys.stderr.close() return EXIT_FAILURE + except Exception as e: + logger.error("Unexpected error: %s", e) + return EXIT_FAILURE if __name__ == "__main__": From 530665e0f2650ffb0fcb01ded30ef9d28ceaa533 Mon Sep 17 00:00:00 2001 From: auyidi Date: Mon, 4 May 2026 18:01:36 +0000 Subject: [PATCH 19/22] fix(skills): add .pptx extension check and PS1 convention fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * validate_geometry.py: validate .pptx extension before Presentation() * Invoke-EmbedAudio.ps1: add .NOTES, #region blocks, main execution guard * Invoke-ExportSvg.ps1: add .NOTES, #region blocks, main execution guard * Invoke-GenerateThemes.ps1: add .NOTES, #region blocks, main execution guard 🔧 - Generated by Copilot --- .../powerpoint/scripts/Invoke-EmbedAudio.ps1 | 52 ++++++++++++------- .../powerpoint/scripts/Invoke-ExportSvg.ps1 | 52 ++++++++++++------- .../scripts/Invoke-GenerateThemes.ps1 | 50 ++++++++++++------ .../powerpoint/scripts/validate_geometry.py | 4 ++ 4 files changed, 105 insertions(+), 53 deletions(-) diff --git a/.github/skills/experimental/powerpoint/scripts/Invoke-EmbedAudio.ps1 b/.github/skills/experimental/powerpoint/scripts/Invoke-EmbedAudio.ps1 index def93b51a..7eedc4532 100644 --- a/.github/skills/experimental/powerpoint/scripts/Invoke-EmbedAudio.ps1 +++ b/.github/skills/experimental/powerpoint/scripts/Invoke-EmbedAudio.ps1 @@ -31,6 +31,10 @@ .EXAMPLE ./Invoke-EmbedAudio.ps1 -InputPath deck.pptx -AudioDir voice-over/ -OutputPath out.pptx + +.NOTES + Part of the powerpoint skill. Manages uv virtual environment setup + and delegates to embed_audio.py for WAV embedding into PPTX slides. #> [CmdletBinding()] @@ -44,29 +48,41 @@ param( $ErrorActionPreference = 'Stop' +#region Environment Setup + $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $SkillRoot = Split-Path -Parent $ScriptDir $VenvDir = Join-Path $SkillRoot '.venv' -if (-not $SkipVenvSetup) { - if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { - throw 'uv is required but was not found on PATH.' +#endregion Environment Setup + +#region Main + +if ($MyInvocation.InvocationName -ne '.') { + + if (-not $SkipVenvSetup) { + if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { + throw 'uv is required but was not found on PATH.' + } + uv sync --directory $SkillRoot } - uv sync --directory $SkillRoot -} -$python = if (Test-Path (Join-Path $VenvDir 'Scripts/python.exe')) { - Join-Path $VenvDir 'Scripts/python.exe' -} elseif (Test-Path (Join-Path $VenvDir 'bin/python')) { - Join-Path $VenvDir 'bin/python' -} else { - throw "Python interpreter not found in venv. Run: uv sync --directory `"$SkillRoot`"" -} + $python = if (Test-Path (Join-Path $VenvDir 'Scripts/python.exe')) { + Join-Path $VenvDir 'Scripts/python.exe' + } elseif (Test-Path (Join-Path $VenvDir 'bin/python')) { + Join-Path $VenvDir 'bin/python' + } else { + throw "Python interpreter not found in venv. Run: uv sync --directory `"$SkillRoot`"" + } + + $script = Join-Path $ScriptDir 'embed_audio.py' + $ScriptArgs = @($script, '--input', $InputPath, '--audio-dir', $AudioDir, '--output', $OutputPath) + if ($Slides) { $ScriptArgs += '--slides'; $ScriptArgs += $Slides } + if ($VerbosePreference -eq 'Continue') { $ScriptArgs += '-v' } -$script = Join-Path $ScriptDir 'embed_audio.py' -$ScriptArgs = @($script, '--input', $InputPath, '--audio-dir', $AudioDir, '--output', $OutputPath) -if ($Slides) { $ScriptArgs += '--slides'; $ScriptArgs += $Slides } -if ($VerbosePreference -eq 'Continue') { $ScriptArgs += '-v' } + & $python @ScriptArgs + exit $LASTEXITCODE + +} -& $python @ScriptArgs -exit $LASTEXITCODE +#endregion Main diff --git a/.github/skills/experimental/powerpoint/scripts/Invoke-ExportSvg.ps1 b/.github/skills/experimental/powerpoint/scripts/Invoke-ExportSvg.ps1 index 991a263a4..c0a51648e 100644 --- a/.github/skills/experimental/powerpoint/scripts/Invoke-ExportSvg.ps1 +++ b/.github/skills/experimental/powerpoint/scripts/Invoke-ExportSvg.ps1 @@ -28,6 +28,10 @@ .EXAMPLE ./Invoke-ExportSvg.ps1 -InputPath deck.pptx -OutputDir svg/ + +.NOTES + Part of the powerpoint skill. Manages uv virtual environment setup + and delegates to export_svg.py for PPTX-to-SVG conversion. #> [CmdletBinding()] @@ -40,29 +44,41 @@ param( $ErrorActionPreference = 'Stop' +#region Environment Setup + $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $SkillRoot = Split-Path -Parent $ScriptDir $VenvDir = Join-Path $SkillRoot '.venv' -if (-not $SkipVenvSetup) { - if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { - throw 'uv is required but was not found on PATH.' +#endregion Environment Setup + +#region Main + +if ($MyInvocation.InvocationName -ne '.') { + + if (-not $SkipVenvSetup) { + if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { + throw 'uv is required but was not found on PATH.' + } + uv sync --directory $SkillRoot } - uv sync --directory $SkillRoot -} -$python = if (Test-Path (Join-Path $VenvDir 'Scripts/python.exe')) { - Join-Path $VenvDir 'Scripts/python.exe' -} elseif (Test-Path (Join-Path $VenvDir 'bin/python')) { - Join-Path $VenvDir 'bin/python' -} else { - throw "Python interpreter not found in venv. Run: uv sync --directory `"$SkillRoot`"" -} + $python = if (Test-Path (Join-Path $VenvDir 'Scripts/python.exe')) { + Join-Path $VenvDir 'Scripts/python.exe' + } elseif (Test-Path (Join-Path $VenvDir 'bin/python')) { + Join-Path $VenvDir 'bin/python' + } else { + throw "Python interpreter not found in venv. Run: uv sync --directory `"$SkillRoot`"" + } + + $script = Join-Path $ScriptDir 'export_svg.py' + $ScriptArgs = @($script, '--input', $InputPath, '--output-dir', $OutputDir) + if ($Slides) { $ScriptArgs += '--slides'; $ScriptArgs += $Slides } + if ($VerbosePreference -eq 'Continue') { $ScriptArgs += '-v' } -$script = Join-Path $ScriptDir 'export_svg.py' -$ScriptArgs = @($script, '--input', $InputPath, '--output-dir', $OutputDir) -if ($Slides) { $ScriptArgs += '--slides'; $ScriptArgs += $Slides } -if ($VerbosePreference -eq 'Continue') { $ScriptArgs += '-v' } + & $python @ScriptArgs + exit $LASTEXITCODE + +} -& $python @ScriptArgs -exit $LASTEXITCODE +#endregion Main diff --git a/.github/skills/experimental/powerpoint/scripts/Invoke-GenerateThemes.ps1 b/.github/skills/experimental/powerpoint/scripts/Invoke-GenerateThemes.ps1 index 6a1c93e66..eda14f5e6 100644 --- a/.github/skills/experimental/powerpoint/scripts/Invoke-GenerateThemes.ps1 +++ b/.github/skills/experimental/powerpoint/scripts/Invoke-GenerateThemes.ps1 @@ -28,6 +28,10 @@ .EXAMPLE ./Invoke-GenerateThemes.ps1 -ContentDir content/ -ThemesPath themes.yaml -OutputDir ../ + +.NOTES + Part of the powerpoint skill. Manages uv virtual environment setup + and delegates to generate_themes.py for themed content generation. #> [CmdletBinding()] @@ -40,28 +44,40 @@ param( $ErrorActionPreference = 'Stop' +#region Environment Setup + $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $SkillRoot = Split-Path -Parent $ScriptDir $VenvDir = Join-Path $SkillRoot '.venv' -if (-not $SkipVenvSetup) { - if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { - throw 'uv is required but was not found on PATH.' +#endregion Environment Setup + +#region Main + +if ($MyInvocation.InvocationName -ne '.') { + + if (-not $SkipVenvSetup) { + if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { + throw 'uv is required but was not found on PATH.' + } + uv sync --directory $SkillRoot } - uv sync --directory $SkillRoot -} -$python = if (Test-Path (Join-Path $VenvDir 'Scripts/python.exe')) { - Join-Path $VenvDir 'Scripts/python.exe' -} elseif (Test-Path (Join-Path $VenvDir 'bin/python')) { - Join-Path $VenvDir 'bin/python' -} else { - throw "Python interpreter not found in venv. Run: uv sync --directory `"$SkillRoot`"" -} + $python = if (Test-Path (Join-Path $VenvDir 'Scripts/python.exe')) { + Join-Path $VenvDir 'Scripts/python.exe' + } elseif (Test-Path (Join-Path $VenvDir 'bin/python')) { + Join-Path $VenvDir 'bin/python' + } else { + throw "Python interpreter not found in venv. Run: uv sync --directory `"$SkillRoot`"" + } + + $script = Join-Path $ScriptDir 'generate_themes.py' + $ScriptArgs = @($script, '--content-dir', $ContentDir, '--themes', $ThemesPath, '--output-dir', $OutputDir) + if ($VerbosePreference -eq 'Continue') { $ScriptArgs += '-v' } -$script = Join-Path $ScriptDir 'generate_themes.py' -$ScriptArgs = @($script, '--content-dir', $ContentDir, '--themes', $ThemesPath, '--output-dir', $OutputDir) -if ($VerbosePreference -eq 'Continue') { $ScriptArgs += '-v' } + & $python @ScriptArgs + exit $LASTEXITCODE + +} -& $python @ScriptArgs -exit $LASTEXITCODE +#endregion Main diff --git a/.github/skills/experimental/powerpoint/scripts/validate_geometry.py b/.github/skills/experimental/powerpoint/scripts/validate_geometry.py index 0a8acc7fb..4505a665e 100644 --- a/.github/skills/experimental/powerpoint/scripts/validate_geometry.py +++ b/.github/skills/experimental/powerpoint/scripts/validate_geometry.py @@ -501,6 +501,10 @@ def run(args: argparse.Namespace) -> int: logger.error("File not found: %s", pptx_path) return EXIT_ERROR + if pptx_path.suffix.lower() != ".pptx": + logger.error("Input file must be a .pptx file: %s", pptx_path) + return EXIT_ERROR + slide_filter = parse_slide_filter(args.slides) logger.info("Validating geometry: %s", pptx_path) From 059796c2311c4ff78ffc9560f7d74980e4f8c712 Mon Sep 17 00:00:00 2001 From: auyidi Date: Tue, 5 May 2026 17:29:36 +0000 Subject: [PATCH 20/22] fix(skills): add future annotations to 4 Python scripts and document ruamel.yaml rationale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * embed_audio.py, export_svg.py, generate_themes.py, validate_geometry.py: add from __future__ import annotations * generate_themes.py: add comment explaining ruamel.yaml round-trip fidelity requirement 🔧 - Generated by Copilot --- .../skills/experimental/powerpoint/scripts/embed_audio.py | 2 ++ .../skills/experimental/powerpoint/scripts/export_svg.py | 2 ++ .../experimental/powerpoint/scripts/generate_themes.py | 7 +++++++ .../experimental/powerpoint/scripts/validate_geometry.py | 2 ++ 4 files changed, 13 insertions(+) diff --git a/.github/skills/experimental/powerpoint/scripts/embed_audio.py b/.github/skills/experimental/powerpoint/scripts/embed_audio.py index 1ecc48a73..de5622f97 100644 --- a/.github/skills/experimental/powerpoint/scripts/embed_audio.py +++ b/.github/skills/experimental/powerpoint/scripts/embed_audio.py @@ -17,6 +17,8 @@ --audio-dir voice-over/ --output out.pptx -v """ +from __future__ import annotations + import argparse import io import logging diff --git a/.github/skills/experimental/powerpoint/scripts/export_svg.py b/.github/skills/experimental/powerpoint/scripts/export_svg.py index 9e0b96715..a707294e6 100644 --- a/.github/skills/experimental/powerpoint/scripts/export_svg.py +++ b/.github/skills/experimental/powerpoint/scripts/export_svg.py @@ -12,6 +12,8 @@ python export_svg.py --input presentation.pptx --output-dir svg/ --slides 1,3,5 """ +from __future__ import annotations + import argparse import logging import platform diff --git a/.github/skills/experimental/powerpoint/scripts/generate_themes.py b/.github/skills/experimental/powerpoint/scripts/generate_themes.py index f1eb3d7bb..e1b35279c 100644 --- a/.github/skills/experimental/powerpoint/scripts/generate_themes.py +++ b/.github/skills/experimental/powerpoint/scripts/generate_themes.py @@ -10,9 +10,12 @@ Usage:: python generate_themes.py --content-dir content/ \ + --themes themes.yaml --output-dir ../ """ +from __future__ import annotations + import argparse import logging import re @@ -27,6 +30,10 @@ EXIT_SUCCESS, configure_logging, ) + +# ruamel.yaml is used intentionally for round-trip fidelity in +# update_style_metadata: preserves comments, key ordering, and quoting +# style when patching style.yaml files. pyyaml cannot preserve these. from ruamel.yaml import YAML logger = logging.getLogger(__name__) diff --git a/.github/skills/experimental/powerpoint/scripts/validate_geometry.py b/.github/skills/experimental/powerpoint/scripts/validate_geometry.py index 4505a665e..6e33bd1a7 100644 --- a/.github/skills/experimental/powerpoint/scripts/validate_geometry.py +++ b/.github/skills/experimental/powerpoint/scripts/validate_geometry.py @@ -16,6 +16,8 @@ --slides "1,3" --margin 0.6 --gap 0.4 """ +from __future__ import annotations + import argparse import json import logging From 6c7dce5e0c6a5becdb9139d1dc23b8e7b55722c4 Mon Sep 17 00:00:00 2001 From: auyidi Date: Tue, 5 May 2026 18:55:11 +0000 Subject: [PATCH 21/22] fix(skills): close fitz.Document via context manager and make --output optional in dry-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * export_svg.py: use 'with fitz.open() as doc' to close handle deterministically * build_deck.py: make --output optional; validate presence when not in --dry-run mode * SKILL.md: remove /dev/null from dry-run example since --output is no longer needed * test_export_svg.py: add __enter__/__exit__ to mock_doc for context manager compatibility 🔧 - Generated by Copilot --- .../skills/experimental/powerpoint/SKILL.md | 1 - .../powerpoint/scripts/build_deck.py | 8 +++- .../powerpoint/scripts/export_svg.py | 45 ++++++++++--------- .../powerpoint/tests/test_export_svg.py | 6 +++ 4 files changed, 37 insertions(+), 23 deletions(-) diff --git a/.github/skills/experimental/powerpoint/SKILL.md b/.github/skills/experimental/powerpoint/SKILL.md index e6ad8459e..686a9a3e4 100644 --- a/.github/skills/experimental/powerpoint/SKILL.md +++ b/.github/skills/experimental/powerpoint/SKILL.md @@ -410,7 +410,6 @@ python scripts/render_pdf_images.py \ python scripts/build_deck.py \ --content-dir content/ \ --style content/global/style.yaml \ - --output /dev/null \ --dry-run ``` diff --git a/.github/skills/experimental/powerpoint/scripts/build_deck.py b/.github/skills/experimental/powerpoint/scripts/build_deck.py index 40560125b..a59bc2de5 100644 --- a/.github/skills/experimental/powerpoint/scripts/build_deck.py +++ b/.github/skills/experimental/powerpoint/scripts/build_deck.py @@ -1111,7 +1111,9 @@ def main(): "--content-dir", required=True, help="Path to the content/ directory" ) parser.add_argument("--style", required=True, help="Path to the global style.yaml") - parser.add_argument("--output", required=True, help="Output PPTX file path") + parser.add_argument( + "--output", help="Output PPTX file path (required unless --dry-run)" + ) parser.add_argument("--template", help="Template PPTX file path for themed builds") parser.add_argument("--source", help="Source PPTX to update (for partial rebuilds)") parser.add_argument( @@ -1196,6 +1198,10 @@ def main(): ) return EXIT_FAILURE if errors else EXIT_SUCCESS + if not args.output: + logger.error("--output is required when not using --dry-run") + return EXIT_ERROR + output_path = Path(args.output) output_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/.github/skills/experimental/powerpoint/scripts/export_svg.py b/.github/skills/experimental/powerpoint/scripts/export_svg.py index a707294e6..5f20aea60 100644 --- a/.github/skills/experimental/powerpoint/scripts/export_svg.py +++ b/.github/skills/experimental/powerpoint/scripts/export_svg.py @@ -171,28 +171,31 @@ def export_pdf_to_svg( "PyMuPDF is required for SVG export. Install via: pip install pymupdf" ) from e - doc = fitz.open(str(pdf_path)) - total_pages = len(doc) - output_dir.mkdir(parents=True, exist_ok=True) + with fitz.open(str(pdf_path)) as doc: + total_pages = len(doc) + output_dir.mkdir(parents=True, exist_ok=True) + + if slides: + page_numbers = [n for n in slides if 1 <= n <= total_pages] + skipped = [n for n in slides if n < 1 or n > total_pages] + for num in skipped: + logger.warning( + "Slide %d out of range (1-%d), skipping", + num, + total_pages, + ) + else: + page_numbers = list(range(1, total_pages + 1)) + + exported: list[Path] = [] + for page_num in page_numbers: + page = doc[page_num - 1] + svg_text = page.get_svg_image() + svg_path = output_dir / f"slide-{page_num:03d}.svg" + svg_path.write_text(svg_text, encoding="utf-8") + logger.info("Exported slide %d → %s", page_num, svg_path.name) + exported.append(svg_path) - if slides: - page_numbers = [n for n in slides if 1 <= n <= total_pages] - skipped = [n for n in slides if n < 1 or n > total_pages] - for num in skipped: - logger.warning("Slide %d out of range (1-%d), skipping", num, total_pages) - else: - page_numbers = list(range(1, total_pages + 1)) - - exported: list[Path] = [] - for page_num in page_numbers: - page = doc[page_num - 1] - svg_text = page.get_svg_image() - svg_path = output_dir / f"slide-{page_num:03d}.svg" - svg_path.write_text(svg_text, encoding="utf-8") - logger.info("Exported slide %d → %s", page_num, svg_path.name) - exported.append(svg_path) - - doc.close() return exported diff --git a/.github/skills/experimental/powerpoint/tests/test_export_svg.py b/.github/skills/experimental/powerpoint/tests/test_export_svg.py index 8d7cd418c..9f9cd1fe2 100644 --- a/.github/skills/experimental/powerpoint/tests/test_export_svg.py +++ b/.github/skills/experimental/powerpoint/tests/test_export_svg.py @@ -149,6 +149,8 @@ def test_exports_all_pages(self, mocker, tmp_path): mock_doc = MagicMock() mock_doc.__len__ = MagicMock(return_value=3) mock_doc.__getitem__ = MagicMock(return_value=mock_page) + mock_doc.__enter__ = MagicMock(return_value=mock_doc) + mock_doc.__exit__ = MagicMock(return_value=False) mock_fitz = MagicMock() mock_fitz.open.return_value = mock_doc @@ -170,6 +172,8 @@ def test_exports_filtered_pages(self, mocker, tmp_path): mock_doc = MagicMock() mock_doc.__len__ = MagicMock(return_value=5) mock_doc.__getitem__ = MagicMock(return_value=mock_page) + mock_doc.__enter__ = MagicMock(return_value=mock_doc) + mock_doc.__exit__ = MagicMock(return_value=False) mock_fitz = MagicMock() mock_fitz.open.return_value = mock_doc @@ -190,6 +194,8 @@ def test_out_of_range_slides_skipped(self, mocker, tmp_path): mock_doc = MagicMock() mock_doc.__len__ = MagicMock(return_value=2) mock_doc.__getitem__ = MagicMock(return_value=mock_page) + mock_doc.__enter__ = MagicMock(return_value=mock_doc) + mock_doc.__exit__ = MagicMock(return_value=False) mock_fitz = MagicMock() mock_fitz.open.return_value = mock_doc From 656de643bdb277d893ce92bb70e63a49f90e5500 Mon Sep 17 00:00:00 2001 From: auyidi Date: Tue, 5 May 2026 21:54:36 +0000 Subject: [PATCH 22/22] fix(skills): add PyMuPDFError class, fix docstring, document add_movie rationale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * export_svg.py: introduce PyMuPDFError for PyMuPDF import failures; catch both error types * generate_themes.py: remove spurious blank line in docstring usage block * embed_audio.py: document why add_movie is used for WAV audio embedding 🔧 - Generated by Copilot --- .../skills/experimental/powerpoint/scripts/embed_audio.py | 5 +++++ .../skills/experimental/powerpoint/scripts/export_svg.py | 8 ++++++-- .../experimental/powerpoint/scripts/generate_themes.py | 1 - 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/skills/experimental/powerpoint/scripts/embed_audio.py b/.github/skills/experimental/powerpoint/scripts/embed_audio.py index de5622f97..68419f36a 100644 --- a/.github/skills/experimental/powerpoint/scripts/embed_audio.py +++ b/.github/skills/experimental/powerpoint/scripts/embed_audio.py @@ -141,6 +141,11 @@ def embed_audio( logger.debug("Slide %d: no audio file found, skipping", slide_num) continue + # python-pptx does not expose a public audio-embedding API, so we use + # add_movie which creates a video relationship type. PowerPoint Desktop + # handles WAV media embedded this way correctly for narration timing and + # video export via "Use Recorded Timings and Narrations". Other viewers + # (LibreOffice, Google Slides) may display a video icon instead. slide.shapes.add_movie( movie_file=str(wav_path), left=AUDIO_LEFT, diff --git a/.github/skills/experimental/powerpoint/scripts/export_svg.py b/.github/skills/experimental/powerpoint/scripts/export_svg.py index 5f20aea60..d474aaeb1 100644 --- a/.github/skills/experimental/powerpoint/scripts/export_svg.py +++ b/.github/skills/experimental/powerpoint/scripts/export_svg.py @@ -38,6 +38,10 @@ class LibreOfficeError(RuntimeError): """Raised when LibreOffice is missing or conversion fails.""" +class PyMuPDFError(RuntimeError): + """Raised when PyMuPDF is missing or SVG rendering fails.""" + + def create_parser() -> argparse.ArgumentParser: """Create and configure argument parser.""" parser = argparse.ArgumentParser( @@ -167,7 +171,7 @@ def export_pdf_to_svg( try: import fitz # noqa: PLC0415 — PyMuPDF except ImportError as e: - raise LibreOfficeError( + raise PyMuPDFError( "PyMuPDF is required for SVG export. Install via: pip install pymupdf" ) from e @@ -223,7 +227,7 @@ def run(args: argparse.Namespace) -> int: try: pdf_path = convert_pptx_to_pdf(pptx_path, tmp_path) exported = export_pdf_to_svg(pdf_path, output_dir, slides) - except LibreOfficeError as e: + except (LibreOfficeError, PyMuPDFError) as e: logger.error("%s", e) return EXIT_FAILURE diff --git a/.github/skills/experimental/powerpoint/scripts/generate_themes.py b/.github/skills/experimental/powerpoint/scripts/generate_themes.py index e1b35279c..65905b6cf 100644 --- a/.github/skills/experimental/powerpoint/scripts/generate_themes.py +++ b/.github/skills/experimental/powerpoint/scripts/generate_themes.py @@ -10,7 +10,6 @@ Usage:: python generate_themes.py --content-dir content/ \ - --themes themes.yaml --output-dir ../ """