Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
946 lines
36 KiB
Python
946 lines
36 KiB
Python
#!/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())
|