#!/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 (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())