Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 59s
Forgejo Docker Build / Root app tests (push) Successful in 53s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 8s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Markdown could express about five of the things the renderer can draw, so the model had no way to say "put this figure beside these three bullets" or "make this a comparison with two labelled columns" — my parser inferred a layout from the shape of a list, and inferring is what made every deck look the same. A presentation is now described as a deck: the model returns JSON naming a layout per slide and the prompt it wants each figure drawn from. Four layouts were added to the renderer for it — two tinted labelled columns for a comparison, a callout card for a red flag or a dose, a figure beside its bullets, and a full-slide figure. Articles stay markdown, which is what prose wants. Markdown is still produced, serialised from the deck, so Word export and text editing keep working and the stored artifact stays readable by a person. The deck is stored alongside it because that serialisation is lossy by design: round-tripping through markdown would throw away exactly the layout choices this was built to capture. A resource made before this, or an article forced into slides, still renders by inferring from its markdown. Nothing here can cost more than the thing that went wrong. A reply that is not a deck falls back to asking for markdown rather than saving the model's apology; a malformed slide degrades to bullets rather than throwing; a comparison with one column is not a comparison; a figure that cannot be queued leaves a slide of text rather than an empty frame; and JSON wrapped in fences or a covering sentence is read rather than refused. Verified live on "croup versus epiglottitis": the model chose section, bullets, table, compare, figure, callout and image layouts across thirteen slides, and the exported deck was rendered to PDF, rasterised and looked at — the comparison renders as two tinted cards, the red flag as a callout, and the figure sits beside its bullets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
464 lines
16 KiB
Python
464 lines
16 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.dml.color import RGBColor
|
||
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
|
||
|
||
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)
|
||
|
||
FONT = "Calibri"
|
||
|
||
# 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):
|
||
frame = _textbox(slide, MARGIN, HEADING_TOP, BODY_W, 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:
|
||
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)
|
||
frame = _textbox(slide, MARGIN, Emu(2057400), BODY_W, Emu(1828800))
|
||
frame.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||
para = frame.paragraphs[0]
|
||
heading = spec.get("heading") or "Untitled"
|
||
_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):
|
||
slide = _blank(prs)
|
||
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 []
|
||
size = _fit_size(items)
|
||
frame = _textbox(slide, MARGIN, BODY_TOP, BODY_W, BODY_H)
|
||
_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 = [RGBColor(0xEF, 0xF6, 0xFF), RGBColor(0xFE, 0xF3, 0xC7)]
|
||
edges = [ACCENT, RGBColor(0xD9, 0x77, 0x06)]
|
||
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 "")
|
||
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 = RGBColor(0xFE, 0xF3, 0xC7)
|
||
card.line.color.rgb = RGBColor(0xD9, 0x77, 0x06)
|
||
card.line.width = Pt(1.5)
|
||
card.shadow.inherit = False
|
||
card.text_frame.text = ""
|
||
|
||
text = (spec.get("text") or "").strip()
|
||
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
|
||
size = 28 if len(text) <= 90 else (22 if len(text) <= 180 else 18)
|
||
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_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
|
||
|
||
|
||
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,
|
||
"figure": slide_figure,
|
||
}
|
||
|
||
|
||
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)
|
||
|
||
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
|
||
|
||
for item in slides:
|
||
BUILDERS.get(item.get("type") or "bullets", slide_bullets)(prs, item)
|
||
|
||
prs.save(sys.argv[1])
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|