#!/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 [] 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 return theme return None # 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 = [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 "") 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 = "" 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 # ── 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, "figure": slide_figure, "flow": slide_flow, "custom": slide_custom, } 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) 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 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())