pediatric-ai-scribe-v3/scripts/render_pptx.py
Daniel de13c8306b
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 6s
feat: templates, not just palettes — colour title and section pages, heading bands, striped callouts, question slides with answers, key-point strips, page footers; Grand Rounds and Board Review join the catalogue
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 18:05:41 +02:00

946 lines
36 KiB
Python
Raw 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
"""Render a slide deck from a JSON spec on stdin to a .pptx file.
Replaces pandoc's pptx writer, which could only map markdown onto a handful of
reference layouts. Everything that made those decks look generated is decided
here instead: which layout a slide gets, how large its text is, where an image
sits and at what aspect ratio, how a table is drawn.
Input (stdin, JSON):
{"title": str, "subtitle": str, "date": str,
"slides": [ {...} ],
"images": [ "/abs/path.png", ... ] }
Each slide is one of:
{"type": "title", "heading": str, "subtitle": str}
{"type": "section", "heading": str}
{"type": "bullets", "heading": str, "bullets": [{"text":str,"level":int}], "notes": str}
{"type": "two", "heading": str, "left": [...], "right": [...]}
{"type": "table", "heading": str, "header": [str], "rows": [[str]]}
{"type": "image", "heading": str, "image": str, "caption": str}
Output: argv[1], a .pptx path. Errors go to stderr and exit non-zero, so the
caller can fall back rather than ship a broken file.
"""
import json
import sys
import os
from pptx import Presentation
from pptx.util import Emu, Pt
from pptx.chart.data import CategoryChartData
from pptx.dml.color import RGBColor
from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION
from pptx.enum.shapes import MSO_SHAPE
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
try:
from PIL import Image
except Exception: # pragma: no cover - Pillow is installed alongside
Image = None
# 16:9. Pandoc's default reference doc is 4:3, which is why those decks looked
# dated before anything else about them did.
SLIDE_W = Emu(12192000)
SLIDE_H = Emu(6858000)
MARGIN = Emu(685800) # 0.75"
HEADING_TOP = Emu(457200) # 0.5"
HEADING_H = Emu(1005840) # 1.1"
BODY_TOP = HEADING_TOP + HEADING_H + Emu(228600) # clears the accent rule
BODY_H = SLIDE_H - BODY_TOP - MARGIN
BODY_W = SLIDE_W - MARGIN * 2
# The palette. Every slide builder reads these five names and the font, which is
# what makes theming a rebinding rather than a rewrite: apply_theme() below
# reassigns them once, and a comparison slide, a table header and a callout card
# all follow without a line changing in any builder.
#
# These values are the clinical-blue theme, kept as the literal default so the
# renderer still works standalone with no theme and no catalogue on disk.
INK = RGBColor(0x1F, 0x29, 0x37)
MUTED = RGBColor(0x4B, 0x55, 0x63)
ACCENT = RGBColor(0x25, 0x63, 0xEB)
RULE = RGBColor(0xE5, 0xE7, 0xEB)
PAPER = RGBColor(0xFF, 0xFF, 0xFF)
# The two card fills a comparison uses, and the second hue that edges the
# second card and a callout. These were written inline at both call sites, so a
# comparison stayed blue-and-amber under every theme and only the headings
# moved. Defaults match what was hardcoded, so a deck with no theme is
# unchanged.
TINT = RGBColor(0xEF, 0xF6, 0xFF)
TINT_ALT = RGBColor(0xFE, 0xF3, 0xC7)
ACCENT_ALT = RGBColor(0xD9, 0x77, 0x06)
FONT = "Calibri"
THEMES_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "assets", "deck-themes.json")
def _rgb(value):
"""'2563EB' -> RGBColor. Raises on anything that is not six hex digits."""
text = str(value).lstrip("#")
if len(text) != 6:
raise ValueError("colour must be six hex digits: %r" % (value,))
return RGBColor(int(text[0:2], 16), int(text[2:4], 16), int(text[4:6], 16))
def load_themes():
"""The catalogue, or an empty list if it cannot be read."""
try:
with open(THEMES_PATH, "r", encoding="utf-8") as handle:
return json.load(handle).get("themes") or []
except Exception:
# A deck must still render with no catalogue: the defaults above stand.
return []
# What a template does beyond its colours. Every key has the value the
# renderer always had, so a theme without a style block looks as it always did.
STYLE = {"title": "band", "section": "quiet", "heading": "rule", "callout": "card",
"question": "panel", "footer": False, "takeaway": False}
DECK_TITLE = ""
SECTION_NO = 0
FOOTER_H = Emu(320040) # 0.35"
TAKEAWAY_H = Emu(640080) # 0.7"
def apply_theme(theme_id):
"""Rebind the palette. An unknown or missing id leaves the default in place,
because a deck rendering in the wrong colours beats a deck not rendering."""
if not theme_id:
return None
for theme in load_themes():
if theme.get("id") != theme_id:
continue
global INK, MUTED, ACCENT, RULE, PAPER, FONT, TINT, TINT_ALT, ACCENT_ALT
try:
INK = _rgb(theme["ink"])
MUTED = _rgb(theme["muted"])
ACCENT = _rgb(theme["accent"])
# .get, not [...]: a theme written before these existed should
# still apply its other colours rather than fall back wholesale.
TINT = _rgb(theme.get("tint") or "EFF6FF")
TINT_ALT = _rgb(theme.get("tint_alt") or "FEF3C7")
ACCENT_ALT = _rgb(theme.get("accent_alt") or "D97706")
RULE = _rgb(theme["rule"])
PAPER = _rgb(theme["paper"])
except Exception:
return None
FONT = theme.get("font") or FONT
style = theme.get("style") or {}
for key in list(STYLE):
if key in style:
STYLE[key] = style[key]
return theme
return None
def _rect(slide, left, top, width, height, fill, shape=1):
box = slide.shapes.add_shape(shape, left, top, width, height)
box.fill.solid()
box.fill.fore_color.rgb = fill
box.line.fill.background()
box.shadow.inherit = False
box.text_frame.text = ""
return box
def _footer(slide, number, total):
"""Deck title left, page number right, in the margin below the body."""
if not STYLE.get("footer") or number <= 1:
return
top = SLIDE_H - FOOTER_H
left = _textbox(slide, MARGIN, top, Emu(int(BODY_W * 0.7)), FOOTER_H)
left.vertical_anchor = MSO_ANCHOR.MIDDLE
_run(left.paragraphs[0], DECK_TITLE[:90], 10, color=MUTED)
right = _textbox(slide, MARGIN + Emu(int(BODY_W * 0.7)), top, Emu(int(BODY_W * 0.3)), FOOTER_H)
right.vertical_anchor = MSO_ANCHOR.MIDDLE
para = right.paragraphs[0]
para.alignment = PP_ALIGN.RIGHT
_run(para, "%d / %d" % (number, total), 10, color=MUTED)
def _takeaway(slide, text):
"""One line the audience should leave with, on a strip under the body.
Returns how much body height it took, so the builder can shrink its frame."""
text = (text or "").strip()
if not text or not STYLE.get("takeaway"):
return Emu(0)
top = SLIDE_H - MARGIN - TAKEAWAY_H
_rect(slide, MARGIN, top, BODY_W, TAKEAWAY_H, TINT, shape=5)
_rect(slide, MARGIN, top, Emu(91440), TAKEAWAY_H, ACCENT)
frame = _textbox(slide, MARGIN + Emu(228600), top, BODY_W - Emu(457200), TAKEAWAY_H)
frame.vertical_anchor = MSO_ANCHOR.MIDDLE
para = frame.paragraphs[0]
_run(para, "Key point ", 12, bold=True, color=ACCENT)
for chunk, bold in _split_bold(text):
_run(para, chunk, 16 if len(text) <= 110 else 14, bold=bold, color=INK)
return TAKEAWAY_H + Emu(182880)
# Text is sized to fit rather than left to the renderer's autofit, which only
# some viewers honour and which LibreOffice ignores entirely when converting to
# PDF — the reason slides were being cut off mid-sentence.
BULLET_SIZES = [26, 24, 22, 20, 18, 16, 14]
CHARS_PER_LINE_AT_24PT = 62.0
def _blank(prs):
return prs.slides.add_slide(prs.slide_layouts[6])
def _textbox(slide, left, top, width, height):
box = slide.shapes.add_textbox(left, top, width, height)
frame = box.text_frame
frame.word_wrap = True
frame.margin_left = 0
frame.margin_right = 0
frame.margin_top = 0
frame.margin_bottom = 0
return frame
def _run(paragraph, text, size, bold=False, color=INK, italic=False):
run = paragraph.add_run()
run.text = text
run.font.size = Pt(size)
run.font.bold = bold
run.font.italic = italic
run.font.name = FONT
run.font.color.rgb = color
return run
def _heading(slide, text, accent_rule=True):
style = STYLE.get("heading") or "rule"
left = MARGIN
if style == "band":
_rect(slide, Emu(0), Emu(0), SLIDE_W, HEADING_TOP + HEADING_H + Emu(91440), TINT)
_rect(slide, Emu(0), Emu(0), Emu(137160), HEADING_TOP + HEADING_H + Emu(91440), ACCENT)
elif style == "bar":
_rect(slide, MARGIN, HEADING_TOP + Emu(182880), Emu(91440), HEADING_H - Emu(182880), ACCENT)
left = MARGIN + Emu(274320)
frame = _textbox(slide, left, HEADING_TOP, BODY_W - (left - MARGIN), HEADING_H)
frame.vertical_anchor = MSO_ANCHOR.BOTTOM
para = frame.paragraphs[0]
# Long headings shrink rather than wrapping into the body.
size = 34 if len(text) <= 52 else (30 if len(text) <= 74 else 26)
_run(para, text, size, bold=True)
if accent_rule and style == "rule":
bar = slide.shapes.add_shape(1, MARGIN, HEADING_TOP + HEADING_H + Emu(45720), Emu(548640), Emu(45720))
bar.fill.solid()
bar.fill.fore_color.rgb = ACCENT
bar.line.fill.background()
bar.shadow.inherit = False
def _estimate_lines(text, size, width_frac=1.0):
"""How many wrapped lines this run of text will take at this size."""
if not text:
return 1
per_line = max(12.0, CHARS_PER_LINE_AT_24PT * (24.0 / size) * width_frac)
return max(1, int(len(text) / per_line) + (1 if len(text) % per_line else 0))
def _fit_size(items, width_frac=1.0, available_h_emu=None):
"""Largest size from BULLET_SIZES at which every bullet fits the body box."""
available = available_h_emu if available_h_emu is not None else int(BODY_H)
for size in BULLET_SIZES:
line_emu = Pt(size * 1.35).emu
gap_emu = Pt(size * 0.55).emu
total = 0
for item in items:
total += _estimate_lines(item.get("text", ""), size, width_frac) * line_emu + gap_emu
if total <= available:
return size
return BULLET_SIZES[-1]
def _hang(para, size, level):
"""Wrapped lines align under the text, not back at the margin.
python-pptx exposes no indent API, so this writes marL/indent onto a:pPr
directly. Without it every bullet that wrapped ran back to the left edge,
which is the clearest single tell that a deck was generated rather than
made."""
indent = int(Pt(size * 0.95).emu)
left = indent * (level + 1)
pPr = para._pPr if para._pPr is not None else para._p.get_or_add_pPr()
pPr.set("marL", str(left))
pPr.set("indent", str(-indent))
def _bullets(frame, items, size, width_frac=1.0):
first = True
for item in items:
text = (item.get("text") or "").strip()
if not text:
continue
para = frame.paragraphs[0] if first else frame.add_paragraph()
first = False
level = min(int(item.get("level") or 0), 4)
para.level = level
para.space_after = Pt(size * 0.55)
_hang(para, size, level)
marker = "" if level == 0 else " "
_run(para, marker, size, color=ACCENT if level == 0 else MUTED)
# Inline **bold** is kept, because emphasis is most of what a bullet has.
for chunk, bold in _split_bold(text):
_run(para, chunk, size, bold=bold, color=INK if level == 0 else MUTED)
def _split_bold(text):
out = []
rest = text
while "**" in rest:
before, _, after = rest.partition("**")
if before:
out.append((before, False))
bold, sep, remainder = after.partition("**")
if not sep:
out.append(("**" + bold, False))
return out
out.append((bold, True))
rest = remainder
if rest:
out.append((rest, False))
return out or [(text, False)]
def _notes(slide, text):
if not text:
return
slide.notes_slide.notes_text_frame.text = text
def slide_title(prs, spec):
slide = _blank(prs)
style = STYLE.get("title") or "band"
heading = spec.get("heading") or "Untitled"
if style == "full":
_rect(slide, Emu(0), Emu(0), SLIDE_W, SLIDE_H, ACCENT)
_rect(slide, MARGIN, Emu(1874520), Emu(1097280), Emu(54864), PAPER)
frame = _textbox(slide, MARGIN, Emu(2057400), BODY_W, Emu(2286000))
frame.vertical_anchor = MSO_ANCHOR.MIDDLE
para = frame.paragraphs[0]
_run(para, heading, 44 if len(heading) <= 60 else 36, bold=True, color=PAPER)
for line in [spec.get("subtitle"), spec.get("date")]:
if line:
sub = frame.add_paragraph()
sub.space_before = Pt(10)
_run(sub, line, 18, color=PAPER)
return slide
if style == "split":
panel_w = Emu(int(SLIDE_W * 0.42))
_rect(slide, Emu(0), Emu(0), panel_w, SLIDE_H, ACCENT)
frame = _textbox(slide, MARGIN, Emu(1600200), panel_w - MARGIN - Emu(274320), Emu(3657600))
frame.vertical_anchor = MSO_ANCHOR.MIDDLE
_run(frame.paragraphs[0], heading, 36 if len(heading) <= 60 else 30, bold=True, color=PAPER)
right = _textbox(slide, panel_w + Emu(548640), Emu(2514600), SLIDE_W - panel_w - Emu(548640) - MARGIN, Emu(1828800))
right.vertical_anchor = MSO_ANCHOR.MIDDLE
first = True
for line in [spec.get("subtitle"), spec.get("date")]:
if not line:
continue
para = right.paragraphs[0] if first else right.add_paragraph()
if not first:
para.space_before = Pt(10)
_run(para, line, 20 if first else 16, color=INK if first else MUTED)
first = False
return slide
frame = _textbox(slide, MARGIN, Emu(2057400), BODY_W, Emu(1828800))
frame.vertical_anchor = MSO_ANCHOR.MIDDLE
para = frame.paragraphs[0]
_run(para, heading, 44 if len(heading) <= 60 else 36, bold=True)
for line in [spec.get("subtitle"), spec.get("date")]:
if not line:
continue
sub = frame.add_paragraph()
sub.space_before = Pt(10)
_run(sub, line, 18, color=MUTED)
bar = slide.shapes.add_shape(1, MARGIN, Emu(1874520), Emu(1097280), Emu(54864))
bar.fill.solid()
bar.fill.fore_color.rgb = ACCENT
bar.line.fill.background()
bar.shadow.inherit = False
return slide
def slide_section(prs, spec):
global SECTION_NO
SECTION_NO += 1
slide = _blank(prs)
if (STYLE.get("section") or "quiet") == "band":
_rect(slide, Emu(0), Emu(0), SLIDE_W, SLIDE_H, ACCENT)
number = _textbox(slide, MARGIN, Emu(1371600), BODY_W, Emu(1600200))
number.vertical_anchor = MSO_ANCHOR.BOTTOM
_run(number.paragraphs[0], "%02d" % SECTION_NO, 72, bold=True, color=PAPER)
_rect(slide, MARGIN, Emu(3017520), Emu(1097280), Emu(54864), PAPER)
frame = _textbox(slide, MARGIN, Emu(3200400), BODY_W, Emu(1828800))
frame.vertical_anchor = MSO_ANCHOR.TOP
_run(frame.paragraphs[0], spec.get("heading") or "", 36, bold=True, color=PAPER)
return slide
frame = _textbox(slide, MARGIN, Emu(2743200), BODY_W, Emu(1371600))
frame.vertical_anchor = MSO_ANCHOR.MIDDLE
para = frame.paragraphs[0]
_run(para, spec.get("heading") or "", 36, bold=True, color=ACCENT)
return slide
def slide_bullets(prs, spec):
slide = _blank(prs)
_heading(slide, spec.get("heading") or "")
items = spec.get("bullets") or []
taken = _takeaway(slide, spec.get("takeaway"))
size = _fit_size(items, available_h_emu=BODY_H - taken)
frame = _textbox(slide, MARGIN, BODY_TOP, BODY_W, BODY_H - taken)
_bullets(frame, items, size)
_notes(slide, spec.get("notes"))
return slide
def slide_two(prs, spec):
slide = _blank(prs)
_heading(slide, spec.get("heading") or "")
gutter = Emu(457200)
col_w = Emu(int((BODY_W - gutter) / 2))
left_items = spec.get("left") or []
right_items = spec.get("right") or []
size = min(_fit_size(left_items, 0.46), _fit_size(right_items, 0.46))
for index, items in enumerate((left_items, right_items)):
if not items:
continue
left = MARGIN + (col_w + gutter) * index
frame = _textbox(slide, left, BODY_TOP, col_w, BODY_H)
_bullets(frame, items, size, width_frac=0.46)
_notes(slide, spec.get("notes"))
return slide
def slide_table(prs, spec):
slide = _blank(prs)
_heading(slide, spec.get("heading") or "")
header = spec.get("header") or []
rows = spec.get("rows") or []
if not header and not rows:
return slide
cols = max(len(header), max((len(r) for r in rows), default=1))
body_rows = len(rows) + (1 if header else 0)
height = min(int(BODY_H), Emu(int(365760 * body_rows)))
shape = slide.shapes.add_table(body_rows, cols, MARGIN, BODY_TOP, BODY_W, height)
table = shape.table
# Sized to the row count: eleven rows at one size is unreadable, four is airy.
size = 16 if body_rows <= 6 else (13 if body_rows <= 9 else 11)
def write(cell, text, bold, color):
cell.text = ""
cell.margin_left = Emu(91440)
cell.margin_right = Emu(91440)
cell.margin_top = Emu(45720)
cell.margin_bottom = Emu(45720)
cell.vertical_anchor = MSO_ANCHOR.MIDDLE
para = cell.text_frame.paragraphs[0]
for chunk, is_bold in _split_bold(text or ""):
_run(para, chunk, size, bold=bold or is_bold, color=color)
offset = 0
if header:
for c in range(cols):
cell = table.cell(0, c)
write(cell, header[c] if c < len(header) else "", True, PAPER)
cell.fill.solid()
cell.fill.fore_color.rgb = ACCENT
offset = 1
for r, row in enumerate(rows):
for c in range(cols):
cell = table.cell(r + offset, c)
write(cell, row[c] if c < len(row) else "", False, INK)
cell.fill.solid()
cell.fill.fore_color.rgb = PAPER if r % 2 == 0 else RGBColor(0xF9, 0xFA, 0xFB)
_notes(slide, spec.get("notes"))
return slide
def slide_image(prs, spec):
"""A figure sized to its own aspect ratio, never stretched to a box."""
slide = _blank(prs)
heading = spec.get("heading") or ""
if heading:
_heading(slide, heading)
top, avail_h = BODY_TOP, BODY_H
else:
top, avail_h = MARGIN, SLIDE_H - MARGIN * 2
path = spec.get("image")
caption = (spec.get("caption") or "").strip()
caption_h = Emu(365760) if caption else Emu(0)
avail_h = Emu(int(avail_h - caption_h))
ratio = 1.0
if Image is not None and path and os.path.exists(path):
try:
with Image.open(path) as img:
if img.height:
ratio = img.width / float(img.height)
except Exception:
ratio = 1.0
# Fit inside the box, preserving aspect, then centre it.
width = int(BODY_W)
height = int(width / ratio) if ratio else int(avail_h)
if height > int(avail_h):
height = int(avail_h)
width = int(height * ratio)
left = Emu(int((SLIDE_W - width) / 2))
if path and os.path.exists(path):
slide.shapes.add_picture(path, left, Emu(int(top)), Emu(width), Emu(height))
if caption:
frame = _textbox(slide, MARGIN, Emu(int(top) + height + 91440), BODY_W, caption_h)
para = frame.paragraphs[0]
para.alignment = PP_ALIGN.CENTER
_run(para, caption, 14, color=MUTED, italic=True)
_notes(slide, spec.get("notes"))
return slide
def slide_compare(prs, spec):
"""Two labelled columns on tinted cards — the layout a differential, a
mild-versus-severe or a before-and-after actually wants, and the one
markdown had no way to ask for."""
slide = _blank(prs)
_heading(slide, spec.get("heading") or "")
gutter = Emu(365760)
col_w = Emu(int((BODY_W - gutter) / 2))
columns = (spec.get("columns") or [])[:2]
tints = [TINT, TINT_ALT]
edges = [ACCENT, ACCENT_ALT]
sizes = [_fit_size(c.get("bullets") or [], 0.44, int(BODY_H) - int(Emu(548640)))
for c in columns] or [BULLET_SIZES[0]]
size = min(sizes)
for index, column in enumerate(columns):
left = MARGIN + (col_w + gutter) * index
card = slide.shapes.add_shape(5, left, BODY_TOP, col_w, BODY_H) # rounded rect
card.fill.solid()
card.fill.fore_color.rgb = tints[index % 2]
card.line.color.rgb = edges[index % 2]
card.line.width = Pt(1)
card.shadow.inherit = False
card.text_frame.text = ""
label = _textbox(slide, left + Emu(228600), BODY_TOP + Emu(182880),
Emu(int(col_w) - 457200), Emu(365760))
_run(label.paragraphs[0], (column.get("label") or "").upper(), 14,
bold=True, color=edges[index % 2])
frame = _textbox(slide, left + Emu(228600), BODY_TOP + Emu(640080),
Emu(int(col_w) - 457200), Emu(int(BODY_H) - 822960))
_bullets(frame, column.get("bullets") or [], size, width_frac=0.44)
_notes(slide, spec.get("notes"))
return slide
def slide_callout(prs, spec):
"""One thing worth stopping on: a red flag, a dose, a rule of thumb."""
slide = _blank(prs)
_heading(slide, spec.get("heading") or "")
text = (spec.get("text") or "").strip()
size = 28 if len(text) <= 90 else (22 if len(text) <= 180 else 18)
if (STYLE.get("callout") or "card") == "stripe":
# A white card with a thick accent stripe and a mark in the margin.
card_h = Emu(int(BODY_H * 0.62))
_rect(slide, MARGIN, BODY_TOP, BODY_W, card_h, TINT, shape=5)
_rect(slide, MARGIN, BODY_TOP, Emu(182880), card_h, ACCENT_ALT)
mark = _rect(slide, MARGIN + Emu(457200), BODY_TOP + Emu(int(card_h / 2)) - Emu(320040), Emu(640080), Emu(640080), ACCENT_ALT, shape=9)
mp = mark.text_frame.paragraphs[0]
mp.alignment = PP_ALIGN.CENTER
_run(mp, "!", 28, bold=True, color=PAPER)
frame = _textbox(slide, MARGIN + Emu(1371600), BODY_TOP + Emu(365760),
Emu(int(BODY_W) - 1371600 - 457200), card_h - Emu(731520))
frame.vertical_anchor = MSO_ANCHOR.MIDDLE
para = frame.paragraphs[0]
for chunk, bold in _split_bold(text):
_run(para, chunk, size, bold=True, color=INK)
_notes(slide, spec.get("notes"))
return slide
card = slide.shapes.add_shape(5, MARGIN, BODY_TOP, BODY_W, Emu(int(BODY_H * 0.62)))
card.fill.solid()
card.fill.fore_color.rgb = TINT_ALT
card.line.color.rgb = ACCENT_ALT
card.line.width = Pt(1.5)
card.shadow.inherit = False
card.text_frame.text = ""
frame = _textbox(slide, MARGIN + Emu(457200), BODY_TOP + Emu(365760),
Emu(int(BODY_W) - 914400), Emu(int(BODY_H * 0.62) - 731520))
frame.vertical_anchor = MSO_ANCHOR.MIDDLE
para = frame.paragraphs[0]
para.alignment = PP_ALIGN.CENTER
for chunk, bold in _split_bold(text):
_run(para, chunk, size, bold=bold or True, color=RGBColor(0x78, 0x35, 0x0F))
_notes(slide, spec.get("notes"))
return slide
def slide_question(prs, spec):
"""A check-yourself moment: the question with its options, a big mark to say
so, and — when an answer was given — the answer on the slide after, so the
room can commit before it is shown."""
slides = []
question = (spec.get("question") or "").strip()
options = [o for o in (spec.get("options") or []) if o]
style = STYLE.get("question") or "panel"
slide = _blank(prs)
slides.append(slide)
if style == "spotlight":
_rect(slide, Emu(0), Emu(0), SLIDE_W, SLIDE_H, INK)
fg, muted, panel = PAPER, RULE, ACCENT
_heading_color = PAPER
else:
fg, muted, panel = INK, MUTED, TINT
if style == "spotlight":
head = _textbox(slide, MARGIN, HEADING_TOP, BODY_W, HEADING_H)
head.vertical_anchor = MSO_ANCHOR.BOTTOM
_run(head.paragraphs[0], spec.get("heading") or "Check yourself", 30, bold=True, color=PAPER)
else:
_heading(slide, spec.get("heading") or "Check yourself")
mark_w = Emu(1828800)
mark = _rect(slide, MARGIN, BODY_TOP, mark_w, mark_w, panel, shape=9)
mp = mark.text_frame.paragraphs[0]
mp.alignment = PP_ALIGN.CENTER
mark.text_frame.vertical_anchor = MSO_ANCHOR.MIDDLE
_run(mp, "?", 80, bold=True, color=PAPER if style == "spotlight" else ACCENT)
frame = _textbox(slide, MARGIN + mark_w + Emu(457200), BODY_TOP, BODY_W - mark_w - Emu(457200), BODY_H)
para = frame.paragraphs[0]
_run(para, question, 26 if len(question) <= 120 else (22 if len(question) <= 220 else 18), bold=True, color=fg)
letters = "ABCDEFGH"
for i, option in enumerate(options[:6]):
p = frame.add_paragraph()
p.space_before = Pt(10)
_run(p, letters[i] + " ", 18, bold=True, color=ACCENT if style != "spotlight" else RULE)
_run(p, option, 18, color=fg)
_notes(slide, spec.get("notes"))
answer = (spec.get("answer") or "").strip()
if answer:
reveal = _blank(prs)
slides.append(reveal)
_heading(reveal, "Answer")
_rect(reveal, MARGIN, BODY_TOP, BODY_W, Emu(int(BODY_H * 0.45)), TINT_ALT, shape=5)
_rect(reveal, MARGIN, BODY_TOP, Emu(182880), Emu(int(BODY_H * 0.45)), ACCENT_ALT)
box = _textbox(reveal, MARGIN + Emu(457200), BODY_TOP + Emu(228600), BODY_W - Emu(914400), Emu(int(BODY_H * 0.45)) - Emu(457200))
box.vertical_anchor = MSO_ANCHOR.MIDDLE
_run(box.paragraphs[0], answer, 24 if len(answer) <= 120 else 18, bold=True, color=INK)
why = (spec.get("explanation") or "").strip()
if why:
wf = _textbox(reveal, MARGIN, BODY_TOP + Emu(int(BODY_H * 0.45)) + Emu(274320), BODY_W, Emu(int(BODY_H * 0.5)))
for chunk, bold in _split_bold(why):
_run(wf.paragraphs[0], chunk, 18, bold=bold, color=INK)
return slides
def slide_figure(prs, spec):
"""A figure beside its text, rather than alone on a slide of its own."""
slide = _blank(prs)
_heading(slide, spec.get("heading") or "")
gutter = Emu(365760)
text_w = Emu(int((BODY_W - gutter) * 0.46))
img_w = Emu(int((BODY_W - gutter) * 0.54))
items = spec.get("bullets") or []
size = _fit_size(items, 0.42)
frame = _textbox(slide, MARGIN, BODY_TOP, text_w, BODY_H)
_bullets(frame, items, size, width_frac=0.42)
path = spec.get("image")
if path and os.path.exists(path):
ratio = 1.0
if Image is not None:
try:
with Image.open(path) as img:
if img.height:
ratio = img.width / float(img.height)
except Exception:
ratio = 1.0
width = int(img_w)
height = int(width / ratio) if ratio else int(BODY_H)
if height > int(BODY_H):
height = int(BODY_H)
width = int(height * ratio)
left = MARGIN + text_w + gutter + Emu(int((int(img_w) - width) / 2))
top = BODY_TOP + Emu(int((int(BODY_H) - height) / 2))
slide.shapes.add_picture(path, left, top, Emu(width), Emu(height))
_notes(slide, spec.get("notes"))
return slide
# ── Custom slides ────────────────────────────────────────────────────────
# The named layouts are a fixed vocabulary. A custom slide is a list of shapes
# placed in percentages of the slide, which is what lets a model follow an
# instruction the vocabulary has no word for — boxes with arrows between them, a
# timeline, a chart beside its commentary.
#
# Everything arriving here has already been validated: kinds are an allowlist,
# colours are six hex digits, coordinates are clamped inside the slide. This
# draws what it is given and nothing else.
AUTOSHAPES = {
"rect": MSO_SHAPE.RECTANGLE,
"roundRect": MSO_SHAPE.ROUNDED_RECTANGLE,
"ellipse": MSO_SHAPE.OVAL,
"arrow": MSO_SHAPE.RIGHT_ARROW,
"arrowDown": MSO_SHAPE.DOWN_ARROW,
"chevron": MSO_SHAPE.CHEVRON,
"diamond": MSO_SHAPE.DIAMOND,
"hexagon": MSO_SHAPE.HEXAGON,
}
CHART_TYPES = {
"column": XL_CHART_TYPE.COLUMN_CLUSTERED,
"bar": XL_CHART_TYPE.BAR_CLUSTERED,
"line": XL_CHART_TYPE.LINE,
"pie": XL_CHART_TYPE.PIE,
"doughnut": XL_CHART_TYPE.DOUGHNUT,
}
ALIGNMENTS = {"left": PP_ALIGN.LEFT, "center": PP_ALIGN.CENTER, "right": PP_ALIGN.RIGHT}
ANCHORS = {"top": MSO_ANCHOR.TOP, "middle": MSO_ANCHOR.MIDDLE, "bottom": MSO_ANCHOR.BOTTOM}
def pct(value, total):
return Emu(int(total * (float(value) / 100.0)))
def box(shape):
return (pct(shape.get("x", 0), SLIDE_W), pct(shape.get("y", 0), SLIDE_H),
pct(shape.get("w", 10), SLIDE_W), pct(shape.get("h", 10), SLIDE_H))
def rgb(value, fallback=None):
if not value:
return fallback
return RGBColor(int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16))
def write_runs(frame, shape, default_size=16):
frame.word_wrap = True
anchor = ANCHORS.get(shape.get("valign"))
if anchor is not None:
frame.vertical_anchor = anchor
first = True
for item in shape.get("runs") or []:
para = frame.paragraphs[0] if first else frame.add_paragraph()
first = False
alignment = ALIGNMENTS.get(item.get("align") or shape.get("align"))
if alignment is not None:
para.alignment = alignment
size = item.get("size") or default_size
para.space_after = Pt(size * 0.4)
level = int(item.get("level") or 0)
if level:
_hang(para, size, level)
if item.get("bullet"):
if not level:
_hang(para, size, 0)
_run(para, "", size, color=rgb(item.get("color"), ACCENT))
for chunk, bold in _split_bold(item.get("text") or ""):
_run(para, chunk, size, bold=bold or bool(item.get("bold")),
italic=bool(item.get("italic")), color=rgb(item.get("color"), INK))
def draw_autoshape(slide, shape, kind):
left, top, width, height = box(shape)
drawn = slide.shapes.add_shape(AUTOSHAPES[kind], left, top, width, height)
fill = rgb(shape.get("fill"))
if fill is None:
drawn.fill.background()
else:
drawn.fill.solid()
drawn.fill.fore_color.rgb = fill
line = rgb(shape.get("line"))
if line is None:
drawn.line.fill.background()
else:
drawn.line.color.rgb = line
drawn.line.width = Pt(shape.get("lineWidth") or 1)
drawn.shadow.inherit = False
if shape.get("rotation"):
drawn.rotation = float(shape["rotation"])
if shape.get("runs"):
write_runs(drawn.text_frame, shape)
return drawn
def draw_line(slide, shape):
left, top, width, height = box(shape)
connector = slide.shapes.add_connector(1, left, top, left + width, top + height) # straight
connector.line.color.rgb = rgb(shape.get("line"), RULE)
connector.line.width = Pt(shape.get("lineWidth") or 1.5)
def draw_text(slide, shape):
left, top, width, height = box(shape)
frame = _textbox(slide, left, top, width, height)
fill = rgb(shape.get("fill"))
if fill is not None:
# A textbox has no fill of its own; a rectangle behind it is the way.
backing = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, left, top, width, height)
backing.fill.solid()
backing.fill.fore_color.rgb = fill
backing.line.fill.background()
backing.shadow.inherit = False
frame._parent._element.addnext(backing._element) # keep the text on top
write_runs(frame, shape)
def draw_table(slide, shape):
left, top, width, height = box(shape)
header = shape.get("header") or []
rows = shape.get("rows") or []
cols = max(len(header), max((len(r) for r in rows), default=1))
count = len(rows) + (1 if header else 0)
table = slide.shapes.add_table(count, cols, left, top, width, height).table
size = 14 if count <= 6 else (12 if count <= 9 else 10)
offset = 0
if header:
for c in range(cols):
cell = table.cell(0, c)
cell.text = ""
_run(cell.text_frame.paragraphs[0], header[c] if c < len(header) else "", size, bold=True, color=PAPER)
cell.fill.solid()
cell.fill.fore_color.rgb = rgb(shape.get("fill"), ACCENT)
offset = 1
for r, row in enumerate(rows):
for c in range(cols):
cell = table.cell(r + offset, c)
cell.text = ""
_run(cell.text_frame.paragraphs[0], row[c] if c < len(row) else "", size, color=INK)
def draw_chart(slide, shape):
left, top, width, height = box(shape)
data = CategoryChartData()
data.categories = shape.get("categories") or []
for series in shape.get("series") or []:
data.add_series(series.get("name") or "Series", series.get("values") or [])
frame = slide.shapes.add_chart(CHART_TYPES.get(shape.get("chart"), XL_CHART_TYPE.COLUMN_CLUSTERED),
left, top, width, height, data)
chart = frame.chart
chart.has_title = False
if len(shape.get("series") or []) > 1 or shape.get("chart") in ("pie", "doughnut"):
chart.has_legend = True
chart.legend.position = XL_LEGEND_POSITION.BOTTOM
chart.legend.include_in_layout = False
def draw_image(slide, shape):
left, top, width, height = box(shape)
path = shape.get("image")
if not path or not os.path.exists(path):
return
if shape.get("fit") == "fill":
slide.shapes.add_picture(path, left, top, width, height)
return
ratio = 1.0
if Image is not None:
try:
with Image.open(path) as img:
if img.height:
ratio = img.width / float(img.height)
except Exception:
ratio = 1.0
draw_w, draw_h = int(width), int(int(width) / ratio) if ratio else int(height)
if draw_h > int(height):
draw_h = int(height)
draw_w = int(draw_h * ratio)
slide.shapes.add_picture(path, Emu(int(left) + (int(width) - draw_w) // 2),
Emu(int(top) + (int(height) - draw_h) // 2), Emu(draw_w), Emu(draw_h))
def slide_flow(prs, spec):
"""A flowchart. Laid out by the exporter (deckSchema.flowShapes) into
shapes before it gets here, so it is drawn as a custom slide; a bare flow
that arrives with steps only is drawn as the numbered list it is."""
if spec.get("shapes"):
return slide_custom(prs, spec)
steps = spec.get("steps") or []
return slide_bullets(prs, {"heading": spec.get("heading"), "notes": spec.get("notes"),
"bullets": [{"text": f"{i + 1}. {s.get('text', '')}", "level": 0}
for i, s in enumerate(steps) if isinstance(s, dict)]})
def slide_custom(prs, spec):
slide = _blank(prs)
if spec.get("heading"):
_heading(slide, spec["heading"])
for shape in spec.get("shapes") or []:
kind = shape.get("kind")
try:
if kind in AUTOSHAPES:
draw_autoshape(slide, shape, kind)
elif kind == "line":
draw_line(slide, shape)
elif kind == "table":
draw_table(slide, shape)
elif kind == "chart":
draw_chart(slide, shape)
elif kind == "image":
draw_image(slide, shape)
else:
draw_text(slide, shape)
except Exception as exc: # one bad shape must not cost the slide
print("skipped %s: %s" % (kind, exc), file=sys.stderr)
_notes(slide, spec.get("notes"))
return slide
BUILDERS = {
"title": slide_title,
"section": slide_section,
"bullets": slide_bullets,
"two": slide_two,
"table": slide_table,
"image": slide_image,
"compare": slide_compare,
"callout": slide_callout,
"question": slide_question,
"figure": slide_figure,
"flow": slide_flow,
"custom": slide_custom,
}
def main():
if len(sys.argv) < 2:
print("usage: render_pptx.py <output.pptx> (spec on stdin)", file=sys.stderr)
return 2
spec = json.load(sys.stdin)
apply_theme(spec.get("theme"))
prs = Presentation()
prs.slide_width = SLIDE_W
prs.slide_height = SLIDE_H
slides = spec.get("slides") or []
if not slides:
print("spec contains no slides", file=sys.stderr)
return 3
global DECK_TITLE, SECTION_NO
DECK_TITLE = str(spec.get("title") or "")
SECTION_NO = 0
built = []
for item in slides:
result = BUILDERS.get(item.get("type") or "bullets", slide_bullets)(prs, item)
built.extend(result if isinstance(result, list) else [result])
for number, slide in enumerate(built, start=1):
if slide is not None:
_footer(slide, number, len(built))
prs.save(sys.argv[1])
return 0
if __name__ == "__main__":
sys.exit(main())