#!/usr/bin/env python3 """scripts/render_pptx.py — render a structured, editable, S&P-themed PPTX from the consolidated Marp markdown deck, using python-pptx. REQ-269 (v1.23): a comparison artifact to the primary MARP-rendered PPTX. The HTML deck remains the pixel-perfect artifact; this PPTX is the editable, native-shape version (real text boxes, native tables, embedded PNGs) so a reviewer can open it in PowerPoint and see a properly S&P-themed deck with titles, bullets, blockquotes, images, tables, and benefit callouts. Usage: python3 scripts/render_pptx.py [deck-name] python3 scripts/render_pptx.py [--output ] Defaults to `nova-autonomous-cloud-delivery`. If the first arg ends in `.md` or contains a path separator, it is treated as an explicit source path (D-242 extension); else it is a deck name (reads `docs/presentations/{deck}-marp.md`, writes `docs/presentations/{deck}-python.pptx`). `--output` overrides the output path. The Marp `footer:` frontmatter directive is rendered as a right-aligned textbox on every slide (D-242). """ import os import re import sys from pathlib import Path # --- Dependency check -------------------------------------------------------- try: from pptx import Presentation from pptx.util import Inches, Pt, Emu from pptx.dml.color import RGBColor from pptx.enum.shapes import MSO_SHAPE from pptx.enum.text import PP_ALIGN, MSO_ANCHOR from pptx.oxml.ns import qn except ImportError: print("ERROR: python-pptx not installed.", file=sys.stderr) print(" pip install -e .[slides]", file=sys.stderr) sys.exit(1) # --- S&P theme constants ----------------------------------------------------- RED = RGBColor(0xD6, 0x00, 0x2A) # S&P red BLACK = RGBColor(0x1B, 0x1B, 0x1B) WHITE = RGBColor(0xFF, 0xFF, 0xFF) GREY_TEXT = RGBColor(0x2E, 0x2E, 0x2E) GREY_HEADER = RGBColor(0xF0, 0xF0, 0xF0) BODY_TEXT = RGBColor(0x1B, 0x1B, 0x1B) FONT_NAME = "Akkurat Pro" SLIDE_W = Inches(13.333) SLIDE_H = Inches(7.5) # Content area geometry (matches Marp padding ~48/56 px at 96dpi → ~0.5"/0.58") MARGIN_X = Inches(0.58) MARGIN_TOP = Inches(0.4) CONTENT_W = Inches(12.17) TITLE_H = Inches(0.7) # Image fit IMG_MAX_W = Inches(8.0) IMG_MAX_H = Inches(4.0) # --- Markdown parsing -------------------------------------------------------- def parse_frontmatter(md_text: str): """Extract YAML frontmatter as a dict (simple key: value parse). Returns {} if no frontmatter. Only handles flat key:value pairs (no nested structures) — sufficient for Marp deck frontmatter (marp, theme, footer, paginate, size). The `style:` block (multi- line `|`) is skipped (not needed by the python-pptx renderer). """ if not md_text.lstrip().startswith("---"): return {} end = md_text.find("\n---", 3) if end == -1: return {} fm_text = md_text[3:end] fm = {} in_multiline = False for line in fm_text.splitlines(): s = line.strip() if not s or s.startswith("#"): continue if in_multiline: # skip multi-line block values (e.g. style: |) if s and not s.startswith(" ") and ":" in s: in_multiline = False else: continue if ":" in s: k, _, v = s.partition(":") k = k.strip() v = v.strip() if v in ("|", ">"): in_multiline = True continue # strip surrounding quotes if v and v[0] in "\"'" and v[-1] == v[0]: v = v[1:-1] fm[k] = v return fm def split_slides(md_text: str): """Strip YAML frontmatter, then split the deck into slide source strings.""" # Strip YAML frontmatter (between first pair of `---` lines). if md_text.lstrip().startswith("---"): end = md_text.find("\n---", 3) if end != -1: md_text = md_text[end + 4 :] # Normalize slide separators. Marp uses `\n---\n` on its own line. parts = re.split(r"\n---\s*\n", md_text) slides = [] for p in parts: p = p.strip("\n") if p.strip(): slides.append(p) return slides # --- Cell/table helpers ------------------------------------------------------ def _set_cell_text(cell, text: str, *, bold: bool = False, size: int = 14, color: RGBColor = BODY_TEXT, fill=None): cell.text = "" tf = cell.text_frame tf.word_wrap = True p = tf.paragraphs[0] p.alignment = PP_ALIGN.LEFT run = p.add_run() run.text = text run.font.name = FONT_NAME run.font.size = Pt(size) run.font.bold = bold run.font.color.rgb = color if fill is not None: cell.fill.solid() cell.fill.fore_color.rgb = fill # tighten cell margins cell.margin_left = Inches(0.06) cell.margin_right = Inches(0.06) cell.margin_top = Inches(0.02) cell.margin_bottom = Inches(0.02) def _add_red_header_bottom_border(table): """Add a red 2pt bottom border to the header row (row 0) cells.""" for col_idx in range(len(table.columns)): cell = table.cell(0, col_idx) tcPr = cell._tc.get_or_add_tcPr() for tag in ("a:lnB",): for old in tcPr.findall(qn(tag)): tcPr.remove(old) ln = tcPr.makeelement(qn("a:lnB"), { "w": "12700", # 1pt = 12700 EMU; ~2pt "cap": "flat", "cmpd": "sng", "algn": "ctr", }) solidFill = ln.makeelement(qn("a:solidFill"), {}) srgb = solidFill.makeelement(qn("a:srgbClr"), {"val": "D6002A"}) solidFill.append(srgb) ln.append(solidFill) tcPr.append(ln) # --- Slide builders ---------------------------------------------------------- def _set_bg(slide, rgb: RGBColor): """Solid-fill a slide background with `rgb`.""" bg = slide.background fill = bg.fill fill.solid() fill.fore_color.rgb = rgb def _add_title_bar(slide): """Red rectangle across the top of a content slide (subtle accent).""" bar = slide.shapes.add_shape( MSO_SHAPE.RECTANGLE, 0, 0, SLIDE_W, Inches(0.08) ) bar.fill.solid() bar.fill.fore_color.rgb = RED bar.line.fill.background() bar.shadow.inherit = False return bar def _add_footer(slide, text: str): """Right-aligned footer textbox at the bottom of every slide. REQ-372.5 / D-242: the python-pptx path does not read the Marp `footer:` directive, so the footer is rendered as a textbox. """ if not text: return None tb = slide.shapes.add_textbox( MARGIN_X, Inches(7.12), CONTENT_W, Inches(0.3) ) tf = tb.text_frame tf.word_wrap = True p = tf.paragraphs[0] p.alignment = PP_ALIGN.RIGHT r = p.add_run() r.text = text r.font.name = FONT_NAME r.font.size = Pt(10) r.font.color.rgb = GREY_HEADER return tb def _add_title_text(slide, title: str, *, color: RGBColor = RED, size: int = 28, top: float = 0.25, bold: bool = True, height: float = 0.7, white_bg: bool = False): box = slide.shapes.add_textbox(MARGIN_X, Inches(top), CONTENT_W, Inches(height)) tf = box.text_frame tf.word_wrap = True p = tf.paragraphs[0] p.alignment = PP_ALIGN.LEFT run = p.add_run() run.text = title run.font.name = FONT_NAME run.font.size = Pt(size) run.font.bold = bold run.font.color.rgb = color return box def _add_text_block(slide, text: str, *, top: Inches, left: Inches = None, width: Inches = None, size: int = 18, color: RGBColor = BODY_TEXT, bold: bool = False, italic: bool = False, align=PP_ALIGN.LEFT, height: Inches = None): if left is None: left = MARGIN_X if width is None: width = CONTENT_W if height is None: height = Inches(0.4) tb = slide.shapes.add_textbox(left, top, width, height) tf = tb.text_frame tf.word_wrap = True p = tf.paragraphs[0] p.alignment = align run = p.add_run() run.text = text run.font.name = FONT_NAME run.font.size = Pt(size) run.font.bold = bold run.font.italic = italic run.font.color.rgb = color return tb def _add_bullets(slide, bullets, *, top: Inches, size: int = 18, color: RGBColor = BODY_TEXT, width: Inches = None, height: Inches = None): if width is None: width = CONTENT_W if height is None: height = Inches(0.35) * len(bullets) + Inches(0.2) tb = slide.shapes.add_textbox(MARGIN_X, top, width, height) tf = tb.text_frame tf.word_wrap = True for i, (lvl, text) in enumerate(bullets): p = tf.paragraphs[0] if i == 0 else tf.add_paragraph() p.alignment = PP_ALIGN.LEFT p.level = lvl run = p.add_run() prefix = "• " if lvl == 0 else ("– " if lvl == 1 else "· ") run.text = prefix + text run.font.name = FONT_NAME run.font.size = Pt(size if lvl == 0 else max(12, size - 2)) run.font.color.rgb = color return tb def _strip_inline_emphasis(text: str) -> str: """Strip `**bold**` and `*italic*` and `` `code` `` markers for plain runs. We render bold via separate runs only for the **lead** paragraph; here we collapse emphasis to plain text (the python PPTX is a comparison artifact). """ # `code` → plain text = re.sub(r"`([^`]+)`", r"\1", text) # **bold** → text text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text) # *italic* → text text = re.sub(r"(?= 2: r = p.add_run() r.text = tok[1:-1] r.font.name = FONT_NAME r.font.size = Pt(size) r.font.italic = True r.font.color.rgb = base_color else: r = p.add_run() r.text = tok r.font.name = FONT_NAME r.font.size = Pt(size) r.font.color.rgb = base_color def _add_picture(slide, image_path: Path, *, top: Inches, max_w: Inches = IMG_MAX_W, max_h: Inches = IMG_MAX_H): """Add an image, centered horizontally, scaled to fit max_w x max_h.""" if not image_path.is_file(): # Placeholder text box if image missing tb = slide.shapes.add_textbox(MARGIN_X, top, CONTENT_W, Inches(0.4)) tf = tb.text_frame p = tf.paragraphs[0] r = p.add_run() r.text = f"[image not found: {image_path}]" r.font.name = FONT_NAME r.font.size = Pt(14) r.font.color.rgb = GREY_TEXT return tb # native size of the picture pic = slide.shapes.add_picture(str(image_path), MARGIN_X, top) # scale w = pic.width h = pic.height ratio = min(max_w / w, max_h / h, 1.0) w = Emu(int(w * ratio)) h = Emu(int(h * ratio)) pic.width = w pic.height = h # center horizontally pic.left = Emu(int((SLIDE_W - w) / 2)) return pic def _add_table(slide, rows, *, top: Inches, width: Inches = None): """rows: list of list[str]. First row is header.""" if width is None: width = CONTENT_W n_rows = len(rows) n_cols = max(len(r) for r in rows) # pad ragged rows rows = [r + [""] * (n_cols - len(r)) for r in rows] # estimate height height = Inches(0.3) * n_rows tbl_shape = slide.shapes.add_table(n_rows, n_cols, MARGIN_X, top, width, height) table = tbl_shape.table # remove default banding style for a cleaner look try: table.first_row = False table.horz_banding = False except Exception: pass for r_idx, row in enumerate(rows): for c_idx, val in enumerate(row): is_header = r_idx == 0 _set_cell_text( table.cell(r_idx, c_idx), _strip_inline_emphasis(val), bold=is_header, size=13 if is_header else 12, color=BODY_TEXT, fill=GREY_HEADER if is_header else WHITE, ) _add_red_header_bottom_border(table) return tbl_shape def _add_benefit(slide, text: str, *, top: Inches): """Benefit callout: a thin red top-rule rectangle, then italic text.""" rule = slide.shapes.add_shape( MSO_SHAPE.RECTANGLE, MARGIN_X, top, Inches(6.0), Inches(0.03) ) rule.fill.solid() rule.fill.fore_color.rgb = RED rule.line.fill.background() rule.shadow.inherit = False tb = slide.shapes.add_textbox( MARGIN_X, top + Inches(0.08), CONTENT_W, Inches(0.6) ) tf = tb.text_frame tf.word_wrap = True p = tf.paragraphs[0] p.alignment = PP_ALIGN.LEFT r = p.add_run() r.text = text r.font.name = FONT_NAME r.font.size = Pt(16) r.font.italic = True r.font.color.rgb = BODY_TEXT return tb # --- Slide parse + render ---------------------------------------------------- HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$") IMAGE_RE = re.compile(r"^!\[[^\]]*\]\(([^)\s]+)(?:\s+\"([^\"]*)\")?\)") TABLE_SEP_RE = re.compile(r"^\|?[\s:|-]+\|?$") def parse_slide(slide_src: str): """Parse a single slide's markdown into a structured dict.""" lines = slide_src.splitlines() title = None title_is_h1 = False is_title_class = False body = [] # list of ("lead", text) | ("bullet", lvl, text) | ("quote", text) # | ("code", text) | ("image", path) | ("table", rows) # | ("benefit", text) | ("plain", text) | ("ordered", n, text) i = 0 while i < len(lines): line = lines[i] stripped = line.strip() # HTML comments — skip, but detect Marp directives if stripped.startswith(""): inner = stripped[4:-3].strip() if "_class: title" in inner: is_title_class = True # _paginate: false / _class etc. — just skip i += 1 continue # multi-line HTML comments (rare in this deck) if stripped.startswith("" not in stripped: while i < len(lines) and "-->" not in lines[i]: i += 1 i += 1 continue # Heading m = HEADING_RE.match(stripped) if m and title is None: level = len(m.group(1)) title = m.group(2).strip() if level == 1: title_is_h1 = True i += 1 continue if m and title is not None: # Sub-heading inside a slide — treat as plain bold lead text. body.append(("lead", m.group(2).strip())) i += 1 continue # Fenced code block if stripped.startswith("```"): i += 1 code_lines = [] while i < len(lines) and not lines[i].strip().startswith("```"): code_lines.append(lines[i]) i += 1 if i < len(lines): i += 1 # skip closing fence body.append(("code", "\n".join(code_lines))) continue # Image m = IMAGE_RE.match(stripped) if m: body.append(("image", m.group(1))) i += 1 continue # Blockquote if stripped.startswith(">"): quote_text = stripped[1:].strip() # join consecutive blockquote lines i += 1 while i < len(lines) and lines[i].strip().startswith(">"): quote_text += " " + lines[i].strip().lstrip(">").strip() i += 1 body.append(("quote", quote_text)) continue # Benefit callout bm = re.match(r"(.*)", stripped) if bm: body.append(("benefit", bm.group(1).strip())) i += 1 continue # Table — starts with `| ... |` and the next line is a separator if stripped.startswith("|") and i + 1 < len(lines) and TABLE_SEP_RE.match(lines[i + 1].strip()): table_rows = [] # header header = [c.strip() for c in stripped.strip("|").split("|")] table_rows.append(header) i += 2 # header + separator while i < len(lines) and lines[i].strip().startswith("|"): row = [c.strip() for c in lines[i].strip().strip("|").split("|")] table_rows.append(row) i += 1 body.append(("table", table_rows)) continue # Ordered list item: `1. ` or `1. ` om = re.match(r"^(\d+)\.\s+(.*)", stripped) if om: body.append(("ordered", int(om.group(1)), om.group(2).strip())) i += 1 continue # Unordered list item um = re.match(r"^(\s*)([-*+])\s+(.*)", line) if um: indent = len(um.group(1)) lvl = 0 if indent < 2 else (1 if indent < 4 else 2) body.append(("bullet", lvl, um.group(3).strip())) i += 1 continue # Blank line if not stripped: i += 1 continue # Bold lead paragraph (entire paragraph wrapped in **...**) if stripped.startswith("**") and stripped.endswith("**") and stripped.count("**") == 2: body.append(("lead", stripped[2:-2].strip())) i += 1 continue # Plain text # collect contiguous non-empty, non-special lines into one paragraph para_lines = [line] i += 1 while i < len(lines): nxt = lines[i].strip() if (not nxt or nxt.startswith("#") or nxt.startswith("-") or nxt.startswith("*") or nxt.startswith(">") or nxt.startswith("|") or nxt.startswith("<") or nxt.startswith("```") or nxt.startswith("!") or re.match(r"^\d+\.\s", nxt)): break para_lines.append(lines[i]) i += 1 para_text = " ".join(l.strip() for l in para_lines).strip() if para_text: body.append(("plain", para_text)) continue return { "title": title or "(untitled)", "title_is_h1": title_is_h1, "is_title_class": is_title_class, "body": body, } def render_title_slide(prs, slide_data, footer_text: str = ""): slide = prs.slides.add_slide(prs.slide_layouts[6]) # blank _set_bg(slide, BLACK) # red top bar bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, SLIDE_W, Inches(0.4)) bar.fill.solid() bar.fill.fore_color.rgb = RED bar.line.fill.background() bar.shadow.inherit = False # title title = slide_data["title"] tb = slide.shapes.add_textbox(MARGIN_X, Inches(2.5), CONTENT_W, Inches(2.0)) tf = tb.text_frame tf.word_wrap = True p = tf.paragraphs[0] p.alignment = PP_ALIGN.LEFT r = p.add_run() r.text = title r.font.name = FONT_NAME r.font.size = Pt(40) r.font.bold = True r.font.color.rgb = WHITE # body content (subtitle/lead/benefit) on black bg cur_top = Inches(4.6) for item in slide_data["body"]: kind = item[0] if kind == "lead": _add_text_block(slide, _strip_inline_emphasis(item[1]), top=cur_top, size=20, color=WHITE, bold=True, height=Inches(0.5)) cur_top += Inches(0.55) elif kind == "plain": _add_text_block(slide, _strip_inline_emphasis(item[1]), top=cur_top, size=16, color=WHITE, height=Inches(0.4)) cur_top += Inches(0.45) elif kind == "benefit": # benefit on title slide: italic white tb_b = slide.shapes.add_textbox(MARGIN_X, cur_top, CONTENT_W, Inches(0.8)) tf_b = tb_b.text_frame tf_b.word_wrap = True p_b = tf_b.paragraphs[0] r_b = p_b.add_run() r_b.text = item[1] r_b.font.name = FONT_NAME r_b.font.size = Pt(16) r_b.font.italic = True r_b.font.color.rgb = WHITE cur_top += Inches(0.85) _add_footer(slide, footer_text) def render_content_slide(prs, slide_data, deck_dir: Path, footer_text: str = ""): slide = prs.slides.add_slide(prs.slide_layouts[6]) # blank _set_bg(slide, WHITE) _add_title_bar(slide) _add_title_text(slide, slide_data["title"], color=RED, size=28, top=0.25, bold=True, height=0.7) cur_top = Inches(1.05) for item in slide_data["body"]: kind = item[0] if kind == "lead": tb = slide.shapes.add_textbox(MARGIN_X, cur_top, CONTENT_W, Inches(0.5)) tf = tb.text_frame tf.word_wrap = True p = tf.paragraphs[0] p.alignment = PP_ALIGN.LEFT _inline_runs(p, item[1], size=18, base_color=RED) # make the whole lead bold-strong-red for r in p.runs: r.font.bold = True r.font.color.rgb = RED cur_top += Inches(0.5) elif kind == "plain": tb = slide.shapes.add_textbox(MARGIN_X, cur_top, CONTENT_W, Inches(0.4)) tf = tb.text_frame tf.word_wrap = True p = tf.paragraphs[0] p.alignment = PP_ALIGN.LEFT _inline_runs(p, item[1], size=18, base_color=BODY_TEXT) cur_top += Inches(0.4) elif kind == "quote": tb = slide.shapes.add_textbox( MARGIN_X + Inches(0.3), cur_top, CONTENT_W - Inches(0.3), Inches(0.6) ) tf = tb.text_frame tf.word_wrap = True p = tf.paragraphs[0] p.alignment = PP_ALIGN.LEFT _inline_runs(p, item[1], size=18, base_color=GREY_TEXT) # italicize the whole blockquote for r in p.runs: r.font.italic = True r.font.color.rgb = GREY_TEXT cur_top += Inches(0.6) elif kind == "bullet": # accumulate consecutive bullets into one text frame # (handled below in a second pass; we render single here as fallback) tb = slide.shapes.add_textbox(MARGIN_X, cur_top, CONTENT_W, Inches(0.35)) tf = tb.text_frame tf.word_wrap = True p = tf.paragraphs[0] p.alignment = PP_ALIGN.LEFT p.level = item[1] r = p.add_run() prefix = "• " if item[1] == 0 else ("– " if item[1] == 1 else "· ") r.text = prefix + _strip_inline_emphasis(item[2]) r.font.name = FONT_NAME r.font.size = Pt(18 if item[1] == 0 else 16) r.font.color.rgb = BODY_TEXT cur_top += Inches(0.35) elif kind == "ordered": tb = slide.shapes.add_textbox(MARGIN_X, cur_top, CONTENT_W, Inches(0.35)) tf = tb.text_frame tf.word_wrap = True p = tf.paragraphs[0] p.alignment = PP_ALIGN.LEFT r = p.add_run() r.text = f"{item[1]}. " + _strip_inline_emphasis(item[2]) r.font.name = FONT_NAME r.font.size = Pt(18) r.font.color.rgb = BODY_TEXT cur_top += Inches(0.35) elif kind == "image": img_path = deck_dir / item[1] _add_picture(slide, img_path, top=cur_top) cur_top += Inches(4.1) elif kind == "table": rows = item[1] _add_table(slide, rows, top=cur_top) cur_top += Inches(0.32) * len(rows) + Inches(0.1) elif kind == "code": tb = slide.shapes.add_textbox(MARGIN_X, cur_top, CONTENT_W, Inches(0.6)) tf = tb.text_frame tf.word_wrap = True p = tf.paragraphs[0] p.alignment = PP_ALIGN.LEFT r = p.add_run() r.text = item[1] r.font.name = "Courier New" r.font.size = Pt(14) r.font.color.rgb = BLACK cur_top += Inches(0.5) elif kind == "benefit": _add_benefit(slide, item[1], top=cur_top) cur_top += Inches(0.75) _add_footer(slide, footer_text) def render_deck(md_path: Path, pptx_path: Path): md_text = md_path.read_text(encoding="utf-8") fm = parse_frontmatter(md_text) footer_text = fm.get("footer", "") slide_sources = split_slides(md_text) prs = Presentation() prs.slide_width = SLIDE_W prs.slide_height = SLIDE_H deck_dir = md_path.parent print(f"Parsing {len(slide_sources)} slides from {md_path}") for idx, src in enumerate(slide_sources): data = parse_slide(src) is_title = (idx == 0) or data["is_title_class"] or data["title_is_h1"] # The appendix is a content slide (rendered normally) if idx == 0 and (data["title_is_h1"] or data["is_title_class"]): render_title_slide(prs, data, footer_text=footer_text) elif data["is_title_class"] and not data["title_is_h1"] and idx != 0: # Marp _class: title on a non-H1 slide (e.g., appendix) — render as # content but with a title-style bar. Keep it simple: content slide. render_content_slide(prs, data, deck_dir, footer_text=footer_text) else: render_content_slide(prs, data, deck_dir, footer_text=footer_text) print(f" [{idx + 1:02d}] {data['title']} (body: {len(data['body'])} blocks)") pptx_path.parent.mkdir(parents=True, exist_ok=True) prs.save(str(pptx_path)) print(f"Saved: {pptx_path} ({len(prs.slides)} slides)") def main(): # Argv handling (D-242 extension): # python3 scripts/render_pptx.py [source.md | deck-name] [--output out.pptx] # If argv[1] ends in .md or contains a path separator, treat as an # explicit source path; else treat as a deck name (backward compatible: # reads docs/presentations/{deck}-marp.md, writes {deck}-python.pptx). repo_root = Path(__file__).resolve().parent.parent args = sys.argv[1:] output_arg = None if "--output" in args: i = args.index("--output") if i + 1 < len(args): output_arg = args[i + 1] args = args[:i] + args[i + 2 :] deck = args[0] if args else "nova-autonomous-cloud-delivery" if deck.endswith(".md") or "/" in deck or "\\" in deck: # Explicit source path (relative to repo root if not absolute) p = Path(deck) md_path = p if p.is_absolute() else (repo_root / p) if output_arg: op = Path(output_arg) pptx_path = op if op.is_absolute() else (repo_root / op) else: # default output: strip -marp.md, add .pptx stem = md_path.name if stem.endswith("-marp.md"): stem = stem[: -len("-marp.md")] elif stem.endswith(".md"): stem = stem[: -len(".md")] pptx_path = md_path.parent / f"{stem}.pptx" else: # Deck name (backward compatible) md_path = repo_root / "docs" / "presentations" / f"{deck}-marp.md" if output_arg: op = Path(output_arg) pptx_path = op if op.is_absolute() else (repo_root / op) else: pptx_path = repo_root / "docs" / "presentations" / f"{deck}-python.pptx" if not md_path.is_file(): print(f"ERROR: source deck not found: {md_path}", file=sys.stderr) sys.exit(1) render_deck(md_path, pptx_path) if __name__ == "__main__": main()