microsoft/hve-core
Publicmirrored fromhttps://github.com/microsoft/hve-coreAvailable
.github/skills/experimental/powerpoint/scripts/build_deck.py
1324lines · modecode
| 1 | # Copyright (c) Microsoft Corporation. |
| 2 | # SPDX-License-Identifier: MIT |
| 3 | """Build a PowerPoint slide deck from YAML content and style definitions. |
| 4 | |
| 5 | Usage:: |
| 6 | |
| 7 | python build_deck.py --content-dir content/ \ |
| 8 | --style content/global/style.yaml \ |
| 9 | --output slide-deck/presentation.pptx |
| 10 | |
| 11 | python build_deck.py --content-dir content/ \ |
| 12 | --style content/global/style.yaml \ |
| 13 | --source existing.pptx \ |
| 14 | --output slide-deck/presentation.pptx --slides 3,7,15 |
| 15 | """ |
| 16 | |
| 17 | from __future__ import annotations |
| 18 | |
| 19 | import argparse |
| 20 | import ast |
| 21 | import builtins |
| 22 | import importlib.util |
| 23 | import logging |
| 24 | import re |
| 25 | import sys |
| 26 | from pathlib import Path |
| 27 | |
| 28 | from lxml import etree |
| 29 | from pptx import Presentation |
| 30 | from pptx.enum.shapes import MSO_CONNECTOR_TYPE, MSO_SHAPE |
| 31 | from pptx.oxml.ns import qn |
| 32 | from pptx.util import Inches, Pt |
| 33 | from pptx_charts import add_chart_element |
| 34 | from pptx_colors import apply_color_to_font, resolve_color |
| 35 | from pptx_fills import apply_effect_list, apply_fill, apply_line |
| 36 | from pptx_fonts import ALIGNMENT_MAP |
| 37 | from pptx_shapes import SHAPE_MAP, apply_rotation |
| 38 | from pptx_tables import add_table_element |
| 39 | from pptx_text import ( |
| 40 | SHAPE_KEYS, |
| 41 | TEXTBOX_KEYS, |
| 42 | apply_run_properties, |
| 43 | apply_text_properties, |
| 44 | populate_text_frame, |
| 45 | ) |
| 46 | from pptx_utils import ( |
| 47 | EXIT_ERROR, |
| 48 | EXIT_FAILURE, |
| 49 | EXIT_SUCCESS, |
| 50 | configure_logging, |
| 51 | load_yaml, |
| 52 | ) |
| 53 | |
| 54 | logger = logging.getLogger(__name__) |
| 55 | |
| 56 | CONNECTOR_TYPE_MAP = { |
| 57 | "straight": MSO_CONNECTOR_TYPE.STRAIGHT, |
| 58 | "elbow": MSO_CONNECTOR_TYPE.ELBOW, |
| 59 | "curve": MSO_CONNECTOR_TYPE.CURVE, |
| 60 | } |
| 61 | |
| 62 | PNS = "http://schemas.openxmlformats.org/presentationml/2006/main" |
| 63 | ANS = "http://schemas.openxmlformats.org/drawingml/2006/main" |
| 64 | |
| 65 | # Stdlib modules blocked in content-extra.py scripts due to security risk. |
| 66 | # content-extra.py may only import from pptx and safe standard-library modules. |
| 67 | _BLOCKED_STDLIB_MODULES = frozenset( |
| 68 | { |
| 69 | "code", |
| 70 | "codeop", |
| 71 | "compileall", |
| 72 | "ctypes", |
| 73 | "dbm", |
| 74 | "ensurepip", |
| 75 | "ftplib", |
| 76 | "http", |
| 77 | "imaplib", |
| 78 | "importlib", |
| 79 | "marshal", |
| 80 | "multiprocessing", |
| 81 | "os", |
| 82 | "pickle", |
| 83 | "pkgutil", |
| 84 | "poplib", |
| 85 | "py_compile", |
| 86 | "runpy", |
| 87 | "shelve", |
| 88 | "shutil", |
| 89 | "signal", |
| 90 | "smtplib", |
| 91 | "socket", |
| 92 | "sqlite3", |
| 93 | "subprocess", |
| 94 | "sys", |
| 95 | "telnetlib", |
| 96 | "tempfile", |
| 97 | "threading", |
| 98 | "urllib", |
| 99 | "venv", |
| 100 | "webbrowser", |
| 101 | "xmlrpc", |
| 102 | "zipimport", |
| 103 | } |
| 104 | ) |
| 105 | |
| 106 | _DANGEROUS_BUILTINS = frozenset( |
| 107 | { |
| 108 | "__import__", |
| 109 | "breakpoint", |
| 110 | "compile", |
| 111 | "eval", |
| 112 | "exec", |
| 113 | } |
| 114 | ) |
| 115 | |
| 116 | # Builtins that can bypass the import allowlist or execute arbitrary strings |
| 117 | # when called indirectly through attribute access or introspection. |
| 118 | _INDIRECT_BYPASS_BUILTINS = frozenset( |
| 119 | { |
| 120 | "delattr", |
| 121 | "getattr", |
| 122 | "globals", |
| 123 | "locals", |
| 124 | "setattr", |
| 125 | "vars", |
| 126 | } |
| 127 | ) |
| 128 | |
| 129 | |
| 130 | class ContentExtraError(Exception): |
| 131 | """A content-extra.py script failed security validation.""" |
| 132 | |
| 133 | |
| 134 | def _check_module_allowed( |
| 135 | module_name: str, script_path: Path, stdlib_names: frozenset[str] |
| 136 | ) -> None: |
| 137 | """Raise ContentExtraError if *module_name* is not on the allowlist.""" |
| 138 | top_level = module_name.split(".")[0] |
| 139 | |
| 140 | if top_level == "pptx": |
| 141 | return |
| 142 | |
| 143 | if top_level in _BLOCKED_STDLIB_MODULES: |
| 144 | raise ContentExtraError(f"Blocked import '{module_name}' in {script_path}") |
| 145 | |
| 146 | if top_level in stdlib_names: |
| 147 | return |
| 148 | |
| 149 | raise ContentExtraError( |
| 150 | f"Disallowed import '{module_name}' in {script_path}: " |
| 151 | "only pptx and safe standard library modules are permitted" |
| 152 | ) |
| 153 | |
| 154 | |
| 155 | def _validate_content_extra(script_path: Path) -> None: |
| 156 | """Validate a content-extra.py script's AST before execution. |
| 157 | |
| 158 | Parses the script and rejects imports outside of pptx and safe stdlib |
| 159 | modules, as well as calls to dangerous builtins (exec, eval, __import__, |
| 160 | compile, breakpoint). Raises ContentExtraError on any violation. |
| 161 | """ |
| 162 | source = script_path.read_text(encoding="utf-8") |
| 163 | try: |
| 164 | tree = ast.parse(source, filename=str(script_path)) |
| 165 | except SyntaxError as exc: |
| 166 | raise ContentExtraError(f"Syntax error in {script_path}: {exc}") from exc |
| 167 | |
| 168 | stdlib_names = sys.stdlib_module_names |
| 169 | |
| 170 | for node in ast.walk(tree): |
| 171 | if isinstance(node, ast.Import): |
| 172 | for alias in node.names: |
| 173 | _check_module_allowed(alias.name, script_path, stdlib_names) |
| 174 | elif isinstance(node, ast.ImportFrom): |
| 175 | if node.module: |
| 176 | _check_module_allowed(node.module, script_path, stdlib_names) |
| 177 | elif isinstance(node, ast.Call): |
| 178 | func = node.func |
| 179 | if isinstance(func, ast.Name): |
| 180 | if func.id in _DANGEROUS_BUILTINS: |
| 181 | raise ContentExtraError( |
| 182 | f"Dangerous builtin '{func.id}' in {script_path}" |
| 183 | ) |
| 184 | if func.id in _INDIRECT_BYPASS_BUILTINS: |
| 185 | raise ContentExtraError( |
| 186 | f"Indirect bypass builtin '{func.id}' in {script_path}" |
| 187 | ) |
| 188 | |
| 189 | |
| 190 | def _reset_effect_ref(shape): |
| 191 | """Reset effectRef idx to 0 to prevent theme shadow inheritance. |
| 192 | |
| 193 | python-pptx defaults effectRef idx to 2, which references the theme's |
| 194 | effectStyleLst[2] that typically includes an outerShdw element. |
| 195 | """ |
| 196 | style_el = shape._element.find(f"{{{PNS}}}style") |
| 197 | if style_el is not None: |
| 198 | effect_ref = style_el.find(f"{{{ANS}}}effectRef") |
| 199 | if effect_ref is not None: |
| 200 | effect_ref.set("idx", "0") |
| 201 | |
| 202 | |
| 203 | def set_slide_bg(slide, fill_spec, colors: dict): |
| 204 | """Set a background fill on a slide.""" |
| 205 | apply_fill(slide.background, fill_spec, colors) |
| 206 | |
| 207 | |
| 208 | def set_slide_bg_image(slide, image_path: str, content_dir: Path): |
| 209 | """Set a background image on a slide using blipFill in the background element.""" |
| 210 | img_file = content_dir / image_path |
| 211 | if not img_file.exists(): |
| 212 | return |
| 213 | |
| 214 | from pptx.opc.constants import RELATIONSHIP_TYPE as RT |
| 215 | from pptx.parts.image import Image, ImagePart |
| 216 | |
| 217 | sld = slide._element |
| 218 | cSld = sld.find(qn("p:cSld")) |
| 219 | if cSld is None: |
| 220 | return |
| 221 | |
| 222 | spTree = cSld.find(qn("p:spTree")) |
| 223 | |
| 224 | # Remove existing p:bg element if present |
| 225 | existing_bg = cSld.find(qn("p:bg")) |
| 226 | if existing_bg is not None: |
| 227 | cSld.remove(existing_bg) |
| 228 | |
| 229 | # Create image part and relate to slide |
| 230 | image = Image.from_file(str(img_file)) |
| 231 | image_part = ImagePart.new(slide.part.package, image) |
| 232 | rel = slide.part.relate_to(image_part, RT.IMAGE) |
| 233 | |
| 234 | # Build p:bg > p:bgPr > a:blipFill structure |
| 235 | bg = etree.SubElement(cSld, qn("p:bg")) |
| 236 | bgPr = etree.SubElement(bg, qn("p:bgPr")) |
| 237 | blipFill = etree.SubElement(bgPr, qn("a:blipFill")) |
| 238 | blipFill.set("dpi", "0") |
| 239 | blipFill.set("rotWithShape", "1") |
| 240 | |
| 241 | blip = etree.SubElement(blipFill, qn("a:blip")) |
| 242 | blip.set(qn("r:embed"), rel) |
| 243 | |
| 244 | stretch = etree.SubElement(blipFill, qn("a:stretch")) |
| 245 | etree.SubElement(stretch, qn("a:fillRect")) |
| 246 | |
| 247 | etree.SubElement(bgPr, qn("a:effectLst")) |
| 248 | |
| 249 | # Ensure p:bg appears before p:spTree (required by schema) |
| 250 | if spTree is not None: |
| 251 | cSld.remove(bg) |
| 252 | cSld.insert(list(cSld).index(spTree), bg) |
| 253 | |
| 254 | |
| 255 | def add_textbox( |
| 256 | slide, |
| 257 | left, |
| 258 | top, |
| 259 | width, |
| 260 | height, |
| 261 | text, |
| 262 | font_name=None, |
| 263 | font_size=16, |
| 264 | font_color=None, |
| 265 | bold=False, |
| 266 | italic=False, |
| 267 | alignment=None, |
| 268 | name=None, |
| 269 | rotation=None, |
| 270 | elem=None, |
| 271 | colors=None, |
| 272 | ): |
| 273 | """Add a text box to a slide with font and layout properties. |
| 274 | |
| 275 | Args: |
| 276 | slide: Target slide object. |
| 277 | left: Left position in inches. |
| 278 | top: Top position in inches. |
| 279 | width: Width in inches. |
| 280 | height: Height in inches. |
| 281 | text: Text content for the box. |
| 282 | font_name: Font family name. |
| 283 | font_size: Font size in points. |
| 284 | font_color: Resolved color spec dict. |
| 285 | bold: Apply bold formatting. |
| 286 | italic: Apply italic formatting. |
| 287 | alignment: Paragraph alignment name. |
| 288 | name: Shape name identifier. |
| 289 | rotation: Rotation angle in degrees. |
| 290 | elem: Full element dict from content.yaml for |
| 291 | paragraph-level and run-level properties. |
| 292 | colors: Color resolution dict. |
| 293 | |
| 294 | Returns: |
| 295 | The created textbox shape object. |
| 296 | """ |
| 297 | txBox = slide.shapes.add_textbox( |
| 298 | Inches(left), Inches(top), Inches(width), Inches(height) |
| 299 | ) |
| 300 | if name: |
| 301 | txBox.name = name |
| 302 | apply_rotation(txBox, rotation) |
| 303 | |
| 304 | defaults = { |
| 305 | "font": font_name, |
| 306 | "size": font_size, |
| 307 | "color": font_color, |
| 308 | "bold": bold, |
| 309 | "italic": italic, |
| 310 | "alignment": alignment, |
| 311 | } |
| 312 | source = elem or {"text": text} |
| 313 | if "text" not in source: |
| 314 | source = {**source, "text": text} |
| 315 | populate_text_frame(txBox.text_frame, source, colors or {}, TEXTBOX_KEYS, defaults) |
| 316 | return txBox |
| 317 | |
| 318 | |
| 319 | def add_shape_element(slide, elem, colors, typography): |
| 320 | """Add a shape element from a content.yaml definition.""" |
| 321 | shape_type = SHAPE_MAP.get(elem.get("shape", "rectangle"), MSO_SHAPE.RECTANGLE) |
| 322 | left = Inches(elem["left"]) |
| 323 | top = Inches(elem["top"]) |
| 324 | width = Inches(elem["width"]) |
| 325 | height = Inches(elem["height"]) |
| 326 | |
| 327 | shape = slide.shapes.add_shape(shape_type, left, top, width, height) |
| 328 | |
| 329 | _reset_effect_ref(shape) |
| 330 | |
| 331 | if "name" in elem: |
| 332 | shape.name = elem["name"] |
| 333 | |
| 334 | apply_rotation(shape, elem.get("rotation")) |
| 335 | apply_fill(shape, elem.get("fill"), colors) |
| 336 | apply_line(shape, elem, colors) |
| 337 | |
| 338 | if "corner_radius" in elem: |
| 339 | shape.adjustments[0] = elem["corner_radius"] |
| 340 | |
| 341 | if "effect" in elem: |
| 342 | apply_effect_list(shape, elem["effect"]) |
| 343 | |
| 344 | if "text" in elem: |
| 345 | populate_text_frame(shape.text_frame, elem, colors, SHAPE_KEYS) |
| 346 | |
| 347 | return shape |
| 348 | |
| 349 | |
| 350 | def add_image_element(slide, elem, content_dir: Path): |
| 351 | """Add an image element from a content.yaml definition.""" |
| 352 | img_path = content_dir / elem["path"] |
| 353 | if not img_path.exists(): |
| 354 | # Fallback: add a text box with the path as placeholder |
| 355 | add_textbox( |
| 356 | slide, |
| 357 | elem["left"], |
| 358 | elem["top"], |
| 359 | elem["width"], |
| 360 | elem["height"], |
| 361 | f"[Image: {elem['path']}]", |
| 362 | font_size=12, |
| 363 | ) |
| 364 | return None |
| 365 | |
| 366 | left = Inches(elem["left"]) |
| 367 | top = Inches(elem["top"]) |
| 368 | width = Inches(elem["width"]) |
| 369 | height = Inches(elem["height"]) |
| 370 | pic = slide.shapes.add_picture(str(img_path), left, top, width, height) |
| 371 | if "name" in elem: |
| 372 | pic.name = elem["name"] |
| 373 | apply_rotation(pic, elem.get("rotation")) |
| 374 | |
| 375 | # Restore blipFill attributes (rotWithShape, dpi, etc.) |
| 376 | if "blip_fill_attrs" in elem: |
| 377 | blipFill = pic._element.find(qn("p:blipFill")) |
| 378 | if blipFill is not None: |
| 379 | for attr_name, attr_val in elem["blip_fill_attrs"].items(): |
| 380 | blipFill.set(attr_name, attr_val) |
| 381 | |
| 382 | # Apply image crop via srcRect on blipFill |
| 383 | if "crop" in elem: |
| 384 | blipFill = pic._element.find(qn("p:blipFill")) |
| 385 | if blipFill is not None: |
| 386 | srcRect = blipFill.find(qn("a:srcRect")) |
| 387 | if srcRect is None: |
| 388 | # Insert srcRect after a:blip |
| 389 | blip_el = blipFill.find(qn("a:blip")) |
| 390 | idx = list(blipFill).index(blip_el) + 1 if blip_el is not None else 0 |
| 391 | srcRect = etree.Element(qn("a:srcRect")) |
| 392 | blipFill.insert(idx, srcRect) |
| 393 | crop = elem["crop"] |
| 394 | for side in ("l", "t", "r", "b"): |
| 395 | if side in crop: |
| 396 | srcRect.set(side, str(crop[side])) |
| 397 | |
| 398 | # Apply image opacity via alphaModFix on the blip element |
| 399 | if "opacity" in elem: |
| 400 | blip = pic._element.find(".//" + qn("a:blip")) |
| 401 | if blip is not None: |
| 402 | amt = str(int(elem["opacity"] * 1000)) |
| 403 | amf = blip.find(qn("a:alphaModFix")) |
| 404 | if amf is None: |
| 405 | amf = etree.SubElement(blip, qn("a:alphaModFix")) |
| 406 | amf.set("amt", amt) |
| 407 | |
| 408 | return pic |
| 409 | |
| 410 | |
| 411 | def add_rich_text_element(slide, elem, colors, typography): |
| 412 | """Add a rich text element with mixed font/color segments.""" |
| 413 | txBox = slide.shapes.add_textbox( |
| 414 | Inches(elem["left"]), |
| 415 | Inches(elem["top"]), |
| 416 | Inches(elem["width"]), |
| 417 | Inches(elem["height"]), |
| 418 | ) |
| 419 | if "name" in elem: |
| 420 | txBox.name = elem["name"] |
| 421 | tf = txBox.text_frame |
| 422 | tf.word_wrap = True |
| 423 | p = tf.paragraphs[0] |
| 424 | |
| 425 | # Apply text frame-level properties |
| 426 | apply_text_properties(tf, elem) |
| 427 | |
| 428 | for i, seg in enumerate(elem.get("segments", [])): |
| 429 | run = p.add_run() if i > 0 else (p.runs[0] if p.runs else p.add_run()) |
| 430 | run.text = seg["text"] |
| 431 | seg_font = seg.get("font") |
| 432 | if seg_font: |
| 433 | run.font.name = seg_font |
| 434 | run.font.size = Pt(seg.get("size", 16)) |
| 435 | if "color" in seg: |
| 436 | color_spec = resolve_color(seg["color"]) |
| 437 | apply_color_to_font(run.font.color, color_spec) |
| 438 | run.font.bold = seg.get("bold", False) |
| 439 | run.font.italic = seg.get("italic", False) |
| 440 | apply_run_properties(run, seg, colors) |
| 441 | |
| 442 | return txBox |
| 443 | |
| 444 | |
| 445 | def add_card_element(slide, elem, colors, typography): |
| 446 | """Add a card panel with optional title bar and bullet content.""" |
| 447 | left = Inches(elem["left"]) |
| 448 | top = Inches(elem["top"]) |
| 449 | width = Inches(elem["width"]) |
| 450 | height = Inches(elem["height"]) |
| 451 | |
| 452 | # Card background |
| 453 | shape = slide.shapes.add_shape( |
| 454 | MSO_SHAPE.ROUNDED_RECTANGLE, left, top, width, height |
| 455 | ) |
| 456 | apply_fill(shape, elem.get("fill", "#2D2D35"), colors) |
| 457 | if "border_color" in elem: |
| 458 | apply_line( |
| 459 | shape, |
| 460 | { |
| 461 | "line_color": elem["border_color"], |
| 462 | "line_width": elem.get("border_width", 1), |
| 463 | }, |
| 464 | colors, |
| 465 | ) |
| 466 | else: |
| 467 | shape.line.fill.background() |
| 468 | |
| 469 | # Accent bar |
| 470 | if elem.get("accent_bar"): |
| 471 | bar = slide.shapes.add_shape( |
| 472 | MSO_SHAPE.RECTANGLE, |
| 473 | Inches(elem["left"] + 0.15), |
| 474 | Inches(elem["top"] + 0.1), |
| 475 | Inches(elem["width"] - 0.3), |
| 476 | Inches(0.04), |
| 477 | ) |
| 478 | apply_fill(bar, elem.get("accent_color", "#0078D4"), colors) |
| 479 | bar.line.fill.background() |
| 480 | |
| 481 | # Title |
| 482 | y_offset = 0.2 |
| 483 | if "title" in elem: |
| 484 | add_textbox( |
| 485 | slide, |
| 486 | elem["left"] + 0.2, |
| 487 | elem["top"] + y_offset, |
| 488 | elem["width"] - 0.4, |
| 489 | 0.4, |
| 490 | elem["title"], |
| 491 | font_name="Segoe UI", |
| 492 | font_size=elem.get("title_size", 16), |
| 493 | font_color=resolve_color(elem.get("title_color", "#F8F8FC")), |
| 494 | bold=elem.get("title_bold", True), |
| 495 | ) |
| 496 | y_offset += 0.5 |
| 497 | |
| 498 | # Content bullets |
| 499 | for item in elem.get("content", []): |
| 500 | bullet_text = ( |
| 501 | f"\u2022 {item['bullet']}" if "bullet" in item else item.get("text", "") |
| 502 | ) |
| 503 | color = resolve_color(item.get("color", "#F8F8FC")) |
| 504 | add_textbox( |
| 505 | slide, |
| 506 | elem["left"] + 0.2, |
| 507 | elem["top"] + y_offset, |
| 508 | elem["width"] - 0.4, |
| 509 | 0.35, |
| 510 | bullet_text, |
| 511 | font_name="Segoe UI", |
| 512 | font_size=item.get("size", 14), |
| 513 | font_color=color, |
| 514 | ) |
| 515 | y_offset += 0.35 |
| 516 | |
| 517 | return shape |
| 518 | |
| 519 | |
| 520 | def add_arrow_flow_element(slide, elem, colors, typography): |
| 521 | """Add a horizontal arrow flow diagram.""" |
| 522 | items = elem.get("items", []) |
| 523 | if not items: |
| 524 | return |
| 525 | |
| 526 | total_width = elem["width"] |
| 527 | item_width = total_width / len(items) - 0.3 |
| 528 | x = elem["left"] |
| 529 | |
| 530 | for item in items: |
| 531 | shape = slide.shapes.add_shape( |
| 532 | MSO_SHAPE.CHEVRON, |
| 533 | Inches(x), |
| 534 | Inches(elem["top"]), |
| 535 | Inches(item_width), |
| 536 | Inches(elem["height"]), |
| 537 | ) |
| 538 | apply_fill(shape, item.get("color", "#0078D4"), colors) |
| 539 | shape.line.fill.background() |
| 540 | |
| 541 | tf = shape.text_frame |
| 542 | tf.word_wrap = True |
| 543 | p = tf.paragraphs[0] |
| 544 | p.text = item["label"] |
| 545 | p.alignment = ALIGNMENT_MAP["center"] |
| 546 | run = p.runs[0] |
| 547 | run.font.name = "Segoe UI" |
| 548 | run.font.size = Pt(14) |
| 549 | apply_color_to_font(run.font.color, resolve_color("#F8F8FC")) |
| 550 | run.font.bold = True |
| 551 | |
| 552 | x += item_width + 0.3 |
| 553 | |
| 554 | |
| 555 | def add_numbered_step_element(slide, elem, colors, typography): |
| 556 | """Add a numbered step with circle, label, and description.""" |
| 557 | number = elem.get("number", 1) |
| 558 | |
| 559 | # Number circle |
| 560 | circle = slide.shapes.add_shape( |
| 561 | MSO_SHAPE.OVAL, |
| 562 | Inches(elem["left"]), |
| 563 | Inches(elem["top"]), |
| 564 | Inches(0.5), |
| 565 | Inches(0.5), |
| 566 | ) |
| 567 | apply_fill(circle, elem.get("accent_color", "#0078D4"), colors) |
| 568 | circle.line.fill.background() |
| 569 | tf = circle.text_frame |
| 570 | p = tf.paragraphs[0] |
| 571 | p.text = str(number) |
| 572 | p.alignment = ALIGNMENT_MAP["center"] |
| 573 | run = p.runs[0] |
| 574 | run.font.name = "Segoe UI" |
| 575 | run.font.size = Pt(16) |
| 576 | apply_color_to_font(run.font.color, resolve_color("#F8F8FC")) |
| 577 | run.font.bold = True |
| 578 | |
| 579 | # Label |
| 580 | add_textbox( |
| 581 | slide, |
| 582 | elem["left"] + 0.6, |
| 583 | elem["top"], |
| 584 | elem["width"] - 0.6, |
| 585 | 0.35, |
| 586 | elem["label"], |
| 587 | font_name="Segoe UI", |
| 588 | font_size=16, |
| 589 | font_color=resolve_color("#F8F8FC"), |
| 590 | bold=True, |
| 591 | ) |
| 592 | |
| 593 | # Description |
| 594 | if "description" in elem: |
| 595 | add_textbox( |
| 596 | slide, |
| 597 | elem["left"] + 0.6, |
| 598 | elem["top"] + 0.35, |
| 599 | elem["width"] - 0.6, |
| 600 | 0.4, |
| 601 | elem["description"], |
| 602 | font_name="Segoe UI", |
| 603 | font_size=14, |
| 604 | font_color=resolve_color("#9CA3AF"), |
| 605 | ) |
| 606 | |
| 607 | |
| 608 | def add_connector_element(slide, elem: dict, colors: dict): |
| 609 | """Add a connector element from a content.yaml definition. |
| 610 | |
| 611 | YAML schema: |
| 612 | - type: connector |
| 613 | connector_type: straight |
| 614 | begin_x: 3.0 |
| 615 | begin_y: 2.0 |
| 616 | end_x: 7.0 |
| 617 | end_y: 4.0 |
| 618 | line_color: "#0078D4" |
| 619 | line_width: 2 |
| 620 | dash_style: solid |
| 621 | head_end: none |
| 622 | tail_end: arrow |
| 623 | """ |
| 624 | conn_type = CONNECTOR_TYPE_MAP.get( |
| 625 | elem.get("connector_type", "straight"), MSO_CONNECTOR_TYPE.STRAIGHT |
| 626 | ) |
| 627 | |
| 628 | connector = slide.shapes.add_connector( |
| 629 | conn_type, |
| 630 | Inches(elem["begin_x"]), |
| 631 | Inches(elem["begin_y"]), |
| 632 | Inches(elem["end_x"]), |
| 633 | Inches(elem["end_y"]), |
| 634 | ) |
| 635 | |
| 636 | apply_line(connector, elem, colors) |
| 637 | |
| 638 | # Arrow heads via lxml XML manipulation |
| 639 | sp_pr = connector._element.find(qn("a:ln")) |
| 640 | if sp_pr is None: |
| 641 | ln_parent = connector._element.spPr |
| 642 | sp_pr = ln_parent.find(qn("a:ln")) |
| 643 | if sp_pr is None: |
| 644 | sp_pr = etree.SubElement(connector._element.spPr, qn("a:ln")) |
| 645 | |
| 646 | if "head_end" in elem and elem["head_end"] != "none": |
| 647 | head = etree.SubElement(sp_pr, qn("a:headEnd")) |
| 648 | head.set("type", elem["head_end"]) |
| 649 | if "tail_end" in elem and elem["tail_end"] != "none": |
| 650 | tail = etree.SubElement(sp_pr, qn("a:tailEnd")) |
| 651 | tail.set("type", elem["tail_end"]) |
| 652 | |
| 653 | if "name" in elem: |
| 654 | connector.name = elem["name"] |
| 655 | |
| 656 | return connector |
| 657 | |
| 658 | |
| 659 | MAX_GROUP_DEPTH = 20 |
| 660 | |
| 661 | |
| 662 | def add_group_element( |
| 663 | slide, |
| 664 | elem: dict, |
| 665 | colors: dict, |
| 666 | typography: dict, |
| 667 | content_dir: Path, |
| 668 | *, |
| 669 | _depth: int = 0, |
| 670 | max_depth: int = MAX_GROUP_DEPTH, |
| 671 | ): |
| 672 | """Add a group element containing nested child elements. |
| 673 | |
| 674 | Raises ValueError when nesting exceeds *max_depth*. |
| 675 | |
| 676 | YAML schema: |
| 677 | - type: group |
| 678 | left: 1.0 |
| 679 | top: 2.0 |
| 680 | width: 5.0 |
| 681 | height: 3.0 |
| 682 | elements: |
| 683 | - type: shape |
| 684 | shape: rectangle |
| 685 | left: 0 |
| 686 | top: 0 |
| 687 | width: 5.0 |
| 688 | height: 3.0 |
| 689 | fill: "#2D2D35" |
| 690 | - type: textbox |
| 691 | left: 0.2 |
| 692 | top: 0.2 |
| 693 | width: 4.6 |
| 694 | height: 0.5 |
| 695 | text: "Group Title" |
| 696 | """ |
| 697 | if _depth >= max_depth: |
| 698 | raise ValueError(f"Group nesting depth {_depth} exceeds limit of {max_depth}") |
| 699 | group = slide.shapes.add_group_shape() |
| 700 | |
| 701 | group.left = Inches(elem["left"]) |
| 702 | group.top = Inches(elem["top"]) |
| 703 | group.width = Inches(elem["width"]) |
| 704 | group.height = Inches(elem["height"]) |
| 705 | |
| 706 | for child_elem in elem.get("elements", []): |
| 707 | build_element_in_group( |
| 708 | group, |
| 709 | child_elem, |
| 710 | colors, |
| 711 | typography, |
| 712 | content_dir, |
| 713 | _depth=_depth + 1, |
| 714 | max_depth=max_depth, |
| 715 | ) |
| 716 | |
| 717 | if "name" in elem: |
| 718 | group.name = elem["name"] |
| 719 | |
| 720 | return group |
| 721 | |
| 722 | |
| 723 | def build_element_in_group( |
| 724 | group, |
| 725 | elem: dict, |
| 726 | colors: dict, |
| 727 | typography: dict, |
| 728 | content_dir: Path, |
| 729 | *, |
| 730 | _depth: int = 0, |
| 731 | max_depth: int = MAX_GROUP_DEPTH, |
| 732 | ): |
| 733 | """Dispatch a child element build within a group shape. |
| 734 | |
| 735 | Reuses top-level builders for shape and textbox. Groups do not support |
| 736 | table or chart elements. |
| 737 | """ |
| 738 | elem_type = elem.get("type", "textbox") |
| 739 | |
| 740 | if elem_type == "shape": |
| 741 | _add_shape_to_collection(group.shapes, elem, colors) |
| 742 | elif elem_type == "textbox": |
| 743 | _add_textbox_to_collection(group.shapes, elem, colors) |
| 744 | elif elem_type == "connector": |
| 745 | add_connector_element(group, elem, colors) |
| 746 | elif elem_type == "image": |
| 747 | add_image_element(group, elem, content_dir) |
| 748 | elif elem_type == "group": |
| 749 | add_group_element( |
| 750 | group, |
| 751 | elem, |
| 752 | colors, |
| 753 | typography, |
| 754 | content_dir, |
| 755 | _depth=_depth, |
| 756 | max_depth=max_depth, |
| 757 | ) |
| 758 | |
| 759 | |
| 760 | def _add_shape_to_collection(shapes, elem: dict, colors: dict): |
| 761 | """Add a shape to any shapes collection (slide or group).""" |
| 762 | shape_type = SHAPE_MAP.get(elem.get("shape", "rectangle"), MSO_SHAPE.RECTANGLE) |
| 763 | shape = shapes.add_shape( |
| 764 | shape_type, |
| 765 | Inches(elem["left"]), |
| 766 | Inches(elem["top"]), |
| 767 | Inches(elem["width"]), |
| 768 | Inches(elem["height"]), |
| 769 | ) |
| 770 | if "name" in elem: |
| 771 | shape.name = elem["name"] |
| 772 | apply_rotation(shape, elem.get("rotation")) |
| 773 | apply_fill(shape, elem.get("fill"), colors) |
| 774 | apply_line(shape, elem, colors) |
| 775 | if "text" in elem: |
| 776 | populate_text_frame(shape.text_frame, elem, colors, SHAPE_KEYS) |
| 777 | return shape |
| 778 | |
| 779 | |
| 780 | def _add_textbox_to_collection(shapes, elem: dict, colors: dict): |
| 781 | """Add a textbox to any shapes collection (slide or group).""" |
| 782 | txBox = shapes.add_textbox( |
| 783 | Inches(elem["left"]), |
| 784 | Inches(elem["top"]), |
| 785 | Inches(elem["width"]), |
| 786 | Inches(elem["height"]), |
| 787 | ) |
| 788 | if "name" in elem: |
| 789 | txBox.name = elem["name"] |
| 790 | populate_text_frame(txBox.text_frame, elem, colors, TEXTBOX_KEYS) |
| 791 | return txBox |
| 792 | |
| 793 | |
| 794 | def _build_textbox_element(slide, elem, colors, typography, content_dir): |
| 795 | """Build a textbox element with full parameter resolution for YAML keys.""" |
| 796 | font_name = elem.get("font") |
| 797 | font_color = resolve_color(elem["font_color"]) if "font_color" in elem else None |
| 798 | is_bold = elem.get("font_bold", elem.get("bold", False)) |
| 799 | add_textbox( |
| 800 | slide, |
| 801 | elem["left"], |
| 802 | elem["top"], |
| 803 | elem["width"], |
| 804 | elem["height"], |
| 805 | elem.get("text", ""), |
| 806 | font_name=font_name, |
| 807 | font_size=elem.get("font_size", 16), |
| 808 | font_color=font_color, |
| 809 | bold=is_bold, |
| 810 | italic=elem.get("italic", False), |
| 811 | alignment=elem.get("alignment"), |
| 812 | name=elem.get("name"), |
| 813 | rotation=elem.get("rotation"), |
| 814 | elem=elem, |
| 815 | colors=colors, |
| 816 | ) |
| 817 | |
| 818 | |
| 819 | def _build_image_element(slide, elem, colors, typography, content_dir): |
| 820 | """Delegate image element building to add_image_element.""" |
| 821 | add_image_element(slide, elem, content_dir) |
| 822 | |
| 823 | |
| 824 | def _build_group_element(slide, elem, colors, typography, content_dir): |
| 825 | """Delegate group element building to add_group_element.""" |
| 826 | add_group_element(slide, elem, colors, typography, content_dir, _depth=0) |
| 827 | |
| 828 | |
| 829 | def _build_connector_element(slide, elem, colors, typography, content_dir): |
| 830 | """Delegate connector building to add_connector_element.""" |
| 831 | add_connector_element(slide, elem, colors) |
| 832 | |
| 833 | |
| 834 | def _build_chart_element(slide, elem, colors, typography, content_dir): |
| 835 | """Delegate chart building to add_chart_element.""" |
| 836 | add_chart_element(slide, elem, colors) |
| 837 | |
| 838 | |
| 839 | def _build_table_element(slide, elem, colors, typography, content_dir): |
| 840 | """Delegate table building to add_table_element.""" |
| 841 | add_table_element(slide, elem, colors, typography) |
| 842 | |
| 843 | |
| 844 | # Element builder registry: maps element type names to builder functions. |
| 845 | # All builders share the signature (slide, elem, colors, typography, content_dir). |
| 846 | ELEMENT_BUILDERS = { |
| 847 | "shape": lambda slide, elem, colors, typography, content_dir: add_shape_element( |
| 848 | slide, elem, colors, typography |
| 849 | ), |
| 850 | "textbox": _build_textbox_element, |
| 851 | "image": _build_image_element, |
| 852 | "rich_text": lambda slide, elem, colors, typography, content_dir: ( |
| 853 | add_rich_text_element(slide, elem, colors, typography) |
| 854 | ), |
| 855 | "card": lambda slide, elem, colors, typography, content_dir: add_card_element( |
| 856 | slide, elem, colors, typography |
| 857 | ), |
| 858 | "arrow_flow": lambda slide, elem, colors, typography, content_dir: ( |
| 859 | add_arrow_flow_element(slide, elem, colors, typography) |
| 860 | ), |
| 861 | "numbered_step": lambda slide, elem, colors, typography, content_dir: ( |
| 862 | add_numbered_step_element(slide, elem, colors, typography) |
| 863 | ), |
| 864 | "table": _build_table_element, |
| 865 | "chart": _build_chart_element, |
| 866 | "connector": _build_connector_element, |
| 867 | "group": _build_group_element, |
| 868 | } |
| 869 | |
| 870 | |
| 871 | def _build_element( |
| 872 | slide, elem: dict, colors: dict, typography: dict, content_dir: Path |
| 873 | ): |
| 874 | """Dispatch element building via registry lookup.""" |
| 875 | elem_type = elem.get("type", "textbox") |
| 876 | builder = ELEMENT_BUILDERS.get(elem_type) |
| 877 | if builder: |
| 878 | builder(slide, elem, colors, typography, content_dir) |
| 879 | |
| 880 | |
| 881 | def clear_slide_shapes(slide): |
| 882 | """Remove all shapes from a slide, preserving the slide itself.""" |
| 883 | sp_tree = slide.shapes._spTree |
| 884 | shapes_to_remove = [ |
| 885 | sp |
| 886 | for sp in sp_tree.iterchildren() |
| 887 | if sp.tag.endswith("}sp") |
| 888 | or sp.tag.endswith("}pic") |
| 889 | or sp.tag.endswith("}grpSp") |
| 890 | or sp.tag.endswith("}cxnSp") |
| 891 | ] |
| 892 | for sp in shapes_to_remove: |
| 893 | sp_tree.remove(sp) |
| 894 | |
| 895 | |
| 896 | def _all_layouts(prs): |
| 897 | """Iterate layouts across all slide masters.""" |
| 898 | for master in prs.slide_masters: |
| 899 | yield from master.slide_layouts |
| 900 | |
| 901 | |
| 902 | def _find_blank_layout(prs): |
| 903 | """Find the best blank layout in the presentation, with fallbacks.""" |
| 904 | # Try index 6 first (default blank in standard templates) |
| 905 | try: |
| 906 | return prs.slide_layouts[6] |
| 907 | except IndexError: |
| 908 | # Template has fewer than 7 layouts; fall through to name search. |
| 909 | pass |
| 910 | # Search by name across all masters |
| 911 | for layout in _all_layouts(prs): |
| 912 | if layout.name.lower() in ("blank", "blank slide"): |
| 913 | return layout |
| 914 | # Fall back to last layout of first master |
| 915 | return prs.slide_layouts[len(prs.slide_layouts) - 1] |
| 916 | |
| 917 | |
| 918 | def get_slide_layout(prs, slide_content: dict, style: dict): |
| 919 | """Select slide layout based on content.yaml or style.yaml configuration.""" |
| 920 | layout_spec = slide_content.get("layout") |
| 921 | layouts_map = style.get("layouts", {}) |
| 922 | |
| 923 | if layout_spec is None or layout_spec == "blank": |
| 924 | return _find_blank_layout(prs) |
| 925 | |
| 926 | # Resolve through style.yaml layouts map |
| 927 | if layout_spec in layouts_map: |
| 928 | layout_ref = layouts_map[layout_spec] |
| 929 | if isinstance(layout_ref, int): |
| 930 | try: |
| 931 | return prs.slide_layouts[layout_ref] |
| 932 | except IndexError: |
| 933 | return _find_blank_layout(prs) |
| 934 | elif isinstance(layout_ref, str): |
| 935 | for layout in _all_layouts(prs): |
| 936 | if layout.name == layout_ref: |
| 937 | return layout |
| 938 | |
| 939 | # Direct name lookup across all slide masters |
| 940 | if isinstance(layout_spec, str): |
| 941 | for layout in _all_layouts(prs): |
| 942 | if layout.name == layout_spec: |
| 943 | return layout |
| 944 | |
| 945 | # Direct index lookup |
| 946 | if isinstance(layout_spec, int): |
| 947 | try: |
| 948 | return prs.slide_layouts[layout_spec] |
| 949 | except IndexError: |
| 950 | return _find_blank_layout(prs) |
| 951 | |
| 952 | # Fallback to blank |
| 953 | return _find_blank_layout(prs) |
| 954 | |
| 955 | |
| 956 | def build_slide( |
| 957 | prs, |
| 958 | slide_content: dict, |
| 959 | style: dict, |
| 960 | content_dir: Path, |
| 961 | existing_slide=None, |
| 962 | *, |
| 963 | allow_scripts: bool = False, |
| 964 | ): |
| 965 | """Build a single slide from content.yaml data and style context. |
| 966 | |
| 967 | When existing_slide is provided, clears its shapes and rebuilds in place |
| 968 | instead of appending a new slide. Set *allow_scripts* to skip AST |
| 969 | validation of content-extra.py (use only with trusted content). |
| 970 | """ |
| 971 | colors = {} |
| 972 | typography = {} |
| 973 | |
| 974 | # Populate colors from the matching theme's color map in style.yaml so |
| 975 | # content-extra.py scripts can reference theme colors programmatically |
| 976 | # via style["colors"]["accent_blue"] instead of hardcoding hex values. |
| 977 | # Uses a per-slide lookup based on the themes[].slides list and falls |
| 978 | # back to themes[0] when no explicit assignment exists. |
| 979 | slide_num = slide_content.get("slide", 0) |
| 980 | themes = style.get("themes", []) |
| 981 | if themes and isinstance(themes, list): |
| 982 | matched_theme = next( |
| 983 | ( |
| 984 | t |
| 985 | for t in themes |
| 986 | if isinstance(t, dict) and slide_num in t.get("slides", []) |
| 987 | ), |
| 988 | themes[0] if isinstance(themes[0], dict) else None, |
| 989 | ) |
| 990 | if matched_theme: |
| 991 | style_colors = matched_theme.get("colors", {}) |
| 992 | if style_colors: |
| 993 | style = {**style, "colors": style_colors} |
| 994 | |
| 995 | if existing_slide is not None: |
| 996 | slide = existing_slide |
| 997 | clear_slide_shapes(slide) |
| 998 | else: |
| 999 | layout = get_slide_layout(prs, slide_content, style) |
| 1000 | slide = prs.slides.add_slide(layout) |
| 1001 | |
| 1002 | # Populate themed layout placeholders |
| 1003 | placeholders = slide_content.get("placeholders", {}) |
| 1004 | for idx_str, value in placeholders.items(): |
| 1005 | idx = int(idx_str) |
| 1006 | if idx in slide.placeholders: |
| 1007 | ph = slide.placeholders[idx] |
| 1008 | if isinstance(value, str): |
| 1009 | ph.text = value |
| 1010 | elif isinstance(value, list): |
| 1011 | tf = ph.text_frame |
| 1012 | tf.text = value[0] |
| 1013 | for line in value[1:]: |
| 1014 | tf.add_paragraph().text = line |
| 1015 | |
| 1016 | # Remove unused placeholder shapes inherited from the layout |
| 1017 | used_ph_indices = {int(k) for k in placeholders} |
| 1018 | sp_tree = slide.shapes._spTree |
| 1019 | for sp in list(sp_tree.iterchildren()): |
| 1020 | nvSpPr = sp.find(qn("p:nvSpPr")) |
| 1021 | if nvSpPr is None: |
| 1022 | continue |
| 1023 | nvPr = nvSpPr.find(qn("p:nvPr")) |
| 1024 | if nvPr is None: |
| 1025 | continue |
| 1026 | ph = nvPr.find(qn("p:ph")) |
| 1027 | if ph is not None: |
| 1028 | idx = int(ph.get("idx", "0")) |
| 1029 | if idx not in used_ph_indices: |
| 1030 | sp_tree.remove(sp) |
| 1031 | |
| 1032 | # Set background from per-slide definition only |
| 1033 | bg_block = slide_content.get("background") |
| 1034 | if bg_block and "image" in bg_block: |
| 1035 | set_slide_bg_image(slide, bg_block["image"], content_dir) |
| 1036 | elif bg_block and "fill" in bg_block: |
| 1037 | set_slide_bg(slide, bg_block["fill"], colors) |
| 1038 | |
| 1039 | # Sort elements by z_order to preserve stacking order |
| 1040 | elements = slide_content.get("elements", []) |
| 1041 | elements = sorted(elements, key=lambda e: e.get("z_order", 0)) |
| 1042 | |
| 1043 | # Filter out empty placeholder elements |
| 1044 | elements = [ |
| 1045 | e |
| 1046 | for e in elements |
| 1047 | if not (e.get("_placeholder") and not e.get("text", "").strip()) |
| 1048 | ] |
| 1049 | |
| 1050 | turbo_enabled = len(elements) > 20 |
| 1051 | if turbo_enabled: |
| 1052 | slide.shapes.turbo_add_enabled = True |
| 1053 | |
| 1054 | # Process elements in order |
| 1055 | for elem in elements: |
| 1056 | _build_element(slide, elem, colors, typography, content_dir) |
| 1057 | |
| 1058 | # Execute content-extra.py if present (validated before loading) |
| 1059 | extra_script = content_dir / "content-extra.py" |
| 1060 | if extra_script.exists(): |
| 1061 | if not allow_scripts: |
| 1062 | _validate_content_extra(extra_script) |
| 1063 | spec = importlib.util.spec_from_file_location( |
| 1064 | "content_extra", str(extra_script) |
| 1065 | ) |
| 1066 | mod = importlib.util.module_from_spec(spec) |
| 1067 | if not allow_scripts: |
| 1068 | # __import__ is kept because the import machinery needs it; |
| 1069 | # the AST checker already blocks direct __import__() calls. |
| 1070 | stripped = (_DANGEROUS_BUILTINS | _INDIRECT_BYPASS_BUILTINS) - { |
| 1071 | "__import__" |
| 1072 | } |
| 1073 | safe_builtins = { |
| 1074 | k: v for k, v in builtins.__dict__.items() if k not in stripped |
| 1075 | } |
| 1076 | mod.__builtins__ = safe_builtins |
| 1077 | spec.loader.exec_module(mod) |
| 1078 | if hasattr(mod, "render"): |
| 1079 | mod.render(slide, style, content_dir) |
| 1080 | |
| 1081 | if turbo_enabled: |
| 1082 | slide.shapes.turbo_add_enabled = False |
| 1083 | |
| 1084 | # Add speaker notes (preserve empty strings when notes slide exists) |
| 1085 | notes = slide_content.get("speaker_notes") |
| 1086 | if notes is not None: |
| 1087 | notes_slide = slide.notes_slide |
| 1088 | notes_text = re.sub(r"\v", "\n", notes) if notes else "" |
| 1089 | notes_slide.notes_text_frame.text = notes_text |
| 1090 | |
| 1091 | return slide |
| 1092 | |
| 1093 | |
| 1094 | def discover_slides(content_dir: Path) -> list[tuple[int, Path]]: |
| 1095 | """Discover slide content directories and return sorted (number, path) pairs.""" |
| 1096 | slides = [] |
| 1097 | for child in content_dir.iterdir(): |
| 1098 | if child.is_dir() and child.name.startswith("slide-"): |
| 1099 | match = re.match(r"slide-(\d+)", child.name) |
| 1100 | if match: |
| 1101 | num = int(match.group(1)) |
| 1102 | content_yaml = child / "content.yaml" |
| 1103 | if content_yaml.exists(): |
| 1104 | slides.append((num, child)) |
| 1105 | return sorted(slides, key=lambda x: x[0]) |
| 1106 | |
| 1107 | |
| 1108 | def main(): |
| 1109 | """CLI entry point for building a PowerPoint deck from YAML.""" |
| 1110 | parser = argparse.ArgumentParser( |
| 1111 | description="Build a PowerPoint deck from YAML content" |
| 1112 | ) |
| 1113 | parser.add_argument( |
| 1114 | "--content-dir", required=True, help="Path to the content/ directory" |
| 1115 | ) |
| 1116 | parser.add_argument("--style", required=True, help="Path to the global style.yaml") |
| 1117 | parser.add_argument( |
| 1118 | "--output", help="Output PPTX file path (required unless --dry-run)" |
| 1119 | ) |
| 1120 | parser.add_argument("--template", help="Template PPTX file path for themed builds") |
| 1121 | parser.add_argument("--source", help="Source PPTX to update (for partial rebuilds)") |
| 1122 | parser.add_argument( |
| 1123 | "--slides", help="Comma-separated slide numbers to rebuild (requires --source)" |
| 1124 | ) |
| 1125 | parser.add_argument( |
| 1126 | "--allow-scripts", |
| 1127 | action="store_true", |
| 1128 | help="Skip AST validation of content-extra.py (trusted content only)", |
| 1129 | ) |
| 1130 | parser.add_argument( |
| 1131 | "--dry-run", |
| 1132 | action="store_true", |
| 1133 | help=( |
| 1134 | "Validate content without building PPTX" |
| 1135 | " (parse YAML, check images, validate scripts)" |
| 1136 | ), |
| 1137 | ) |
| 1138 | parser.add_argument( |
| 1139 | "-v", |
| 1140 | "--verbose", |
| 1141 | action="store_true", |
| 1142 | help="Enable verbose logging output", |
| 1143 | ) |
| 1144 | args = parser.parse_args() |
| 1145 | if not args.dry_run and not args.output: |
| 1146 | parser.error("--output is required when not using --dry-run") |
| 1147 | configure_logging(args.verbose) |
| 1148 | |
| 1149 | content_dir = Path(args.content_dir) |
| 1150 | style = load_yaml(Path(args.style)) |
| 1151 | |
| 1152 | # Dry-run mode: validate content files without producing a PPTX |
| 1153 | if args.dry_run: |
| 1154 | slides_data = discover_slides(content_dir) |
| 1155 | if not slides_data: |
| 1156 | logger.error("No slide content found in %s", content_dir) |
| 1157 | return EXIT_ERROR |
| 1158 | errors = 0 |
| 1159 | for num, slide_dir in slides_data: |
| 1160 | content_yaml = slide_dir / "content.yaml" |
| 1161 | try: |
| 1162 | slide_content = load_yaml(content_yaml) |
| 1163 | title = slide_content.get("title", "Untitled") |
| 1164 | # Check for speaker notes |
| 1165 | notes = slide_content.get("speaker_notes") |
| 1166 | notes_status = "✅" if notes else "⚠️ no notes" |
| 1167 | # Validate content-extra.py if present |
| 1168 | extra = slide_dir / "content-extra.py" |
| 1169 | extra_status = "" |
| 1170 | if extra.exists(): |
| 1171 | if not args.allow_scripts: |
| 1172 | try: |
| 1173 | _validate_content_extra(extra) |
| 1174 | extra_status = " | extra: ✅" |
| 1175 | except ContentExtraError as exc: |
| 1176 | extra_status = f" | extra: ❌ {exc}" |
| 1177 | errors += 1 |
| 1178 | else: |
| 1179 | extra_status = " | extra: skipped" |
| 1180 | # Check image references |
| 1181 | images = slide_dir / "images" |
| 1182 | img_count = ( |
| 1183 | sum( |
| 1184 | len(list(images.glob(f"*{ext}"))) |
| 1185 | for ext in (".png", ".jpg", ".jpeg") |
| 1186 | ) |
| 1187 | if images.exists() |
| 1188 | else 0 |
| 1189 | ) |
| 1190 | img_status = f" | {img_count} images" if img_count else "" |
| 1191 | logger.info( |
| 1192 | " Slide %03d: %s [%s%s%s]", |
| 1193 | num, |
| 1194 | title, |
| 1195 | notes_status, |
| 1196 | extra_status, |
| 1197 | img_status, |
| 1198 | ) |
| 1199 | except Exception as exc: |
| 1200 | logger.error(" Slide %03d: ❌ YAML parse error: %s", num, exc) |
| 1201 | errors += 1 |
| 1202 | logger.info( |
| 1203 | "Dry-run complete: %d slides, %d error(s)", |
| 1204 | len(slides_data), |
| 1205 | errors, |
| 1206 | ) |
| 1207 | return EXIT_FAILURE if errors else EXIT_SUCCESS |
| 1208 | |
| 1209 | output_path = Path(args.output) |
| 1210 | output_path.parent.mkdir(parents=True, exist_ok=True) |
| 1211 | |
| 1212 | dims = style.get("dimensions", {}) |
| 1213 | width = dims.get("width_inches", 13.333) |
| 1214 | height = dims.get("height_inches", 7.5) |
| 1215 | |
| 1216 | if args.template: |
| 1217 | # Template build: open template and preserve its theme/layouts |
| 1218 | prs = Presentation(args.template) |
| 1219 | # Only override dimensions when explicitly set in style.yaml |
| 1220 | if "dimensions" in style: |
| 1221 | prs.slide_width = Inches(width) |
| 1222 | prs.slide_height = Inches(height) |
| 1223 | |
| 1224 | # Remove existing slides from the template — keep only theme/layouts |
| 1225 | while len(prs.slides) > 0: |
| 1226 | rId = prs.slides._sldIdLst[0].rId |
| 1227 | prs.part.drop_rel(rId) |
| 1228 | prs.slides._sldIdLst.remove(prs.slides._sldIdLst[0]) |
| 1229 | |
| 1230 | # Apply presentation metadata from style.yaml |
| 1231 | metadata = style.get("metadata", {}) |
| 1232 | if metadata: |
| 1233 | props = prs.core_properties |
| 1234 | for key, value in metadata.items(): |
| 1235 | if hasattr(props, key): |
| 1236 | setattr(props, key, value) |
| 1237 | |
| 1238 | slides_data = discover_slides(content_dir) |
| 1239 | if not slides_data: |
| 1240 | print("No slide content found in", content_dir) |
| 1241 | sys.exit(1) |
| 1242 | |
| 1243 | for num, slide_dir in slides_data: |
| 1244 | slide_content = load_yaml(slide_dir / "content.yaml") |
| 1245 | build_slide( |
| 1246 | prs, |
| 1247 | slide_content, |
| 1248 | style, |
| 1249 | slide_dir, |
| 1250 | allow_scripts=args.allow_scripts, |
| 1251 | ) |
| 1252 | print(f"Built slide {num}: {slide_content.get('title', 'Untitled')}") |
| 1253 | elif args.source and args.slides: |
| 1254 | # Partial rebuild: open existing deck and replace specific slides |
| 1255 | prs = Presentation(args.source) |
| 1256 | slide_nums = [int(s.strip()) for s in args.slides.split(",")] |
| 1257 | slides_data = discover_slides(content_dir) |
| 1258 | slides_to_rebuild = { |
| 1259 | num: path for num, path in slides_data if num in slide_nums |
| 1260 | } |
| 1261 | |
| 1262 | for num in slide_nums: |
| 1263 | if num not in slides_to_rebuild: |
| 1264 | print(f"Warning: No content found for slide {num}, skipping") |
| 1265 | continue |
| 1266 | slide_dir = slides_to_rebuild[num] |
| 1267 | slide_content = load_yaml(slide_dir / "content.yaml") |
| 1268 | # Rebuild in-place: clear shapes on the existing slide and repopulate |
| 1269 | idx = num - 1 |
| 1270 | if idx < len(prs.slides): |
| 1271 | existing_slide = prs.slides[idx] |
| 1272 | build_slide( |
| 1273 | prs, |
| 1274 | slide_content, |
| 1275 | style, |
| 1276 | slide_dir, |
| 1277 | existing_slide=existing_slide, |
| 1278 | allow_scripts=args.allow_scripts, |
| 1279 | ) |
| 1280 | print(f"Rebuilt slide {num} in-place") |
| 1281 | else: |
| 1282 | slide_count = len(prs.slides) |
| 1283 | print( |
| 1284 | f"Warning: Slide {num} does not exist" |
| 1285 | f" in deck (has {slide_count} slides)," |
| 1286 | f" skipping" |
| 1287 | ) |
| 1288 | else: |
| 1289 | # Full build |
| 1290 | prs = Presentation() |
| 1291 | prs.slide_width = Inches(width) |
| 1292 | prs.slide_height = Inches(height) |
| 1293 | |
| 1294 | # Apply presentation metadata from style.yaml |
| 1295 | metadata = style.get("metadata", {}) |
| 1296 | if metadata: |
| 1297 | props = prs.core_properties |
| 1298 | for key, value in metadata.items(): |
| 1299 | if hasattr(props, key): |
| 1300 | setattr(props, key, value) |
| 1301 | |
| 1302 | slides_data = discover_slides(content_dir) |
| 1303 | if not slides_data: |
| 1304 | print("No slide content found in", content_dir) |
| 1305 | sys.exit(1) |
| 1306 | |
| 1307 | for num, slide_dir in slides_data: |
| 1308 | slide_content = load_yaml(slide_dir / "content.yaml") |
| 1309 | build_slide( |
| 1310 | prs, |
| 1311 | slide_content, |
| 1312 | style, |
| 1313 | slide_dir, |
| 1314 | allow_scripts=args.allow_scripts, |
| 1315 | ) |
| 1316 | print(f"Built slide {num}: {slide_content.get('title', 'Untitled')}") |
| 1317 | |
| 1318 | prs.save(str(output_path)) |
| 1319 | print(f"\nDeck saved to {output_path}") |
| 1320 | print(f"Total slides: {len(prs.slides)}") |
| 1321 | |
| 1322 | |
| 1323 | if __name__ == "__main__": |
| 1324 | main() |
| 1325 | |