microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
docs/transparency-note

Branches

Tags

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

Clone

HTTPS

Download ZIP

.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
5Usage::
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
17from __future__ import annotations
18
19import argparse
20import ast
21import builtins
22import importlib.util
23import logging
24import re
25import sys
26from pathlib import Path
27
28from lxml import etree
29from pptx import Presentation
30from pptx.enum.shapes import MSO_CONNECTOR_TYPE, MSO_SHAPE
31from pptx.oxml.ns import qn
32from pptx.util import Inches, Pt
33from pptx_charts import add_chart_element
34from pptx_colors import apply_color_to_font, resolve_color
35from pptx_fills import apply_effect_list, apply_fill, apply_line
36from pptx_fonts import ALIGNMENT_MAP
37from pptx_shapes import SHAPE_MAP, apply_rotation
38from pptx_tables import add_table_element
39from pptx_text import (
40 SHAPE_KEYS,
41 TEXTBOX_KEYS,
42 apply_run_properties,
43 apply_text_properties,
44 populate_text_frame,
45)
46from pptx_utils import (
47 EXIT_ERROR,
48 EXIT_FAILURE,
49 EXIT_SUCCESS,
50 configure_logging,
51 load_yaml,
52)
53
54logger = logging.getLogger(__name__)
55
56CONNECTOR_TYPE_MAP = {
57 "straight": MSO_CONNECTOR_TYPE.STRAIGHT,
58 "elbow": MSO_CONNECTOR_TYPE.ELBOW,
59 "curve": MSO_CONNECTOR_TYPE.CURVE,
60}
61
62PNS = "http://schemas.openxmlformats.org/presentationml/2006/main"
63ANS = "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
130class ContentExtraError(Exception):
131 """A content-extra.py script failed security validation."""
132
133
134def _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
155def _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
190def _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
203def 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
208def 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
255def 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
319def 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
350def 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
411def 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
445def 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
520def 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
555def 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
608def 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
659MAX_GROUP_DEPTH = 20
660
661
662def 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
723def 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
760def _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
780def _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
794def _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
819def _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
824def _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
829def _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
834def _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
839def _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).
846ELEMENT_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
871def _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
881def 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
896def _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
902def _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
918def 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
956def 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
1094def 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
1108def 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
1323if __name__ == "__main__":
1324 main()
1325