"Not all of it was saved: Workflow not found" on every press of Save availability. The card sent image settings for three workflows, and the server has only had two since Learning Hub was removed — the DB constraint allows clinical_assistant and my_resources and nothing else. One rejection failed the whole save, so the two settings that were valid looked unsaved as well. The frontend was the only place that still believed in it. Also gone: the learning_hub.image_behavior prompt, its Learning prompts section in the admin — which held that one prompt and nothing else — and the theme's card tints, which never applied. While there: My Resources had a Model dropdown labelled "set per request", permanently disabled and permanently empty. A control that can never do anything reads as broken rather than as not applicable, so it is now a sentence saying where the model is actually chosen. Its fallbacks stay — those apply to whichever model the request picked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
740 lines
26 KiB
Python
740 lines
26 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 []
|
||
|
||
|
||
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_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,
|
||
"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
|
||
|
||
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())
|