microsoft/hve-core

Public

mirrored fromhttps://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
ci/884-codeql-python-analysis

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

.github/skills/experimental/powerpoint/scripts/build_deck.py

1006lines · modecode

1"""Build a PowerPoint slide deck from YAML content and style definitions.
2
3Usage::
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
15import argparse
16import importlib.util
17import re
18import sys
19from pathlib import Path
20
21from lxml import etree
22from pptx import Presentation
23from pptx.enum.shapes import MSO_CONNECTOR_TYPE, MSO_SHAPE
24from pptx.oxml.ns import qn
25from pptx.util import Inches, Pt
26from pptx_charts import add_chart_element
27from pptx_colors import apply_color_to_font, resolve_color
28from pptx_fills import apply_effect_list, apply_fill, apply_line
29from pptx_fonts import ALIGNMENT_MAP
30from pptx_shapes import SHAPE_MAP, apply_rotation
31from pptx_tables import add_table_element
32from pptx_text import (
33 SHAPE_KEYS,
34 TEXTBOX_KEYS,
35 apply_run_properties,
36 apply_text_properties,
37 populate_text_frame,
38)
39from pptx_utils import load_yaml
40
41CONNECTOR_TYPE_MAP = {
42 "straight": MSO_CONNECTOR_TYPE.STRAIGHT,
43 "elbow": MSO_CONNECTOR_TYPE.ELBOW,
44 "curve": MSO_CONNECTOR_TYPE.CURVE,
45}
46
47PNS = "http://schemas.openxmlformats.org/presentationml/2006/main"
48ANS = "http://schemas.openxmlformats.org/drawingml/2006/main"
49
50
51def _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
64def 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
69def 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
116def 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
180def 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
211def 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
272def 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
306def 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
381def 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
416def 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
469def 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
520def 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
562def 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
582def _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
602def _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
616def _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
641def _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
646def _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
651def _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
656def _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
661def _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).
668ELEMENT_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
693def _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
703def 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
718def _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
724def _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
739def 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
777def 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
875def 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
889def 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
1005if __name__ == "__main__":
1006 main()
1007