Files
acdl/scripts/render_pptx.py
T
CIAgent 0c4f5582f3 feat(P03): render PPTX + F5(b) inline bold de-emphasize — all checks pass
---ci---
project: acdl
phase: 3
milestone: v1.30
status: verify
wave: 4
persona: backend-engineer
---/ci---
2026-08-20 14:25:53 +00:00

866 lines
32 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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]
python3 scripts/render_pptx.py <source.md> [--output <out.pptx>]
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`). `--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
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, MSO_AUTO_SIZE
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 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).
Skips leading HTML comments before the frontmatter fence.
"""
text = md_text.lstrip()
# Skip leading HTML comments before frontmatter.
while text.startswith("<!--"):
end = text.find("-->")
if end == -1:
return {}
text = text[end + 3 :].lstrip()
if not text.startswith("---"):
return {}
end = text.find("\n---", 3)
if end == -1:
return {}
fm_text = 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 leading HTML comments + YAML frontmatter, then split into slides.
A Marp deck may carry a header HTML comment before the frontmatter
(e.g. the REQ-372 related-artifacts comment). Skip leading comments
before detecting the `---` frontmatter fence.
"""
text = md_text.lstrip()
# Skip leading HTML comments (<!-- ... -->) before frontmatter.
while text.startswith("<!--"):
end = text.find("-->")
if end == -1:
break
text = text[end + 3 :].lstrip()
# Strip YAML frontmatter (between first pair of `---` lines).
if text.startswith("---"):
end = text.find("\n---", 3)
if end != -1:
text = text[end + 4 :]
# Normalize slide separators. Marp uses `\n---\n` on its own line.
parts = re.split(r"\n---\s*\n", 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_footer(slide, text: str, dark_bg: bool = False):
"""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.
F4 polish: dark text on white content slides, light text on the
black cover slide.
"""
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 if dark_bg else BODY_TEXT
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):
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,
bold_color: RGBColor = None):
"""Add inline runs to paragraph `p`, rendering **bold** as strong,
`code` as monospace, *italic* as italic. Other text is plain.
F5(b): bold_color defaults to RED (legacy behavior) but can be
overridden to base_color to de-emphasize bold on slides with many
bold sections (avoids a red wall)."""
if bold_color is None:
bold_color = RED
# 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 = bold_color
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, deck_dir: Path, footer_text: str = ""):
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)
elif kind == "image":
# image on title slide (D-246 diagrams on cover)
img_path = deck_dir / item[1]
_add_picture(slide, img_path, top=cur_top)
cur_top += Inches(3.6)
_add_footer(slide, footer_text, dark_bg=True)
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)
_add_title_text(slide, slide_data["title"], color=RED, size=28, top=0.25,
bold=True, height=0.7)
# F5(b): if a slide has >=4 lead/bold-prefixed blocks, de-emphasize
# them to black+bold (section labels) instead of red+bold (avoids a
# red wall on milestone-timeline slides like slide 6). Count both
# explicit lead blocks (**...** on own line) and plain blocks that
# start with **bold** inline.
def _is_bold_block(item):
if item[0] == "lead":
return True
if item[0] == "plain" and item[1].lstrip().startswith("**"):
return True
return False
bold_count = sum(1 for item in slide_data["body"] if _is_bold_block(item))
lead_color = BODY_TEXT if bold_count >= 4 else RED
# F1: vertical balance — pre-compute the content height to center
# the block between the title (bottom ~0.95") and the footer
# (top ~7.12"). Content starts at 1.05" by default; if the total
# content height is short, push it down to vertically center.
INC = {"lead": 0.50, "plain": 0.45, "quote": 0.55, "bullet": 0.40,
"ordered": 0.40, "image": 4.10, "table": 0.42, "code": 0.50,
"benefit": 0.75}
total_h = sum(INC.get(item[0], 0.40) for item in slide_data["body"])
content_start = 1.05
available = 7.12 - content_start # footer at 7.12
if total_h < available - 0.5 and total_h > 0:
offset = (available - total_h) / 2.0
offset = min(max(offset, 0), 1.2) # cap at +1.2"
content_start = round(content_start + offset, 2)
cur_top = Inches(content_start)
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
tf.auto_size = MSO_AUTO_SIZE.SHAPE_TO_FIT_TEXT
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.LEFT
_inline_runs(p, item[1], size=18, base_color=lead_color,
bold_color=lead_color)
# make the whole lead bold
for r in p.runs:
r.font.bold = True
r.font.color.rgb = lead_color
cur_top += Inches(0.50)
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
tf.auto_size = MSO_AUTO_SIZE.SHAPE_TO_FIT_TEXT
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.LEFT
_inline_runs(p, item[1], size=18, base_color=BODY_TEXT,
bold_color=lead_color)
cur_top += Inches(0.45)
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
tf.auto_size = MSO_AUTO_SIZE.SHAPE_TO_FIT_TEXT
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.LEFT
_inline_runs(p, item[1], size=18, base_color=BODY_TEXT)
# italicize the whole blockquote
for r in p.runs:
r.font.italic = True
r.font.color.rgb = BODY_TEXT
cur_top += Inches(0.55)
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
tf.auto_size = MSO_AUTO_SIZE.SHAPE_TO_FIT_TEXT
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.LEFT
p.level = item[1]
r = p.add_run()
# F3: if the bullet text starts with →, omit the bullet glyph
stripped_text = _strip_inline_emphasis(item[2])
if item[1] == 0:
prefix = "" if stripped_text.lstrip().startswith("") else ""
else:
prefix = " " if item[1] == 1 else "· "
r.text = prefix + stripped_text
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.40)
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
tf.auto_size = MSO_AUTO_SIZE.SHAPE_TO_FIT_TEXT
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.40)
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
tf.auto_size = MSO_AUTO_SIZE.SHAPE_TO_FIT_TEXT
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)
_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
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, deck_dir, 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, footer_text=footer_text)
else:
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)
prs.save(str(pptx_path))
print(f"Saved: {pptx_path} ({len(prs.slides)} slides)")
def main():
# 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
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)
render_deck(md_path, pptx_path)
if __name__ == "__main__":
main()