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:
|
Usage:
|
||||||
python3 scripts/render_pptx.py [deck-name]
|
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}-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 os
|
||||||
import re
|
import re
|
||||||
@@ -58,6 +63,46 @@ IMG_MAX_H = Inches(4.0)
|
|||||||
|
|
||||||
|
|
||||||
# --- Markdown parsing --------------------------------------------------------
|
# --- 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):
|
def split_slides(md_text: str):
|
||||||
"""Strip YAML frontmatter, then split the deck into slide source strings."""
|
"""Strip YAML frontmatter, then split the deck into slide source strings."""
|
||||||
# Strip YAML frontmatter (between first pair of `---` lines).
|
# Strip YAML frontmatter (between first pair of `---` lines).
|
||||||
@@ -141,6 +186,29 @@ def _add_title_bar(slide):
|
|||||||
return bar
|
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,
|
def _add_title_text(slide, title: str, *, color: RGBColor = RED,
|
||||||
size: int = 28, top: float = 0.25, bold: bool = True,
|
size: int = 28, top: float = 0.25, bold: bool = True,
|
||||||
height: float = 0.7, white_bg: bool = False):
|
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
|
slide = prs.slides.add_slide(prs.slide_layouts[6]) # blank
|
||||||
_set_bg(slide, BLACK)
|
_set_bg(slide, BLACK)
|
||||||
# red top bar
|
# red top bar
|
||||||
@@ -547,9 +615,10 @@ def render_title_slide(prs, slide_data):
|
|||||||
r_b.font.italic = True
|
r_b.font.italic = True
|
||||||
r_b.font.color.rgb = WHITE
|
r_b.font.color.rgb = WHITE
|
||||||
cur_top += Inches(0.85)
|
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
|
slide = prs.slides.add_slide(prs.slide_layouts[6]) # blank
|
||||||
_set_bg(slide, WHITE)
|
_set_bg(slide, WHITE)
|
||||||
_add_title_bar(slide)
|
_add_title_bar(slide)
|
||||||
@@ -643,10 +712,13 @@ def render_content_slide(prs, slide_data, deck_dir: Path):
|
|||||||
elif kind == "benefit":
|
elif kind == "benefit":
|
||||||
_add_benefit(slide, item[1], top=cur_top)
|
_add_benefit(slide, item[1], top=cur_top)
|
||||||
cur_top += Inches(0.75)
|
cur_top += Inches(0.75)
|
||||||
|
_add_footer(slide, footer_text)
|
||||||
|
|
||||||
|
|
||||||
def render_deck(md_path: Path, pptx_path: Path):
|
def render_deck(md_path: Path, pptx_path: Path):
|
||||||
md_text = md_path.read_text(encoding="utf-8")
|
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)
|
slide_sources = split_slides(md_text)
|
||||||
prs = Presentation()
|
prs = Presentation()
|
||||||
prs.slide_width = SLIDE_W
|
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"]
|
is_title = (idx == 0) or data["is_title_class"] or data["title_is_h1"]
|
||||||
# The appendix is a content slide (rendered normally)
|
# The appendix is a content slide (rendered normally)
|
||||||
if idx == 0 and (data["title_is_h1"] or data["is_title_class"]):
|
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:
|
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
|
# 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.
|
# 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:
|
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)")
|
print(f" [{idx + 1:02d}] {data['title']} (body: {len(data['body'])} blocks)")
|
||||||
|
|
||||||
pptx_path.parent.mkdir(parents=True, exist_ok=True)
|
pptx_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -674,10 +746,43 @@ def render_deck(md_path: Path, pptx_path: Path):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
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
|
repo_root = Path(__file__).resolve().parent.parent
|
||||||
md_path = repo_root / "docs" / "presentations" / f"{deck}-marp.md"
|
args = sys.argv[1:]
|
||||||
pptx_path = repo_root / "docs" / "presentations" / f"{deck}-python.pptx"
|
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():
|
if not md_path.is_file():
|
||||||
print(f"ERROR: source deck not found: {md_path}", file=sys.stderr)
|
print(f"ERROR: source deck not found: {md_path}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
Reference in New Issue
Block a user