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:
+115
-10
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user