0c4f5582f3
---ci--- project: acdl phase: 3 milestone: v1.30 status: verify wave: 4 persona: backend-engineer ---/ci---
866 lines
32 KiB
Python
866 lines
32 KiB
Python
#!/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() |