#!/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] Defaults to `nova-autonomous-cloud-delivery`. Reads `docs/presentations/{deck}-marp.md`, writes `docs/presentations/{deck}-python.pptx`. """ 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 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_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"