diff --git a/.github/instructions/experimental/pptx.instructions.md b/.github/instructions/experimental/pptx.instructions.md index 39a5850d3..e06a365c5 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 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 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..686a9a3e4 100644 --- a/.github/skills/experimental/powerpoint/SKILL.md +++ b/.github/skills/experimental/powerpoint/SKILL.md @@ -404,6 +404,63 @@ 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 \ + --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 (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 + +```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 +476,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/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/Invoke-EmbedAudio.ps1 b/.github/skills/experimental/powerpoint/scripts/Invoke-EmbedAudio.ps1 new file mode 100644 index 000000000..7eedc4532 --- /dev/null +++ b/.github/skills/experimental/powerpoint/scripts/Invoke-EmbedAudio.ps1 @@ -0,0 +1,88 @@ +#!/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 + +.NOTES + Part of the powerpoint skill. Manages uv virtual environment setup + and delegates to embed_audio.py for WAV embedding into PPTX slides. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$InputPath, + [Parameter(Mandatory = $true)][string]$AudioDir, + [Parameter(Mandatory = $true)][string]$OutputPath, + [string]$Slides, + [switch]$SkipVenvSetup +) + +$ErrorActionPreference = 'Stop' + +#region Environment Setup + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$SkillRoot = Split-Path -Parent $ScriptDir +$VenvDir = Join-Path $SkillRoot '.venv' + +#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 + } + + $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' } + + & $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 new file mode 100644 index 000000000..c0a51648e --- /dev/null +++ b/.github/skills/experimental/powerpoint/scripts/Invoke-ExportSvg.ps1 @@ -0,0 +1,84 @@ +#!/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/ + +.NOTES + Part of the powerpoint skill. Manages uv virtual environment setup + and delegates to export_svg.py for PPTX-to-SVG conversion. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$InputPath, + [Parameter(Mandatory = $true)][string]$OutputDir, + [string]$Slides, + [switch]$SkipVenvSetup +) + +$ErrorActionPreference = 'Stop' + +#region Environment Setup + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$SkillRoot = Split-Path -Parent $ScriptDir +$VenvDir = Join-Path $SkillRoot '.venv' + +#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 + } + + $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' } + + & $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 new file mode 100644 index 000000000..eda14f5e6 --- /dev/null +++ b/.github/skills/experimental/powerpoint/scripts/Invoke-GenerateThemes.ps1 @@ -0,0 +1,83 @@ +#!/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 ../ + +.NOTES + Part of the powerpoint skill. Manages uv virtual environment setup + and delegates to generate_themes.py for themed content generation. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$ContentDir, + [Parameter(Mandatory = $true)][string]$ThemesPath, + [Parameter(Mandatory = $true)][string]$OutputDir, + [switch]$SkipVenvSetup +) + +$ErrorActionPreference = 'Stop' + +#region Environment Setup + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$SkillRoot = Split-Path -Parent $ScriptDir +$VenvDir = Join-Path $SkillRoot '.venv' + +#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 + } + + $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' } + + & $python @ScriptArgs + exit $LASTEXITCODE + +} + +#endregion Main diff --git a/.github/skills/experimental/powerpoint/scripts/Invoke-PptxPipeline.ps1 b/.github/skills/experimental/powerpoint/scripts/Invoke-PptxPipeline.ps1 index 7133cf841..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) { 3 } else { 2 } + $totalSteps = if ($hasVisionPrompt) { 4 } else { 3 } # Default image output directory when not specified if (-not $ImageOutputDir) { @@ -429,9 +429,40 @@ function Invoke-ValidateDeck { Write-Host "PPTX property checks found warnings — see $deckReportPath" } - # Step 3: Run Copilot SDK vision validation (when prompt provided) + # 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, + '--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 + if ($VerbosePreference -eq 'Continue') { + $geomArgs += '-v' + } + + & $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 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 704e79124..a59bc2de5 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,15 @@ apply_text_properties, populate_text_frame, ) -from pptx_utils import load_yaml +from pptx_utils import ( + EXIT_ERROR, + EXIT_FAILURE, + EXIT_SUCCESS, + configure_logging, + load_yaml, +) + +logger = logging.getLogger(__name__) CONNECTOR_TYPE_MAP = { "straight": MSO_CONNECTOR_TYPE.STRAIGHT, @@ -959,6 +968,27 @@ def build_slide( colors = {} typography = {} + # 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): + 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 clear_slide_shapes(slide) @@ -1081,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( @@ -1092,10 +1124,84 @@ 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)" + ), + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Enable verbose logging output", + ) args = parser.parse_args() + configure_logging(args.verbose) 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: + 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" + 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 "" + logger.info( + " Slide %03d: %s [%s%s%s]", + num, + title, + notes_status, + extra_status, + img_status, + ) + except Exception as exc: + logger.error(" Slide %03d: ❌ YAML parse error: %s", num, exc) + errors += 1 + logger.info( + "Dry-run complete: %d slides, %d error(s)", + len(slides_data), + errors, + ) + 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/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/embed_audio.py b/.github/skills/experimental/powerpoint/scripts/embed_audio.py new file mode 100644 index 000000000..68419f36a --- /dev/null +++ b/.github/skills/experimental/powerpoint/scripts/embed_audio.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +# 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 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 +""" + +from __future__ import annotations + +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_WIDTH = Inches(0.3) +AUDIO_HEIGHT = Inches(0.3) +AUDIO_OFFSCREEN_OFFSET = Inches(0.5) + + +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 + 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 + wav_path = audio_map.get(slide_num) + if not wav_path: + 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, + 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.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/export_svg.py b/.github/skills/experimental/powerpoint/scripts/export_svg.py new file mode 100644 index 000000000..d474aaeb1 --- /dev/null +++ b/.github/skills/experimental/powerpoint/scripts/export_svg.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +# 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 +""" + +from __future__ import annotations + +import argparse +import logging +import platform +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +from pptx_utils import ( + EXIT_ERROR, + EXIT_FAILURE, + EXIT_SUCCESS, + configure_logging, + parse_slide_filter, +) + +logger = logging.getLogger(__name__) + + +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( + 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 Path(candidate).is_file(): + 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: + 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) + + try: + result = subprocess.run( + [ + soffice, + "--headless", + "--convert-to", + "pdf", + "--outdir", + str(output_dir), + str(pptx_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: + 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(): + raise LibreOfficeError(f"Expected PDF not found: {pdf_path}") + + return pdf_path + + +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 as e: + raise PyMuPDFError( + "PyMuPDF is required for SVG export. Install via: pip install pymupdf" + ) from e + + 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) + + 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 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: + 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: + tmp_path = Path(tmp_dir) + try: + pdf_path = convert_pptx_to_pdf(pptx_path, tmp_path) + exported = export_pdf_to_svg(pdf_path, output_dir, slides) + except (LibreOfficeError, PyMuPDFError) 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 + + +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.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 new file mode 100644 index 000000000..65905b6cf --- /dev/null +++ b/.github/skills/experimental/powerpoint/scripts/generate_themes.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +# 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 ../ +""" + +from __future__ import annotations + +import argparse +import logging +import re +import shutil +import sys +from pathlib import Path +from typing import Any + +from pptx_utils import ( + EXIT_ERROR, + EXIT_FAILURE, + 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__) + + +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", help="Enable verbose output" + ) + return parser + + +def load_themes(themes_path: Path) -> dict[str, Any]: + """Load and validate the themes YAML file. + + Returns the ``themes`` mapping keyed by theme-id. + """ + 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"] + 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 + + +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* 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()} + 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( + 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: + """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. + + 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(): + 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 + + 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_double(hex6: str) -> str: + return rf'"#{re.escape(hex6)}"' + + 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_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, 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) + 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]}"' + if m.group(2 * 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: + """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") + # 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) + 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. + + Uses ruamel.yaml for round-trip fidelity: preserves comments, + key ordering, and quoting style from the original file. + """ + if not style_path.exists(): + return + ryaml = YAML() + ryaml.preserve_quotes = True + data = ryaml.load(style_path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + return + + # 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 + + with style_path.open("w", encoding="utf-8") as f: + ryaml.dump(data, f) + + +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/invoke-pptx-pipeline.sh b/.github/skills/experimental/powerpoint/scripts/invoke-pptx-pipeline.sh index 01f0baff7..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=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,9 +281,32 @@ invoke_validate_deck() { echo "PPTX property checks found warnings — see ${deck_report}" fi - # Step 3: Vision validation (when prompt provided) + # 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=( + "${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}") + [[ "${VERBOSE:-false}" == "true" ]] && geom_args+=("-v") + + 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 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 new file mode 100644 index 000000000..6e33bd1a7 --- /dev/null +++ b/.github/skills/experimental/powerpoint/scripts/validate_geometry.py @@ -0,0 +1,582 @@ +#!/usr/bin/env python3 +# 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 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 +""" + +from __future__ import annotations + +import argparse +import json +import logging +import sys +from datetime import datetime, timezone +from pathlib import Path + +from pptx import Presentation +from pptx.shapes.base import BaseShape +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: 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) + width_in = emu_to_inches(shape.width) + left_in = emu_to_inches(shape.left) + return ( + 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 + ) + + +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: + preview = shape.text[:40].replace("\n", " ") + return f'{name} ("{preview}")' + return name + + +def check_boundary_overflow( + shape: BaseShape, + 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 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( + { + "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: BaseShape, + 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}\") < 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}\") < 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: list[BaseShape], 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 + # 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( + { + "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: list[BaseShape], 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'}→{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", "").replace("|", "\\|") + desc = issue.get("description", "").replace("|", "\\|") + 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 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) + 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) + 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 == "error": + return EXIT_ERROR + if severity == "warning": + return EXIT_FAILURE + 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 + 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/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)") 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_embed_audio.py b/.github/skills/experimental/powerpoint/tests/test_embed_audio.py new file mode 100644 index 000000000..874163e70 --- /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 struct + +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.""" + 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..9f9cd1fe2 --- /dev/null +++ b/.github/skills/experimental/powerpoint/tests/test_export_svg.py @@ -0,0 +1,225 @@ +# Copyright (c) Microsoft Corporation. +# 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, +) + + +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 TestFindLibreoffice: + """Tests for find_libreoffice.""" + + def test_returns_string_or_none(self): + result = find_libreoffice() + assert result is None or isinstance(result, str) + + def test_finds_on_path(self, mocker): + mocker.patch("shutil.which", return_value="/usr/bin/libreoffice") + assert find_libreoffice() == "/usr/bin/libreoffice" + + def test_returns_none_when_missing(self, mocker): + mocker.patch("shutil.which", return_value=None) + mocker.patch.object(Path, "is_file", return_value=False) + 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 + + 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( + [ + "--input", + str(deck), + "--output-dir", + str(tmp_path / "out"), + ] + ) + rc = run(args) + assert rc == 1 + + +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 + + +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_doc.__enter__ = MagicMock(return_value=mock_doc) + mock_doc.__exit__ = MagicMock(return_value=False) + + 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_doc.__enter__ = MagicMock(return_value=mock_doc) + mock_doc.__exit__ = MagicMock(return_value=False) + + 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_doc.__enter__ = MagicMock(return_value=mock_doc) + mock_doc.__exit__ = MagicMock(return_value=False) + + 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" 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..0524b2568 --- /dev/null +++ b/.github/skills/experimental/powerpoint/tests/test_generate_themes.py @@ -0,0 +1,285 @@ +# 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 + + 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.""" + + 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 + + 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.""" + + 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..2293ad9d9 --- /dev/null +++ b/.github/skills/experimental/powerpoint/tests/test_validate_geometry.py @@ -0,0 +1,527 @@ +# 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() + + 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.""" + + 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 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"