pediatric-ai-scribe-v3/scripts/render_pptx.py
Daniel 73ce4049d4
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 48s
Forgejo Docker Build / Root app tests (push) Successful in 59s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 25s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
feat: decks are built with python-pptx instead of pandoc, and carry their figures
Pandoc's pptx writer was the ceiling on how good a generated deck could be, and
the model on top made no difference to it. It maps markdown onto a handful of
reference layouts with no per-slide layout, no positioning and no control over
how large an image is drawn, which is why every deck came out as bullets on a
template — and why autofit had to be injected into its emitted OOXML by hand
afterwards, because LibreOffice ignores the autofit pandoc leaves off.

scripts/render_pptx.py draws the deck and src/utils/slideSpec.js decides what
each slide is. Markdown stays the stored artifact, so "change slide 4" is still
a text edit and Word export is untouched — pandoc still writes docx, where its
output is good.

What that buys, all of it visible in a rendered deck rather than argued for:

- 16:9, not pandoc's 4:3.
- A pipe table becomes a real table with a header band and banded rows, not
  eight lines of text with pipes in them.
- A list longer than seven items becomes two columns instead of a wall of text.
- Text is measured and sized to fit before the file is written, so nothing
  depends on a renderer honouring autofit.
- Wrapped lines hang under the text instead of running back to the margin,
  which is the clearest single tell that a deck was generated.
- An image is drawn at its own aspect ratio, centred, with a caption.

Figures now reach the deck at all, which they never did. They were queued and
shown on the page, but nothing recorded that they belonged to the resource, so
an export could not include them: user_resources.image_ids holds them, a
modification adds to that list rather than replacing it, and export fetches the
finished ones to a scratch directory. They are spread through the deck rather
than appended, because ending on three unexplained pictures is worse than
showing each near its material, and a References slide stays last.

If the renderer fails for any reason, pandoc still produces a deck — a plainer
deck beats a failed download.

Verified end to end: a seven-slide request with three figures exported as a
13-page deck; the slides were rendered to PDF, rasterised and looked at. All
three formats still download.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 19:34:26 +02:00

365 lines
12 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.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
BUILDERS = {
"title": slide_title,
"section": slide_section,
"bullets": slide_bullets,
"two": slide_two,
"table": slide_table,
"image": slide_image,
}
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())