feat(P03): render_pptx.py polish F1-F5 (balance, autofit, bullets, footer, blockquote/slide6)

---ci---
project: acdl
phase: 3
milestone: v1.30
status: execute
wave: 1
persona: backend-engineer
---/ci---
This commit is contained in:
CIAgent
2026-08-20 14:19:51 +00:00
parent 50a8089e27
commit 246c0e93b3
+49 -17
View File
@@ -31,7 +31,7 @@ try:
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.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)
@@ -206,11 +206,13 @@ def _add_title_bar(slide):
return bar
def _add_footer(slide, text: str):
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
@@ -225,7 +227,7 @@ def _add_footer(slide, text: str):
r.text = text
r.font.name = FONT_NAME
r.font.size = Pt(10)
r.font.color.rgb = GREY_HEADER
r.font.color.rgb = GREY_HEADER if dark_bg else BODY_TEXT
return tb
@@ -635,7 +637,7 @@ def render_title_slide(prs, slide_data, footer_text: str = ""):
r_b.font.italic = True
r_b.font.color.rgb = WHITE
cur_top += Inches(0.85)
_add_footer(slide, footer_text)
_add_footer(slide, footer_text, dark_bg=True)
def render_content_slide(prs, slide_data, deck_dir: Path, footer_text: str = ""):
@@ -644,63 +646,92 @@ def render_content_slide(prs, slide_data, deck_dir: Path, footer_text: str = "")
_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)
# F5(b): if a slide has >=4 lead blocks, de-emphasize them to
# black+bold (section labels) instead of red+bold (avoids a red
# wall on milestone-timeline slides like slide 6).
lead_count = sum(1 for item in slide_data["body"] if item[0] == "lead")
lead_color = BODY_TEXT if lead_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=RED)
# make the whole lead bold-strong-red
_inline_runs(p, item[1], size=18, base_color=lead_color)
# make the whole lead bold
for r in p.runs:
r.font.bold = True
r.font.color.rgb = RED
cur_top += Inches(0.5)
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)
cur_top += Inches(0.4)
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=GREY_TEXT)
_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 = GREY_TEXT
cur_top += Inches(0.6)
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()
prefix = "" if item[1] == 0 else (" " if item[1] == 1 else "· ")
r.text = prefix + _strip_inline_emphasis(item[2])
# 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.35)
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()
@@ -708,7 +739,7 @@ def render_content_slide(prs, slide_data, deck_dir: Path, footer_text: str = "")
r.font.name = FONT_NAME
r.font.size = Pt(18)
r.font.color.rgb = BODY_TEXT
cur_top += Inches(0.35)
cur_top += Inches(0.40)
elif kind == "image":
img_path = deck_dir / item[1]
_add_picture(slide, img_path, top=cur_top)
@@ -721,6 +752,7 @@ def render_content_slide(prs, slide_data, deck_dir: Path, footer_text: str = "")
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()