diff --git a/scripts/render_pptx.py b/scripts/render_pptx.py index 87ebe5d..f1cc942 100644 --- a/scripts/render_pptx.py +++ b/scripts/render_pptx.py @@ -10,10 +10,15 @@ 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`. Reads +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`. +`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 @@ -58,6 +63,46 @@ 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). @@ -141,6 +186,29 @@ def _add_title_bar(slide): 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): @@ -498,7 +566,7 @@ def parse_slide(slide_src: str): } -def render_title_slide(prs, slide_data): +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 @@ -547,9 +615,10 @@ def render_title_slide(prs, slide_data): 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): +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) @@ -643,10 +712,13 @@ def render_content_slide(prs, slide_data, deck_dir: Path): 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 @@ -659,13 +731,13 @@ def render_deck(md_path: Path, pptx_path: Path): 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) + 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) + render_content_slide(prs, data, deck_dir, footer_text=footer_text) else: - render_content_slide(prs, data, deck_dir) + 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) @@ -674,10 +746,43 @@ def render_deck(md_path: Path, pptx_path: Path): def main(): - deck = sys.argv[1] if len(sys.argv) > 1 else "nova-autonomous-cloud-delivery" + # 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 - md_path = repo_root / "docs" / "presentations" / f"{deck}-marp.md" - pptx_path = repo_root / "docs" / "presentations" / f"{deck}-python.pptx" + 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)