diff --git a/scripts/render_pptx.py b/scripts/render_pptx.py index 79391ef..5e50c58 100644 --- a/scripts/render_pptx.py +++ b/scripts/render_pptx.py @@ -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()