
Powerpoint
- 322 installs
- 1.3k repo stars
- Updated August 5, 2026
- microsoft/hve-core
PowerPoint slide deck generation and management using python-pptx with YAML-driven content and styling.
About
PowerPoint slide deck generation and management using python-pptx with YAML-driven content and styling. Generates, updates, and manages PowerPoint slide decks using `python-pptx` with YAML-driven content and styling definitions.
- Generates, updates, and manages PowerPoint slide decks using `python-pptx` with YAML-driven content and styling definiti
- The `Invoke-PptxPipeline.ps1` script handles virtual environment creation and dependency installation automatically via
- curl -LsSf https://astral.sh/uv/install.sh | sh
- powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
- ### System Dependencies (Export and Validation)
Powerpoint by the numbers
- 322 all-time installs (skills.sh)
- +10 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #847 of 1,879 Marketing & SEO skills by installs in the Skillselion catalog
- Security screen: CRITICAL risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
powerpoint capabilities & compatibility
- Capabilities
- generates, updates, and manages powerpoint slide · the `invoke pptxpipeline.ps1` script handles vir · curl lssf https://astral.sh/uv/install.sh | sh · powershell executionpolicy bypass c "irm https
- Use cases
- documentation
What powerpoint says it does
PowerPoint slide deck generation and management using python-pptx with YAML-driven content and styling
npx skills add https://github.com/microsoft/hve-core --skill powerpointAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 322 |
|---|---|
| repo stars | ★ 1.3k |
| Security audit | 1 / 3 scanners passed |
| Last updated | August 5, 2026 |
| Repository | microsoft/hve-core ↗ |
How do I apply powerpoint using the workflow in its SKILL.md?
PowerPoint slide deck generation and management using python-pptx with YAML-driven content and styling
Who is it for?
Developers following the powerpoint skill for the tasks it documents.
Skip if: Tasks outside the powerpoint scope described in SKILL.md.
When should I use this skill?
User mentions powerpoint or related triggers from the skill description.
What you get
Working powerpoint setup aligned with the documented patterns and constraints.
- content-extra.py render module
- programmatic slide overlays
Files
PowerPoint Skill
Generates, updates, and manages PowerPoint slide decks using python-pptx with YAML-driven content and styling definitions.
Overview
This skill provides Python scripts that consume YAML configuration files to produce PowerPoint slide decks. Each slide is defined by a content.yaml file describing its layout, text, and shapes. A style.yaml file defines dimensions, template configuration, layout mappings, metadata, and defaults.
SKILL.md covers technical reference: prerequisites, commands, script architecture, API constraints, and troubleshooting. For conventions and design rules (element positioning, visual quality, color and contrast, contextual styling), follow pptx.instructions.md.
Prerequisites
PowerShell
The Invoke-PptxPipeline.ps1 script handles virtual environment creation and dependency installation automatically via uv sync. Requires uv, Python 3.11+, and PowerShell 7+.
Installing uv
If uv is not installed:
# macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh
# Windows
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
# Via pip (fallback)
pip install uvSystem Dependencies (Export and Validation)
The Export and Validate actions require LibreOffice for PPTX-to-PDF conversion and optionally pdftoppm from poppler for PDF-to-JPG rendering. When pdftoppm is not available, PyMuPDF handles the image rendering.
The Validate action's vision-based checks require the GitHub Copilot CLI for model access.
# macOS
brew install --cask libreoffice
brew install poppler # optional, provides pdftoppm
# Linux
sudo apt-get install libreoffice poppler-utils
# Windows (winget preferred, choco fallback)
winget install TheDocumentFoundation.LibreOffice
# choco install libreoffice-still # alternative
# poppler: no winget package; use choco install poppler (optional, provides pdftoppm)Copilot CLI (Vision Validation)
The validate_slides.py script uses the GitHub Copilot SDK to send slide images to vision-capable models. The Copilot CLI must be installed and authenticated:
# Install Copilot CLI
npm install -g @github/copilot-cli
# Authenticate (uses the same GitHub account as VS Code Copilot)
copilot auth login
# Verify
copilot --versionRequired Files
style.yaml— Dimensions, defaults, template configuration, and metadatacontent.yaml— Per-slide content definition (text, shapes, images, layout)- (Optional)
content-extra.py— Custom Python for complex slide drawings
Content Directory Structure
All slide content lives under the working directory's content/ folder:
content/
├── global/
│ ├── style.yaml # Dimensions, defaults, template config, and theme metadata
│ └── voice-guide.md # Voice and tone guidelines
├── slide-001/
│ ├── content.yaml # Slide 1 content and layout
│ └── images/ # Slide-specific images
│ ├── background.png
│ └── background.yaml # Image metadata sidecar
├── slide-002/
│ ├── content.yaml # Slide 2 content and layout
│ ├── content-extra.py # Custom Python for complex drawings
│ └── images/
│ └── screenshot.png
├── slide-003/
│ ├── content.yaml
│ └── images/
│ ├── diagram.png
│ └── diagram.yaml
└── ...Global Style Definition (style.yaml)
The global style.yaml defines dimensions, template configuration, layout mappings, metadata, and defaults. Color and font choices are specified per-element in each slide's content.yaml rather than centralized in the style file.
See the style.yaml template for the full template, field reference, and usage instructions.
Per-Slide Content Definition (content.yaml)
Each slide's content.yaml defines layout, text, shapes, and positioning. All position and size values are in inches. Color values use #RRGGBB hex format or @theme_name references.
Text contract: markdown-like list lines in textbox.text and shape.text are interpreted as PowerPoint lists during rendering. Unordered markers (-, +, *) become bulleted paragraphs, ordered markers (1., 1)) become auto-numbered paragraphs, and leading indentation maps to paragraph level.
See the content.yaml template for the full template, supported element types, supported shape types, and usage instructions.
Complex Drawings (content-extra.py)
When a slide requires complex drawings that cannot be expressed through content.yaml element definitions, create a content-extra.py file in the slide folder. The render() function signature is fixed. The build script calls it after placing standard content.yaml elements.
See the content-extra.py template for the full template, function parameters, and usage guidelines.
Security Validation
Before executing a content-extra.py file, the build script performs AST-based static analysis to reject dangerous code. Validation runs automatically unless the --allow-scripts flag is passed.
Allowed imports:
pptxand allpptx.*submodules- Safe standard-library modules (e.g.,
math,copy,json,re,pathlib,collections,itertools,functools,typing,enum,dataclasses,decimal,fractions,string,textwrap)
Blocked imports:
subprocess,os,shutil,socket,ctypes,signal,multiprocessing,threading,http,urllib,ftplib,smtplib,imaplib,poplib,xmlrpc,webbrowser,code,codeop,compileall,py_compile,zipimport,pkgutil,runpy,ensurepip,venv,sqlite3,tempfile,shelve,dbm,pickle,marshal,importlib,sys,telnetlib- Any third-party package not on the allowlist
Blocked builtins:
- Dangerous:
eval,exec,__import__,compile,breakpoint - Indirect bypass:
getattr,setattr,delattr,globals,locals,vars
Runtime namespace restriction:
Even after AST validation passes, the executed module runs in a restricted namespace where __builtins__ is limited to safe builtins only. The dangerous and indirect-bypass builtins listed above are removed from the module namespace before execution (__import__ is kept because the import machinery requires it; the AST checker blocks direct __import__() calls).
`--allow-scripts` flag:
Pass --allow-scripts to skip AST validation and namespace restriction for trusted content. This flag is required when a content-extra.py script legitimately needs blocked imports or builtins.
python scripts/build_deck.py \
--content-dir content/ \
--style content/global/style.yaml \
--output slide-deck/presentation.pptx \
--allow-scriptsWhen validation fails, the build raises ContentExtraError with a message identifying the violation and file path.
Script Reference
All operations are available through the PowerShell orchestrator (Invoke-PptxPipeline.ps1) or directly via the Python scripts. The PowerShell script manages the Python virtual environment and dependency installation automatically via uv sync.
Build a Slide Deck
./scripts/Invoke-PptxPipeline.ps1 -Action Build `
-ContentDir content/ `
-StylePath content/global/style.yaml `
-OutputPath slide-deck/presentation.pptxpython scripts/build_deck.py \
--content-dir content/ \
--style content/global/style.yaml \
--output slide-deck/presentation.pptxReads all content/slide-*/content.yaml files in numeric order and generates the complete deck. Executes content-extra.py files when present.
Build from a Template
[!WARNING]
--templatecreates a NEW presentation inheriting only slide masters, layouts, and theme from the template. All existing slides are discarded. Use--sourcefor partial rebuilds.
./scripts/Invoke-PptxPipeline.ps1 -Action Build `
-ContentDir content/ `
-StylePath content/global/style.yaml `
-OutputPath slide-deck/presentation.pptx `
-TemplatePath corporate-template.pptxpython scripts/build_deck.py \
--content-dir content/ \
--style content/global/style.yaml \
--output slide-deck/presentation.pptx \
--template corporate-template.pptxLoads slide masters and layouts from the template PPTX. Layout names in each slide's content.yaml resolve against the template's layouts, with optional name mapping via the layouts section in style.yaml. Populate themed layout placeholders using the placeholders section in content YAML.
Update Specific Slides
[!IMPORTANT]
Use--source(not--template) for partial rebuilds. Combining--templateand--sourceis not supported.
./scripts/Invoke-PptxPipeline.ps1 -Action Build `
-ContentDir content/ `
-StylePath content/global/style.yaml `
-OutputPath slide-deck/presentation.pptx `
-SourcePath slide-deck/presentation.pptx `
-Slides "3,7,15"python scripts/build_deck.py \
--content-dir content/ \
--style content/global/style.yaml \
--source slide-deck/presentation.pptx \
--output slide-deck/presentation.pptx \
--slides 3,7,15Opens the existing deck, clears shapes on the specified slides, rebuilds them in-place from their content.yaml, and saves. All other slides remain untouched. After building, verify the output slide count matches the original deck.
Extract Content from Existing PPTX
./scripts/Invoke-PptxPipeline.ps1 -Action Extract `
-InputPath existing-deck.pptx `
-OutputDir content/python scripts/extract_content.py \
--input existing-deck.pptx \
--output-dir content/Extracts text, shapes, images, and styling from an existing PPTX into the content/ folder structure. Creates content.yaml files for each slide and populates the global/style.yaml from detected patterns.
Extract Specific Slides
./scripts/Invoke-PptxPipeline.ps1 -Action Extract `
-InputPath existing-deck.pptx `
-OutputDir content/ `
-Slides "3,7,15"python scripts/extract_content.py \
--input existing-deck.pptx \
--output-dir content/ \
--slides 3,7,15Extracts only the specified slides (plus the global style). Useful for targeted updates on large decks.
Extraction Limitations
- Picture shapes that reference external (linked) images instead of embedded blobs are recorded with
path: LINKED_IMAGE_NOT_EMBEDDED. The script does not crash but the image must be re-embedded manually. - When text elements inherit font, size, or color from the slide master or layout, the extraction records no inline styling. Content YAML for these elements needs explicit font properties added before rebuild.
- The
detect_global_style()function uses frequency analysis across all slides. For decks with mixed styling, review and adjuststyle.yamlvalues manually after extraction.
Validate a Deck
./scripts/Invoke-PptxPipeline.ps1 -Action Validate `
-InputPath slide-deck/presentation.pptx `
-ContentDir content/The Validate action runs a two- or three-step pipeline:
1. Export — Clears stale slide images from the output directory, then renders slides to JPG images via LibreOffice (PPTX → PDF → JPG). When -Slides is used, output images are named to match original slide numbers (e.g., slide-023.jpg for slide 23), not sequential PDF page numbers. 2. PPTX validation — Checks PPTX-only properties (validate_deck.py) for speaker notes and slide count. 3. Vision validation (optional) — Sends slide images to a vision-capable model via the Copilot SDK (validate_slides.py) for visual quality checks. Runs when -ValidationPrompt or -ValidationPromptFile is provided.
For validation criteria (element positioning, visual quality, color contrast, content completeness), see pptx.instructions.md Validation Criteria.
Built-in System Message
The validate_slides.py script includes a built-in system message that focuses on issue detection only (not full slide description). It checks overlapping elements, text overflow/cutoff, decorative line mismatch after title wraps, citation/footer collisions, tight spacing, uneven gaps, insufficient edge margins, alignment inconsistencies, low contrast, narrow text boxes, and leftover placeholders. For dense slides, near-edge placement or tight boundaries are acceptable when readability is not materially affected. The -ValidationPrompt parameter provides supplementary user-level context and does not need to repeat these checks.
Validate with Vision Checks
./scripts/Invoke-PptxPipeline.ps1 -Action Validate `
-InputPath slide-deck/presentation.pptx `
-ContentDir content/ `
-ValidationPrompt "Validate visual quality. Focus on recently modified slides for content accuracy." `
-ValidationModel claude-haiku-4.5Vision validation results are written to validation-results.json in the image output directory, containing raw model responses per slide with quality findings. Per-slide response text is also written to slide-NNN-validation.txt files next to each slide image.
Validate Specific Slides
./scripts/Invoke-PptxPipeline.ps1 -Action Validate `
-InputPath slide-deck/presentation.pptx `
-ContentDir content/ `
-Slides "3,7,15"Validates only the specified slides. When content directories cover fewer slides than the PPTX, the slide count check reports an informational note rather than an error.
validate_slides.py CLI Reference
| Flag | Required | Default | Description |
|---|---|---|---|
--image-dir | Yes | — | Directory containing slide-NNN.jpg images |
--prompt | One of --prompt / --prompt-file | — | Validation prompt text |
--prompt-file | One of --prompt / --prompt-file | — | Path to file containing the validation prompt |
--model | No | claude-haiku-4.5 | Vision model ID |
--output | No | stdout | JSON results file path |
--slides | No | all | Comma-separated slide numbers to validate |
-v, --verbose | No | — | Enable debug-level logging |
validate_deck.py CLI Reference
| Flag | Required | Default | Description |
|---|---|---|---|
--input | Yes | — | Input PPTX file path |
--content-dir | No | — | Content directory for slide count comparison |
--slides | No | all | Comma-separated slide numbers to validate |
--output | No | stdout | JSON results file path |
--report | No | — | Markdown report file path |
--per-slide-dir | No | — | Directory for per-slide JSON files (slide-NNN-deck-validation.json) |
Validation Outputs
When run through the pipeline, validation produces these files in the image output directory:
| File | Format | Content |
|---|---|---|
deck-validation-results.json | JSON | Per-slide PPTX property issues (speaker notes, slide count) |
deck-validation-report.md | Markdown | Human-readable report for PPTX property validation |
validation-results.json | JSON | Consolidated vision model responses with quality findings |
slide-NNN-validation.txt | Text | Per-slide vision response text (next to slide-NNN.jpg) |
slide-NNN-deck-validation.json | JSON | Per-slide PPTX property validation result (next to slide-NNN.jpg) |
Per-slide vision text files are written alongside their corresponding slide-NNN.jpg images, enabling agents to read validation findings for individual slides without parsing the consolidated JSON file.
Validation Scope for Changed Slides
When validating after modifying or adding specific slides, always validate a block that includes one slide before and one slide after the changed or added slides. This catches edge-proximity issues, transition inconsistencies, and spacing problems that arise between adjacent slides.
For example, when slides 5 and 6 were changed, validate slides 4 through 7:
./scripts/Invoke-PptxPipeline.ps1 -Action Validate `
-InputPath slide-deck/presentation.pptx `
-ContentDir content/ `
-Slides "4,5,6,7" `
-ValidationPrompt "Check for text overlay, overflow, margin issues, color contrast"Export Slides to Images
./scripts/Invoke-PptxPipeline.ps1 -Action Export `
-InputPath slide-deck/presentation.pptx `
-ImageOutputDir slide-deck/validation/ `
-Slides "1,3,5" `
-Resolution 150# Step 1: PPTX to PDF
python scripts/export_slides.py \
--input slide-deck/presentation.pptx \
--output slide-deck/validation/slides.pdf \
--slides 1,3,5
# Step 2: PDF to JPG (pdftoppm from poppler)
pdftoppm -jpeg -r 150 slide-deck/validation/slides.pdf slide-deck/validation/slideConverts specified slides to JPG images for visual inspection. The PowerShell orchestrator handles both steps automatically, clears stale images before exporting, names output images to match original slide numbers when -Slides is used, and uses a PyMuPDF fallback when pdftoppm is not installed.
When running the two-step process manually (outside the pipeline), note that render_pdf_images.py uses sequential numbering by default. Pass --slide-numbers to map output images to original slide positions:
python scripts/render_pdf_images.py \
--input slide-deck/validation/slides.pdf \
--output-dir slide-deck/validation/ \
--dpi 150 \
--slide-numbers 1,3,5Dependencies: Requires LibreOffice for PPTX-to-PDF conversion and either pdftoppm (from poppler) or pymupdf (pip) for PDF-to-JPG rendering.
Dry-Run Validation
python scripts/build_deck.py \
--content-dir content/ \
--style content/global/style.yaml \
--dry-runValidates 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. Exit codes:
- code 0: no errors found
- code 1: one or more slide-level content errors (YAML parse failures, invalid scripts)
- code 2: configuration error (e.g., no slide content found in the content directory)
Generate Theme Variants
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:
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
python scripts/embed_audio.py \
--input slide-deck/presentation.pptx \
--audio-dir voice-over/ \
--output slide-deck/presentation-narrated.pptxEmbeds 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.
[!NOTE]
WAV files are embedded uncompressed. For large narrated decks, consider pre-compressing audio before embedding to manage PPTX file size.
Export Slides to SVG
python scripts/export_svg.py \
--input slide-deck/presentation.pptx \
--output-dir slide-deck/svg/ \
--slides 3,5,10Exports 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:
| Module | Purpose |
|---|---|
pptx_utils.py | Shared utilities: exit codes, logging configuration, slide filter parsing, unit conversion (emu_to_inches()), YAML loading |
pptx_colors.py | Color resolution (#hex, @theme, dict with brightness), theme color map (16 entries) |
pptx_fonts.py | Font resolution, family normalization, weight suffix handling, alignment mapping |
pptx_shapes.py | Shape constant map (29 entries + circle alias), auto-shape name mapping, rotation utilities |
pptx_fills.py | Solid, gradient, and pattern fill application/extraction; line/border styling with dash styles |
pptx_text.py | Text frame properties (margins, auto-size, vertical anchor), paragraph properties (spacing, level), run properties (underline, hyperlink), markdown-like list parsing to bullet/auto-number paragraphs |
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
- python-pptx does NOT support SVG images. Always convert to PNG via
cairosvgorPillow. - python-pptx cannot create new slide masters or layouts programmatically. Use blank layouts or start from a template PPTX with the
--templateargument. - Transitions and animations are preserved when opening and saving existing files, but cannot be created or modified via the API.
- When extracting content, slide master and layout inheritance means many text elements have no inline styling. Add explicit font properties in content YAML before rebuilding.
- The Export and Validate actions require LibreOffice for PPTX-to-PDF conversion. The PowerShell orchestrator checks for LibreOffice availability before starting and provides platform-specific install instructions if missing.
- Accessing
background.fillon slides with inherited backgrounds replaces them withNoFill. Checkslide.follow_master_backgroundbefore accessing the fill property. - Gradient fills use the python-pptx
GradientFillAPI withGradientStopobjects. Each stop specifies a position (0–100) and a color. - Theme colors resolve via
MSO_THEME_COLORenum. Brightness adjustments apply through the color format'sbrightnessproperty. - Template-based builds load layouts by name or index. Layout name resolution falls back to index 6 (blank) when no match is found.
Troubleshooting
| Issue | Cause | Solution |
|---|---|---|
| SVG runtime error | python-pptx cannot embed SVG | Convert to PNG via cairosvg before adding |
| Text overlay between elements | Insufficient vertical spacing | Follow element positioning conventions in pptx.instructions.md |
| Width overflow off-slide | Element extends beyond slide boundary | Follow element positioning conventions in pptx.instructions.md |
| Bright accent color unreadable as fill | White text on bright background | Darken accent to ~60% saturation for box fills |
| Background fill replaced with NoFill | Accessed background.fill on inherited background | Check slide.follow_master_background before accessing |
| Missing speaker notes | Notes not specified in content.yaml | Add speaker_notes field to every content slide |
| LibreOffice not found during Validate | Validate exports slides to images first | Install LibreOffice: brew install --cask libreoffice (macOS) |
uv not found | uv package manager not installed | Install uv: `curl -LsSf https://astral.sh/uv/install.sh \ |
| Python not found by uv | No Python 3.11+ on PATH | Install via uv python install 3.11 or pyenv install 3.11 |
uv sync fails | Missing or corrupt .venv | Delete .venv/ at the skill root and re-run uv sync |
| Import errors in scripts | Dependencies not installed or stale venv | Run uv sync from the skill root to recreate the environment |
Environment Recovery
When scripts fail due to missing modules, import errors, or a corrupt virtual environment, recover with:
cd .github/skills/experimental/powerpoint
rm -rf .venv
uv syncThis recreates the virtual environment from scratch using pyproject.toml as the single source of truth. The Invoke-PptxPipeline.ps1 orchestrator runs uv sync automatically on each invocation unless -SkipVenvSetup is passed.
When uv itself is not available, install it first (see Installing uv above), then retry. When Python 3.11+ is not available, run uv python install 3.11 to have uv fetch and manage the interpreter.
Brought to you by microsoft/hve-core
Content Extra Python Template
Use this template when a slide requires complex drawings that cannot be expressed through content.yaml element definitions. Create a content-extra.py file in the slide's content folder alongside its content.yaml.
Instructions
- The
render()function signature is fixed — do not change the parameter list. - The build script calls
render()after placing standardcontent.yamlelements, so custom shapes draw on top of YAML-defined elements. - Use the
styledictionary to access defaults and metadata. - Use
#RRGGBBhex values for all colors. Named color references ($color_name) are not supported. - Use the
content_dirpath to reference images or other assets in the slide's folder. - Import only from
pptxand Python standard library modules. Do not add external dependencies beyond those listed in the skill prerequisites.
Template
"""Custom drawing for slide NNN — description of what this draws."""
from pptx.util import Inches, Pt
from pptx.dml.color import RGBColor
def render(slide, style, content_dir):
"""Add custom elements to the slide.
Args:
slide: python-pptx slide object (already created with base elements).
style: Style dictionary with defaults and metadata.
content_dir: Path to this slide's content directory for image references.
"""
# Custom drawing logic here
# Example: complex layered architecture diagram
layers = [
("Application Layer", "#0078D4", 1.0),
("Service Layer", "#00B4D8", 2.5),
("Data Layer", "#10B981", 4.0),
]
for label, color, top in layers:
shape = slide.shapes.add_shape(
1, # MSO_SHAPE.RECTANGLE
Inches(2.0), Inches(top), Inches(9.0), Inches(1.2)
)
shape.fill.solid()
shape.fill.fore_color.rgb = RGBColor.from_string(color.lstrip("#"))
tf = shape.text_frame
tf.text = labelFunction Parameters
| Parameter | Type | Description |
|---|---|---|
slide | pptx.slide.Slide | The slide object with base elements already placed from content.yaml |
style | dict | Style dictionary with defaults and metadata keys |
content_dir | pathlib.Path | Path to the slide's content directory for referencing local assets |
Guidelines
- Keep custom scripts focused on a single slide's needs. If the same drawing pattern repeats across slides, consider defining a new element type in
content.yamlinstead. - Use
#RRGGBBhex values for all colors to keep the script self-contained and independent of global style configuration. - Test the script independently by importing the function and passing mock objects before running the full build.
🤖 Crafted with precision by ✨Copilot following brilliant human instruction, then carefully refined by our team of discerning human reviewers.
Content YAML Template
Use this template when creating or updating a slide's content.yaml file. Each slide folder (content/slide-NNN/) contains one content.yaml that defines the slide's layout, text, shapes, and optional style overrides.
Instructions
- All position and size values (
left,top,width,height) are in inches. - Color values use
#RRGGBBhex format or@theme_namereferences. Named color references ($color_name) are not supported. - Font names are specified as literal font family names (e.g.,
Segoe UI,Cascadia Code). - Elements render in the order listed — later elements draw on top of earlier ones.
- Speaker notes are required on all content slides when
speaker_notes_required: trueis set in the global style. - The
layoutfield is informational and helps describe the slide structure; it does not auto-apply a PowerPoint layout. - The
backgroundblock sets a per-slide background fill. When omitted, no background fill is applied. - The
rotationfield (degrees, 0–360) is supported onshape,textbox, andimageelements. Omit or set to 0 for no rotation. - Markdown-like list lines in
textfields are interpreted as PowerPoint lists during rendering.
Template
# Slide metadata
slide: 1
title: "Production-Grade AI-Assisted Software Engineering"
section: "Introduction"
layout: "title" # title | content | divider | two-column | blank
# Optional per-slide background
background:
fill: "#1B1B1F" # solid color fill; use #RRGGBB or @theme_name
# Elements placed on the slide, rendered in order
elements:
- type: shape
shape: rectangle
left: 0
top: 0
width: 13.333
height: 0.12
fill: "#0078D4"
- type: textbox
left: 0.8
top: 1.5
width: 11.0
height: 1.8
text: "Production-Grade AI-Assisted\nSoftware Engineering"
font: "Segoe UI"
font_size: 36
font_color: "#F8F8FC"
font_bold: true
alignment: left # left | center | right | justify
- type: textbox
left: 0.8
top: 4.4
width: 10.0
height: 0.8
text: "Beyond Vibe Coding: Engineering with AI for Real-World Software"
font: "Segoe UI"
font_size: 20
font_color: "#9CA3AF"
- type: shape
shape: rounded_rectangle
left: 0.8
top: 1.5
width: 2.8
height: 0.55
fill: "#0078D4"
corner_radius: 0.1
rotation: 270 # degrees; vertical text bottom-to-top
text: "HYPER-VELOCITY ENGINEERING"
text_font: "Segoe UI"
text_size: 11
text_color: "#F8F8FC"
text_bold: true
- type: image
path: "images/background.png"
left: 0
top: 0
width: 13.333
height: 7.5
rotation: 0 # optional; degrees 0-360
- type: rich_text
left: 0.8
top: 5.8
width: 10.0
height: 0.6
segments:
- text: "GitHub Copilot | "
font: "Segoe UI"
size: 14
color: "#9CA3AF"
- text: "context engineering"
font: "Cascadia Code"
size: 14
color: "#FFD700"
- text: " | RPI Workflow"
font: "Segoe UI"
size: 14
color: "#9CA3AF"
- type: card
left: 0.8
top: 1.4
width: 5.5
height: 2.8
title: "WHAT MOST TEAMS DO"
title_color: "#F8F8FC"
title_size: 16
title_bold: true
accent_bar: true
accent_color: "#00B4D8"
content:
- bullet: "Open Copilot Chat, type a prompt, paste the result"
color: "#F8F8FC"
- bullet: "No structure, no verification, no persistence"
color: "#9CA3AF"
- type: arrow_flow
left: 1.0
top: 3.0
width: 11.0
height: 1.5
items:
- label: "Research"
color: "#0078D4"
- label: "Plan"
color: "#00B4D8"
- label: "Implement"
color: "#10B981"
- type: numbered_step
left: 1.0
top: 2.0
width: 5.0
height: 0.8
number: 1
label: "Configure VS Code Extensions"
description: "Install the HVE extension pack."
accent_color: "#0078D4"
- type: table
left: 1.0
top: 2.0
width: 11.0
height: 3.0
columns:
- width: 3.0
- width: 4.0
- width: 4.0
rows:
- cells:
- text: "Feature"
font_bold: true
fill: "#0078D4"
font_color: "#F8F8FC"
- text: "Status"
font_bold: true
fill: "#0078D4"
font_color: "#F8F8FC"
- text: "Notes"
font_bold: true
fill: "#0078D4"
font_color: "#F8F8FC"
- cells:
- text: "Authentication"
- text: "Complete"
font_color: "#10B981"
- text: "OAuth 2.0 with PKCE"
- cells:
- text: "Merge status"
merge_right: 2 # merge this cell across 2 additional columns
- text: ""
- text: ""
first_row: true # style first row as header
horz_banding: true # alternate row shading
- type: chart
left: 1.0
top: 2.0
width: 10.0
height: 5.0
chart_type: column_clustered # see Supported Chart Types table
categories:
- "Q1"
- "Q2"
- "Q3"
- "Q4"
series:
- name: "Revenue"
values: [100, 150, 130, 180]
- name: "Costs"
values: [80, 90, 85, 95]
title: "Quarterly Results"
has_legend: true
- type: connector
connector_type: elbow # straight | elbow | curve
begin_x: 2.0
begin_y: 3.0
end_x: 8.0
end_y: 5.0
line_color: "#0078D4"
line_width: 2
dash_style: solid # see Line Dash Styles table
head_end: none # none | arrow | triangle | stealth | diamond | oval
tail_end: arrow
- type: group
left: 1.0
top: 2.0
width: 5.0
height: 3.0
elements:
- type: shape
shape: rectangle
left: 1.0
top: 2.0
width: 2.0
height: 1.0
fill: "#0078D4"
- type: textbox
left: 1.2
top: 2.2
width: 1.6
height: 0.6
text: "Group Title"
font_color: "#F8F8FC"
# Speaker notes (required for all content slides)
speaker_notes: |
Welcome to the HVE workshop. This presentation covers how to use AI
as a reliable engineering partner rather than a copy-paste tool.
Key points: structured workflows, context engineering, verification.Supported Element Types
| Type | Description | Required Fields |
|---|---|---|
shape | Rectangle, rounded rectangle, arrow, etc. | shape, left, top, width, height |
textbox | Plain text box | left, top, width, height, text |
rich_text | Mixed font/color text segments | left, top, width, height, segments |
image | PNG image placement | path, left, top, width, height |
card | Styled panel with optional title and bullets | left, top, width, height |
arrow_flow | Horizontal arrow flow diagram | left, top, width, height, items |
numbered_step | Numbered step with label and description | left, top, width, height, number, label |
table | Data table with headers, merging, and styling | left, top, width, height, columns, rows |
chart | Data chart (bar, line, pie, scatter, etc.) | left, top, width, height, chart_type, categories, series |
connector | Line connecting two points with optional arrows | connector_type, begin_x, begin_y, end_x, end_y |
group | Container grouping nested child elements | left, top, width, height, elements |
Supported Shape Types
| Shape | python-pptx Constant |
|---|---|
rectangle | MSO_SHAPE.RECTANGLE |
rounded_rectangle | MSO_SHAPE.ROUNDED_RECTANGLE |
oval | MSO_SHAPE.OVAL |
circle | MSO_SHAPE.OVAL (alias) |
diamond | MSO_SHAPE.DIAMOND |
pentagon | MSO_SHAPE.PENTAGON |
hexagon | MSO_SHAPE.HEXAGON |
right_triangle | MSO_SHAPE.RIGHT_TRIANGLE |
trapezoid | MSO_SHAPE.TRAPEZOID |
parallelogram | MSO_SHAPE.PARALLELOGRAM |
cross | MSO_SHAPE.CROSS |
donut | MSO_SHAPE.DONUT |
cloud | MSO_SHAPE.CLOUD |
star_5_point | MSO_SHAPE.STAR_5_POINT |
right_arrow | MSO_SHAPE.RIGHT_ARROW |
left_arrow | MSO_SHAPE.LEFT_ARROW |
up_arrow | MSO_SHAPE.UP_ARROW |
down_arrow | MSO_SHAPE.DOWN_ARROW |
left_right_arrow | MSO_SHAPE.LEFT_RIGHT_ARROW |
notched_right_arrow | MSO_SHAPE.NOTCHED_RIGHT_ARROW |
chevron | MSO_SHAPE.CHEVRON |
flowchart_process | MSO_SHAPE.FLOWCHART_PROCESS |
flowchart_decision | MSO_SHAPE.FLOWCHART_DECISION |
flowchart_terminator | MSO_SHAPE.FLOWCHART_TERMINATOR |
flowchart_data | MSO_SHAPE.FLOWCHART_DATA |
left_brace | MSO_SHAPE.LEFT_BRACE |
right_brace | MSO_SHAPE.RIGHT_BRACE |
callout_rectangle | MSO_SHAPE.RECTANGULAR_CALLOUT |
callout_rounded_rectangle | MSO_SHAPE.ROUNDED_RECTANGULAR_CALLOUT |
Supported Chart Types
| Chart Type | python-pptx Constant |
|---|---|
column_clustered | XL_CHART_TYPE.COLUMN_CLUSTERED |
column_stacked | XL_CHART_TYPE.COLUMN_STACKED |
bar_clustered | XL_CHART_TYPE.BAR_CLUSTERED |
bar_stacked | XL_CHART_TYPE.BAR_STACKED |
line | XL_CHART_TYPE.LINE |
line_markers | XL_CHART_TYPE.LINE_MARKERS |
pie | XL_CHART_TYPE.PIE |
doughnut | XL_CHART_TYPE.DOUGHNUT |
area | XL_CHART_TYPE.AREA |
radar | XL_CHART_TYPE.RADAR |
scatter | XL_CHART_TYPE.XY_SCATTER |
bubble | XL_CHART_TYPE.BUBBLE |
Line Dash Styles
| Style | python-pptx Constant |
|---|---|
solid | MSO_LINE_DASH_STYLE.SOLID |
dash | MSO_LINE_DASH_STYLE.DASH |
dash_dot | MSO_LINE_DASH_STYLE.DASH_DOT |
dash_dot_dot | MSO_LINE_DASH_STYLE.DASH_DOT_DOT |
long_dash | MSO_LINE_DASH_STYLE.LONG_DASH |
long_dash_dot | MSO_LINE_DASH_STYLE.LONG_DASH_DOT |
round_dot | MSO_LINE_DASH_STYLE.ROUND_DOT |
square_dot | MSO_LINE_DASH_STYLE.SQUARE_DOT |
Slide-Level Fields
| Field | Type | Description |
|---|---|---|
slide | int | 1-based slide number |
title | string | Slide title (informational) |
section | string | Optional section grouping |
layout | string | Informational layout hint: title, content, divider, two-column, blank |
background | object | Per-slide background; contains fill with a color value (#RRGGBB or @theme_name) |
speaker_notes | string | Speaker notes text; required when speaker_notes_required is true |
Common Element Fields
These optional fields apply to shape, textbox, and image element types:
| Field | Type | Default | Description |
|---|---|---|---|
left | float | — | Horizontal position in inches |
top | float | — | Vertical position in inches |
width | float | — | Element width in inches |
height | float | — | Element height in inches |
name | string | auto | Shape name for identification |
rotation | float | 0 | Rotation in degrees (0–360); 90 = clockwise quarter turn, 270 = counter-clockwise |
Textbox Fields
| Field | Type | Default | Description |
|---|---|---|---|
text | string | — | Text content; use \n for line breaks |
font | string | Segoe UI | Font family name |
font_size | int | 16 | Font size in points |
font_color | string | — | Text color as #RRGGBB or @theme_name |
font_bold | bool | false | Bold text weight. bold is accepted as an alias |
italic | bool | false | Italic text style |
underline | bool | false | Underline text decoration |
alignment | string | inherited | Paragraph alignment: left, center, right, justify |
hyperlink | string | — | URL applied to the text run |
space_before | float | — | Space before paragraph in points |
space_after | float | — | Space after paragraph in points |
line_spacing | float | — | Line spacing in points |
level | int | 0 | Paragraph indentation level (0–8) |
margin_left | float | — | Text frame left margin in inches |
margin_right | float | — | Text frame right margin in inches |
margin_top | float | — | Text frame top margin in inches |
margin_bottom | float | — | Text frame bottom margin in inches |
auto_size | string | — | Auto-size behavior: none, fit (shape to fit text), shrink (text to fit shape) |
vertical_anchor | string | — | Vertical text alignment within frame: top, middle, bottom |
Markdown List Interpretation Contract
For textbox.text values, markdown-like list lines are always interpreted as PowerPoint list paragraphs.
- Unordered list markers:
-,+,* - Ordered list markers:
1.,2.,3.and1),2),3) - Leading indentation controls PowerPoint paragraph level
- Lines that do not match list markers are rendered as normal text paragraphs
Example:
text: |
Outcomes
- Improve cycle time
- Reduce rework
1. Identify bottlenecks
2) Validate fixesShape Text Fields
When a shape contains inline text, use these prefixed fields:
| Field | Type | Default | Description |
|---|---|---|---|
text | string | — | Text displayed inside the shape |
text_font | string | Segoe UI | Font family for shape text |
text_size | int | 16 | Font size in points for shape text |
text_color | string | — | Text color as #RRGGBB or @theme_name |
text_bold | bool | false | Bold text weight for shape text |
For shape.text, the same markdown list interpretation contract applies as textbox.text.
Color Syntax
Color values in content YAML accept three formats:
| Syntax | Example | Description |
|---|---|---|
| Hex value | "#0078D4" | Direct RGB hex color |
| Theme reference | "@accent_1" | Maps to the presentation theme's MSO_THEME_COLOR enum |
| Theme with brightness | {theme: "accent_1", brightness: 0.4} | Theme color with brightness adjustment (-1.0 to 1.0) |
Available theme color names: accent_1 through accent_6, dark_1, dark_2, light_1, light_2, text_1, text_2, background_1, background_2, hyperlink, followed_hyperlink.
Fill Syntax
The fill field on shapes and backgrounds accepts three formats:
Solid fill
fill: "#0078D4" # hex value
fill: "@accent_1" # theme colorGradient fill
fill:
type: "gradient"
angle: 90 # gradient direction in degrees
stops:
- position: 0
color: "#0078D4"
- position: 50
color: "#00B4D8"
- position: 100
color: "#10B981"Pattern fill
fill:
type: "pattern"
pattern: "cross" # MSO_PATTERN_TYPE name (e.g., cross, diagonal_stripe)
foreground: "#000000"
background: "#FFFFFF"Line Properties
Line/border properties apply to shapes and connectors:
| Field | Type | Description |
|---|---|---|
line_color | string | Line color (any color syntax) |
line_width | float | Line width in points |
dash_style | string | Dash style (see Line Dash Styles table) |
Connector Fields
| Field | Type | Default | Description |
|---|---|---|---|
connector_type | string | straight | Connector routing: straight, elbow, curve |
begin_x | float | — | Start X position in inches |
begin_y | float | — | Start Y position in inches |
end_x | float | — | End X position in inches |
end_y | float | — | End Y position in inches |
head_end | string | none | Start arrowhead: none, arrow, triangle, stealth, diamond, oval |
tail_end | string | none | End arrowhead: none, arrow, triangle, stealth, diamond, oval |
Table Fields
| Field | Type | Default | Description |
|---|---|---|---|
columns | list | — | Column definitions with width in inches |
rows | list | — | Row definitions with cells list |
first_row | bool | false | Apply first-row (header) banding |
last_row | bool | false | Apply last-row banding |
first_col | bool | false | Apply first-column banding |
last_col | bool | false | Apply last-column banding |
horz_banding | bool | false | Apply horizontal row banding |
vert_banding | bool | false | Apply vertical column banding |
Cell Fields
| Field | Type | Description |
|---|---|---|
text | string | Cell text content |
fill | string | Cell background color |
font_color | string | Cell text color |
font_bold | bool | Bold text in cell |
font_size | int | Font size in points |
font | string | Font family |
vertical_anchor | string | Vertical alignment: top, middle, bottom |
merge_right | int | Merge across N additional columns |
merge_down | int | Merge across N additional rows |
Chart Fields
| Field | Type | Default | Description |
|---|---|---|---|
chart_type | string | column_clustered | Chart type (see Supported Chart Types table) |
categories | list | — | Category labels for x-axis |
series | list | — | Data series; each has name and values |
title | string | — | Chart title |
has_legend | bool | true | Display chart legend |
Scatter and bubble charts use data_points instead of categories/values:
series:
- name: "Scatter Data"
data_points:
- x: 1.0
y: 2.5
- x: 3.0
y: 4.1Placeholder Content
When using a template PPTX with themed layouts, populate layout placeholders with the placeholders section:
slide: 1
layout: "Title Slide"
placeholders:
0: "Presentation Title" # placeholder index 0 (typically title)
1: "Subtitle text here" # placeholder index 1 (typically subtitle)
elements: []
speaker_notes: |
Opening slide with template placeholders populated.Placeholder indices correspond to the layout's placeholder positions. Use the --template argument with build_deck.py to load layouts from the template file, and define layout name mappings in style.yaml under the layouts section.
🤖 Crafted with precision by ✨Copilot following brilliant human instruction, then carefully refined by our team of discerning human reviewers.
[project]
name = "powerpoint-skill-tests"
version = "0.0.0"
requires-python = ">=3.11"
dependencies = [
"python-pptx",
"pyyaml",
"ruamel.yaml", # required by generate_themes.py for round-trip YAML fidelity
"cairosvg",
"Pillow",
"pymupdf",
"github-copilot-sdk",
]
[dependency-groups]
dev = [
"pytest>=9.0",
"pytest-cov>=7.0",
"pytest-mock>=3.14",
"ruff>=0.15",
"hypothesis>=6.100",
]
# Atheris ships manylinux-only wheels; keep separate from dev so uv sync works on macOS.
fuzz = [
"atheris>=3.0",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["scripts"]
python_files = ["test_*.py", "fuzz_harness.py"]
markers = [
"integration: roundtrip integration tests",
"slow: tests that create full presentations",
"hypothesis: property-based tests using Hypothesis",
]
[tool.coverage.run]
source = ["scripts"]
[tool.coverage.report]
fail_under = 85
show_missing = true
[tool.ruff]
line-length = 88
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "W"]
# Copyright (c) Microsoft Corporation.
# SPDX-License-Identifier: MIT
"""Build a PowerPoint slide deck from YAML content and style definitions.
Usage::
python build_deck.py --content-dir content/ \
--style content/global/style.yaml \
--output slide-deck/presentation.pptx
python build_deck.py --content-dir content/ \
--style content/global/style.yaml \
--source existing.pptx \
--output slide-deck/presentation.pptx --slides 3,7,15
"""
from __future__ import annotations
import argparse
import ast
import builtins
import importlib.util
import logging
import re
import sys
from pathlib import Path
from lxml import etree
from pptx import Presentation
from pptx.enum.shapes import MSO_CONNECTOR_TYPE, MSO_SHAPE
from pptx.oxml.ns import qn
from pptx.util import Inches, Pt
from pptx_charts import add_chart_element
from pptx_colors import apply_color_to_font, resolve_color
from pptx_fills import apply_effect_list, apply_fill, apply_line
from pptx_fonts import ALIGNMENT_MAP
from pptx_shapes import SHAPE_MAP, apply_rotation
from pptx_tables import add_table_element
from pptx_text import (
SHAPE_KEYS,
TEXTBOX_KEYS,
apply_run_properties,
apply_text_properties,
populate_text_frame,
)
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,
"elbow": MSO_CONNECTOR_TYPE.ELBOW,
"curve": MSO_CONNECTOR_TYPE.CURVE,
}
PNS = "http://schemas.openxmlformats.org/presentationml/2006/main"
ANS = "http://schemas.openxmlformats.org/drawingml/2006/main"
# Stdlib modules blocked in content-extra.py scripts due to security risk.
# content-extra.py may only import from pptx and safe standard-library modules.
_BLOCKED_STDLIB_MODULES = frozenset(
{
"code",
"codeop",
"compileall",
"ctypes",
"dbm",
"ensurepip",
"ftplib",
"http",
"imaplib",
"importlib",
"marshal",
"multiprocessing",
"os",
"pickle",
"pkgutil",
"poplib",
"py_compile",
"runpy",
"shelve",
"shutil",
"signal",
"smtplib",
"socket",
"sqlite3",
"subprocess",
"sys",
"telnetlib",
"tempfile",
"threading",
"urllib",
"venv",
"webbrowser",
"xmlrpc",
"zipimport",
}
)
_DANGEROUS_BUILTINS = frozenset(
{
"__import__",
"breakpoint",
"compile",
"eval",
"exec",
}
)
# Builtins that can bypass the import allowlist or execute arbitrary strings
# when called indirectly through attribute access or introspection.
_INDIRECT_BYPASS_BUILTINS = frozenset(
{
"delattr",
"getattr",
"globals",
"locals",
"setattr",
"vars",
}
)
class ContentExtraError(Exception):
"""A content-extra.py script failed security validation."""
def _check_module_allowed(
module_name: str, script_path: Path, stdlib_names: frozenset[str]
) -> None:
"""Raise ContentExtraError if *module_name* is not on the allowlist."""
top_level = module_name.split(".")[0]
if top_level == "pptx":
return
if top_level in _BLOCKED_STDLIB_MODULES:
raise ContentExtraError(f"Blocked import '{module_name}' in {script_path}")
if top_level in stdlib_names:
return
raise ContentExtraError(
f"Disallowed import '{module_name}' in {script_path}: "
"only pptx and safe standard library modules are permitted"
)
def _validate_content_extra(script_path: Path) -> None:
"""Validate a content-extra.py script's AST before execution.
Parses the script and rejects imports outside of pptx and safe stdlib
modules, as well as calls to dangerous builtins (exec, eval, __import__,
compile, breakpoint). Raises ContentExtraError on any violation.
"""
source = script_path.read_text(encoding="utf-8")
try:
tree = ast.parse(source, filename=str(script_path))
except SyntaxError as exc:
raise ContentExtraError(f"Syntax error in {script_path}: {exc}") from exc
stdlib_names = sys.stdlib_module_names
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
_check_module_allowed(alias.name, script_path, stdlib_names)
elif isinstance(node, ast.ImportFrom):
if node.module:
_check_module_allowed(node.module, script_path, stdlib_names)
elif isinstance(node, ast.Call):
func = node.func
if isinstance(func, ast.Name):
if func.id in _DANGEROUS_BUILTINS:
raise ContentExtraError(
f"Dangerous builtin '{func.id}' in {script_path}"
)
if func.id in _INDIRECT_BYPASS_BUILTINS:
raise ContentExtraError(
f"Indirect bypass builtin '{func.id}' in {script_path}"
)
def _reset_effect_ref(shape):
"""Reset effectRef idx to 0 to prevent theme shadow inheritance.
python-pptx defaults effectRef idx to 2, which references the theme's
effectStyleLst[2] that typically includes an outerShdw element.
"""
style_el = shape._element.find(f"{{{PNS}}}style")
if style_el is not None:
effect_ref = style_el.find(f"{{{ANS}}}effectRef")
if effect_ref is not None:
effect_ref.set("idx", "0")
def set_slide_bg(slide, fill_spec, colors: dict):
"""Set a background fill on a slide."""
apply_fill(slide.background, fill_spec, colors)
def set_slide_bg_image(slide, image_path: str, content_dir: Path):
"""Set a background image on a slide using blipFill in the background element."""
img_file = content_dir / image_path
if not img_file.exists():
return
from pptx.opc.constants import RELATIONSHIP_TYPE as RT
from pptx.parts.image import Image, ImagePart
sld = slide._element
cSld = sld.find(qn("p:cSld"))
if cSld is None:
return
spTree = cSld.find(qn("p:spTree"))
# Remove existing p:bg element if present
existing_bg = cSld.find(qn("p:bg"))
if existing_bg is not None:
cSld.remove(existing_bg)
# Create image part and relate to slide
image = Image.from_file(str(img_file))
image_part = ImagePart.new(slide.part.package, image)
rel = slide.part.relate_to(image_part, RT.IMAGE)
# Build p:bg > p:bgPr > a:blipFill structure
bg = etree.SubElement(cSld, qn("p:bg"))
bgPr = etree.SubElement(bg, qn("p:bgPr"))
blipFill = etree.SubElement(bgPr, qn("a:blipFill"))
blipFill.set("dpi", "0")
blipFill.set("rotWithShape", "1")
blip = etree.SubElement(blipFill, qn("a:blip"))
blip.set(qn("r:embed"), rel)
stretch = etree.SubElement(blipFill, qn("a:stretch"))
etree.SubElement(stretch, qn("a:fillRect"))
etree.SubElement(bgPr, qn("a:effectLst"))
# Ensure p:bg appears before p:spTree (required by schema)
if spTree is not None:
cSld.remove(bg)
cSld.insert(list(cSld).index(spTree), bg)
def add_textbox(
slide,
left,
top,
width,
height,
text,
font_name=None,
font_size=16,
font_color=None,
bold=False,
italic=False,
alignment=None,
name=None,
rotation=None,
elem=None,
colors=None,
):
"""Add a text box to a slide with font and layout properties.
Args:
slide: Target slide object.
left: Left position in inches.
top: Top position in inches.
width: Width in inches.
height: Height in inches.
text: Text content for the box.
font_name: Font family name.
font_size: Font size in points.
font_color: Resolved color spec dict.
bold: Apply bold formatting.
italic: Apply italic formatting.
alignment: Paragraph alignment name.
name: Shape name identifier.
rotation: Rotation angle in degrees.
elem: Full element dict from content.yaml for
paragraph-level and run-level properties.
colors: Color resolution dict.
Returns:
The created textbox shape object.
"""
txBox = slide.shapes.add_textbox(
Inches(left), Inches(top), Inches(width), Inches(height)
)
if name:
txBox.name = name
apply_rotation(txBox, rotation)
defaults = {
"font": font_name,
"size": font_size,
"color": font_color,
"bold": bold,
"italic": italic,
"alignment": alignment,
}
source = elem or {"text": text}
if "text" not in source:
source = {**source, "text": text}
populate_text_frame(txBox.text_frame, source, colors or {}, TEXTBOX_KEYS, defaults)
return txBox
def add_shape_element(slide, elem, colors, typography):
"""Add a shape element from a content.yaml definition."""
shape_type = SHAPE_MAP.get(elem.get("shape", "rectangle"), MSO_SHAPE.RECTANGLE)
left = Inches(elem["left"])
top = Inches(elem["top"])
width = Inches(elem["width"])
height = Inches(elem["height"])
shape = slide.shapes.add_shape(shape_type, left, top, width, height)
_reset_effect_ref(shape)
if "name" in elem:
shape.name = elem["name"]
apply_rotation(shape, elem.get("rotation"))
apply_fill(shape, elem.get("fill"), colors)
apply_line(shape, elem, colors)
if "corner_radius" in elem:
shape.adjustments[0] = elem["corner_radius"]
if "effect" in elem:
apply_effect_list(shape, elem["effect"])
if "text" in elem:
populate_text_frame(shape.text_frame, elem, colors, SHAPE_KEYS)
return shape
def add_image_element(slide, elem, content_dir: Path):
"""Add an image element from a content.yaml definition."""
img_path = content_dir / elem["path"]
if not img_path.exists():
# Fallback: add a text box with the path as placeholder
add_textbox(
slide,
elem["left"],
elem["top"],
elem["width"],
elem["height"],
f"[Image: {elem['path']}]",
font_size=12,
)
return None
left = Inches(elem["left"])
top = Inches(elem["top"])
width = Inches(elem["width"])
height = Inches(elem["height"])
pic = slide.shapes.add_picture(str(img_path), left, top, width, height)
if "name" in elem:
pic.name = elem["name"]
apply_rotation(pic, elem.get("rotation"))
# Restore blipFill attributes (rotWithShape, dpi, etc.)
if "blip_fill_attrs" in elem:
blipFill = pic._element.find(qn("p:blipFill"))
if blipFill is not None:
for attr_name, attr_val in elem["blip_fill_attrs"].items():
blipFill.set(attr_name, attr_val)
# Apply image crop via srcRect on blipFill
if "crop" in elem:
blipFill = pic._element.find(qn("p:blipFill"))
if blipFill is not None:
srcRect = blipFill.find(qn("a:srcRect"))
if srcRect is None:
# Insert srcRect after a:blip
blip_el = blipFill.find(qn("a:blip"))
idx = list(blipFill).index(blip_el) + 1 if blip_el is not None else 0
srcRect = etree.Element(qn("a:srcRect"))
blipFill.insert(idx, srcRect)
crop = elem["crop"]
for side in ("l", "t", "r", "b"):
if side in crop:
srcRect.set(side, str(crop[side]))
# Apply image opacity via alphaModFix on the blip element
if "opacity" in elem:
blip = pic._element.find(".//" + qn("a:blip"))
if blip is not None:
amt = str(int(elem["opacity"] * 1000))
amf = blip.find(qn("a:alphaModFix"))
if amf is None:
amf = etree.SubElement(blip, qn("a:alphaModFix"))
amf.set("amt", amt)
return pic
def add_rich_text_element(slide, elem, colors, typography):
"""Add a rich text element with mixed font/color segments."""
txBox = slide.shapes.add_textbox(
Inches(elem["left"]),
Inches(elem["top"]),
Inches(elem["width"]),
Inches(elem["height"]),
)
if "name" in elem:
txBox.name = elem["name"]
tf = txBox.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
# Apply text frame-level properties
apply_text_properties(tf, elem)
for i, seg in enumerate(elem.get("segments", [])):
run = p.add_run() if i > 0 else (p.runs[0] if p.runs else p.add_run())
run.text = seg["text"]
seg_font = seg.get("font")
if seg_font:
run.font.name = seg_font
run.font.size = Pt(seg.get("size", 16))
if "color" in seg:
color_spec = resolve_color(seg["color"])
apply_color_to_font(run.font.color, color_spec)
run.font.bold = seg.get("bold", False)
run.font.italic = seg.get("italic", False)
apply_run_properties(run, seg, colors)
return txBox
def add_card_element(slide, elem, colors, typography):
"""Add a card panel with optional title bar and bullet content."""
left = Inches(elem["left"])
top = Inches(elem["top"])
width = Inches(elem["width"])
height = Inches(elem["height"])
# Card background
shape = slide.shapes.add_shape(
MSO_SHAPE.ROUNDED_RECTANGLE, left, top, width, height
)
apply_fill(shape, elem.get("fill", "#2D2D35"), colors)
if "border_color" in elem:
apply_line(
shape,
{
"line_color": elem["border_color"],
"line_width": elem.get("border_width", 1),
},
colors,
)
else:
shape.line.fill.background()
# Accent bar
if elem.get("accent_bar"):
bar = slide.shapes.add_shape(
MSO_SHAPE.RECTANGLE,
Inches(elem["left"] + 0.15),
Inches(elem["top"] + 0.1),
Inches(elem["width"] - 0.3),
Inches(0.04),
)
apply_fill(bar, elem.get("accent_color", "#0078D4"), colors)
bar.line.fill.background()
# Title
y_offset = 0.2
if "title" in elem:
add_textbox(
slide,
elem["left"] + 0.2,
elem["top"] + y_offset,
elem["width"] - 0.4,
0.4,
elem["title"],
font_name="Segoe UI",
font_size=elem.get("title_size", 16),
font_color=resolve_color(elem.get("title_color", "#F8F8FC")),
bold=elem.get("title_bold", True),
)
y_offset += 0.5
# Content bullets
for item in elem.get("content", []):
bullet_text = (
f"\u2022 {item['bullet']}" if "bullet" in item else item.get("text", "")
)
color = resolve_color(item.get("color", "#F8F8FC"))
add_textbox(
slide,
elem["left"] + 0.2,
elem["top"] + y_offset,
elem["width"] - 0.4,
0.35,
bullet_text,
font_name="Segoe UI",
font_size=item.get("size", 14),
font_color=color,
)
y_offset += 0.35
return shape
def add_arrow_flow_element(slide, elem, colors, typography):
"""Add a horizontal arrow flow diagram."""
items = elem.get("items", [])
if not items:
return
total_width = elem["width"]
item_width = total_width / len(items) - 0.3
x = elem["left"]
for item in items:
shape = slide.shapes.add_shape(
MSO_SHAPE.CHEVRON,
Inches(x),
Inches(elem["top"]),
Inches(item_width),
Inches(elem["height"]),
)
apply_fill(shape, item.get("color", "#0078D4"), colors)
shape.line.fill.background()
tf = shape.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.text = item["label"]
p.alignment = ALIGNMENT_MAP["center"]
run = p.runs[0]
run.font.name = "Segoe UI"
run.font.size = Pt(14)
apply_color_to_font(run.font.color, resolve_color("#F8F8FC"))
run.font.bold = True
x += item_width + 0.3
def add_numbered_step_element(slide, elem, colors, typography):
"""Add a numbered step with circle, label, and description."""
number = elem.get("number", 1)
# Number circle
circle = slide.shapes.add_shape(
MSO_SHAPE.OVAL,
Inches(elem["left"]),
Inches(elem["top"]),
Inches(0.5),
Inches(0.5),
)
apply_fill(circle, elem.get("accent_color", "#0078D4"), colors)
circle.line.fill.background()
tf = circle.text_frame
p = tf.paragraphs[0]
p.text = str(number)
p.alignment = ALIGNMENT_MAP["center"]
run = p.runs[0]
run.font.name = "Segoe UI"
run.font.size = Pt(16)
apply_color_to_font(run.font.color, resolve_color("#F8F8FC"))
run.font.bold = True
# Label
add_textbox(
slide,
elem["left"] + 0.6,
elem["top"],
elem["width"] - 0.6,
0.35,
elem["label"],
font_name="Segoe UI",
font_size=16,
font_color=resolve_color("#F8F8FC"),
bold=True,
)
# Description
if "description" in elem:
add_textbox(
slide,
elem["left"] + 0.6,
elem["top"] + 0.35,
elem["width"] - 0.6,
0.4,
elem["description"],
font_name="Segoe UI",
font_size=14,
font_color=resolve_color("#9CA3AF"),
)
def add_connector_element(slide, elem: dict, colors: dict):
"""Add a connector element from a content.yaml definition.
YAML schema:
- type: connector
connector_type: straight
begin_x: 3.0
begin_y: 2.0
end_x: 7.0
end_y: 4.0
line_color: "#0078D4"
line_width: 2
dash_style: solid
head_end: none
tail_end: arrow
"""
conn_type = CONNECTOR_TYPE_MAP.get(
elem.get("connector_type", "straight"), MSO_CONNECTOR_TYPE.STRAIGHT
)
connector = slide.shapes.add_connector(
conn_type,
Inches(elem["begin_x"]),
Inches(elem["begin_y"]),
Inches(elem["end_x"]),
Inches(elem["end_y"]),
)
apply_line(connector, elem, colors)
# Arrow heads via lxml XML manipulation
sp_pr = connector._element.find(qn("a:ln"))
if sp_pr is None:
ln_parent = connector._element.spPr
sp_pr = ln_parent.find(qn("a:ln"))
if sp_pr is None:
sp_pr = etree.SubElement(connector._element.spPr, qn("a:ln"))
if "head_end" in elem and elem["head_end"] != "none":
head = etree.SubElement(sp_pr, qn("a:headEnd"))
head.set("type", elem["head_end"])
if "tail_end" in elem and elem["tail_end"] != "none":
tail = etree.SubElement(sp_pr, qn("a:tailEnd"))
tail.set("type", elem["tail_end"])
if "name" in elem:
connector.name = elem["name"]
return connector
MAX_GROUP_DEPTH = 20
def add_group_element(
slide,
elem: dict,
colors: dict,
typography: dict,
content_dir: Path,
*,
_depth: int = 0,
max_depth: int = MAX_GROUP_DEPTH,
):
"""Add a group element containing nested child elements.
Raises ValueError when nesting exceeds *max_depth*.
YAML schema:
- type: group
left: 1.0
top: 2.0
width: 5.0
height: 3.0
elements:
- type: shape
shape: rectangle
left: 0
top: 0
width: 5.0
height: 3.0
fill: "#2D2D35"
- type: textbox
left: 0.2
top: 0.2
width: 4.6
height: 0.5
text: "Group Title"
"""
if _depth >= max_depth:
raise ValueError(f"Group nesting depth {_depth} exceeds limit of {max_depth}")
group = slide.shapes.add_group_shape()
group.left = Inches(elem["left"])
group.top = Inches(elem["top"])
group.width = Inches(elem["width"])
group.height = Inches(elem["height"])
for child_elem in elem.get("elements", []):
build_element_in_group(
group,
child_elem,
colors,
typography,
content_dir,
_depth=_depth + 1,
max_depth=max_depth,
)
if "name" in elem:
group.name = elem["name"]
return group
def build_element_in_group(
group,
elem: dict,
colors: dict,
typography: dict,
content_dir: Path,
*,
_depth: int = 0,
max_depth: int = MAX_GROUP_DEPTH,
):
"""Dispatch a child element build within a group shape.
Reuses top-level builders for shape and textbox. Groups do not support
table or chart elements.
"""
elem_type = elem.get("type", "textbox")
if elem_type == "shape":
_add_shape_to_collection(group.shapes, elem, colors)
elif elem_type == "textbox":
_add_textbox_to_collection(group.shapes, elem, colors)
elif elem_type == "connector":
add_connector_element(group, elem, colors)
elif elem_type == "image":
add_image_element(group, elem, content_dir)
elif elem_type == "group":
add_group_element(
group,
elem,
colors,
typography,
content_dir,
_depth=_depth,
max_depth=max_depth,
)
def _add_shape_to_collection(shapes, elem: dict, colors: dict):
"""Add a shape to any shapes collection (slide or group)."""
shape_type = SHAPE_MAP.get(elem.get("shape", "rectangle"), MSO_SHAPE.RECTANGLE)
shape = shapes.add_shape(
shape_type,
Inches(elem["left"]),
Inches(elem["top"]),
Inches(elem["width"]),
Inches(elem["height"]),
)
if "name" in elem:
shape.name = elem["name"]
apply_rotation(shape, elem.get("rotation"))
apply_fill(shape, elem.get("fill"), colors)
apply_line(shape, elem, colors)
if "text" in elem:
populate_text_frame(shape.text_frame, elem, colors, SHAPE_KEYS)
return shape
def _add_textbox_to_collection(shapes, elem: dict, colors: dict):
"""Add a textbox to any shapes collection (slide or group)."""
txBox = shapes.add_textbox(
Inches(elem["left"]),
Inches(elem["top"]),
Inches(elem["width"]),
Inches(elem["height"]),
)
if "name" in elem:
txBox.name = elem["name"]
populate_text_frame(txBox.text_frame, elem, colors, TEXTBOX_KEYS)
return txBox
def _build_textbox_element(slide, elem, colors, typography, content_dir):
"""Build a textbox element with full parameter resolution for YAML keys."""
font_name = elem.get("font")
font_color = resolve_color(elem["font_color"]) if "font_color" in elem else None
is_bold = elem.get("font_bold", elem.get("bold", False))
add_textbox(
slide,
elem["left"],
elem["top"],
elem["width"],
elem["height"],
elem.get("text", ""),
font_name=font_name,
font_size=elem.get("font_size", 16),
font_color=font_color,
bold=is_bold,
italic=elem.get("italic", False),
alignment=elem.get("alignment"),
name=elem.get("name"),
rotation=elem.get("rotation"),
elem=elem,
colors=colors,
)
def _build_image_element(slide, elem, colors, typography, content_dir):
"""Delegate image element building to add_image_element."""
add_image_element(slide, elem, content_dir)
def _build_group_element(slide, elem, colors, typography, content_dir):
"""Delegate group element building to add_group_element."""
add_group_element(slide, elem, colors, typography, content_dir, _depth=0)
def _build_connector_element(slide, elem, colors, typography, content_dir):
"""Delegate connector building to add_connector_element."""
add_connector_element(slide, elem, colors)
def _build_chart_element(slide, elem, colors, typography, content_dir):
"""Delegate chart building to add_chart_element."""
add_chart_element(slide, elem, colors)
def _build_table_element(slide, elem, colors, typography, content_dir):
"""Delegate table building to add_table_element."""
add_table_element(slide, elem, colors, typography)
# Element builder registry: maps element type names to builder functions.
# All builders share the signature (slide, elem, colors, typography, content_dir).
ELEMENT_BUILDERS = {
"shape": lambda slide, elem, colors, typography, content_dir: add_shape_element(
slide, elem, colors, typography
),
"textbox": _build_textbox_element,
"image": _build_image_element,
"rich_text": lambda slide, elem, colors, typography, content_dir: (
add_rich_text_element(slide, elem, colors, typography)
),
"card": lambda slide, elem, colors, typography, content_dir: add_card_element(
slide, elem, colors, typography
),
"arrow_flow": lambda slide, elem, colors, typography, content_dir: (
add_arrow_flow_element(slide, elem, colors, typography)
),
"numbered_step": lambda slide, elem, colors, typography, content_dir: (
add_numbered_step_element(slide, elem, colors, typography)
),
"table": _build_table_element,
"chart": _build_chart_element,
"connector": _build_connector_element,
"group": _build_group_element,
}
def _build_element(
slide, elem: dict, colors: dict, typography: dict, content_dir: Path
):
"""Dispatch element building via registry lookup."""
elem_type = elem.get("type", "textbox")
builder = ELEMENT_BUILDERS.get(elem_type)
if builder:
builder(slide, elem, colors, typography, content_dir)
def clear_slide_shapes(slide):
"""Remove all shapes from a slide, preserving the slide itself."""
sp_tree = slide.shapes._spTree
shapes_to_remove = [
sp
for sp in sp_tree.iterchildren()
if sp.tag.endswith("}sp")
or sp.tag.endswith("}pic")
or sp.tag.endswith("}grpSp")
or sp.tag.endswith("}cxnSp")
]
for sp in shapes_to_remove:
sp_tree.remove(sp)
def _all_layouts(prs):
"""Iterate layouts across all slide masters."""
for master in prs.slide_masters:
yield from master.slide_layouts
def _find_blank_layout(prs):
"""Find the best blank layout in the presentation, with fallbacks."""
# Try index 6 first (default blank in standard templates)
try:
return prs.slide_layouts[6]
except IndexError:
# Template has fewer than 7 layouts; fall through to name search.
pass
# Search by name across all masters
for layout in _all_layouts(prs):
if layout.name.lower() in ("blank", "blank slide"):
return layout
# Fall back to last layout of first master
return prs.slide_layouts[len(prs.slide_layouts) - 1]
def get_slide_layout(prs, slide_content: dict, style: dict):
"""Select slide layout based on content.yaml or style.yaml configuration."""
layout_spec = slide_content.get("layout")
layouts_map = style.get("layouts", {})
if layout_spec is None or layout_spec == "blank":
return _find_blank_layout(prs)
# Resolve through style.yaml layouts map
if layout_spec in layouts_map:
layout_ref = layouts_map[layout_spec]
if isinstance(layout_ref, int):
try:
return prs.slide_layouts[layout_ref]
except IndexError:
return _find_blank_layout(prs)
elif isinstance(layout_ref, str):
for layout in _all_layouts(prs):
if layout.name == layout_ref:
return layout
# Direct name lookup across all slide masters
if isinstance(layout_spec, str):
for layout in _all_layouts(prs):
if layout.name == layout_spec:
return layout
# Direct index lookup
if isinstance(layout_spec, int):
try:
return prs.slide_layouts[layout_spec]
except IndexError:
return _find_blank_layout(prs)
# Fallback to blank
return _find_blank_layout(prs)
def build_slide(
prs,
slide_content: dict,
style: dict,
content_dir: Path,
existing_slide=None,
*,
allow_scripts: bool = False,
):
"""Build a single slide from content.yaml data and style context.
When existing_slide is provided, clears its shapes and rebuilds in place
instead of appending a new slide. Set *allow_scripts* to skip AST
validation of content-extra.py (use only with trusted content).
"""
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)
else:
layout = get_slide_layout(prs, slide_content, style)
slide = prs.slides.add_slide(layout)
# Populate themed layout placeholders
placeholders = slide_content.get("placeholders", {})
for idx_str, value in placeholders.items():
idx = int(idx_str)
if idx in slide.placeholders:
ph = slide.placeholders[idx]
if isinstance(value, str):
ph.text = value
elif isinstance(value, list):
tf = ph.text_frame
tf.text = value[0]
for line in value[1:]:
tf.add_paragraph().text = line
# Remove unused placeholder shapes inherited from the layout
used_ph_indices = {int(k) for k in placeholders}
sp_tree = slide.shapes._spTree
for sp in list(sp_tree.iterchildren()):
nvSpPr = sp.find(qn("p:nvSpPr"))
if nvSpPr is None:
continue
nvPr = nvSpPr.find(qn("p:nvPr"))
if nvPr is None:
continue
ph = nvPr.find(qn("p:ph"))
if ph is not None:
idx = int(ph.get("idx", "0"))
if idx not in used_ph_indices:
sp_tree.remove(sp)
# Set background from per-slide definition only
bg_block = slide_content.get("background")
if bg_block and "image" in bg_block:
set_slide_bg_image(slide, bg_block["image"], content_dir)
elif bg_block and "fill" in bg_block:
set_slide_bg(slide, bg_block["fill"], colors)
# Sort elements by z_order to preserve stacking order
elements = slide_content.get("elements", [])
elements = sorted(elements, key=lambda e: e.get("z_order", 0))
# Filter out empty placeholder elements
elements = [
e
for e in elements
if not (e.get("_placeholder") and not e.get("text", "").strip())
]
turbo_enabled = len(elements) > 20
if turbo_enabled:
slide.shapes.turbo_add_enabled = True
# Process elements in order
for elem in elements:
_build_element(slide, elem, colors, typography, content_dir)
# Execute content-extra.py if present (validated before loading)
extra_script = content_dir / "content-extra.py"
if extra_script.exists():
if not allow_scripts:
_validate_content_extra(extra_script)
spec = importlib.util.spec_from_file_location(
"content_extra", str(extra_script)
)
mod = importlib.util.module_from_spec(spec)
if not allow_scripts:
# __import__ is kept because the import machinery needs it;
# the AST checker already blocks direct __import__() calls.
stripped = (_DANGEROUS_BUILTINS | _INDIRECT_BYPASS_BUILTINS) - {
"__import__"
}
safe_builtins = {
k: v for k, v in builtins.__dict__.items() if k not in stripped
}
mod.__builtins__ = safe_builtins
spec.loader.exec_module(mod)
if hasattr(mod, "render"):
mod.render(slide, style, content_dir)
if turbo_enabled:
slide.shapes.turbo_add_enabled = False
# Add speaker notes (preserve empty strings when notes slide exists)
notes = slide_content.get("speaker_notes")
if notes is not None:
notes_slide = slide.notes_slide
notes_text = re.sub(r"\v", "\n", notes) if notes else ""
notes_slide.notes_text_frame.text = notes_text
return slide
def discover_slides(content_dir: Path) -> list[tuple[int, Path]]:
"""Discover slide content directories and return sorted (number, path) pairs."""
slides = []
for child in content_dir.iterdir():
if child.is_dir() and child.name.startswith("slide-"):
match = re.match(r"slide-(\d+)", child.name)
if match:
num = int(match.group(1))
content_yaml = child / "content.yaml"
if content_yaml.exists():
slides.append((num, child))
return sorted(slides, key=lambda x: x[0])
def main():
"""CLI entry point for building a PowerPoint deck from YAML."""
parser = argparse.ArgumentParser(
description="Build a PowerPoint deck from YAML content"
)
parser.add_argument(
"--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", 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(
"--slides", help="Comma-separated slide numbers to rebuild (requires --source)"
)
parser.add_argument(
"--allow-scripts",
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()
if not args.dry_run and not args.output:
parser.error("--output is required when not using --dry-run")
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 = (
sum(
len(list(images.glob(f"*{ext}")))
for ext in (".png", ".jpg", ".jpeg")
)
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
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
dims = style.get("dimensions", {})
width = dims.get("width_inches", 13.333)
height = dims.get("height_inches", 7.5)
if args.template:
# Template build: open template and preserve its theme/layouts
prs = Presentation(args.template)
# Only override dimensions when explicitly set in style.yaml
if "dimensions" in style:
prs.slide_width = Inches(width)
prs.slide_height = Inches(height)
# Remove existing slides from the template — keep only theme/layouts
while len(prs.slides) > 0:
rId = prs.slides._sldIdLst[0].rId
prs.part.drop_rel(rId)
prs.slides._sldIdLst.remove(prs.slides._sldIdLst[0])
# Apply presentation metadata from style.yaml
metadata = style.get("metadata", {})
if metadata:
props = prs.core_properties
for key, value in metadata.items():
if hasattr(props, key):
setattr(props, key, value)
slides_data = discover_slides(content_dir)
if not slides_data:
print("No slide content found in", content_dir)
return EXIT_ERROR
for num, slide_dir in slides_data:
slide_content = load_yaml(slide_dir / "content.yaml")
build_slide(
prs,
slide_content,
style,
slide_dir,
allow_scripts=args.allow_scripts,
)
print(f"Built slide {num}: {slide_content.get('title', 'Untitled')}")
elif args.source and args.slides:
# Partial rebuild: open existing deck and replace specific slides
prs = Presentation(args.source)
slide_nums = [int(s.strip()) for s in args.slides.split(",")]
slides_data = discover_slides(content_dir)
slides_to_rebuild = {
num: path for num, path in slides_data if num in slide_nums
}
for num in slide_nums:
if num not in slides_to_rebuild:
print(f"Warning: No content found for slide {num}, skipping")
continue
slide_dir = slides_to_rebuild[num]
slide_content = load_yaml(slide_dir / "content.yaml")
# Rebuild in-place: clear shapes on the existing slide and repopulate
idx = num - 1
if idx < len(prs.slides):
existing_slide = prs.slides[idx]
build_slide(
prs,
slide_content,
style,
slide_dir,
existing_slide=existing_slide,
allow_scripts=args.allow_scripts,
)
print(f"Rebuilt slide {num} in-place")
else:
slide_count = len(prs.slides)
print(
f"Warning: Slide {num} does not exist"
f" in deck (has {slide_count} slides),"
f" skipping"
)
else:
# Full build
prs = Presentation()
prs.slide_width = Inches(width)
prs.slide_height = Inches(height)
# Apply presentation metadata from style.yaml
metadata = style.get("metadata", {})
if metadata:
props = prs.core_properties
for key, value in metadata.items():
if hasattr(props, key):
setattr(props, key, value)
slides_data = discover_slides(content_dir)
if not slides_data:
print("No slide content found in", content_dir)
return EXIT_ERROR
for num, slide_dir in slides_data:
slide_content = load_yaml(slide_dir / "content.yaml")
build_slide(
prs,
slide_content,
style,
slide_dir,
allow_scripts=args.allow_scripts,
)
print(f"Built slide {num}: {slide_content.get('title', 'Untitled')}")
prs.save(str(output_path))
print(f"\nDeck saved to {output_path}")
print(f"Total slides: {len(prs.slides)}")
return EXIT_SUCCESS
if __name__ == "__main__":
sys.exit(main())
#!/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())
#!/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
VERBOSE=false
err() {
printf "ERROR: %s\n" "$1" >&2
exit 1
}
usage() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS]
Options:
--input <path> Input PPTX file path (required)
--audio-dir <path> Directory containing WAV files (required)
--output <path> Output PPTX file path (required)
--slides <list> 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 ;;
-v|--verbose) VERBOSE=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)"
[[ "${VERBOSE:-false}" == "true" ]] && pass_through+=("-v")
"${python}" "${SCRIPT_DIR}/embed_audio.py" "${pass_through[@]}"
}
main "$@"
# Copyright (c) Microsoft Corporation.
# SPDX-License-Identifier: MIT
"""Export PowerPoint slides to PDF with optional slide filtering.
Converts a PPTX file to PDF using LibreOffice headless mode. When specific
slide numbers are provided, filters the resulting PDF to include only those
pages using PyMuPDF.
Usage:
python export_slides.py --input presentation.pptx --output slides.pdf
python export_slides.py --input presentation.pptx --output slides.pdf --slides 1,3,5
"""
import argparse
import logging
import os
import platform
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
EXIT_SUCCESS = 0
EXIT_FAILURE = 1
EXIT_ERROR = 2
logger = logging.getLogger(__name__)
def configure_logging(verbose: bool = False) -> None:
"""Configure logging based on verbosity level."""
level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig(level=level, format="%(levelname)s: %(message)s")
def create_parser() -> argparse.ArgumentParser:
"""Create and configure argument parser."""
parser = argparse.ArgumentParser(description="Export PowerPoint slides to PDF")
parser.add_argument(
"--input", required=True, type=Path, help="Input PPTX file path"
)
parser.add_argument(
"--output", required=True, type=Path, help="Output PDF file path"
)
parser.add_argument(
"--slides",
help="Comma-separated slide numbers to export (1-based, default: all)",
)
parser.add_argument(
"-v", "--verbose", action="store_true", help="Enable verbose output"
)
return parser
def find_libreoffice() -> str | None:
"""Locate the LibreOffice/soffice executable across platforms."""
for cmd in ("libreoffice", "soffice"):
path = shutil.which(cmd)
if path:
return path
system = platform.system()
if system == "Darwin":
candidates = [
"/Applications/LibreOffice.app/Contents/MacOS/soffice",
]
elif system == "Windows":
candidates = [
r"C:\Program Files\LibreOffice\program\soffice.exe",
r"C:\Program Files (x86)\LibreOffice\program\soffice.exe",
]
else:
candidates = [
"/usr/bin/libreoffice",
"/usr/bin/soffice",
"/snap/bin/libreoffice",
]
for candidate in candidates:
if os.path.isfile(candidate):
return candidate
return None
def convert_pptx_to_pdf(pptx_path: Path, output_dir: Path) -> Path:
"""Convert a PPTX file to PDF using LibreOffice headless mode.
Args:
pptx_path: Path to the input PPTX file.
output_dir: Directory where the PDF will be written.
Returns:
Path to the generated PDF file.
"""
soffice = find_libreoffice()
if not soffice:
logger.error("LibreOffice is required for PPTX-to-PDF conversion.")
logger.error("Install via:")
logger.error(" macOS: brew install --cask libreoffice")
logger.error(" Linux: sudo apt-get install libreoffice")
logger.error(" Windows: winget install TheDocumentFoundation.LibreOffice")
sys.exit(EXIT_FAILURE)
output_dir.mkdir(parents=True, exist_ok=True)
logger.info("Converting %s to PDF via LibreOffice", pptx_path.name)
try:
result = subprocess.run(
[
soffice,
"--headless",
"--convert-to",
"pdf",
"--outdir",
str(output_dir),
str(pptx_path),
],
capture_output=True,
text=True,
check=True,
)
logger.debug("LibreOffice stdout: %s", result.stdout)
except subprocess.CalledProcessError as e:
logger.error("LibreOffice conversion failed: %s", e.stderr)
sys.exit(EXIT_FAILURE)
except FileNotFoundError:
logger.error("LibreOffice executable not found: %s", soffice)
sys.exit(EXIT_FAILURE)
pdf_name = pptx_path.stem + ".pdf"
pdf_path = output_dir / pdf_name
if not pdf_path.exists():
logger.error("Expected PDF not found: %s", pdf_path)
sys.exit(EXIT_FAILURE)
return pdf_path
def filter_pdf_pages(pdf_path: Path, pages: list[int], output_path: Path) -> Path:
"""Extract specific pages from a PDF using PyMuPDF.
Args:
pdf_path: Path to the full PDF.
pages: 1-based page numbers to keep.
output_path: Where to write the filtered PDF.
Returns:
Path to the filtered PDF.
"""
try:
import fitz # noqa: PLC0415 — PyMuPDF
except ImportError:
logger.error(
"PyMuPDF is required for slide filtering. Install via: pip install pymupdf"
)
sys.exit(EXIT_FAILURE)
doc = fitz.open(str(pdf_path))
new_doc = fitz.open()
total_pages = len(doc)
for page_num in pages:
if 1 <= page_num <= total_pages:
new_doc.insert_pdf(doc, from_page=page_num - 1, to_page=page_num - 1)
else:
logger.warning(
"Slide %d out of range (1-%d), skipping", page_num, total_pages
)
output_path.parent.mkdir(parents=True, exist_ok=True)
new_doc.save(str(output_path))
new_doc.close()
doc.close()
logger.info("Filtered PDF saved: %s (%d pages)", output_path, len(pages))
return output_path
def parse_slide_numbers(slides_str: str) -> list[int]:
"""Parse comma-separated slide numbers into a sorted list of integers."""
numbers = []
for part in slides_str.split(","):
part = part.strip()
if part:
numbers.append(int(part))
return sorted(set(numbers))
def run(args: argparse.Namespace) -> int:
"""Execute the export pipeline."""
pptx_path = args.input.resolve()
output_path = args.output.resolve()
if not pptx_path.exists():
logger.error("Input file not found: %s", pptx_path)
return EXIT_ERROR
if not pptx_path.suffix.lower() == ".pptx":
logger.error("Input file must be a .pptx file: %s", pptx_path)
return EXIT_ERROR
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_path = Path(tmp_dir)
full_pdf = convert_pptx_to_pdf(pptx_path, tmp_path)
if args.slides:
slide_nums = parse_slide_numbers(args.slides)
logger.info("Filtering to slides: %s", slide_nums)
filter_pdf_pages(full_pdf, slide_nums, output_path)
else:
output_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(str(full_pdf), str(output_path))
logger.info("Full PDF exported: %s", output_path)
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())
#!/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())
#!/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
VERBOSE=false
err() {
printf "ERROR: %s\n" "$1" >&2
exit 1
}
usage() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS]
Options:
--input <path> Input PPTX file path (required)
--output-dir <path> Output directory for SVG files (required)
--slides <list> 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 ;;
-v|--verbose) VERBOSE=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)"
[[ "${VERBOSE:-false}" == "true" ]] && pass_through+=("-v")
"${python}" "${SCRIPT_DIR}/export_svg.py" "${pass_through[@]}"
}
main "$@"
#!/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
VERBOSE=false
err() {
printf "ERROR: %s\n" "$1" >&2
exit 1
}
usage() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS]
Options:
--content-dir <path> Path to base theme content directory (required)
--themes <path> Path to themes YAML file (required)
--output-dir <path> 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 ;;
-v|--verbose) VERBOSE=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)"
[[ "${VERBOSE:-false}" == "true" ]] && pass_through+=("-v")
"${python}" "${SCRIPT_DIR}/generate_themes.py" "${pass_through[@]}"
}
main "$@"
# Copyright (c) Microsoft Corporation.
# SPDX-License-Identifier: MIT
"""Shared utilities for PowerPoint skill scripts.
Provides YAML loading, EMU conversion, and validation helpers used by
build_deck.py, extract_content.py, validate_deck.py, and validate_slides.py.
"""
import logging
from pathlib import Path
import yaml
EXIT_SUCCESS = 0
EXIT_FAILURE = 1
EXIT_ERROR = 2
def configure_logging(verbose: bool = False) -> None:
"""Configure logging based on verbosity level."""
level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig(level=level, format="%(levelname)s: %(message)s")
def parse_slide_filter(slides_arg: str | None) -> set[int] | None:
"""Parse comma-separated slide numbers into a filter set."""
if not slides_arg:
return None
return {int(s.strip()) for s in slides_arg.split(",")}
def emu_to_inches(emu_val) -> float:
"""Convert EMU to inches, rounded to 3 decimal places."""
if emu_val is None:
return 0.0
return round(emu_val / 914400, 3)
def load_yaml(path: Path) -> dict:
"""Load a YAML file and return the parsed dictionary."""
with open(path, encoding="utf-8") as f:
return yaml.safe_load(f) or {}
AB@nonexistent_theme_name_that_is_very_long_and_weird#FF00FF@accent1xxxxxxxx{"theme":"accent1","brightness":0.5}#AABBCCZZZZZZZZZZ#000000extraArial#FF0000CalibriArialRelated skills
How it compares
Prefer powerpoint over manual .pptx editing when slides are generated from the hve-core YAML pipeline and only specific slides need programmatic vector overlays.
FAQ
What does powerpoint do?
PowerPoint slide deck generation and management using python-pptx with YAML-driven content and styling
When should I use powerpoint?
Invoke when PowerPoint slide deck generation and management using python-pptx with YAML-driven content and styling.
Is powerpoint safe to install?
Review the Security Audits panel on this page before installing in production.