Skill: herve-clawd/apple-style-ppt-maker (version 1.0.0)

--- name: apple-style-ppt-maker description: Create Apple-style minimalist presentation slides through a strict JSON-first workflow. Use when an agent must clarify user requirements, lock topic/content/structure, produce per-slide text plus visual specifications, generate 2K WebP images (Nano Banana Pro), support page-level redesign/regeneration, and export the final deck to PPTX. homepage: https://github.com/herve-ves/open-apple-style-ppt-maker-skill metadata: {"clawdbot":{"emoji":"🍎","requires":{"bins":["uv"],"env":[[REDACTED_SECRET],"GEMINI_API_KEY"]},"primaryEnv":[REDACTED_SECRET]}} --- # Apple Style PPT Maker ## Overview Drive a deterministic presentation pipeline for Apple-style minimalist decks. Enforce requirement clarification, strict schema validation, full-plan review, consistent slide rendering, selective page regeneration, and PPTX export. ## Workflow 0. Check `uv` availability. If missing, stop and ask the user to install `uv`. 1. Clarify requirements with the user. 2. Build and validate `slides_plan.json`. 3. Get user review for the full JSON plan before image generation. 4. Generate all slide images at default `2K` and `webp`. 5. Run result review and regenerate specific pages when requested. 6. Export slides to `final.pptx`. ## Step 0: Verify uv Run: ```bash uv --version ``` If the command fails: - stop the workflow immediately - ask the user to install `uv` - continue only after `uv` is available ## Step 1: Clarify Requirements Use a friendly, conversational tone. Do not expose internal schema field names to the user. Ask in natural language and confirm: - this deck's core topic - target audience and meeting context - expected speaking duration and target page count - desired tone and language - must-keep points and sensitive points to avoid Follow user input language by default. Do not generate images before all clarification items are confirmed. Internal mapping rule (do not show to user): - map the confirmed answers to the internal requirement fields used by the JSON plan ## Step 2: Build Structured Slide JSON Create `slides_plan.json` and validate it against [references/slides-schema.json](references/slides-schema.json). Start from [references/slides-plan-template.json](references/slides-plan-template.json) when you need a fast baseline. Use [references/apple-style-spec.md](references/apple-style-spec.md) to keep visual consistency and style quality. Define both content and visual intent for each page: - text payload (`on_slide_text`) - visual blueprint (`visual_blueprint`) - consistency checks (`consistency_checks`) ## Step 3: Review Full Plan Before Rendering Always show the complete `slides_plan.json` to the user. Pause until the user approves the full plan. Apply edits to the JSON plan first, then render images. ## Step 4: Generate Slide Images Run: ```bash uv run scripts/generate_slides.py --plan slides_plan.json --out outputs/deck_a ``` Defaults: - resolution: `2K` - format: `webp` - aspect ratio: `16:9` - model: `gemini-3-pro-image-preview` (Nano Banana Pro path) Generated files: - `images/slide-XX.webp` - `prompts/slide-XX.prompt.md` - `meta/slide-XX.meta.json` - `deck_manifest.json` ## Step 5: Review and Regenerate Specific Page Run: ```bash uv run scripts/regenerate_slide.py \ --plan slides_plan.json \ --slide 4 \ --change "Simplify data narrative and increase whitespace" \ --out outputs/deck_a ``` Behavior: - Regenerate only the target slide. - Snapshot prior image, prompt, and metadata to `history/`. - Update manifest revision metadata. ## Step 6: Export PPTX Run: ```bash uv run scripts/export_pptx.py \ --images-dir outputs/deck_a/images \ --output-pptx outputs/deck_a/final.pptx ``` ## Environment Set one of: - [REDACTED_SECRET] - `GEMINI_API_KEY` Optional: - place `.env` in current directory - place `.env` in this skill directory Dependency management: - each executable script uses PEP 723 inline metadata - run scripts with `uv run ...` so dependencies bootstrap automatically ## Notes - Keep style and spacing consistent across all pages. - Prefer concise, readable text over dense paragraphs. - Use regeneration instead of re-rendering the entire deck for isolated edits.

path: agents/openai.yaml
content: interface:
  display_name: "Open Apple Style PPT Maker"
  short_description: "Apple-minimal PPT workflow with strict JSON and page regeneration"
  default_prompt: "Use $open-apple-style-ppt-maker to clarify requirements, review a strict slide JSON plan, generate 2K WebP slides, and regenerate any requested page."


path: references/ai-product-strategy-showcase-plan.json
content: {
  "project": {
    "title": "AI Product Strategy 2026",
    "subtitle": "Execution Priorities",
    "language": "en",
    "audience": "Product and executive stakeholders",
    "objective": "Align teams on priorities, execution sequencing, and measurable outcomes"
  },
  "global_style_tokens": {
    "aesthetic": "Apple-style minimal, calm, premium",
    "palette": {
      "background": "#F7F7F5",
      "surface": "#FFFFFF",
      "text_primary": "#111111",
      "text_secondary": "#555555",
      "accent": "#0A84FF"
    },
    "type_scale": {
      "h1": "56/64 semibold",
      "h2": "36/44 semibold",
      "body": "24/34 regular",
      "caption": "18/24 regular"
    },
    "spacing_scale": "8-16-24-40",
    "layout_system": "12-column grid with wide outer margins",
    "motion_rule": "still frame; transition-safe composition",
    "icon_style": "simple line icons",
    "image_style": "subtle abstract gradient background"
  },
  "slides": [
    {
      "slide_number": 1,
      "slide_type": "cover",
      "objective": "Introduce strategic focus for 2026",
      "on_slide_text": {
        "title": "AI Product Strategy 2026",
        "subtitle": "Execution Priorities",
        "footer": "Leadership Review"
      },
      "visual_blueprint": {
        "composition": "Large title block on left, restrained abstract visual anchor on right",
        "key_elements": [
          "left-aligned title stack",
          "single abstract shape",
          "high whitespace"
        ],
        "imagery_hint": "soft neutral gradient",
        "avoid": [
          "neon glow",
          "dense textures",
          "multiple focal points"
        ]
      },
      "consistency_checks": {
        "must_keep": [
          "same typography hierarchy",
          "same accent role",
          "same margin system"
        ],
        "must_avoid": [
          "visual clutter",
          "low-contrast text"
        ]
      }
    },
    {
      "slide_number": 2,
      "slide_type": "content",
      "objective": "Present the top three strategic priorities",
      "on_slide_text": {
        "title": "Three Strategic Priorities",
        "bullets": [
          "Improve onboarding conversion",
          "Shorten time-to-value",
          "Expand enterprise readiness"
        ],
        "callout": "Focus on measurable outcomes"
      },
      "visual_blueprint": {
        "composition": "Two-column layout: bullets on left, simple icon blocks on right",
        "key_elements": [
          "priority bullet stack",
          "minimal icon cards",
          "clean vertical rhythm"
        ],
        "imagery_hint": "flat low-contrast abstract forms",
        "avoid": [
          "heavy shadows",
          "overlapping cards",
          "multi-color gradients"
        ]
      },
      "consistency_checks": {
        "must_keep": [
          "same grid system",
          "same spacing rhythm",
          "same color roles"
        ],
        "must_avoid": [
          "new decorative motif",
          "misaligned blocks"
        ]
      }
    },
    {
      "slide_number": 3,
      "slide_type": "content",
      "objective": "Show phased execution timeline",
      "on_slide_text": {
        "title": "Execution Roadmap",
        "bullets": [
          "Q1: Baseline metrics and platform hardening",
          "Q2: Feature velocity and AI workflow upgrades",
          "Q3: Enterprise controls and reliability scaling",
          "Q4: Global rollout and cost optimization"
        ],
        "footer": "Quarterly milestone checkpoints"
      },
      "visual_blueprint": {
        "composition": "Horizontal quarterly roadmap bar with concise milestone labels",
        "key_elements": [
          "4-phase timeline",
          "quarter markers",
          "single accent highlight"
        ],
        "imagery_hint": "clean line-based roadmap graphics",
        "chart_hint": "simple 4-step horizontal timeline",
        "avoid": [
          "gantt-level detail",
          "too many arrows",
          "small unreadable labels"
        ]
      },
      "consistency_checks": {
        "must_keep": [
          "same typography and spacing",
          "same card radius and stroke style",
          "same accent usage constraints"
        ],
        "must_avoid": [
          "style drift",
          "text overcrowding"
        ]
      }
    }
  ],
  "output": {
    "aspect_ratio": "16:9",
    "resolution": "2K",
    "format": "webp",
    "output_dir": "/tmp/open_apple_skill_test"
  }
}


path: references/apple-style-spec.md
content: # Apple Minimal Presentation Spec

## Core Direction

Use a restrained keynote-like visual language:
- generous whitespace
- strict typographic hierarchy
- balanced alignment and margins
- low-noise surfaces with minimal decoration
- one accent color family per deck

Keep the design premium through consistency, not effects.

## Global Tokens (Deck Level)

Define once and reuse on every slide:
- `aesthetic`
- `palette.background`
- `palette.surface`
- `palette.text_primary`
- `palette.text_secondary`
- `palette.accent`
- `type_scale.h1`
- `type_scale.h2`
- `type_scale.body`
- `type_scale.caption`
- `spacing_scale`
- `layout_system`
- `motion_rule`

Do not drift these tokens between slides.

## Slide Composition Rules

- Keep one primary message per slide.
- Keep title and body blocks clearly separated.
- Use 2-column or 3-zone structures only when content justifies it.
- Keep visual focal point singular and obvious.
- Keep margins consistent and intentionally wide.

## Typography Rules

- Use short titles.
- Use concise bullets.
- Avoid dense paragraphs whenever possible.
- Keep line length readable.
- Maintain strong contrast between text and background.

## Visual Prohibitions

- avoid neon glow and cyberpunk gradients
- avoid dense texture overlays
- avoid multiple competing visual anchors
- avoid low-contrast text
- avoid decorative 3D clutter

## Data Slide Rules

- Prefer one key chart per slide.
- Keep chart labels minimal and legible.
- Keep annotation count low.
- Highlight only one key metric with accent color.

## Regeneration Rules

When regenerating one slide:
- preserve all deck-level tokens
- apply only the requested change
- do not modify other slide files
- snapshot old assets before overwrite


path: references/slides-plan-template.json
content: {
  "project": {
    "title": "AI Product Strategy 2026",
    "subtitle": "Execution Priorities",
    "language": "en",
    "audience": "Product and executive stakeholders",
    "objective": "Align teams on priorities and timeline"
  },
  "global_style_tokens": {
    "aesthetic": "Apple-style minimal, calm, premium",
    "palette": {
      "background": "#F7F7F5",
      "surface": "#FFFFFF",
      "text_primary": "#111111",
      "text_secondary": "#555555",
      "accent": "#0A84FF"
    },
    "type_scale": {
      "h1": "56/64 semibold",
      "h2": "36/44 semibold",
      "body": "24/34 regular",
      "caption": "18/24 regular"
    },
    "spacing_scale": "8-16-24-40",
    "layout_system": "12-column grid with wide outer margins",
    "motion_rule": "still frame; transition-safe composition",
    "icon_style": "simple line icons",
    "image_style": "subtle abstract gradient background"
  },
  "slides": [
    {
      "slide_number": 1,
      "slide_type": "cover",
      "objective": "Introduce the deck topic and context",
      "on_slide_text": {
        "title": "AI Product Strategy 2026",
        "subtitle": "Execution Priorities",
        "footer": "Internal planning meeting"
      },
      "visual_blueprint": {
        "composition": "Large title block on left, restrained visual anchor on right",
        "key_elements": [
          "single abstract accent shape",
          "left-aligned title stack",
          "high whitespace around text"
        ],
        "imagery_hint": "subtle soft gradient background",
        "avoid": [
          "dense textures",
          "neon glow",
          "3D decorative objects"
        ]
      },
      "consistency_checks": {
        "must_keep": [
          "same typography hierarchy",
          "same accent color usage policy"
        ],
        "must_avoid": [
          "visual clutter",
          "low-contrast text"
        ]
      }
    },
    {
      "slide_number": 2,
      "slide_type": "content",
      "objective": "Explain three strategic priorities",
      "on_slide_text": {
        "title": "Three Strategic Priorities",
        "bullets": [
          "Improve onboarding conversion",
          "Shorten time-to-value",
          "Expand enterprise readiness"
        ],
        "callout": "Focus on measurable outcomes"
      },
      "visual_blueprint": {
        "composition": "Two-column layout: bullets on left, sparse icons on right",
        "key_elements": [
          "left content block with generous line spacing",
          "minimal icon stack on right",
          "subtle divider alignment"
        ],
        "imagery_hint": "flat, low-contrast abstract shapes",
        "chart_hint": "none",
        "avoid": [
          "overlapping cards",
          "thick shadows",
          "multi-color gradients"
        ]
      },
      "consistency_checks": {
        "must_keep": [
          "same grid",
          "same spacing scale",
          "same text color roles"
        ],
        "must_avoid": [
          "new decorative motif",
          "inconsistent alignment"
        ]
      }
    }
  ],
  "output": {
    "aspect_ratio": "16:9",
    "resolution": "2K",
    "format": "webp",
    "output_dir": "outputs/ai_strategy_2026"
  }
}


path: references/slides-schema.json
content: {
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://skills.local/open-apple-style-ppt-maker/slides-schema.json",
  "title": "OpenAppleStyleSlidesPlan",
  "type": "object",
  "additionalProperties": false,
  "required": [
    "project",
    "global_style_tokens",
    "slides",
    "output"
  ],
  "properties": {
    "project": {
      "type": "object",
      "additionalProperties": false,
      "required": [
        "title",
        "language",
        "audience",
        "objective"
      ],
      "properties": {
        "title": {
          "type": "string",
          "minLength": 1,
          "maxLength": 160
        },
        "subtitle": {
          "type": "string",
          "minLength": 1,
          "maxLength": 200
        },
        "language": {
          "type": "string",
          "minLength": 1,
          "maxLength": 32
        },
        "audience": {
          "type": "string",
          "minLength": 1,
          "maxLength": 200
        },
        "objective": {
          "type": "string",
          "minLength": 1,
          "maxLength": 260
        }
      }
    },
    "global_style_tokens": {
      "type": "object",
      "additionalProperties": false,
      "required": [
        "aesthetic",
        "palette",
        "type_scale",
        "spacing_scale",
        "layout_system",
        "motion_rule"
      ],
      "properties": {
        "aesthetic": {
          "type": "string",
          "minLength": 1,
          "maxLength": 200
        },
        "palette": {
          "type": "object",
          "additionalProperties": false,
          "required": [
            "background",
            "surface",
            "text_primary",
            "text_secondary",
            "accent"
          ],
          "properties": {
            "background": {
              "type": "string",
              "minLength": 1,
              "maxLength": 30
            },
            "surface": {
              "type": "string",
              "minLength": 1,
              "maxLength": 30
            },
            "text_primary": {
              "type": "string",
              "minLength": 1,
              "maxLength": 30
            },
            "text_secondary": {
              "type": "string",
              "minLength": 1,
              "maxLength": 30
            },
            "accent": {
              "type": "string",
              "minLength": 1,
              "maxLength": 30
            }
          }
        },
        "type_scale": {
          "type": "object",
          "additionalProperties": false,
          "required": [
            "h1",
            "h2",
            "body",
            "caption"
          ],
          "properties": {
            "h1": {
              "type": "string",
              "minLength": 1,
              "maxLength": 50
            },
            "h2": {
              "type": "string",
              "minLength": 1,
              "maxLength": 50
            },
            "body": {
              "type": "string",
              "minLength": 1,
              "maxLength": 50
            },
            "caption": {
              "type": "string",
              "minLength": 1,
              "maxLength": 50
            }
          }
        },
        "spacing_scale": {
          "type": "string",
          "minLength": 1,
          "maxLength": 120
        },
        "layout_system": {
          "type": "string",
          "minLength": 1,
          "maxLength": 120
        },
        "motion_rule": {
          "type": "string",
          "minLength": 1,
          "maxLength": 120
        },
        "icon_style": {
          "type": "string",
          "minLength": 1,
          "maxLength": 120
        },
        "image_style": {
          "type": "string",
          "minLength": 1,
          "maxLength": 120
        }
      }
    },
    "slides": {
      "type": "array",
      "minItems": 1,
      "maxItems": 60,
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "slide_number",
          "slide_type",
          "objective",
          "on_slide_text",
          "visual_blueprint",
          "consistency_checks"
        ],
        "properties": {
          "slide_number": {
            "type": "integer",
            "minimum": 1
          },
          "slide_type": {
            "type": "string",
            "minLength": 1,
            "maxLength": 40
          },
          "objective": {
            "type": "string",
            "minLength": 1,
            "maxLength": 260
          },
          "on_slide_text": {
            "type": "object",
            "additionalProperties": false,
            "required": [
              "title"
            ],
            "properties": {
              "title": {
                "type": "string",
                "minLength": 1,
                "maxLength": 120
              },
              "subtitle": {
                "type": "string",
                "minLength": 1,
                "maxLength": 180
              },
              "body": {
                "type": "string",
                "minLength": 1,
                "maxLength": 600
              },
              "bullets": {
                "type": "array",
                "minItems": 1,
                "maxItems": 8,
                "items": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 140
                }
              },
              "stats": {
                "type": "array",
                "minItems": 1,
                "maxItems": 8,
                "items": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 120
                }
              },
              "callout": {
                "type": "string",
                "minLength": 1,
                "maxLength": 120
              },
              "footer": {
                "type": "string",
                "minLength": 1,
                "maxLength": 120
              }
            }
          },
          "visual_blueprint": {
            "type": "object",
            "additionalProperties": false,
            "required": [
              "composition",
              "key_elements",
              "imagery_hint",
              "avoid"
            ],
            "properties": {
              "composition": {
                "type": "string",
                "minLength": 1,
                "maxLength": 220
              },
              "key_elements": {
                "type": "array",
                "minItems": 1,
                "maxItems": 10,
                "items": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 120
                }
              },
              "imagery_hint": {
                "type": "string",
                "minLength": 1,
                "maxLength": 220
              },
              "chart_hint": {
                "type": "string",
                "minLength": 1,
                "maxLength": 180
              },
              "avoid": {
                "type": "array",
                "minItems": 1,
                "maxItems": 10,
                "items": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 120
                }
              }
            }
          },
          "consistency_checks": {
            "type": "object",
            "additionalProperties": false,
            "required": [
              "must_keep",
              "must_avoid"
            ],
            "properties": {
              "must_keep": {
                "type": "array",
                "minItems": 1,
                "maxItems": 10,
                "items": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 140
                }
              },
              "must_avoid": {
                "type": "array",
                "minItems": 1,
                "maxItems": 10,
                "items": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 140
                }
              }
            }
          }
        }
      }
    },
    "output": {
      "type": "object",
      "additionalProperties": false,
      "required": [
        "aspect_ratio",
        "resolution",
        "format"
      ],
      "properties": {
        "aspect_ratio": {
          "type": "string",
          "const": "16:9"
        },
        "resolution": {
          "type": "string",
          "enum": [
            "2K",
            "4K"
          ]
        },
        "format": {
          "type": "string",
          "enum": [
            "webp",
            "png",
            "jpg",
            "jpeg"
          ]
        },
        "output_dir": {
          "type": "string",
          "minLength": 1,
          "maxLength": 400
        }
      }
    }
  }
}


path: scripts/export_pptx.py
content: #!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = [
#   "python-pptx>=1.0.2",
#   "pillow>=10.0.0",
# ]
# ///
"""Export generated slide images to a 16:9 PPTX file."""

from __future__ import annotations

import argparse
import re
import sys
from pathlib import Path

SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))


def natural_key(path: Path) -> int:
    match = re.search(r"(\d+)", path.stem)
    return int(match.group(1)) if match else 0


def import_pptx_modules():
    try:
        from pptx import Presentation
        from pptx.util import Inches
    except ImportError as exc:
        raise RuntimeError("python-pptx is required. Run: pip install python-pptx pillow") from exc
    return Presentation, Inches


def enable_webp_support() -> None:
    """
    Patch python-pptx runtime mappings to accept WebP images.

    python-pptx 1.0.x may reject WebP in add_picture() without this patch.
    """
    try:
        from pptx.opc import spec as opc_spec
        from pptx.parts.image import Image as PptxImage
        from pptx.util import lazyproperty
    except Exception:
        return

    opc_spec.image_content_types.setdefault("webp", "image/webp")

    @lazyproperty  # type: ignore[misc]
    def ext(self) -> str:
        mapping = {
            "BMP": "bmp",
            "GIF": "gif",
            "JPEG": "jpg",
            "PNG": "png",
            "TIFF": "tiff",
            "WEBP": "webp",
            "WMF": "wmf",
        }
        if self._format not in mapping:
            expected = ", ".join(mapping.keys())
            raise ValueError(f"unsupported image format, expected one of: {expected}, got '{self._format}'")
        return mapping[self._format]

    PptxImage.ext = ext  # type: ignore[assignment]


def collect_slide_images(images_dir: Path, glob_pattern: str | None) -> list[Path]:
    if glob_pattern:
        return sorted([p for p in images_dir.glob(glob_pattern) if p.is_file()], key=natural_key)

    for ext in ("webp", "png", "jpg", "jpeg"):
        files = sorted([p for p in images_dir.glob(f"slide-*.{ext}") if p.is_file()], key=natural_key)
        if files:
            return files

    return sorted([p for p in images_dir.glob("slide-*.*") if p.is_file()], key=natural_key)


def export_pptx(images_dir: Path, output_pptx: Path, glob_pattern: str | None = None) -> None:
    Presentation, Inches = import_pptx_modules()
    enable_webp_support()
    images = collect_slide_images(images_dir, glob_pattern=glob_pattern)
    if not images:
        raise SystemExit(f"No slide images found in: {images_dir}")

    deck = Presentation()
    deck.slide_width = Inches(13.333)
    deck.slide_height = Inches(7.5)
    blank = deck.slide_layouts[6]

    for image in images:
        slide = deck.slides.add_slide(blank)
        slide.shapes.add_picture(str(image), left=Inches(0), top=Inches(0), width=deck.slide_width, height=deck.slide_height)

    output_pptx.parent.mkdir(parents=True, exist_ok=True)
    deck.save(str(output_pptx))


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Export slide images to PPTX")
    parser.add_argument("--images-dir", required=True, help="Directory containing slide images")
    parser.add_argument("--output-pptx", required=True, help="Target PPTX file")
    parser.add_argument("--glob", default=None, help="Optional glob pattern, e.g. 'slide-*.webp'")
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    images_dir = Path(args.images_dir).resolve()
    output_pptx = Path(args.output_pptx).resolve()

    if not images_dir.exists():
        print(f"[ERROR] Images directory not found: {images_dir}")
        return 2

    try:
        export_pptx(images_dir, output_pptx, glob_pattern=args.glob)
    except Exception as exc:
        print(f"[ERROR] Export failed: {exc}")
        return 1

    print(f"[DONE] PPTX exported: {output_pptx}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/generate_slides.py
content: #!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = [
#   "google-genai>=1.0.0",
#   "pillow>=10.0.0",
#   "python-dotenv>=1.0.0",
# ]
# ///
"""Generate presentation slide images from a strict slides_plan.json file."""

from __future__ import annotations

import argparse
import sys
from pathlib import Path

SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))

from workflow_core import (  # noqa: E402
    DEFAULT_MODEL,
    PlanValidationError,
    build_slide_prompt,
    ensure_output_layout,
    find_and_load_env,
    find_manifest_slide,
    get_genai_client,
    load_or_init_manifest,
    load_plan,
    render_slide_image,
    resolve_output_dir,
    resolve_output_settings,
    sha256_text,
    upsert_manifest_slide,
    utc_now_iso,
    write_json,
    write_text,
)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Generate slide images from a strict JSON plan")
    parser.add_argument("--plan", required=True, help="Path to slides_plan.json")
    parser.add_argument("--out", default=None, help="Output directory (optional)")
    parser.add_argument("--resolution", choices=["2K", "4K"], default=None, help="Override resolution")
    parser.add_argument("--format", choices=["webp", "png", "jpg", "jpeg"], default=None, help="Override format")
    parser.add_argument("--model", default=DEFAULT_MODEL, help="Image generation model")
    parser.add_argument("--retries", type=int, default=3, help="Per-slide retry count for API failures")
    parser.add_argument("--from-slide", type=int, default=None, help="Start slide number for partial rendering")
    parser.add_argument("--to-slide", type=int, default=None, help="End slide number for partial rendering")
    parser.add_argument("--force", action="store_true", help="Regenerate even if output image already exists")
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    plan_path = Path(args.plan).resolve()

    try:
        plan = load_plan(plan_path)
        env_path = find_and_load_env(plan_path)
        resolution, image_format = resolve_output_settings(plan, args.resolution, args.format)
        output_dir = resolve_output_dir(plan, args.out)
        layout = ensure_output_layout(output_dir)
    except PlanValidationError as exc:
        print(f"[ERROR] Invalid plan: {exc}")
        return 2
    except Exception as exc:
        print(f"[ERROR] Setup failed: {exc}")
        return 2

    slides = plan["slides"]
    total_slides = len(slides)
    start = args.from_slide if args.from_slide is not None else 1
    end = args.to_slide if args.to_slide is not None else total_slides

    if start < 1 or end < start or end > total_slides:
        print(f"[ERROR] Invalid range: from-slide={start}, to-slide={end}, total={total_slides}")
        return 2

    selected_numbers = set(range(start, end + 1))

    try:
        client = get_genai_client()
    except Exception as exc:
        print(f"[ERROR] API client initialization failed: {exc}")
        return 2

    manifest = load_or_init_manifest(layout["manifest"], plan, resolution, image_format, args.model)

    generated = 0
    skipped = 0
    failed = 0

    if env_path:
        print(f"[INFO] Loaded environment from: {env_path}")
    else:
        print("[INFO] No .env file discovered, using current environment variables")
    print(f"[INFO] Rendering slides {start}..{end} ({len(selected_numbers)} slide(s))")
    print(f"[INFO] Output: {output_dir}")
    print(f"[INFO] Settings: resolution={resolution}, format={image_format}, model={args.model}")

    for slide in slides:
        number = slide["slide_number"]
        if number not in selected_numbers:
            continue

        image_path = layout["images"] / f"slide-{number:02d}.{image_format}"
        prompt_path = layout["prompts"] / f"slide-{number:02d}.prompt.md"
        meta_path = layout["meta"] / f"slide-{number:02d}.meta.json"

        prompt = build_slide_prompt(
            plan=plan,
            slide=slide,
            total_slides=total_slides,
            resolution=resolution,
            image_format=image_format,
        )
        write_text(prompt_path, prompt)

        existing = find_manifest_slide(manifest, number) or {}
        revision_count = int(existing.get("revision_count", 0))

        status = "generated"
        error_message = None

        if image_path.exists() and not args.force:
            status = "skipped_existing"
            skipped += 1
            print(f"[SKIP] slide {number}: existing image found")
        else:
            print(f"[RUN ] slide {number}: generating image")
            try:
                render_slide_image(
                    client=client,
                    prompt=prompt,
                    image_path=image_path,
                    resolution=resolution,
                    model=args.model,
                    retries=max(1, args.retries),
                )
                generated += 1
            except Exception as exc:  # pragma: no cover - depends on remote API behavior
                status = "failed"
                error_message = str(exc)
                failed += 1
                print(f"[FAIL] slide {number}: {exc}")

        meta = {
            "slide_number": number,
            "slide_type": slide["slide_type"],
            "status": status,
            "generated_at": utc_now_iso(),
            "resolution": resolution,
            "format": image_format,
            "model": args.model,
            "revision_count": revision_count,
            "prompt_sha256": sha256_text(prompt),
            "image_path": str(image_path.relative_to(output_dir)),
            "prompt_path": str(prompt_path.relative_to(output_dir)),
            "error": error_message,
        }
        write_json(meta_path, meta)

        upsert_manifest_slide(
            manifest,
            {
                "slide_number": number,
                "status": status,
                "revision_count": revision_count,
                "generated_at": meta["generated_at"],
                "image_path": meta["image_path"],
                "meta_path": str(meta_path.relative_to(output_dir)),
                "prompt_path": meta["prompt_path"],
                "error": error_message,
            },
        )

    write_json(layout["manifest"], manifest)

    print("[DONE] Generation completed")
    print(f"[DONE] generated={generated}, skipped={skipped}, failed={failed}")
    print(f"[DONE] manifest={layout['manifest']}")

    return 1 if failed else 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/regenerate_slide.py
content: #!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = [
#   "google-genai>=1.0.0",
#   "pillow>=10.0.0",
#   "python-dotenv>=1.0.0",
# ]
# ///
"""Regenerate a single slide with a revision instruction."""

from __future__ import annotations

import argparse
import sys
from datetime import datetime
from pathlib import Path

SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))

from workflow_core import (  # noqa: E402
    DEFAULT_MODEL,
    PlanValidationError,
    build_slide_prompt,
    ensure_output_layout,
    find_and_load_env,
    find_manifest_slide,
    get_genai_client,
    get_slide_by_number,
    load_or_init_manifest,
    load_plan,
    render_slide_image,
    resolve_output_dir,
    resolve_output_settings,
    sha256_text,
    snapshot_if_exists,
    upsert_manifest_slide,
    utc_now_iso,
    write_json,
    write_text,
)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Regenerate one slide in an existing output directory")
    parser.add_argument("--plan", required=True, help="Path to slides_plan.json")
    parser.add_argument("--slide", type=int, required=True, help="Slide number to regenerate")
    parser.add_argument("--change", required=True, help="Revision instruction for this slide")
    parser.add_argument("--out", default=None, help="Output directory containing images/prompts/meta")
    parser.add_argument("--resolution", choices=["2K", "4K"], default=None, help="Override resolution")
    parser.add_argument("--format", choices=["webp", "png", "jpg", "jpeg"], default=None, help="Override format")
    parser.add_argument("--model", default=DEFAULT_MODEL, help="Image generation model")
    parser.add_argument("--retries", type=int, default=3, help="Retry count for API failures")
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    plan_path = Path(args.plan).resolve()

    try:
        plan = load_plan(plan_path)
        env_path = find_and_load_env(plan_path)
        resolution, image_format = resolve_output_settings(plan, args.resolution, args.format)
        output_dir = resolve_output_dir(plan, args.out)
        layout = ensure_output_layout(output_dir)
        target_slide = get_slide_by_number(plan["slides"], args.slide)
    except PlanValidationError as exc:
        print(f"[ERROR] Invalid input: {exc}")
        return 2
    except Exception as exc:
        print(f"[ERROR] Setup failed: {exc}")
        return 2

    try:
        client = get_genai_client()
    except Exception as exc:
        print(f"[ERROR] API client initialization failed: {exc}")
        return 2

    if env_path:
        print(f"[INFO] Loaded environment from: {env_path}")
    else:
        print("[INFO] No .env file discovered, using current environment variables")

    total_slides = len(plan["slides"])
    number = target_slide["slide_number"]

    image_path = layout["images"] / f"slide-{number:02d}.{image_format}"
    prompt_path = layout["prompts"] / f"slide-{number:02d}.prompt.md"
    meta_path = layout["meta"] / f"slide-{number:02d}.meta.json"

    manifest = load_or_init_manifest(layout["manifest"], plan, resolution, image_format, args.model)
    existing = find_manifest_slide(manifest, number) or {}
    revision_count = int(existing.get("revision_count", 0)) + 1

    snapshot_stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    snapshot_dir = layout["history"] / f"slide-{number:02d}" / snapshot_stamp
    snapshot_if_exists(image_path, snapshot_dir, "pre")
    snapshot_if_exists(prompt_path, snapshot_dir, "pre")
    snapshot_if_exists(meta_path, snapshot_dir, "pre")

    prompt = build_slide_prompt(
        plan=plan,
        slide=target_slide,
        total_slides=total_slides,
        resolution=resolution,
        image_format=image_format,
        revision_note=args.change,
    )
    write_text(prompt_path, prompt)

    error_message = None
    status = "regenerated"
    print(f"[RUN ] regenerating slide {number}")
    try:
        render_slide_image(
            client=client,
            prompt=prompt,
            image_path=image_path,
            resolution=resolution,
            model=args.model,
            retries=max(1, args.retries),
        )
    except Exception as exc:  # pragma: no cover - depends on remote API behavior
        status = "failed"
        error_message = str(exc)
        print(f"[FAIL] slide {number}: {exc}")

    meta = {
        "slide_number": number,
        "slide_type": target_slide["slide_type"],
        "status": status,
        "generated_at": utc_now_iso(),
        "resolution": resolution,
        "format": image_format,
        "model": args.model,
        "revision_count": revision_count,
        "revision_note": args.change,
        "prompt_sha256": sha256_text(prompt),
        "image_path": str(image_path.relative_to(output_dir)),
        "prompt_path": str(prompt_path.relative_to(output_dir)),
        "history_path": str(snapshot_dir.relative_to(output_dir)),
        "error": error_message,
    }
    write_json(meta_path, meta)

    upsert_manifest_slide(
        manifest,
        {
            "slide_number": number,
            "status": status,
            "revision_count": revision_count,
            "generated_at": meta["generated_at"],
            "image_path": meta["image_path"],
            "meta_path": str(meta_path.relative_to(output_dir)),
            "prompt_path": meta["prompt_path"],
            "error": error_message,
        },
    )
    write_json(layout["manifest"], manifest)

    if status == "failed":
        return 1

    print(f"[DONE] slide {number} regenerated successfully")
    print(f"[DONE] history snapshot: {snapshot_dir}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/workflow_core.py
content: #!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = [
#   "google-genai>=1.0.0",
#   "pillow>=10.0.0",
#   "python-dotenv>=1.0.0",
# ]
# ///
"""Shared utilities for the open-apple-style-ppt-maker skill."""

from __future__ import annotations

import hashlib
import json
import os
import time
from datetime import datetime, timezone
from pathlib import Path
from shutil import copy2
from typing import Any

try:
    from dotenv import load_dotenv
except ImportError:
    def load_dotenv(*_args: Any, **_kwargs: Any) -> bool:
        return False

DEFAULT_MODEL = "gemini-3-pro-image-preview"
DEFAULT_RESOLUTION = "2K"
DEFAULT_FORMAT = "webp"
ALLOWED_RESOLUTIONS = {"2K", "4K"}
ALLOWED_FORMATS = {"webp", "png", "jpg", "jpeg"}
ALLOWED_ASPECT_RATIO = "16:9"


class PlanValidationError(ValueError):
    """Raised when slides_plan.json does not satisfy the strict contract."""


def utc_now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


def write_json(path: Path, payload: dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")


def read_json(path: Path) -> dict[str, Any]:
    return json.loads(path.read_text(encoding="utf-8"))


def write_text(path: Path, content: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(content, encoding="utf-8")


def sha256_text(content: str) -> str:
    return hashlib.sha256(content.encode("utf-8")).hexdigest()


def find_and_load_env(plan_path: Path) -> Path | None:
    """Load the first discovered .env file from common locations."""
    script_dir = Path(__file__).resolve().parent
    candidates: list[Path] = [
        Path.cwd() / ".env",
        script_dir / ".env",
        plan_path.parent / ".env",
    ]
    candidates.extend(parent / ".env" for parent in plan_path.parent.parents)
    candidates.append(Path.home() / ".codex" / "skills" / "open-apple-style-ppt-maker" / ".env")

    seen: set[Path] = set()
    for candidate in candidates:
        candidate = candidate.resolve()
        if candidate in seen:
            continue
        seen.add(candidate)
        if candidate.exists():
            load_dotenv(candidate, override=False)
            return candidate

    load_dotenv(override=False)
    return None


def load_plan(plan_path: Path) -> dict[str, Any]:
    if not plan_path.exists():
        raise PlanValidationError(f"Plan file not found: {plan_path}")
    payload = read_json(plan_path)
    validate_plan(payload)
    payload["slides"] = sorted(payload["slides"], key=lambda item: item["slide_number"])
    return payload


def _expect(condition: bool, message: str) -> None:
    if not condition:
        raise PlanValidationError(message)


def _expect_object(obj: Any, path: str) -> dict[str, Any]:
    _expect(isinstance(obj, dict), f"{path} must be an object")
    return obj


def _expect_list(obj: Any, path: str) -> list[Any]:
    _expect(isinstance(obj, list), f"{path} must be an array")
    return obj


def _expect_string(obj: Any, path: str, max_len: int = 300) -> str:
    _expect(isinstance(obj, str), f"{path} must be a string")
    value = obj.strip()
    _expect(bool(value), f"{path} must not be empty")
    _expect(len(value) <= max_len, f"{path} must be <= {max_len} chars")
    return value


def _expect_string_list(
    obj: Any,
    path: str,
    min_items: int = 1,
    max_items: int = 12,
    item_max_len: int = 240,
) -> list[str]:
    items = _expect_list(obj, path)
    _expect(min_items <= len(items) <= max_items, f"{path} must contain {min_items}..{max_items} items")
    out: list[str] = []
    for index, item in enumerate(items, start=1):
        out.append(_expect_string(item, f"{path}[{index}]", max_len=item_max_len))
    return out


def _expect_keys(obj: dict[str, Any], path: str, required: set[str], optional: set[str] | None = None) -> None:
    optional = optional or set()
    keys = set(obj.keys())
    missing = required - keys
    extra = keys - (required | optional)
    _expect(not missing, f"{path} missing key(s): {', '.join(sorted(missing))}")
    _expect(not extra, f"{path} has unsupported key(s): {', '.join(sorted(extra))}")


def validate_plan(plan: dict[str, Any]) -> None:
    root = _expect_object(plan, "plan")
    _expect_keys(root, "plan", {"project", "global_style_tokens", "slides", "output"})

    project = _expect_object(root["project"], "project")
    _expect_keys(project, "project", {"title", "language", "audience", "objective"}, {"subtitle"})
    _expect_string(project["title"], "project.title", max_len=160)
    _expect_string(project["language"], "project.language", max_len=32)
    _expect_string(project["audience"], "project.audience", max_len=200)
    _expect_string(project["objective"], "project.objective", max_len=260)
    if "subtitle" in project:
        _expect_string(project["subtitle"], "project.subtitle", max_len=200)

    tokens = _expect_object(root["global_style_tokens"], "global_style_tokens")
    _expect_keys(
        tokens,
        "global_style_tokens",
        {"aesthetic", "palette", "type_scale", "spacing_scale", "layout_system", "motion_rule"},
        {"icon_style", "image_style"},
    )
    _expect_string(tokens["aesthetic"], "global_style_tokens.aesthetic", max_len=200)
    _expect_string(tokens["spacing_scale"], "global_style_tokens.spacing_scale", max_len=120)
    _expect_string(tokens["layout_system"], "global_style_tokens.layout_system", max_len=120)
    _expect_string(tokens["motion_rule"], "global_style_tokens.motion_rule", max_len=120)
    if "icon_style" in tokens:
        _expect_string(tokens["icon_style"], "global_style_tokens.icon_style", max_len=120)
    if "image_style" in tokens:
        _expect_string(tokens["image_style"], "global_style_tokens.image_style", max_len=120)

    palette = _expect_object(tokens["palette"], "global_style_tokens.palette")
    _expect_keys(
        palette,
        "global_style_tokens.palette",
        {"background", "surface", "text_primary", "text_secondary", "accent"},
    )
    for key in ("background", "surface", "text_primary", "text_secondary", "accent"):
        _expect_string(palette[key], f"global_style_tokens.palette.{key}", max_len=30)

    type_scale = _expect_object(tokens["type_scale"], "global_style_tokens.type_scale")
    _expect_keys(type_scale, "global_style_tokens.type_scale", {"h1", "h2", "body", "caption"})
    for key in ("h1", "h2", "body", "caption"):
        _expect_string(type_scale[key], f"global_style_tokens.type_scale.{key}", max_len=50)

    slides = _expect_list(root["slides"], "slides")
    _expect(len(slides) > 0, "slides must not be empty")
    numbers: list[int] = []

    for idx, slide in enumerate(slides, start=1):
        slide_obj = _expect_object(slide, f"slides[{idx}]")
        _expect_keys(
            slide_obj,
            f"slides[{idx}]",
            {
                "slide_number",
                "slide_type",
                "objective",
                "on_slide_text",
                "visual_blueprint",
                "consistency_checks",
            },
        )

        number = slide_obj["slide_number"]
        _expect(isinstance(number, int), f"slides[{idx}].slide_number must be an integer")
        _expect(number > 0, f"slides[{idx}].slide_number must be > 0")
        numbers.append(number)

        _expect_string(slide_obj["slide_type"], f"slides[{idx}].slide_type", max_len=40)
        _expect_string(slide_obj["objective"], f"slides[{idx}].objective", max_len=260)

        on_slide = _expect_object(slide_obj["on_slide_text"], f"slides[{idx}].on_slide_text")
        _expect_keys(
            on_slide,
            f"slides[{idx}].on_slide_text",
            {"title"},
            {"subtitle", "body", "bullets", "stats", "footer", "callout"},
        )
        _expect_string(on_slide["title"], f"slides[{idx}].on_slide_text.title", max_len=120)
        if "subtitle" in on_slide:
            _expect_string(on_slide["subtitle"], f"slides[{idx}].on_slide_text.subtitle", max_len=180)
        if "body" in on_slide:
            _expect_string(on_slide["body"], f"slides[{idx}].on_slide_text.body", max_len=600)
        if "footer" in on_slide:
            _expect_string(on_slide["footer"], f"slides[{idx}].on_slide_text.footer", max_len=120)
        if "callout" in on_slide:
            _expect_string(on_slide["callout"], f"slides[{idx}].on_slide_text.callout", max_len=120)
        if "bullets" in on_slide:
            _expect_string_list(
                on_slide["bullets"],
                f"slides[{idx}].on_slide_text.bullets",
                min_items=1,
                max_items=8,
                item_max_len=140,
            )
        if "stats" in on_slide:
            _expect_string_list(
                on_slide["stats"],
                f"slides[{idx}].on_slide_text.stats",
                min_items=1,
                max_items=8,
                item_max_len=120,
            )

        visual = _expect_object(slide_obj["visual_blueprint"], f"slides[{idx}].visual_blueprint")
        _expect_keys(
            visual,
            f"slides[{idx}].visual_blueprint",
            {"composition", "key_elements", "imagery_hint", "avoid"},
            {"chart_hint"},
        )
        _expect_string(visual["composition"], f"slides[{idx}].visual_blueprint.composition", max_len=220)
        _expect_string(visual["imagery_hint"], f"slides[{idx}].visual_blueprint.imagery_hint", max_len=220)
        _expect_string_list(
            visual["key_elements"],
            f"slides[{idx}].visual_blueprint.key_elements",
            min_items=1,
            max_items=10,
            item_max_len=120,
        )
        _expect_string_list(
            visual["avoid"],
            f"slides[{idx}].visual_blueprint.avoid",
            min_items=1,
            max_items=10,
            item_max_len=120,
        )
        if "chart_hint" in visual:
            _expect_string(visual["chart_hint"], f"slides[{idx}].visual_blueprint.chart_hint", max_len=180)

        checks = _expect_object(slide_obj["consistency_checks"], f"slides[{idx}].consistency_checks")
        _expect_keys(checks, f"slides[{idx}].consistency_checks", {"must_keep", "must_avoid"})
        _expect_string_list(
            checks["must_keep"],
            f"slides[{idx}].consistency_checks.must_keep",
            min_items=1,
            max_items=10,
            item_max_len=140,
        )
        _expect_string_list(
            checks["must_avoid"],
            f"slides[{idx}].consistency_checks.must_avoid",
            min_items=1,
            max_items=10,
            item_max_len=140,
        )

    _expect(len(set(numbers)) == len(numbers), "slides.slide_number must be unique")
    sorted_numbers = sorted(numbers)
    expected_numbers = list(range(1, len(numbers) + 1))
    _expect(sorted_numbers == expected_numbers, "slides.slide_number must be a consecutive sequence starting at 1")

    output = _expect_object(root["output"], "output")
    _expect_keys(output, "output", {"aspect_ratio", "resolution", "format"}, {"output_dir"})
    _expect(output["aspect_ratio"] == ALLOWED_ASPECT_RATIO, "output.aspect_ratio must be 16:9")
    _expect(output["resolution"] in ALLOWED_RESOLUTIONS, "output.resolution must be one of: 2K, 4K")
    _expect(str(output["format"]).lower() in ALLOWED_FORMATS, "output.format must be one of: webp, png, jpg, jpeg")
    if "output_dir" in output:
        _expect_string(output["output_dir"], "output.output_dir", max_len=400)


def resolve_output_settings(
    plan: dict[str, Any],
    resolution_override: str | None,
    format_override: str | None,
) -> tuple[str, str]:
    resolution = (resolution_override or plan["output"]["resolution"] or DEFAULT_RESOLUTION).strip().upper()
    image_format = (format_override or plan["output"]["format"] or DEFAULT_FORMAT).strip().lower()

    if resolution not in ALLOWED_RESOLUTIONS:
        raise PlanValidationError(f"Unsupported resolution: {resolution}")
    if image_format not in ALLOWED_FORMATS:
        raise PlanValidationError(f"Unsupported format: {image_format}")

    return resolution, image_format


def resolve_output_dir(plan: dict[str, Any], output_dir_override: str | None) -> Path:
    if output_dir_override:
        output_dir = Path(output_dir_override)
    else:
        from_plan = plan["output"].get("output_dir")
        if from_plan:
            output_dir = Path(from_plan)
        else:
            stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
            output_dir = Path("outputs") / stamp

    if not output_dir.is_absolute():
        output_dir = (Path.cwd() / output_dir).resolve()

    return output_dir


def ensure_output_layout(output_dir: Path) -> dict[str, Path]:
    images = output_dir / "images"
    prompts = output_dir / "prompts"
    meta = output_dir / "meta"
    history = output_dir / "history"
    for directory in (output_dir, images, prompts, meta, history):
        directory.mkdir(parents=True, exist_ok=True)
    return {
        "root": output_dir,
        "images": images,
        "prompts": prompts,
        "meta": meta,
        "history": history,
        "manifest": output_dir / "deck_manifest.json",
    }


def get_api_key() -> str:
    key = os.getenv("APPLE_STYLE_PPT_MAKER_GEMINI_API_KEY") or os.getenv("GEMINI_API_KEY")
    if not key:
        raise RuntimeError(
            "Missing API key. Set APPLE_STYLE_PPT_MAKER_GEMINI_API_KEY or GEMINI_API_KEY."
        )
    return key


def get_genai_client():
    try:
        from google import genai
    except ImportError as exc:
        raise RuntimeError("google-genai is not installed. Run: pip install google-genai pillow python-dotenv") from exc
    return genai.Client([REDACTED_SECRET]))


def _extract_image_from_response(response: Any, image_path: Path) -> None:
    parts = getattr(response, "parts", None) or []
    for part in parts:
        if getattr(part, "inline_data", None) is not None:
            image = part.as_image()
            image.save(image_path)
            return

    candidates = getattr(response, "candidates", None) or []
    for candidate in candidates:
        content = getattr(candidate, "content", None)
        candidate_parts = getattr(content, "parts", None) or []
        for part in candidate_parts:
            if getattr(part, "inline_data", None) is not None:
                image = part.as_image()
                image.save(image_path)
                return

    raise RuntimeError("Model response does not contain image data.")


def render_slide_image(
    client: Any,
    prompt: str,
    image_path: Path,
    resolution: str,
    model: str,
    retries: int = 3,
) -> None:
    from google.genai import types

    last_error: Exception | None = None
    image_path.parent.mkdir(parents=True, exist_ok=True)

    for attempt in range(1, retries + 1):
        try:
            response = client.models.generate_content(
                model=model,
                contents=prompt,
                config=types.GenerateContentConfig(
                    response_modalities=["IMAGE"],
                    image_config=types.ImageConfig(aspect_ratio=ALLOWED_ASPECT_RATIO, image_size=resolution),
                ),
            )
            _extract_image_from_response(response, image_path)
            return
        except Exception as exc:  # pragma: no cover - depends on remote API behavior
            last_error = exc
            if attempt < retries:
                time.sleep(2**attempt)

    raise RuntimeError(f"Failed to generate image after {retries} attempts: {last_error}")


def _serialize_on_slide_text(on_slide_text: dict[str, Any]) -> str:
    lines: list[str] = [f'Title: "{on_slide_text["title"]}"']
    if "subtitle" in on_slide_text:
        lines.append(f'Subtitle: "{on_slide_text["subtitle"]}"')
    if "body" in on_slide_text:
        lines.append(f'Body: "{on_slide_text["body"]}"')
    if "bullets" in on_slide_text:
        lines.append("Bullets:")
        lines.extend(f'- "{item}"' for item in on_slide_text["bullets"])
    if "stats" in on_slide_text:
        lines.append("Stats:")
        lines.extend(f'- "{item}"' for item in on_slide_text["stats"])
    if "callout" in on_slide_text:
        lines.append(f'Callout: "{on_slide_text["callout"]}"')
    if "footer" in on_slide_text:
        lines.append(f'Footer: "{on_slide_text["footer"]}"')
    return "\n".join(lines)


def build_slide_prompt(
    plan: dict[str, Any],
    slide: dict[str, Any],
    total_slides: int,
    resolution: str,
    image_format: str,
    revision_note: str | None = None,
) -> str:
    project = plan["project"]
    tokens = plan["global_style_tokens"]
    visual = slide["visual_blueprint"]
    checks = slide["consistency_checks"]

    output_lines = [
        "You are an expert presentation designer.",
        "Generate exactly one slide image with a premium Apple-style minimalist aesthetic.",
        "",
        "Hard visual constraints:",
        "- Keep the same visual system across the full deck.",
        "- Use large whitespace and clear visual hierarchy.",
        "- Keep typography clean, precise, and highly legible.",
        "- Avoid neon effects, noisy textures, excessive gradients, and heavy glassmorphism.",
        "- Do not add extra logos, watermarks, or fake UI text.",
        "",
        f'Deck title: "{project["title"]}"',
        f'Deck audience: "{project["audience"]}"',
        f'Deck objective: "{project["objective"]}"',
        f'Deck language: "{project["language"]}"',
        f"Slide number: {slide['slide_number']} of {total_slides}",
        f'Slide type: "{slide["slide_type"]}"',
        f'Slide objective: "{slide["objective"]}"',
        "",
        "Global style tokens:",
        f'- aesthetic: "{tokens["aesthetic"]}"',
        f'- palette: background {tokens["palette"]["background"]}, surface {tokens["palette"]["surface"]}, '
        f'text_primary {tokens["palette"]["text_primary"]}, text_secondary {tokens["palette"]["text_secondary"]}, '
        f'accent {tokens["palette"]["accent"]}',
        f'- type_scale: h1 {tokens["type_scale"]["h1"]}, h2 {tokens["type_scale"]["h2"]}, '
        f'body {tokens["type_scale"]["body"]}, caption {tokens["type_scale"]["caption"]}',
        f'- spacing_scale: "{tokens["spacing_scale"]}"',
        f'- layout_system: "{tokens["layout_system"]}"',
        f'- motion_rule: "{tokens["motion_rule"]}"',
    ]

    if tokens.get("icon_style"):
        output_lines.append(f'- icon_style: "{tokens["icon_style"]}"')
    if tokens.get("image_style"):
        output_lines.append(f'- image_style: "{tokens["image_style"]}"')

    output_lines.extend(
        [
            "",
            "Text content (render text exactly as provided):",
            _serialize_on_slide_text(slide["on_slide_text"]),
            "",
            "Visual blueprint:",
            f'- composition: "{visual["composition"]}"',
            f'- imagery_hint: "{visual["imagery_hint"]}"',
            "- key_elements:",
        ]
    )
    output_lines.extend(f'  - "{item}"' for item in visual["key_elements"])
    if visual.get("chart_hint"):
        output_lines.append(f'- chart_hint: "{visual["chart_hint"]}"')
    output_lines.append("- avoid:")
    output_lines.extend(f'  - "{item}"' for item in visual["avoid"])

    output_lines.extend(
        [
            "",
            "Consistency checks:",
            "- must_keep:",
        ]
    )
    output_lines.extend(f'  - "{item}"' for item in checks["must_keep"])
    output_lines.append("- must_avoid:")
    output_lines.extend(f'  - "{item}"' for item in checks["must_avoid"])

    output_lines.extend(
        [
            "",
            f"Output requirements: one {ALLOWED_ASPECT_RATIO} slide image, resolution {resolution}, format {image_format}.",
        ]
    )

    if revision_note:
        output_lines.extend(
            [
                "",
                "Revision instruction for this generation:",
                revision_note.strip(),
                "",
                "Preserve the same deck-level style tokens while applying this revision only to the target slide.",
            ]
        )

    return "\n".join(output_lines).strip() + "\n"


def init_manifest(plan: dict[str, Any], resolution: str, image_format: str, model: str) -> dict[str, Any]:
    return {
        "schema_version": 1,
        "project": plan["project"],
        "output": {
            "aspect_ratio": ALLOWED_ASPECT_RATIO,
            "resolution": resolution,
            "format": image_format,
            "model": model,
        },
        "created_at": utc_now_iso(),
        "updated_at": utc_now_iso(),
        "slides": [],
    }


def load_or_init_manifest(
    manifest_path: Path,
    plan: dict[str, Any],
    resolution: str,
    image_format: str,
    model: str,
) -> dict[str, Any]:
    if manifest_path.exists():
        data = read_json(manifest_path)
        if isinstance(data, dict) and isinstance(data.get("slides"), list):
            return data
    return init_manifest(plan, resolution, image_format, model)


def find_manifest_slide(manifest: dict[str, Any], slide_number: int) -> dict[str, Any] | None:
    for item in manifest.get("slides", []):
        if item.get("slide_number") == slide_number:
            return item
    return None


def upsert_manifest_slide(manifest: dict[str, Any], slide_record: dict[str, Any]) -> None:
    slides = manifest.setdefault("slides", [])
    for index, item in enumerate(slides):
        if item.get("slide_number") == slide_record.get("slide_number"):
            slides[index] = slide_record
            manifest["updated_at"] = utc_now_iso()
            return
    slides.append(slide_record)
    slides.sort(key=lambda value: value["slide_number"])
    manifest["updated_at"] = utc_now_iso()


def snapshot_if_exists(path: Path, snapshot_dir: Path, suffix: str) -> Path | None:
    if not path.exists():
        return None
    snapshot_dir.mkdir(parents=True, exist_ok=True)
    snapshot_path = snapshot_dir / f"{path.stem}.{suffix}{path.suffix}"
    copy2(path, snapshot_path)
    return snapshot_path


def get_slide_by_number(slides: list[dict[str, Any]], slide_number: int) -> dict[str, Any]:
    for slide in slides:
        if slide["slide_number"] == slide_number:
            return slide
    raise PlanValidationError(f"Slide {slide_number} not found in plan.")


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)