feat(P01): extend render_pptx.py — path arg + --output + footer textbox (D-242)

---ci---
project: acdl
phase: 1
milestone: v1.30
status: execute
wave: 1
persona: backend-engineer
---/ci---
This commit is contained in:
CIAgent
2026-08-20 13:20:23 +00:00
parent fff2bcc606
commit 929d0d94c2
+115 -10
View File
@@ -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 <source.md> [--output <out.pptx>]
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)