microsoft/hve-core

Public

mirrored from https://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/centralized-version-bump

Branches

Tags

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

Clone

HTTPS

Download ZIP

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

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