microsoft/hve-core
Publicmirrored fromhttps://github.com/microsoft/hve-coreAvailable
.github/skills/experimental/powerpoint/scripts/build_deck.py
1006lines · modecode
| 1 | """Build a PowerPoint slide deck from YAML content and style definitions. |
| 2 | |
| 3 | Usage:: |
| 4 | |
| 5 | python build_deck.py --content-dir content/ \ |
| 6 | --style content/global/style.yaml \ |
| 7 | --output slide-deck/presentation.pptx |
| 8 | |
| 9 | python build_deck.py --content-dir content/ \ |
| 10 | --style content/global/style.yaml \ |
| 11 | --source existing.pptx \ |
| 12 | --output slide-deck/presentation.pptx --slides 3,7,15 |
| 13 | """ |
| 14 | |
| 15 | import argparse |
| 16 | import importlib.util |
| 17 | import re |
| 18 | import sys |
| 19 | from pathlib import Path |
| 20 | |
| 21 | from lxml import etree |
| 22 | from pptx import Presentation |
| 23 | from pptx.enum.shapes import MSO_CONNECTOR_TYPE, MSO_SHAPE |
| 24 | from pptx.oxml.ns import qn |
| 25 | from pptx.util import Inches, Pt |
| 26 | from pptx_charts import add_chart_element |
| 27 | from pptx_colors import apply_color_to_font, resolve_color |
| 28 | from pptx_fills import apply_effect_list, apply_fill, apply_line |
| 29 | from pptx_fonts import ALIGNMENT_MAP |
| 30 | from pptx_shapes import SHAPE_MAP, apply_rotation |
| 31 | from pptx_tables import add_table_element |
| 32 | from pptx_text import ( |
| 33 | SHAPE_KEYS, |
| 34 | TEXTBOX_KEYS, |
| 35 | apply_run_properties, |
| 36 | apply_text_properties, |
| 37 | populate_text_frame, |
| 38 | ) |
| 39 | from pptx_utils import load_yaml |
| 40 | |
| 41 | CONNECTOR_TYPE_MAP = { |
| 42 | "straight": MSO_CONNECTOR_TYPE.STRAIGHT, |
| 43 | "elbow": MSO_CONNECTOR_TYPE.ELBOW, |
| 44 | "curve": MSO_CONNECTOR_TYPE.CURVE, |
| 45 | } |
| 46 | |
| 47 | PNS = "http://schemas.openxmlformats.org/presentationml/2006/main" |
| 48 | ANS = "http://schemas.openxmlformats.org/drawingml/2006/main" |
| 49 | |
| 50 | |
| 51 | def _reset_effect_ref(shape): |
| 52 | """Reset effectRef idx to 0 to prevent theme shadow inheritance. |
| 53 | |
| 54 | python-pptx defaults effectRef idx to 2, which references the theme's |
| 55 | effectStyleLst[2] that typically includes an outerShdw element. |
| 56 | """ |
| 57 | style_el = shape._element.find(f"{{{PNS}}}style") |
| 58 | if style_el is not None: |
| 59 | effect_ref = style_el.find(f"{{{ANS}}}effectRef") |
| 60 | if effect_ref is not None: |
| 61 | effect_ref.set("idx", "0") |
| 62 | |
| 63 | |
| 64 | def set_slide_bg(slide, fill_spec, colors: dict): |
| 65 | """Set a background fill on a slide.""" |
| 66 | apply_fill(slide.background, fill_spec, colors) |
| 67 | |
| 68 | |
| 69 | def set_slide_bg_image(slide, image_path: str, content_dir: Path): |
| 70 | """Set a background image on a slide using blipFill in the background element.""" |
| 71 | img_file = content_dir / image_path |
| 72 | if not img_file.exists(): |
| 73 | return |
| 74 | |
| 75 | from pptx.opc.constants import RELATIONSHIP_TYPE as RT |
| 76 | from pptx.parts.image import Image, ImagePart |
| 77 | |
| 78 | sld = slide._element |
| 79 | cSld = sld.find(qn("p:cSld")) |
| 80 | if cSld is None: |
| 81 | return |
| 82 | |
| 83 | spTree = cSld.find(qn("p:spTree")) |
| 84 | |
| 85 | # Remove existing p:bg element if present |
| 86 | existing_bg = cSld.find(qn("p:bg")) |
| 87 | if existing_bg is not None: |
| 88 | cSld.remove(existing_bg) |
| 89 | |
| 90 | # Create image part and relate to slide |
| 91 | image = Image.from_file(str(img_file)) |
| 92 | image_part = ImagePart.new(slide.part.package, image) |
| 93 | rel = slide.part.relate_to(image_part, RT.IMAGE) |
| 94 | |
| 95 | # Build p:bg > p:bgPr > a:blipFill structure |
| 96 | bg = etree.SubElement(cSld, qn("p:bg")) |
| 97 | bgPr = etree.SubElement(bg, qn("p:bgPr")) |
| 98 | blipFill = etree.SubElement(bgPr, qn("a:blipFill")) |
| 99 | blipFill.set("dpi", "0") |
| 100 | blipFill.set("rotWithShape", "1") |
| 101 | |
| 102 | blip = etree.SubElement(blipFill, qn("a:blip")) |
| 103 | blip.set(qn("r:embed"), rel) |
| 104 | |
| 105 | stretch = etree.SubElement(blipFill, qn("a:stretch")) |
| 106 | etree.SubElement(stretch, qn("a:fillRect")) |
| 107 | |
| 108 | etree.SubElement(bgPr, qn("a:effectLst")) |
| 109 | |
| 110 | # Ensure p:bg appears before p:spTree (required by schema) |
| 111 | if spTree is not None: |
| 112 | cSld.remove(bg) |
| 113 | cSld.insert(list(cSld).index(spTree), bg) |
| 114 | |
| 115 | |
| 116 | def add_textbox( |
| 117 | slide, |
| 118 | left, |
| 119 | top, |
| 120 | width, |
| 121 | height, |
| 122 | text, |
| 123 | font_name=None, |
| 124 | font_size=16, |
| 125 | font_color=None, |
| 126 | bold=False, |
| 127 | italic=False, |
| 128 | alignment=None, |
| 129 | name=None, |
| 130 | rotation=None, |
| 131 | elem=None, |
| 132 | colors=None, |
| 133 | ): |
| 134 | """Add a text box to a slide with font and layout properties. |
| 135 | |
| 136 | Args: |
| 137 | slide: Target slide object. |
| 138 | left: Left position in inches. |
| 139 | top: Top position in inches. |
| 140 | width: Width in inches. |
| 141 | height: Height in inches. |
| 142 | text: Text content for the box. |
| 143 | font_name: Font family name. |
| 144 | font_size: Font size in points. |
| 145 | font_color: Resolved color spec dict. |
| 146 | bold: Apply bold formatting. |
| 147 | italic: Apply italic formatting. |
| 148 | alignment: Paragraph alignment name. |
| 149 | name: Shape name identifier. |
| 150 | rotation: Rotation angle in degrees. |
| 151 | elem: Full element dict from content.yaml for |
| 152 | paragraph-level and run-level properties. |
| 153 | colors: Color resolution dict. |
| 154 | |
| 155 | Returns: |
| 156 | The created textbox shape object. |
| 157 | """ |
| 158 | txBox = slide.shapes.add_textbox( |
| 159 | Inches(left), Inches(top), Inches(width), Inches(height) |
| 160 | ) |
| 161 | if name: |
| 162 | txBox.name = name |
| 163 | apply_rotation(txBox, rotation) |
| 164 | |
| 165 | defaults = { |
| 166 | "font": font_name, |
| 167 | "size": font_size, |
| 168 | "color": font_color, |
| 169 | "bold": bold, |
| 170 | "italic": italic, |
| 171 | "alignment": alignment, |
| 172 | } |
| 173 | source = elem or {"text": text} |
| 174 | if "text" not in source: |
| 175 | source = {**source, "text": text} |
| 176 | populate_text_frame(txBox.text_frame, source, colors or {}, TEXTBOX_KEYS, defaults) |
| 177 | return txBox |
| 178 | |
| 179 | |
| 180 | def add_shape_element(slide, elem, colors, typography): |
| 181 | """Add a shape element from a content.yaml definition.""" |
| 182 | shape_type = SHAPE_MAP.get(elem.get("shape", "rectangle"), MSO_SHAPE.RECTANGLE) |
| 183 | left = Inches(elem["left"]) |
| 184 | top = Inches(elem["top"]) |
| 185 | width = Inches(elem["width"]) |
| 186 | height = Inches(elem["height"]) |
| 187 | |
| 188 | shape = slide.shapes.add_shape(shape_type, left, top, width, height) |
| 189 | |
| 190 | _reset_effect_ref(shape) |
| 191 | |
| 192 | if "name" in elem: |
| 193 | shape.name = elem["name"] |
| 194 | |
| 195 | apply_rotation(shape, elem.get("rotation")) |
| 196 | apply_fill(shape, elem.get("fill"), colors) |
| 197 | apply_line(shape, elem, colors) |
| 198 | |
| 199 | if "corner_radius" in elem: |
| 200 | shape.adjustments[0] = elem["corner_radius"] |
| 201 | |
| 202 | if "effect" in elem: |
| 203 | apply_effect_list(shape, elem["effect"]) |
| 204 | |
| 205 | if "text" in elem: |
| 206 | populate_text_frame(shape.text_frame, elem, colors, SHAPE_KEYS) |
| 207 | |
| 208 | return shape |
| 209 | |
| 210 | |
| 211 | def add_image_element(slide, elem, content_dir: Path): |
| 212 | """Add an image element from a content.yaml definition.""" |
| 213 | img_path = content_dir / elem["path"] |
| 214 | if not img_path.exists(): |
| 215 | # Fallback: add a text box with the path as placeholder |
| 216 | add_textbox( |
| 217 | slide, |
| 218 | elem["left"], |
| 219 | elem["top"], |
| 220 | elem["width"], |
| 221 | elem["height"], |
| 222 | f"[Image: {elem['path']}]", |
| 223 | font_size=12, |
| 224 | ) |
| 225 | return None |
| 226 | |
| 227 | left = Inches(elem["left"]) |
| 228 | top = Inches(elem["top"]) |
| 229 | width = Inches(elem["width"]) |
| 230 | height = Inches(elem["height"]) |
| 231 | pic = slide.shapes.add_picture(str(img_path), left, top, width, height) |
| 232 | if "name" in elem: |
| 233 | pic.name = elem["name"] |
| 234 | apply_rotation(pic, elem.get("rotation")) |
| 235 | |
| 236 | # Restore blipFill attributes (rotWithShape, dpi, etc.) |
| 237 | if "blip_fill_attrs" in elem: |
| 238 | blipFill = pic._element.find(qn("p:blipFill")) |
| 239 | if blipFill is not None: |
| 240 | for attr_name, attr_val in elem["blip_fill_attrs"].items(): |
| 241 | blipFill.set(attr_name, attr_val) |
| 242 | |
| 243 | # Apply image crop via srcRect on blipFill |
| 244 | if "crop" in elem: |
| 245 | blipFill = pic._element.find(qn("p:blipFill")) |
| 246 | if blipFill is not None: |
| 247 | srcRect = blipFill.find(qn("a:srcRect")) |
| 248 | if srcRect is None: |
| 249 | # Insert srcRect after a:blip |
| 250 | blip_el = blipFill.find(qn("a:blip")) |
| 251 | idx = list(blipFill).index(blip_el) + 1 if blip_el is not None else 0 |
| 252 | srcRect = etree.Element(qn("a:srcRect")) |
| 253 | blipFill.insert(idx, srcRect) |
| 254 | crop = elem["crop"] |
| 255 | for side in ("l", "t", "r", "b"): |
| 256 | if side in crop: |
| 257 | srcRect.set(side, str(crop[side])) |
| 258 | |
| 259 | # Apply image opacity via alphaModFix on the blip element |
| 260 | if "opacity" in elem: |
| 261 | blip = pic._element.find(".//" + qn("a:blip")) |
| 262 | if blip is not None: |
| 263 | amt = str(int(elem["opacity"] * 1000)) |
| 264 | amf = blip.find(qn("a:alphaModFix")) |
| 265 | if amf is None: |
| 266 | amf = etree.SubElement(blip, qn("a:alphaModFix")) |
| 267 | amf.set("amt", amt) |
| 268 | |
| 269 | return pic |
| 270 | |
| 271 | |
| 272 | def add_rich_text_element(slide, elem, colors, typography): |
| 273 | """Add a rich text element with mixed font/color segments.""" |
| 274 | txBox = slide.shapes.add_textbox( |
| 275 | Inches(elem["left"]), |
| 276 | Inches(elem["top"]), |
| 277 | Inches(elem["width"]), |
| 278 | Inches(elem["height"]), |
| 279 | ) |
| 280 | if "name" in elem: |
| 281 | txBox.name = elem["name"] |
| 282 | tf = txBox.text_frame |
| 283 | tf.word_wrap = True |
| 284 | p = tf.paragraphs[0] |
| 285 | |
| 286 | # Apply text frame-level properties |
| 287 | apply_text_properties(tf, elem) |
| 288 | |
| 289 | for i, seg in enumerate(elem.get("segments", [])): |
| 290 | run = p.add_run() if i > 0 else (p.runs[0] if p.runs else p.add_run()) |
| 291 | run.text = seg["text"] |
| 292 | seg_font = seg.get("font") |
| 293 | if seg_font: |
| 294 | run.font.name = seg_font |
| 295 | run.font.size = Pt(seg.get("size", 16)) |
| 296 | if "color" in seg: |
| 297 | color_spec = resolve_color(seg["color"]) |
| 298 | apply_color_to_font(run.font.color, color_spec) |
| 299 | run.font.bold = seg.get("bold", False) |
| 300 | run.font.italic = seg.get("italic", False) |
| 301 | apply_run_properties(run, seg, colors) |
| 302 | |
| 303 | return txBox |
| 304 | |
| 305 | |
| 306 | def add_card_element(slide, elem, colors, typography): |
| 307 | """Add a card panel with optional title bar and bullet content.""" |
| 308 | left = Inches(elem["left"]) |
| 309 | top = Inches(elem["top"]) |
| 310 | width = Inches(elem["width"]) |
| 311 | height = Inches(elem["height"]) |
| 312 | |
| 313 | # Card background |
| 314 | shape = slide.shapes.add_shape( |
| 315 | MSO_SHAPE.ROUNDED_RECTANGLE, left, top, width, height |
| 316 | ) |
| 317 | apply_fill(shape, elem.get("fill", "#2D2D35"), colors) |
| 318 | if "border_color" in elem: |
| 319 | apply_line( |
| 320 | shape, |
| 321 | { |
| 322 | "line_color": elem["border_color"], |
| 323 | "line_width": elem.get("border_width", 1), |
| 324 | }, |
| 325 | colors, |
| 326 | ) |
| 327 | else: |
| 328 | shape.line.fill.background() |
| 329 | |
| 330 | # Accent bar |
| 331 | if elem.get("accent_bar"): |
| 332 | bar = slide.shapes.add_shape( |
| 333 | MSO_SHAPE.RECTANGLE, |
| 334 | Inches(elem["left"] + 0.15), |
| 335 | Inches(elem["top"] + 0.1), |
| 336 | Inches(elem["width"] - 0.3), |
| 337 | Inches(0.04), |
| 338 | ) |
| 339 | apply_fill(bar, elem.get("accent_color", "#0078D4"), colors) |
| 340 | bar.line.fill.background() |
| 341 | |
| 342 | # Title |
| 343 | y_offset = 0.2 |
| 344 | if "title" in elem: |
| 345 | add_textbox( |
| 346 | slide, |
| 347 | elem["left"] + 0.2, |
| 348 | elem["top"] + y_offset, |
| 349 | elem["width"] - 0.4, |
| 350 | 0.4, |
| 351 | elem["title"], |
| 352 | font_name="Segoe UI", |
| 353 | font_size=elem.get("title_size", 16), |
| 354 | font_color=resolve_color(elem.get("title_color", "#F8F8FC")), |
| 355 | bold=elem.get("title_bold", True), |
| 356 | ) |
| 357 | y_offset += 0.5 |
| 358 | |
| 359 | # Content bullets |
| 360 | for item in elem.get("content", []): |
| 361 | bullet_text = ( |
| 362 | f"\u2022 {item['bullet']}" if "bullet" in item else item.get("text", "") |
| 363 | ) |
| 364 | color = resolve_color(item.get("color", "#F8F8FC")) |
| 365 | add_textbox( |
| 366 | slide, |
| 367 | elem["left"] + 0.2, |
| 368 | elem["top"] + y_offset, |
| 369 | elem["width"] - 0.4, |
| 370 | 0.35, |
| 371 | bullet_text, |
| 372 | font_name="Segoe UI", |
| 373 | font_size=item.get("size", 14), |
| 374 | font_color=color, |
| 375 | ) |
| 376 | y_offset += 0.35 |
| 377 | |
| 378 | return shape |
| 379 | |
| 380 | |
| 381 | def add_arrow_flow_element(slide, elem, colors, typography): |
| 382 | """Add a horizontal arrow flow diagram.""" |
| 383 | items = elem.get("items", []) |
| 384 | if not items: |
| 385 | return |
| 386 | |
| 387 | total_width = elem["width"] |
| 388 | item_width = total_width / len(items) - 0.3 |
| 389 | x = elem["left"] |
| 390 | |
| 391 | for item in items: |
| 392 | shape = slide.shapes.add_shape( |
| 393 | MSO_SHAPE.CHEVRON, |
| 394 | Inches(x), |
| 395 | Inches(elem["top"]), |
| 396 | Inches(item_width), |
| 397 | Inches(elem["height"]), |
| 398 | ) |
| 399 | apply_fill(shape, item.get("color", "#0078D4"), colors) |
| 400 | shape.line.fill.background() |
| 401 | |
| 402 | tf = shape.text_frame |
| 403 | tf.word_wrap = True |
| 404 | p = tf.paragraphs[0] |
| 405 | p.text = item["label"] |
| 406 | p.alignment = ALIGNMENT_MAP["center"] |
| 407 | run = p.runs[0] |
| 408 | run.font.name = "Segoe UI" |
| 409 | run.font.size = Pt(14) |
| 410 | apply_color_to_font(run.font.color, resolve_color("#F8F8FC")) |
| 411 | run.font.bold = True |
| 412 | |
| 413 | x += item_width + 0.3 |
| 414 | |
| 415 | |
| 416 | def add_numbered_step_element(slide, elem, colors, typography): |
| 417 | """Add a numbered step with circle, label, and description.""" |
| 418 | number = elem.get("number", 1) |
| 419 | |
| 420 | # Number circle |
| 421 | circle = slide.shapes.add_shape( |
| 422 | MSO_SHAPE.OVAL, |
| 423 | Inches(elem["left"]), |
| 424 | Inches(elem["top"]), |
| 425 | Inches(0.5), |
| 426 | Inches(0.5), |
| 427 | ) |
| 428 | apply_fill(circle, elem.get("accent_color", "#0078D4"), colors) |
| 429 | circle.line.fill.background() |
| 430 | tf = circle.text_frame |
| 431 | p = tf.paragraphs[0] |
| 432 | p.text = str(number) |
| 433 | p.alignment = ALIGNMENT_MAP["center"] |
| 434 | run = p.runs[0] |
| 435 | run.font.name = "Segoe UI" |
| 436 | run.font.size = Pt(16) |
| 437 | apply_color_to_font(run.font.color, resolve_color("#F8F8FC")) |
| 438 | run.font.bold = True |
| 439 | |
| 440 | # Label |
| 441 | add_textbox( |
| 442 | slide, |
| 443 | elem["left"] + 0.6, |
| 444 | elem["top"], |
| 445 | elem["width"] - 0.6, |
| 446 | 0.35, |
| 447 | elem["label"], |
| 448 | font_name="Segoe UI", |
| 449 | font_size=16, |
| 450 | font_color=resolve_color("#F8F8FC"), |
| 451 | bold=True, |
| 452 | ) |
| 453 | |
| 454 | # Description |
| 455 | if "description" in elem: |
| 456 | add_textbox( |
| 457 | slide, |
| 458 | elem["left"] + 0.6, |
| 459 | elem["top"] + 0.35, |
| 460 | elem["width"] - 0.6, |
| 461 | 0.4, |
| 462 | elem["description"], |
| 463 | font_name="Segoe UI", |
| 464 | font_size=14, |
| 465 | font_color=resolve_color("#9CA3AF"), |
| 466 | ) |
| 467 | |
| 468 | |
| 469 | def add_connector_element(slide, elem: dict, colors: dict): |
| 470 | """Add a connector element from a content.yaml definition. |
| 471 | |
| 472 | YAML schema: |
| 473 | - type: connector |
| 474 | connector_type: straight |
| 475 | begin_x: 3.0 |
| 476 | begin_y: 2.0 |
| 477 | end_x: 7.0 |
| 478 | end_y: 4.0 |
| 479 | line_color: "#0078D4" |
| 480 | line_width: 2 |
| 481 | dash_style: solid |
| 482 | head_end: none |
| 483 | tail_end: arrow |
| 484 | """ |
| 485 | conn_type = CONNECTOR_TYPE_MAP.get( |
| 486 | elem.get("connector_type", "straight"), MSO_CONNECTOR_TYPE.STRAIGHT |
| 487 | ) |
| 488 | |
| 489 | connector = slide.shapes.add_connector( |
| 490 | conn_type, |
| 491 | Inches(elem["begin_x"]), |
| 492 | Inches(elem["begin_y"]), |
| 493 | Inches(elem["end_x"]), |
| 494 | Inches(elem["end_y"]), |
| 495 | ) |
| 496 | |
| 497 | apply_line(connector, elem, colors) |
| 498 | |
| 499 | # Arrow heads via lxml XML manipulation |
| 500 | sp_pr = connector._element.find(qn("a:ln")) |
| 501 | if sp_pr is None: |
| 502 | ln_parent = connector._element.spPr |
| 503 | sp_pr = ln_parent.find(qn("a:ln")) |
| 504 | if sp_pr is None: |
| 505 | sp_pr = etree.SubElement(connector._element.spPr, qn("a:ln")) |
| 506 | |
| 507 | if "head_end" in elem and elem["head_end"] != "none": |
| 508 | head = etree.SubElement(sp_pr, qn("a:headEnd")) |
| 509 | head.set("type", elem["head_end"]) |
| 510 | if "tail_end" in elem and elem["tail_end"] != "none": |
| 511 | tail = etree.SubElement(sp_pr, qn("a:tailEnd")) |
| 512 | tail.set("type", elem["tail_end"]) |
| 513 | |
| 514 | if "name" in elem: |
| 515 | connector.name = elem["name"] |
| 516 | |
| 517 | return connector |
| 518 | |
| 519 | |
| 520 | def add_group_element( |
| 521 | slide, elem: dict, colors: dict, typography: dict, content_dir: Path |
| 522 | ): |
| 523 | """Add a group element containing nested child elements. |
| 524 | |
| 525 | YAML schema: |
| 526 | - type: group |
| 527 | left: 1.0 |
| 528 | top: 2.0 |
| 529 | width: 5.0 |
| 530 | height: 3.0 |
| 531 | elements: |
| 532 | - type: shape |
| 533 | shape: rectangle |
| 534 | left: 0 |
| 535 | top: 0 |
| 536 | width: 5.0 |
| 537 | height: 3.0 |
| 538 | fill: "#2D2D35" |
| 539 | - type: textbox |
| 540 | left: 0.2 |
| 541 | top: 0.2 |
| 542 | width: 4.6 |
| 543 | height: 0.5 |
| 544 | text: "Group Title" |
| 545 | """ |
| 546 | group = slide.shapes.add_group_shape() |
| 547 | |
| 548 | group.left = Inches(elem["left"]) |
| 549 | group.top = Inches(elem["top"]) |
| 550 | group.width = Inches(elem["width"]) |
| 551 | group.height = Inches(elem["height"]) |
| 552 | |
| 553 | for child_elem in elem.get("elements", []): |
| 554 | build_element_in_group(group, child_elem, colors, typography, content_dir) |
| 555 | |
| 556 | if "name" in elem: |
| 557 | group.name = elem["name"] |
| 558 | |
| 559 | return group |
| 560 | |
| 561 | |
| 562 | def build_element_in_group( |
| 563 | group, elem: dict, colors: dict, typography: dict, content_dir: Path |
| 564 | ): |
| 565 | """Dispatch a child element build within a group shape. |
| 566 | |
| 567 | Reuses top-level builders for shape and textbox. Groups do not support |
| 568 | table or chart elements. |
| 569 | """ |
| 570 | elem_type = elem.get("type", "textbox") |
| 571 | |
| 572 | if elem_type == "shape": |
| 573 | _add_shape_to_collection(group.shapes, elem, colors) |
| 574 | elif elem_type == "textbox": |
| 575 | _add_textbox_to_collection(group.shapes, elem, colors) |
| 576 | elif elem_type == "connector": |
| 577 | add_connector_element(group, elem, colors) |
| 578 | elif elem_type == "image": |
| 579 | add_image_element(group, elem, content_dir) |
| 580 | |
| 581 | |
| 582 | def _add_shape_to_collection(shapes, elem: dict, colors: dict): |
| 583 | """Add a shape to any shapes collection (slide or group).""" |
| 584 | shape_type = SHAPE_MAP.get(elem.get("shape", "rectangle"), MSO_SHAPE.RECTANGLE) |
| 585 | shape = shapes.add_shape( |
| 586 | shape_type, |
| 587 | Inches(elem["left"]), |
| 588 | Inches(elem["top"]), |
| 589 | Inches(elem["width"]), |
| 590 | Inches(elem["height"]), |
| 591 | ) |
| 592 | if "name" in elem: |
| 593 | shape.name = elem["name"] |
| 594 | apply_rotation(shape, elem.get("rotation")) |
| 595 | apply_fill(shape, elem.get("fill"), colors) |
| 596 | apply_line(shape, elem, colors) |
| 597 | if "text" in elem: |
| 598 | populate_text_frame(shape.text_frame, elem, colors, SHAPE_KEYS) |
| 599 | return shape |
| 600 | |
| 601 | |
| 602 | def _add_textbox_to_collection(shapes, elem: dict, colors: dict): |
| 603 | """Add a textbox to any shapes collection (slide or group).""" |
| 604 | txBox = shapes.add_textbox( |
| 605 | Inches(elem["left"]), |
| 606 | Inches(elem["top"]), |
| 607 | Inches(elem["width"]), |
| 608 | Inches(elem["height"]), |
| 609 | ) |
| 610 | if "name" in elem: |
| 611 | txBox.name = elem["name"] |
| 612 | populate_text_frame(txBox.text_frame, elem, colors, TEXTBOX_KEYS) |
| 613 | return txBox |
| 614 | |
| 615 | |
| 616 | def _build_textbox_element(slide, elem, colors, typography, content_dir): |
| 617 | """Build a textbox element with full parameter resolution for YAML keys.""" |
| 618 | font_name = elem.get("font") |
| 619 | font_color = resolve_color(elem["font_color"]) if "font_color" in elem else None |
| 620 | is_bold = elem.get("font_bold", elem.get("bold", False)) |
| 621 | add_textbox( |
| 622 | slide, |
| 623 | elem["left"], |
| 624 | elem["top"], |
| 625 | elem["width"], |
| 626 | elem["height"], |
| 627 | elem.get("text", ""), |
| 628 | font_name=font_name, |
| 629 | font_size=elem.get("font_size", 16), |
| 630 | font_color=font_color, |
| 631 | bold=is_bold, |
| 632 | italic=elem.get("italic", False), |
| 633 | alignment=elem.get("alignment"), |
| 634 | name=elem.get("name"), |
| 635 | rotation=elem.get("rotation"), |
| 636 | elem=elem, |
| 637 | colors=colors, |
| 638 | ) |
| 639 | |
| 640 | |
| 641 | def _build_image_element(slide, elem, colors, typography, content_dir): |
| 642 | """Delegate image element building to add_image_element.""" |
| 643 | add_image_element(slide, elem, content_dir) |
| 644 | |
| 645 | |
| 646 | def _build_group_element(slide, elem, colors, typography, content_dir): |
| 647 | """Delegate group element building to add_group_element.""" |
| 648 | add_group_element(slide, elem, colors, typography, content_dir) |
| 649 | |
| 650 | |
| 651 | def _build_connector_element(slide, elem, colors, typography, content_dir): |
| 652 | """Delegate connector building to add_connector_element.""" |
| 653 | add_connector_element(slide, elem, colors) |
| 654 | |
| 655 | |
| 656 | def _build_chart_element(slide, elem, colors, typography, content_dir): |
| 657 | """Delegate chart building to add_chart_element.""" |
| 658 | add_chart_element(slide, elem, colors) |
| 659 | |
| 660 | |
| 661 | def _build_table_element(slide, elem, colors, typography, content_dir): |
| 662 | """Delegate table building to add_table_element.""" |
| 663 | add_table_element(slide, elem, colors, typography) |
| 664 | |
| 665 | |
| 666 | # Element builder registry: maps element type names to builder functions. |
| 667 | # All builders share the signature (slide, elem, colors, typography, content_dir). |
| 668 | ELEMENT_BUILDERS = { |
| 669 | "shape": lambda slide, elem, colors, typography, content_dir: add_shape_element( |
| 670 | slide, elem, colors, typography |
| 671 | ), |
| 672 | "textbox": _build_textbox_element, |
| 673 | "image": _build_image_element, |
| 674 | "rich_text": lambda slide, elem, colors, typography, content_dir: ( |
| 675 | add_rich_text_element(slide, elem, colors, typography) |
| 676 | ), |
| 677 | "card": lambda slide, elem, colors, typography, content_dir: add_card_element( |
| 678 | slide, elem, colors, typography |
| 679 | ), |
| 680 | "arrow_flow": lambda slide, elem, colors, typography, content_dir: ( |
| 681 | add_arrow_flow_element(slide, elem, colors, typography) |
| 682 | ), |
| 683 | "numbered_step": lambda slide, elem, colors, typography, content_dir: ( |
| 684 | add_numbered_step_element(slide, elem, colors, typography) |
| 685 | ), |
| 686 | "table": _build_table_element, |
| 687 | "chart": _build_chart_element, |
| 688 | "connector": _build_connector_element, |
| 689 | "group": _build_group_element, |
| 690 | } |
| 691 | |
| 692 | |
| 693 | def _build_element( |
| 694 | slide, elem: dict, colors: dict, typography: dict, content_dir: Path |
| 695 | ): |
| 696 | """Dispatch element building via registry lookup.""" |
| 697 | elem_type = elem.get("type", "textbox") |
| 698 | builder = ELEMENT_BUILDERS.get(elem_type) |
| 699 | if builder: |
| 700 | builder(slide, elem, colors, typography, content_dir) |
| 701 | |
| 702 | |
| 703 | def clear_slide_shapes(slide): |
| 704 | """Remove all shapes from a slide, preserving the slide itself.""" |
| 705 | sp_tree = slide.shapes._spTree |
| 706 | shapes_to_remove = [ |
| 707 | sp |
| 708 | for sp in sp_tree.iterchildren() |
| 709 | if sp.tag.endswith("}sp") |
| 710 | or sp.tag.endswith("}pic") |
| 711 | or sp.tag.endswith("}grpSp") |
| 712 | or sp.tag.endswith("}cxnSp") |
| 713 | ] |
| 714 | for sp in shapes_to_remove: |
| 715 | sp_tree.remove(sp) |
| 716 | |
| 717 | |
| 718 | def _all_layouts(prs): |
| 719 | """Iterate layouts across all slide masters.""" |
| 720 | for master in prs.slide_masters: |
| 721 | yield from master.slide_layouts |
| 722 | |
| 723 | |
| 724 | def _find_blank_layout(prs): |
| 725 | """Find the best blank layout in the presentation, with fallbacks.""" |
| 726 | # Try index 6 first (default blank in standard templates) |
| 727 | try: |
| 728 | return prs.slide_layouts[6] |
| 729 | except IndexError: |
| 730 | pass |
| 731 | # Search by name across all masters |
| 732 | for layout in _all_layouts(prs): |
| 733 | if layout.name.lower() in ("blank", "blank slide"): |
| 734 | return layout |
| 735 | # Fall back to last layout of first master |
| 736 | return prs.slide_layouts[len(prs.slide_layouts) - 1] |
| 737 | |
| 738 | |
| 739 | def get_slide_layout(prs, slide_content: dict, style: dict): |
| 740 | """Select slide layout based on content.yaml or style.yaml configuration.""" |
| 741 | layout_spec = slide_content.get("layout") |
| 742 | layouts_map = style.get("layouts", {}) |
| 743 | |
| 744 | if layout_spec is None or layout_spec == "blank": |
| 745 | return _find_blank_layout(prs) |
| 746 | |
| 747 | # Resolve through style.yaml layouts map |
| 748 | if layout_spec in layouts_map: |
| 749 | layout_ref = layouts_map[layout_spec] |
| 750 | if isinstance(layout_ref, int): |
| 751 | try: |
| 752 | return prs.slide_layouts[layout_ref] |
| 753 | except IndexError: |
| 754 | return _find_blank_layout(prs) |
| 755 | elif isinstance(layout_ref, str): |
| 756 | for layout in _all_layouts(prs): |
| 757 | if layout.name == layout_ref: |
| 758 | return layout |
| 759 | |
| 760 | # Direct name lookup across all slide masters |
| 761 | if isinstance(layout_spec, str): |
| 762 | for layout in _all_layouts(prs): |
| 763 | if layout.name == layout_spec: |
| 764 | return layout |
| 765 | |
| 766 | # Direct index lookup |
| 767 | if isinstance(layout_spec, int): |
| 768 | try: |
| 769 | return prs.slide_layouts[layout_spec] |
| 770 | except IndexError: |
| 771 | return _find_blank_layout(prs) |
| 772 | |
| 773 | # Fallback to blank |
| 774 | return _find_blank_layout(prs) |
| 775 | |
| 776 | |
| 777 | def build_slide( |
| 778 | prs, slide_content: dict, style: dict, content_dir: Path, existing_slide=None |
| 779 | ): |
| 780 | """Build a single slide from content.yaml data and style context. |
| 781 | |
| 782 | When existing_slide is provided, clears its shapes and rebuilds in place |
| 783 | instead of appending a new slide. |
| 784 | """ |
| 785 | colors = {} |
| 786 | typography = {} |
| 787 | |
| 788 | if existing_slide is not None: |
| 789 | slide = existing_slide |
| 790 | clear_slide_shapes(slide) |
| 791 | else: |
| 792 | layout = get_slide_layout(prs, slide_content, style) |
| 793 | slide = prs.slides.add_slide(layout) |
| 794 | |
| 795 | # Populate themed layout placeholders |
| 796 | placeholders = slide_content.get("placeholders", {}) |
| 797 | for idx_str, value in placeholders.items(): |
| 798 | idx = int(idx_str) |
| 799 | if idx in slide.placeholders: |
| 800 | ph = slide.placeholders[idx] |
| 801 | if isinstance(value, str): |
| 802 | ph.text = value |
| 803 | elif isinstance(value, list): |
| 804 | tf = ph.text_frame |
| 805 | tf.text = value[0] |
| 806 | for line in value[1:]: |
| 807 | tf.add_paragraph().text = line |
| 808 | |
| 809 | # Remove unused placeholder shapes inherited from the layout |
| 810 | used_ph_indices = {int(k) for k in placeholders} |
| 811 | sp_tree = slide.shapes._spTree |
| 812 | for sp in list(sp_tree.iterchildren()): |
| 813 | nvSpPr = sp.find(qn("p:nvSpPr")) |
| 814 | if nvSpPr is None: |
| 815 | continue |
| 816 | nvPr = nvSpPr.find(qn("p:nvPr")) |
| 817 | if nvPr is None: |
| 818 | continue |
| 819 | ph = nvPr.find(qn("p:ph")) |
| 820 | if ph is not None: |
| 821 | idx = int(ph.get("idx", "0")) |
| 822 | if idx not in used_ph_indices: |
| 823 | sp_tree.remove(sp) |
| 824 | |
| 825 | # Set background from per-slide definition only |
| 826 | bg_block = slide_content.get("background") |
| 827 | if bg_block and "image" in bg_block: |
| 828 | set_slide_bg_image(slide, bg_block["image"], content_dir) |
| 829 | elif bg_block and "fill" in bg_block: |
| 830 | set_slide_bg(slide, bg_block["fill"], colors) |
| 831 | |
| 832 | # Sort elements by z_order to preserve stacking order |
| 833 | elements = slide_content.get("elements", []) |
| 834 | elements = sorted(elements, key=lambda e: e.get("z_order", 0)) |
| 835 | |
| 836 | # Filter out empty placeholder elements |
| 837 | elements = [ |
| 838 | e |
| 839 | for e in elements |
| 840 | if not (e.get("_placeholder") and not e.get("text", "").strip()) |
| 841 | ] |
| 842 | |
| 843 | turbo_enabled = len(elements) > 20 |
| 844 | if turbo_enabled: |
| 845 | slide.shapes.turbo_add_enabled = True |
| 846 | |
| 847 | # Process elements in order |
| 848 | for elem in elements: |
| 849 | _build_element(slide, elem, colors, typography, content_dir) |
| 850 | |
| 851 | # Execute content-extra.py if present |
| 852 | extra_script = content_dir / "content-extra.py" |
| 853 | if extra_script.exists(): |
| 854 | spec = importlib.util.spec_from_file_location( |
| 855 | "content_extra", str(extra_script) |
| 856 | ) |
| 857 | mod = importlib.util.module_from_spec(spec) |
| 858 | spec.loader.exec_module(mod) |
| 859 | if hasattr(mod, "render"): |
| 860 | mod.render(slide, style, content_dir) |
| 861 | |
| 862 | if turbo_enabled: |
| 863 | slide.shapes.turbo_add_enabled = False |
| 864 | |
| 865 | # Add speaker notes (preserve empty strings when notes slide exists) |
| 866 | notes = slide_content.get("speaker_notes") |
| 867 | if notes is not None: |
| 868 | notes_slide = slide.notes_slide |
| 869 | notes_text = re.sub(r"\v", "\n", notes) if notes else "" |
| 870 | notes_slide.notes_text_frame.text = notes_text |
| 871 | |
| 872 | return slide |
| 873 | |
| 874 | |
| 875 | def discover_slides(content_dir: Path) -> list[tuple[int, Path]]: |
| 876 | """Discover slide content directories and return sorted (number, path) pairs.""" |
| 877 | slides = [] |
| 878 | for child in content_dir.iterdir(): |
| 879 | if child.is_dir() and child.name.startswith("slide-"): |
| 880 | match = re.match(r"slide-(\d+)", child.name) |
| 881 | if match: |
| 882 | num = int(match.group(1)) |
| 883 | content_yaml = child / "content.yaml" |
| 884 | if content_yaml.exists(): |
| 885 | slides.append((num, child)) |
| 886 | return sorted(slides, key=lambda x: x[0]) |
| 887 | |
| 888 | |
| 889 | def main(): |
| 890 | """CLI entry point for building a PowerPoint deck from YAML.""" |
| 891 | parser = argparse.ArgumentParser( |
| 892 | description="Build a PowerPoint deck from YAML content" |
| 893 | ) |
| 894 | parser.add_argument( |
| 895 | "--content-dir", required=True, help="Path to the content/ directory" |
| 896 | ) |
| 897 | parser.add_argument("--style", required=True, help="Path to the global style.yaml") |
| 898 | parser.add_argument("--output", required=True, help="Output PPTX file path") |
| 899 | parser.add_argument("--template", help="Template PPTX file path for themed builds") |
| 900 | parser.add_argument("--source", help="Source PPTX to update (for partial rebuilds)") |
| 901 | parser.add_argument( |
| 902 | "--slides", help="Comma-separated slide numbers to rebuild (requires --source)" |
| 903 | ) |
| 904 | args = parser.parse_args() |
| 905 | |
| 906 | content_dir = Path(args.content_dir) |
| 907 | style = load_yaml(Path(args.style)) |
| 908 | output_path = Path(args.output) |
| 909 | output_path.parent.mkdir(parents=True, exist_ok=True) |
| 910 | |
| 911 | dims = style.get("dimensions", {}) |
| 912 | width = dims.get("width_inches", 13.333) |
| 913 | height = dims.get("height_inches", 7.5) |
| 914 | |
| 915 | if args.template: |
| 916 | # Template build: open template and preserve its theme/layouts |
| 917 | prs = Presentation(args.template) |
| 918 | # Only override dimensions when explicitly set in style.yaml |
| 919 | if "dimensions" in style: |
| 920 | prs.slide_width = Inches(width) |
| 921 | prs.slide_height = Inches(height) |
| 922 | |
| 923 | # Remove existing slides from the template — keep only theme/layouts |
| 924 | while len(prs.slides) > 0: |
| 925 | rId = prs.slides._sldIdLst[0].rId |
| 926 | prs.part.drop_rel(rId) |
| 927 | prs.slides._sldIdLst.remove(prs.slides._sldIdLst[0]) |
| 928 | |
| 929 | # Apply presentation metadata from style.yaml |
| 930 | metadata = style.get("metadata", {}) |
| 931 | if metadata: |
| 932 | props = prs.core_properties |
| 933 | for key, value in metadata.items(): |
| 934 | if hasattr(props, key): |
| 935 | setattr(props, key, value) |
| 936 | |
| 937 | slides_data = discover_slides(content_dir) |
| 938 | if not slides_data: |
| 939 | print("No slide content found in", content_dir) |
| 940 | sys.exit(1) |
| 941 | |
| 942 | for num, slide_dir in slides_data: |
| 943 | slide_content = load_yaml(slide_dir / "content.yaml") |
| 944 | build_slide(prs, slide_content, style, slide_dir) |
| 945 | print(f"Built slide {num}: {slide_content.get('title', 'Untitled')}") |
| 946 | elif args.source and args.slides: |
| 947 | # Partial rebuild: open existing deck and replace specific slides |
| 948 | prs = Presentation(args.source) |
| 949 | slide_nums = [int(s.strip()) for s in args.slides.split(",")] |
| 950 | slides_data = discover_slides(content_dir) |
| 951 | slides_to_rebuild = { |
| 952 | num: path for num, path in slides_data if num in slide_nums |
| 953 | } |
| 954 | |
| 955 | for num in slide_nums: |
| 956 | if num not in slides_to_rebuild: |
| 957 | print(f"Warning: No content found for slide {num}, skipping") |
| 958 | continue |
| 959 | slide_dir = slides_to_rebuild[num] |
| 960 | slide_content = load_yaml(slide_dir / "content.yaml") |
| 961 | # Rebuild in-place: clear shapes on the existing slide and repopulate |
| 962 | idx = num - 1 |
| 963 | if idx < len(prs.slides): |
| 964 | existing_slide = prs.slides[idx] |
| 965 | build_slide( |
| 966 | prs, slide_content, style, slide_dir, existing_slide=existing_slide |
| 967 | ) |
| 968 | print(f"Rebuilt slide {num} in-place") |
| 969 | else: |
| 970 | slide_count = len(prs.slides) |
| 971 | print( |
| 972 | f"Warning: Slide {num} does not exist" |
| 973 | f" in deck (has {slide_count} slides)," |
| 974 | f" skipping" |
| 975 | ) |
| 976 | else: |
| 977 | # Full build |
| 978 | prs = Presentation() |
| 979 | prs.slide_width = Inches(width) |
| 980 | prs.slide_height = Inches(height) |
| 981 | |
| 982 | # Apply presentation metadata from style.yaml |
| 983 | metadata = style.get("metadata", {}) |
| 984 | if metadata: |
| 985 | props = prs.core_properties |
| 986 | for key, value in metadata.items(): |
| 987 | if hasattr(props, key): |
| 988 | setattr(props, key, value) |
| 989 | |
| 990 | slides_data = discover_slides(content_dir) |
| 991 | if not slides_data: |
| 992 | print("No slide content found in", content_dir) |
| 993 | sys.exit(1) |
| 994 | |
| 995 | for num, slide_dir in slides_data: |
| 996 | slide_content = load_yaml(slide_dir / "content.yaml") |
| 997 | build_slide(prs, slide_content, style, slide_dir) |
| 998 | print(f"Built slide {num}: {slide_content.get('title', 'Untitled')}") |
| 999 | |
| 1000 | prs.save(str(output_path)) |
| 1001 | print(f"\nDeck saved to {output_path}") |
| 1002 | print(f"Total slides: {len(prs.slides)}") |
| 1003 | |
| 1004 | |
| 1005 | if __name__ == "__main__": |
| 1006 | main() |
| 1007 | |