feat(P3b): python-pptx generator — structured editable S&P-themed PPTX (REQ-269,270)

New scripts/render_pptx.py parses the consolidated -marp.md and
produces a structured, editable, S&P-themed PPTX via python-pptx.
16:9; title slide black bg + red top bar; content slides with red H2
titles, bullets, blockquotes, embedded PNGs, native tables, benefit
callouts. Added python-pptx>=0.6.23 to pyproject [slides] optional-dep.
render_slides.sh Step 4 produces it; attach_release_asset.py extended
for dual PPTX. Output: nova-autonomous-cloud-delivery-python.pptx.

---ci---
project: acdl
phase: 3
milestone: v1.23
status: execute
phase_role: execution
---/ci---
This commit is contained in:
Jon Chery
2026-08-12 00:21:17 +00:00
parent 66b13a6d0c
commit 863f482f9c
6 changed files with 723 additions and 9 deletions
+1
View File
@@ -16,6 +16,7 @@ test = [
"pytest-json-report>=1.5",
"moto[dynamodb]>=5.0",
]
slides = ["python-pptx>=0.6.23"]
[tool.pytest.ini_options]
testpaths = ["tests"]
+21 -5
View File
@@ -1,14 +1,26 @@
#!/usr/bin/env python3
"""scripts/attach_release_asset.py — upload a file as a Gitea release attachment.
"""scripts/attach_release_asset.py — upload one or more files as Gitea release
attachments.
REQ-228 (v1.18): PPTX (and any deck artifact) is attached to the phase's
Gitea release. Uses the Gitea API:
POST /api/v1/repos/{owner}/{repo}/releases/{id}/assets
multipart form: name=<filename>, attachment=<file bytes>
REQ-270 (v1.23): supports dual PPTX attachment — the MARP PPTX (primary,
attached first) and the python-pptx PPTX (comparison artifact). Multiple
file paths are accepted; the first is the primary attachment.
Usage:
python3 scripts/attach_release_asset.py <file-path> <release-id>
python3 scripts/attach_release_asset.py <file-path> <file-path-2>... <release-id>
python3 scripts/attach_release_asset.py docs/presentations/nova-autonomous-cloud-delivery.pptx 522
python3 scripts/attach_release_asset.py \
docs/presentations/nova-autonomous-cloud-delivery.pptx \
docs/presentations/nova-autonomous-cloud-delivery-python.pptx 522
The last positional argument is always the release id; every preceding
argument is an asset path (backward compatible with the single-asset call).
Token resolution: reads NOVA_GITEA_TOKEN (or ACDL_GITEA_TOKEN) from .env.secrets
/ .env, matching the ship_phase.sh pattern. Never uses shell env tokens.
@@ -73,8 +85,12 @@ def attach_asset(file_path: str, release_id: str) -> dict:
if __name__ == "__main__":
if len(sys.argv) != 3:
print("Usage: attach_release_asset.py <file-path> <release-id>")
if len(sys.argv) < 3:
print("Usage: attach_release_asset.py <file-path> [<file-path-2>...] <release-id>")
sys.exit(1)
result = attach_asset(sys.argv[1], sys.argv[2])
print(f"Attached: {result.get('name')} → release {sys.argv[2]} (asset id {result.get('id')})")
asset_paths = sys.argv[1:-1]
release_id = sys.argv[-1]
for idx, path in enumerate(asset_paths):
result = attach_asset(path, release_id)
primary = " (primary)" if idx == 0 and len(asset_paths) > 1 else ""
print(f"Attached{primary}: {result.get('name')} → release {release_id} (asset id {result.get('id')})")
+688
View File
@@ -0,0 +1,688 @@
#!/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"(?<!\*)\*([^*]+)\*(?!\*)", r"\1", text)
return text
def _inline_runs(p, text: str, *, size: int = 18, base_color: RGBColor = BODY_TEXT):
"""Add inline runs to paragraph `p`, rendering **bold** as red strong,
`code` as monospace, *italic* as italic. Other text is plain."""
# Tokenize on `**...**`, `*...*`, `` `...` ``
tokens = re.split(r"(\*\*[^*]+\*\*|`[^`]+`|\*[^*]+\*)", text)
for tok in tokens:
if not tok:
continue
if tok.startswith("**") and tok.endswith("**"):
r = p.add_run()
r.text = tok[2:-2]
r.font.name = FONT_NAME
r.font.size = Pt(size)
r.font.bold = True
r.font.color.rgb = RED
elif tok.startswith("`") and tok.endswith("`"):
r = p.add_run()
r.text = tok[1:-1]
r.font.name = "Courier New"
r.font.size = Pt(size)
r.font.color.rgb = BODY_TEXT
elif tok.startswith("*") and tok.endswith("*") and len(tok) >= 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("<!--") and stripped.endswith("-->"):
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("<!--") and "-->" 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"<div\s+class=\"benefit\">(.*)</div>", stripped)
if bm:
body.append(("benefit", bm.group(1).strip()))
i += 1
continue
# Table — starts with `| ... |` and the next line is a separator
if stripped.startswith("|") and i + 1 < len(lines) and TABLE_SEP_RE.match(lines[i + 1].strip()):
table_rows = []
# header
header = [c.strip() for c in stripped.strip("|").split("|")]
table_rows.append(header)
i += 2 # header + separator
while i < len(lines) and lines[i].strip().startswith("|"):
row = [c.strip() for c in lines[i].strip().strip("|").split("|")]
table_rows.append(row)
i += 1
body.append(("table", table_rows))
continue
# Ordered list item: `1. ` or `1. `
om = re.match(r"^(\d+)\.\s+(.*)", stripped)
if om:
body.append(("ordered", int(om.group(1)), om.group(2).strip()))
i += 1
continue
# Unordered list item
um = re.match(r"^(\s*)([-*+])\s+(.*)", line)
if um:
indent = len(um.group(1))
lvl = 0 if indent < 2 else (1 if indent < 4 else 2)
body.append(("bullet", lvl, um.group(3).strip()))
i += 1
continue
# Blank line
if not stripped:
i += 1
continue
# Bold lead paragraph (entire paragraph wrapped in **...**)
if stripped.startswith("**") and stripped.endswith("**") and stripped.count("**") == 2:
body.append(("lead", stripped[2:-2].strip()))
i += 1
continue
# Plain text
# collect contiguous non-empty, non-special lines into one paragraph
para_lines = [line]
i += 1
while i < len(lines):
nxt = lines[i].strip()
if (not nxt or nxt.startswith("#") or nxt.startswith("-")
or nxt.startswith("*") or nxt.startswith(">")
or nxt.startswith("|") or nxt.startswith("<")
or nxt.startswith("```") or nxt.startswith("!")
or re.match(r"^\d+\.\s", nxt)):
break
para_lines.append(lines[i])
i += 1
para_text = " ".join(l.strip() for l in para_lines).strip()
if para_text:
body.append(("plain", para_text))
continue
return {
"title": title or "(untitled)",
"title_is_h1": title_is_h1,
"is_title_class": is_title_class,
"body": body,
}
def render_title_slide(prs, slide_data):
slide = prs.slides.add_slide(prs.slide_layouts[6]) # blank
_set_bg(slide, BLACK)
# red top bar
bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, SLIDE_W, Inches(0.4))
bar.fill.solid()
bar.fill.fore_color.rgb = RED
bar.line.fill.background()
bar.shadow.inherit = False
# title
title = slide_data["title"]
tb = slide.shapes.add_textbox(MARGIN_X, Inches(2.5), CONTENT_W, Inches(2.0))
tf = tb.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.LEFT
r = p.add_run()
r.text = title
r.font.name = FONT_NAME
r.font.size = Pt(40)
r.font.bold = True
r.font.color.rgb = WHITE
# body content (subtitle/lead/benefit) on black bg
cur_top = Inches(4.6)
for item in slide_data["body"]:
kind = item[0]
if kind == "lead":
_add_text_block(slide, _strip_inline_emphasis(item[1]),
top=cur_top, size=20, color=WHITE, bold=True,
height=Inches(0.5))
cur_top += Inches(0.55)
elif kind == "plain":
_add_text_block(slide, _strip_inline_emphasis(item[1]),
top=cur_top, size=16, color=WHITE,
height=Inches(0.4))
cur_top += Inches(0.45)
elif kind == "benefit":
# benefit on title slide: italic white
tb_b = slide.shapes.add_textbox(MARGIN_X, cur_top, CONTENT_W, Inches(0.8))
tf_b = tb_b.text_frame
tf_b.word_wrap = True
p_b = tf_b.paragraphs[0]
r_b = p_b.add_run()
r_b.text = item[1]
r_b.font.name = FONT_NAME
r_b.font.size = Pt(16)
r_b.font.italic = True
r_b.font.color.rgb = WHITE
cur_top += Inches(0.85)
def render_content_slide(prs, slide_data, deck_dir: Path):
slide = prs.slides.add_slide(prs.slide_layouts[6]) # blank
_set_bg(slide, WHITE)
_add_title_bar(slide)
_add_title_text(slide, slide_data["title"], color=RED, size=28, top=0.25,
bold=True, height=0.7)
cur_top = Inches(1.05)
for item in slide_data["body"]:
kind = item[0]
if kind == "lead":
tb = slide.shapes.add_textbox(MARGIN_X, cur_top, CONTENT_W, Inches(0.5))
tf = tb.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.LEFT
_inline_runs(p, item[1], size=18, base_color=RED)
# make the whole lead bold-strong-red
for r in p.runs:
r.font.bold = True
r.font.color.rgb = RED
cur_top += Inches(0.5)
elif kind == "plain":
tb = slide.shapes.add_textbox(MARGIN_X, cur_top, CONTENT_W, Inches(0.4))
tf = tb.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.LEFT
_inline_runs(p, item[1], size=18, base_color=BODY_TEXT)
cur_top += Inches(0.4)
elif kind == "quote":
tb = slide.shapes.add_textbox(
MARGIN_X + Inches(0.3), cur_top, CONTENT_W - Inches(0.3), Inches(0.6)
)
tf = tb.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.LEFT
_inline_runs(p, item[1], size=18, base_color=GREY_TEXT)
# italicize the whole blockquote
for r in p.runs:
r.font.italic = True
r.font.color.rgb = GREY_TEXT
cur_top += Inches(0.6)
elif kind == "bullet":
# accumulate consecutive bullets into one text frame
# (handled below in a second pass; we render single here as fallback)
tb = slide.shapes.add_textbox(MARGIN_X, cur_top, CONTENT_W, Inches(0.35))
tf = tb.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.LEFT
p.level = item[1]
r = p.add_run()
prefix = "" if item[1] == 0 else (" " if item[1] == 1 else "· ")
r.text = prefix + _strip_inline_emphasis(item[2])
r.font.name = FONT_NAME
r.font.size = Pt(18 if item[1] == 0 else 16)
r.font.color.rgb = BODY_TEXT
cur_top += Inches(0.35)
elif kind == "ordered":
tb = slide.shapes.add_textbox(MARGIN_X, cur_top, CONTENT_W, Inches(0.35))
tf = tb.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.LEFT
r = p.add_run()
r.text = f"{item[1]}. " + _strip_inline_emphasis(item[2])
r.font.name = FONT_NAME
r.font.size = Pt(18)
r.font.color.rgb = BODY_TEXT
cur_top += Inches(0.35)
elif kind == "image":
img_path = deck_dir / item[1]
_add_picture(slide, img_path, top=cur_top)
cur_top += Inches(4.1)
elif kind == "table":
rows = item[1]
_add_table(slide, rows, top=cur_top)
cur_top += Inches(0.32) * len(rows) + Inches(0.1)
elif kind == "code":
tb = slide.shapes.add_textbox(MARGIN_X, cur_top, 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 = item[1]
r.font.name = "Courier New"
r.font.size = Pt(14)
r.font.color.rgb = BLACK
cur_top += Inches(0.5)
elif kind == "benefit":
_add_benefit(slide, item[1], top=cur_top)
cur_top += Inches(0.75)
def render_deck(md_path: Path, pptx_path: Path):
md_text = md_path.read_text(encoding="utf-8")
slide_sources = split_slides(md_text)
prs = Presentation()
prs.slide_width = SLIDE_W
prs.slide_height = SLIDE_H
deck_dir = md_path.parent
print(f"Parsing {len(slide_sources)} slides from {md_path}")
for idx, src in enumerate(slide_sources):
data = parse_slide(src)
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)
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)
else:
render_content_slide(prs, data, deck_dir)
print(f" [{idx + 1:02d}] {data['title']} (body: {len(data['body'])} blocks)")
pptx_path.parent.mkdir(parents=True, exist_ok=True)
prs.save(str(pptx_path))
print(f"Saved: {pptx_path} ({len(prs.slides)} slides)")
def main():
deck = sys.argv[1] if len(sys.argv) > 1 else "nova-autonomous-cloud-delivery"
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"
if not md_path.is_file():
print(f"ERROR: source deck not found: {md_path}", file=sys.stderr)
sys.exit(1)
render_deck(md_path, pptx_path)
if __name__ == "__main__":
main()
+13 -4
View File
@@ -78,7 +78,16 @@ echo "=== Step 3: Inlining images into HTML ==="
python3 scripts/inline_images.py "$HTML" 2>&1
echo ""
# --- Step 4: stage ---
echo "=== Step 4: Staging rendered artifacts ==="
git add "$PNG_DIR"/*.png "$HTML" "$PPTX" 2>/dev/null || true
echo "=== Done: staged $(ls "$PNG_DIR"/*.png 2>/dev/null | wc -l) PNGs + $HTML + $PPTX ==="
# --- Step 4: render python-pptx (structured, editable, S&P-themed) ---
# REQ-269: python-pptx produces a structured, editable PPTX (native text boxes,
# tables, images) alongside the MARP-rendered PPTX.
echo "=== Step 4: Rendering python-pptx deck ==="
PYTHON_PPTX="docs/presentations/${DECK}-python.pptx"
python3 scripts/render_pptx.py "$DECK" 2>&1
echo " Python PPTX → $PYTHON_PPTX"
echo ""
# --- Step 5: stage ---
echo "=== Step 5: Staging rendered artifacts ==="
git add "$PNG_DIR"/*.png "$HTML" "$PPTX" "$PYTHON_PPTX" 2>/dev/null || true
echo "=== Done: staged $(ls "$PNG_DIR"/*.png 2>/dev/null | wc -l) PNGs + $HTML + $PPTX + $PYTHON_PPTX ==="