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:
Binary file not shown.
Binary file not shown.
@@ -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"]
|
||||
|
||||
@@ -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')})")
|
||||
@@ -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()
|
||||
@@ -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 ==="
|
||||
|
||||
Reference in New Issue
Block a user