feat: templates, not just palettes — colour title and section pages, heading bands, striped callouts, question slides with answers, key-point strips, page footers; Grand Rounds and Board Review join the catalogue
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
parent
9c86c56968
commit
de13c8306b
8 changed files with 373 additions and 22 deletions
|
|
@ -4,7 +4,7 @@
|
||||||
{
|
{
|
||||||
"id": "clinical-blue",
|
"id": "clinical-blue",
|
||||||
"name": "Clinical Blue",
|
"name": "Clinical Blue",
|
||||||
"description": "The default. Calm, high-contrast, reads well on a projector.",
|
"description": "The default. Calm, high-contrast, reads well on a projector. Key-point strips under slides, question panels for check-yourself moments.",
|
||||||
"accent": "2563EB",
|
"accent": "2563EB",
|
||||||
"ink": "1F2937",
|
"ink": "1F2937",
|
||||||
"muted": "4B5563",
|
"muted": "4B5563",
|
||||||
|
|
@ -13,12 +13,21 @@
|
||||||
"font": "Calibri",
|
"font": "Calibri",
|
||||||
"tint": "EFF6FF",
|
"tint": "EFF6FF",
|
||||||
"tint_alt": "FEF3C7",
|
"tint_alt": "FEF3C7",
|
||||||
"accent_alt": "D97706"
|
"accent_alt": "D97706",
|
||||||
|
"style": {
|
||||||
|
"title": "band",
|
||||||
|
"section": "quiet",
|
||||||
|
"heading": "rule",
|
||||||
|
"callout": "card",
|
||||||
|
"question": "panel",
|
||||||
|
"footer": false,
|
||||||
|
"takeaway": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "teaching-amber",
|
"id": "teaching-amber",
|
||||||
"name": "Teaching Amber",
|
"name": "Teaching Amber",
|
||||||
"description": "Warmer and less clinical. Good for sessions with parents or students.",
|
"description": "Warmer and less clinical, for sessions with parents or students. Split title page, numbered section bands, striped callouts.",
|
||||||
"accent": "D97706",
|
"accent": "D97706",
|
||||||
"ink": "1C1917",
|
"ink": "1C1917",
|
||||||
"muted": "57534E",
|
"muted": "57534E",
|
||||||
|
|
@ -27,12 +36,21 @@
|
||||||
"font": "Calibri",
|
"font": "Calibri",
|
||||||
"tint": "FEF3C7",
|
"tint": "FEF3C7",
|
||||||
"tint_alt": "EFF6FF",
|
"tint_alt": "EFF6FF",
|
||||||
"accent_alt": "2563EB"
|
"accent_alt": "2563EB",
|
||||||
|
"style": {
|
||||||
|
"title": "split",
|
||||||
|
"section": "band",
|
||||||
|
"heading": "bar",
|
||||||
|
"callout": "stripe",
|
||||||
|
"question": "panel",
|
||||||
|
"footer": false,
|
||||||
|
"takeaway": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "ward-teal",
|
"id": "ward-teal",
|
||||||
"name": "Ward Teal",
|
"name": "Ward Teal",
|
||||||
"description": "Quieter than blue, still clearly clinical.",
|
"description": "Quieter than blue, still clearly clinical. Page numbers on every slide; questions get a spotlight slide of their own.",
|
||||||
"accent": "0F766E",
|
"accent": "0F766E",
|
||||||
"ink": "134E4A",
|
"ink": "134E4A",
|
||||||
"muted": "4B5563",
|
"muted": "4B5563",
|
||||||
|
|
@ -41,12 +59,21 @@
|
||||||
"font": "Calibri",
|
"font": "Calibri",
|
||||||
"tint": "CCFBF1",
|
"tint": "CCFBF1",
|
||||||
"tint_alt": "FEF3C7",
|
"tint_alt": "FEF3C7",
|
||||||
"accent_alt": "D97706"
|
"accent_alt": "D97706",
|
||||||
|
"style": {
|
||||||
|
"title": "band",
|
||||||
|
"section": "quiet",
|
||||||
|
"heading": "rule",
|
||||||
|
"callout": "card",
|
||||||
|
"question": "spotlight",
|
||||||
|
"footer": true,
|
||||||
|
"takeaway": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "slate",
|
"id": "slate",
|
||||||
"name": "Slate",
|
"name": "Slate",
|
||||||
"description": "Almost monochrome. Lets figures and tables carry the colour.",
|
"description": "Almost monochrome, so figures and tables carry the colour. Striped callouts, page numbers, no key-point strips.",
|
||||||
"accent": "475569",
|
"accent": "475569",
|
||||||
"ink": "0F172A",
|
"ink": "0F172A",
|
||||||
"muted": "64748B",
|
"muted": "64748B",
|
||||||
|
|
@ -55,12 +82,21 @@
|
||||||
"font": "Calibri",
|
"font": "Calibri",
|
||||||
"tint": "F1F5F9",
|
"tint": "F1F5F9",
|
||||||
"tint_alt": "E2E8F0",
|
"tint_alt": "E2E8F0",
|
||||||
"accent_alt": "64748B"
|
"accent_alt": "64748B",
|
||||||
|
"style": {
|
||||||
|
"title": "band",
|
||||||
|
"section": "quiet",
|
||||||
|
"heading": "rule",
|
||||||
|
"callout": "stripe",
|
||||||
|
"question": "panel",
|
||||||
|
"footer": true,
|
||||||
|
"takeaway": false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "high-contrast",
|
"id": "high-contrast",
|
||||||
"name": "High Contrast",
|
"name": "High Contrast",
|
||||||
"description": "For a bright room or a poor projector. Heavier ink, stronger rules.",
|
"description": "For a bright room or a poor projector: full-colour title and section pages, headings on bands, heavier ink.",
|
||||||
"accent": "B91C1C",
|
"accent": "B91C1C",
|
||||||
"ink": "000000",
|
"ink": "000000",
|
||||||
"muted": "27272A",
|
"muted": "27272A",
|
||||||
|
|
@ -69,7 +105,62 @@
|
||||||
"font": "Calibri",
|
"font": "Calibri",
|
||||||
"tint": "FEE2E2",
|
"tint": "FEE2E2",
|
||||||
"tint_alt": "F3F4F6",
|
"tint_alt": "F3F4F6",
|
||||||
"accent_alt": "111827"
|
"accent_alt": "111827",
|
||||||
|
"style": {
|
||||||
|
"title": "full",
|
||||||
|
"section": "band",
|
||||||
|
"heading": "band",
|
||||||
|
"callout": "card",
|
||||||
|
"question": "spotlight",
|
||||||
|
"footer": true,
|
||||||
|
"takeaway": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "grand-rounds",
|
||||||
|
"name": "Grand Rounds",
|
||||||
|
"description": "For a lecture theatre: deep navy title and section pages with big numbers, page numbers, striped callouts, key points under each slide.",
|
||||||
|
"ink": "0F172A",
|
||||||
|
"muted": "475569",
|
||||||
|
"accent": "1E3A8A",
|
||||||
|
"accent_alt": "B45309",
|
||||||
|
"rule": "CBD5E1",
|
||||||
|
"paper": "FFFFFF",
|
||||||
|
"tint": "E0E7FF",
|
||||||
|
"tint_alt": "FEF3C7",
|
||||||
|
"font": "Calibri",
|
||||||
|
"style": {
|
||||||
|
"title": "full",
|
||||||
|
"section": "band",
|
||||||
|
"heading": "bar",
|
||||||
|
"callout": "stripe",
|
||||||
|
"question": "spotlight",
|
||||||
|
"footer": true,
|
||||||
|
"takeaway": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "board-review",
|
||||||
|
"name": "Board Review",
|
||||||
|
"description": "Built around checking yourself: question panels with lettered options and an answer slide, a key point under every slide, headings on bands.",
|
||||||
|
"ink": "1F2937",
|
||||||
|
"muted": "6B7280",
|
||||||
|
"accent": "7C3AED",
|
||||||
|
"accent_alt": "059669",
|
||||||
|
"rule": "E5E7EB",
|
||||||
|
"paper": "FFFFFF",
|
||||||
|
"tint": "F3E8FF",
|
||||||
|
"tint_alt": "D1FAE5",
|
||||||
|
"font": "Calibri",
|
||||||
|
"style": {
|
||||||
|
"title": "split",
|
||||||
|
"section": "band",
|
||||||
|
"heading": "band",
|
||||||
|
"callout": "card",
|
||||||
|
"question": "panel",
|
||||||
|
"footer": true,
|
||||||
|
"takeaway": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -172,6 +172,34 @@ falls back to asking for markdown; a malformed slide degrades to bullets; a
|
||||||
comparison with one column is not a comparison; JSON wrapped in fences or a
|
comparison with one column is not a comparison; JSON wrapped in fences or a
|
||||||
covering sentence is read rather than refused.
|
covering sentence is read rather than refused.
|
||||||
|
|
||||||
|
## Templates
|
||||||
|
|
||||||
|
A template is a theme plus a layout style, all in `assets/deck-themes.json`
|
||||||
|
and all drawn by `scripts/render_pptx.py` — nothing about the look depends on
|
||||||
|
the model, which only ever writes the slide content. Seven ship:
|
||||||
|
|
||||||
|
| Template | What it does beyond its colours |
|
||||||
|
|---|---|
|
||||||
|
| Clinical Blue | Plain title page, rule under headings, amber callout card, key-point strips, question panels |
|
||||||
|
| Teaching Amber | Split title page, numbered colour section pages, accent bar beside headings, striped callouts |
|
||||||
|
| Ward Teal | Page numbers on every slide, questions on a dark spotlight slide |
|
||||||
|
| Slate | Monochrome; striped callouts, page numbers, no key-point strips |
|
||||||
|
| High Contrast | Full-colour title and section pages, headings on bands, spotlight questions |
|
||||||
|
| Grand Rounds | Deep navy title and numbered section pages, striped callouts, key points, page numbers |
|
||||||
|
| Board Review | Built around checking yourself: question panels, an answer slide after each, a key point under every slide |
|
||||||
|
|
||||||
|
The `style` block of a theme has seven keys, each with the value the renderer
|
||||||
|
always had as its default: `title` (`band` / `full` / `split`), `section`
|
||||||
|
(`quiet` / `band`), `heading` (`rule` / `bar` / `band`), `callout` (`card` /
|
||||||
|
`stripe`), `question` (`panel` / `spotlight`), `footer` and `takeaway`
|
||||||
|
(booleans). Two things the content can carry for the templates to draw: a
|
||||||
|
`question` slide (`question`, lettered `options`, an `answer` and its
|
||||||
|
`explanation`, drawn as a "?" slide followed by an answer slide) and a
|
||||||
|
`takeaway` line on any content slide, drawn as a key-point strip under the
|
||||||
|
body where the template allows it and carried into the Word and PDF exports
|
||||||
|
as a bold closing line either way. The sample deck under every theme shows
|
||||||
|
all of it.
|
||||||
|
|
||||||
## Illustrations
|
## Illustrations
|
||||||
|
|
||||||
`resourceImages.js`, **not** the shared `imageTool.dispatch` — that one permits
|
`resourceImages.js`, **not** the shared `imageTool.dispatch` — that one permits
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -96,6 +96,16 @@ def load_themes():
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# What a template does beyond its colours. Every key has the value the
|
||||||
|
# renderer always had, so a theme without a style block looks as it always did.
|
||||||
|
STYLE = {"title": "band", "section": "quiet", "heading": "rule", "callout": "card",
|
||||||
|
"question": "panel", "footer": False, "takeaway": False}
|
||||||
|
DECK_TITLE = ""
|
||||||
|
SECTION_NO = 0
|
||||||
|
FOOTER_H = Emu(320040) # 0.35"
|
||||||
|
TAKEAWAY_H = Emu(640080) # 0.7"
|
||||||
|
|
||||||
|
|
||||||
def apply_theme(theme_id):
|
def apply_theme(theme_id):
|
||||||
"""Rebind the palette. An unknown or missing id leaves the default in place,
|
"""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."""
|
because a deck rendering in the wrong colours beats a deck not rendering."""
|
||||||
|
|
@ -119,9 +129,56 @@ def apply_theme(theme_id):
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
FONT = theme.get("font") or FONT
|
FONT = theme.get("font") or FONT
|
||||||
|
style = theme.get("style") or {}
|
||||||
|
for key in list(STYLE):
|
||||||
|
if key in style:
|
||||||
|
STYLE[key] = style[key]
|
||||||
return theme
|
return theme
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _rect(slide, left, top, width, height, fill, shape=1):
|
||||||
|
box = slide.shapes.add_shape(shape, left, top, width, height)
|
||||||
|
box.fill.solid()
|
||||||
|
box.fill.fore_color.rgb = fill
|
||||||
|
box.line.fill.background()
|
||||||
|
box.shadow.inherit = False
|
||||||
|
box.text_frame.text = ""
|
||||||
|
return box
|
||||||
|
|
||||||
|
|
||||||
|
def _footer(slide, number, total):
|
||||||
|
"""Deck title left, page number right, in the margin below the body."""
|
||||||
|
if not STYLE.get("footer") or number <= 1:
|
||||||
|
return
|
||||||
|
top = SLIDE_H - FOOTER_H
|
||||||
|
left = _textbox(slide, MARGIN, top, Emu(int(BODY_W * 0.7)), FOOTER_H)
|
||||||
|
left.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||||
|
_run(left.paragraphs[0], DECK_TITLE[:90], 10, color=MUTED)
|
||||||
|
right = _textbox(slide, MARGIN + Emu(int(BODY_W * 0.7)), top, Emu(int(BODY_W * 0.3)), FOOTER_H)
|
||||||
|
right.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||||
|
para = right.paragraphs[0]
|
||||||
|
para.alignment = PP_ALIGN.RIGHT
|
||||||
|
_run(para, "%d / %d" % (number, total), 10, color=MUTED)
|
||||||
|
|
||||||
|
|
||||||
|
def _takeaway(slide, text):
|
||||||
|
"""One line the audience should leave with, on a strip under the body.
|
||||||
|
Returns how much body height it took, so the builder can shrink its frame."""
|
||||||
|
text = (text or "").strip()
|
||||||
|
if not text or not STYLE.get("takeaway"):
|
||||||
|
return Emu(0)
|
||||||
|
top = SLIDE_H - MARGIN - TAKEAWAY_H
|
||||||
|
_rect(slide, MARGIN, top, BODY_W, TAKEAWAY_H, TINT, shape=5)
|
||||||
|
_rect(slide, MARGIN, top, Emu(91440), TAKEAWAY_H, ACCENT)
|
||||||
|
frame = _textbox(slide, MARGIN + Emu(228600), top, BODY_W - Emu(457200), TAKEAWAY_H)
|
||||||
|
frame.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||||
|
para = frame.paragraphs[0]
|
||||||
|
_run(para, "Key point ", 12, bold=True, color=ACCENT)
|
||||||
|
for chunk, bold in _split_bold(text):
|
||||||
|
_run(para, chunk, 16 if len(text) <= 110 else 14, bold=bold, color=INK)
|
||||||
|
return TAKEAWAY_H + Emu(182880)
|
||||||
|
|
||||||
# Text is sized to fit rather than left to the renderer's autofit, which only
|
# 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
|
# some viewers honour and which LibreOffice ignores entirely when converting to
|
||||||
# PDF — the reason slides were being cut off mid-sentence.
|
# PDF — the reason slides were being cut off mid-sentence.
|
||||||
|
|
@ -156,13 +213,21 @@ def _run(paragraph, text, size, bold=False, color=INK, italic=False):
|
||||||
|
|
||||||
|
|
||||||
def _heading(slide, text, accent_rule=True):
|
def _heading(slide, text, accent_rule=True):
|
||||||
frame = _textbox(slide, MARGIN, HEADING_TOP, BODY_W, HEADING_H)
|
style = STYLE.get("heading") or "rule"
|
||||||
|
left = MARGIN
|
||||||
|
if style == "band":
|
||||||
|
_rect(slide, Emu(0), Emu(0), SLIDE_W, HEADING_TOP + HEADING_H + Emu(91440), TINT)
|
||||||
|
_rect(slide, Emu(0), Emu(0), Emu(137160), HEADING_TOP + HEADING_H + Emu(91440), ACCENT)
|
||||||
|
elif style == "bar":
|
||||||
|
_rect(slide, MARGIN, HEADING_TOP + Emu(182880), Emu(91440), HEADING_H - Emu(182880), ACCENT)
|
||||||
|
left = MARGIN + Emu(274320)
|
||||||
|
frame = _textbox(slide, left, HEADING_TOP, BODY_W - (left - MARGIN), HEADING_H)
|
||||||
frame.vertical_anchor = MSO_ANCHOR.BOTTOM
|
frame.vertical_anchor = MSO_ANCHOR.BOTTOM
|
||||||
para = frame.paragraphs[0]
|
para = frame.paragraphs[0]
|
||||||
# Long headings shrink rather than wrapping into the body.
|
# Long headings shrink rather than wrapping into the body.
|
||||||
size = 34 if len(text) <= 52 else (30 if len(text) <= 74 else 26)
|
size = 34 if len(text) <= 52 else (30 if len(text) <= 74 else 26)
|
||||||
_run(para, text, size, bold=True)
|
_run(para, text, size, bold=True)
|
||||||
if accent_rule:
|
if accent_rule and style == "rule":
|
||||||
bar = slide.shapes.add_shape(1, MARGIN, HEADING_TOP + HEADING_H + Emu(45720), Emu(548640), Emu(45720))
|
bar = slide.shapes.add_shape(1, MARGIN, HEADING_TOP + HEADING_H + Emu(45720), Emu(548640), Emu(45720))
|
||||||
bar.fill.solid()
|
bar.fill.solid()
|
||||||
bar.fill.fore_color.rgb = ACCENT
|
bar.fill.fore_color.rgb = ACCENT
|
||||||
|
|
@ -251,10 +316,42 @@ def _notes(slide, text):
|
||||||
|
|
||||||
def slide_title(prs, spec):
|
def slide_title(prs, spec):
|
||||||
slide = _blank(prs)
|
slide = _blank(prs)
|
||||||
|
style = STYLE.get("title") or "band"
|
||||||
|
heading = spec.get("heading") or "Untitled"
|
||||||
|
if style == "full":
|
||||||
|
_rect(slide, Emu(0), Emu(0), SLIDE_W, SLIDE_H, ACCENT)
|
||||||
|
_rect(slide, MARGIN, Emu(1874520), Emu(1097280), Emu(54864), PAPER)
|
||||||
|
frame = _textbox(slide, MARGIN, Emu(2057400), BODY_W, Emu(2286000))
|
||||||
|
frame.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||||
|
para = frame.paragraphs[0]
|
||||||
|
_run(para, heading, 44 if len(heading) <= 60 else 36, bold=True, color=PAPER)
|
||||||
|
for line in [spec.get("subtitle"), spec.get("date")]:
|
||||||
|
if line:
|
||||||
|
sub = frame.add_paragraph()
|
||||||
|
sub.space_before = Pt(10)
|
||||||
|
_run(sub, line, 18, color=PAPER)
|
||||||
|
return slide
|
||||||
|
if style == "split":
|
||||||
|
panel_w = Emu(int(SLIDE_W * 0.42))
|
||||||
|
_rect(slide, Emu(0), Emu(0), panel_w, SLIDE_H, ACCENT)
|
||||||
|
frame = _textbox(slide, MARGIN, Emu(1600200), panel_w - MARGIN - Emu(274320), Emu(3657600))
|
||||||
|
frame.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||||
|
_run(frame.paragraphs[0], heading, 36 if len(heading) <= 60 else 30, bold=True, color=PAPER)
|
||||||
|
right = _textbox(slide, panel_w + Emu(548640), Emu(2514600), SLIDE_W - panel_w - Emu(548640) - MARGIN, Emu(1828800))
|
||||||
|
right.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||||
|
first = True
|
||||||
|
for line in [spec.get("subtitle"), spec.get("date")]:
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
para = right.paragraphs[0] if first else right.add_paragraph()
|
||||||
|
if not first:
|
||||||
|
para.space_before = Pt(10)
|
||||||
|
_run(para, line, 20 if first else 16, color=INK if first else MUTED)
|
||||||
|
first = False
|
||||||
|
return slide
|
||||||
frame = _textbox(slide, MARGIN, Emu(2057400), BODY_W, Emu(1828800))
|
frame = _textbox(slide, MARGIN, Emu(2057400), BODY_W, Emu(1828800))
|
||||||
frame.vertical_anchor = MSO_ANCHOR.MIDDLE
|
frame.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||||
para = frame.paragraphs[0]
|
para = frame.paragraphs[0]
|
||||||
heading = spec.get("heading") or "Untitled"
|
|
||||||
_run(para, heading, 44 if len(heading) <= 60 else 36, bold=True)
|
_run(para, heading, 44 if len(heading) <= 60 else 36, bold=True)
|
||||||
for line in [spec.get("subtitle"), spec.get("date")]:
|
for line in [spec.get("subtitle"), spec.get("date")]:
|
||||||
if not line:
|
if not line:
|
||||||
|
|
@ -271,7 +368,19 @@ def slide_title(prs, spec):
|
||||||
|
|
||||||
|
|
||||||
def slide_section(prs, spec):
|
def slide_section(prs, spec):
|
||||||
|
global SECTION_NO
|
||||||
|
SECTION_NO += 1
|
||||||
slide = _blank(prs)
|
slide = _blank(prs)
|
||||||
|
if (STYLE.get("section") or "quiet") == "band":
|
||||||
|
_rect(slide, Emu(0), Emu(0), SLIDE_W, SLIDE_H, ACCENT)
|
||||||
|
number = _textbox(slide, MARGIN, Emu(1371600), BODY_W, Emu(1600200))
|
||||||
|
number.vertical_anchor = MSO_ANCHOR.BOTTOM
|
||||||
|
_run(number.paragraphs[0], "%02d" % SECTION_NO, 72, bold=True, color=PAPER)
|
||||||
|
_rect(slide, MARGIN, Emu(3017520), Emu(1097280), Emu(54864), PAPER)
|
||||||
|
frame = _textbox(slide, MARGIN, Emu(3200400), BODY_W, Emu(1828800))
|
||||||
|
frame.vertical_anchor = MSO_ANCHOR.TOP
|
||||||
|
_run(frame.paragraphs[0], spec.get("heading") or "", 36, bold=True, color=PAPER)
|
||||||
|
return slide
|
||||||
frame = _textbox(slide, MARGIN, Emu(2743200), BODY_W, Emu(1371600))
|
frame = _textbox(slide, MARGIN, Emu(2743200), BODY_W, Emu(1371600))
|
||||||
frame.vertical_anchor = MSO_ANCHOR.MIDDLE
|
frame.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||||
para = frame.paragraphs[0]
|
para = frame.paragraphs[0]
|
||||||
|
|
@ -283,8 +392,9 @@ def slide_bullets(prs, spec):
|
||||||
slide = _blank(prs)
|
slide = _blank(prs)
|
||||||
_heading(slide, spec.get("heading") or "")
|
_heading(slide, spec.get("heading") or "")
|
||||||
items = spec.get("bullets") or []
|
items = spec.get("bullets") or []
|
||||||
size = _fit_size(items)
|
taken = _takeaway(slide, spec.get("takeaway"))
|
||||||
frame = _textbox(slide, MARGIN, BODY_TOP, BODY_W, BODY_H)
|
size = _fit_size(items, available_h_emu=BODY_H - taken)
|
||||||
|
frame = _textbox(slide, MARGIN, BODY_TOP, BODY_W, BODY_H - taken)
|
||||||
_bullets(frame, items, size)
|
_bullets(frame, items, size)
|
||||||
_notes(slide, spec.get("notes"))
|
_notes(slide, spec.get("notes"))
|
||||||
return slide
|
return slide
|
||||||
|
|
@ -435,6 +545,25 @@ def slide_callout(prs, spec):
|
||||||
"""One thing worth stopping on: a red flag, a dose, a rule of thumb."""
|
"""One thing worth stopping on: a red flag, a dose, a rule of thumb."""
|
||||||
slide = _blank(prs)
|
slide = _blank(prs)
|
||||||
_heading(slide, spec.get("heading") or "")
|
_heading(slide, spec.get("heading") or "")
|
||||||
|
text = (spec.get("text") or "").strip()
|
||||||
|
size = 28 if len(text) <= 90 else (22 if len(text) <= 180 else 18)
|
||||||
|
if (STYLE.get("callout") or "card") == "stripe":
|
||||||
|
# A white card with a thick accent stripe and a mark in the margin.
|
||||||
|
card_h = Emu(int(BODY_H * 0.62))
|
||||||
|
_rect(slide, MARGIN, BODY_TOP, BODY_W, card_h, TINT, shape=5)
|
||||||
|
_rect(slide, MARGIN, BODY_TOP, Emu(182880), card_h, ACCENT_ALT)
|
||||||
|
mark = _rect(slide, MARGIN + Emu(457200), BODY_TOP + Emu(int(card_h / 2)) - Emu(320040), Emu(640080), Emu(640080), ACCENT_ALT, shape=9)
|
||||||
|
mp = mark.text_frame.paragraphs[0]
|
||||||
|
mp.alignment = PP_ALIGN.CENTER
|
||||||
|
_run(mp, "!", 28, bold=True, color=PAPER)
|
||||||
|
frame = _textbox(slide, MARGIN + Emu(1371600), BODY_TOP + Emu(365760),
|
||||||
|
Emu(int(BODY_W) - 1371600 - 457200), card_h - Emu(731520))
|
||||||
|
frame.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||||
|
para = frame.paragraphs[0]
|
||||||
|
for chunk, bold in _split_bold(text):
|
||||||
|
_run(para, chunk, size, bold=True, color=INK)
|
||||||
|
_notes(slide, spec.get("notes"))
|
||||||
|
return slide
|
||||||
card = slide.shapes.add_shape(5, MARGIN, BODY_TOP, BODY_W, Emu(int(BODY_H * 0.62)))
|
card = slide.shapes.add_shape(5, MARGIN, BODY_TOP, BODY_W, Emu(int(BODY_H * 0.62)))
|
||||||
card.fill.solid()
|
card.fill.solid()
|
||||||
card.fill.fore_color.rgb = TINT_ALT
|
card.fill.fore_color.rgb = TINT_ALT
|
||||||
|
|
@ -443,19 +572,74 @@ def slide_callout(prs, spec):
|
||||||
card.shadow.inherit = False
|
card.shadow.inherit = False
|
||||||
card.text_frame.text = ""
|
card.text_frame.text = ""
|
||||||
|
|
||||||
text = (spec.get("text") or "").strip()
|
|
||||||
frame = _textbox(slide, MARGIN + Emu(457200), BODY_TOP + Emu(365760),
|
frame = _textbox(slide, MARGIN + Emu(457200), BODY_TOP + Emu(365760),
|
||||||
Emu(int(BODY_W) - 914400), Emu(int(BODY_H * 0.62) - 731520))
|
Emu(int(BODY_W) - 914400), Emu(int(BODY_H * 0.62) - 731520))
|
||||||
frame.vertical_anchor = MSO_ANCHOR.MIDDLE
|
frame.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||||
para = frame.paragraphs[0]
|
para = frame.paragraphs[0]
|
||||||
para.alignment = PP_ALIGN.CENTER
|
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):
|
for chunk, bold in _split_bold(text):
|
||||||
_run(para, chunk, size, bold=bold or True, color=RGBColor(0x78, 0x35, 0x0F))
|
_run(para, chunk, size, bold=bold or True, color=RGBColor(0x78, 0x35, 0x0F))
|
||||||
_notes(slide, spec.get("notes"))
|
_notes(slide, spec.get("notes"))
|
||||||
return slide
|
return slide
|
||||||
|
|
||||||
|
|
||||||
|
def slide_question(prs, spec):
|
||||||
|
"""A check-yourself moment: the question with its options, a big mark to say
|
||||||
|
so, and — when an answer was given — the answer on the slide after, so the
|
||||||
|
room can commit before it is shown."""
|
||||||
|
slides = []
|
||||||
|
question = (spec.get("question") or "").strip()
|
||||||
|
options = [o for o in (spec.get("options") or []) if o]
|
||||||
|
style = STYLE.get("question") or "panel"
|
||||||
|
slide = _blank(prs)
|
||||||
|
slides.append(slide)
|
||||||
|
if style == "spotlight":
|
||||||
|
_rect(slide, Emu(0), Emu(0), SLIDE_W, SLIDE_H, INK)
|
||||||
|
fg, muted, panel = PAPER, RULE, ACCENT
|
||||||
|
_heading_color = PAPER
|
||||||
|
else:
|
||||||
|
fg, muted, panel = INK, MUTED, TINT
|
||||||
|
if style == "spotlight":
|
||||||
|
head = _textbox(slide, MARGIN, HEADING_TOP, BODY_W, HEADING_H)
|
||||||
|
head.vertical_anchor = MSO_ANCHOR.BOTTOM
|
||||||
|
_run(head.paragraphs[0], spec.get("heading") or "Check yourself", 30, bold=True, color=PAPER)
|
||||||
|
else:
|
||||||
|
_heading(slide, spec.get("heading") or "Check yourself")
|
||||||
|
mark_w = Emu(1828800)
|
||||||
|
mark = _rect(slide, MARGIN, BODY_TOP, mark_w, mark_w, panel, shape=9)
|
||||||
|
mp = mark.text_frame.paragraphs[0]
|
||||||
|
mp.alignment = PP_ALIGN.CENTER
|
||||||
|
mark.text_frame.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||||
|
_run(mp, "?", 80, bold=True, color=PAPER if style == "spotlight" else ACCENT)
|
||||||
|
frame = _textbox(slide, MARGIN + mark_w + Emu(457200), BODY_TOP, BODY_W - mark_w - Emu(457200), BODY_H)
|
||||||
|
para = frame.paragraphs[0]
|
||||||
|
_run(para, question, 26 if len(question) <= 120 else (22 if len(question) <= 220 else 18), bold=True, color=fg)
|
||||||
|
letters = "ABCDEFGH"
|
||||||
|
for i, option in enumerate(options[:6]):
|
||||||
|
p = frame.add_paragraph()
|
||||||
|
p.space_before = Pt(10)
|
||||||
|
_run(p, letters[i] + " ", 18, bold=True, color=ACCENT if style != "spotlight" else RULE)
|
||||||
|
_run(p, option, 18, color=fg)
|
||||||
|
_notes(slide, spec.get("notes"))
|
||||||
|
|
||||||
|
answer = (spec.get("answer") or "").strip()
|
||||||
|
if answer:
|
||||||
|
reveal = _blank(prs)
|
||||||
|
slides.append(reveal)
|
||||||
|
_heading(reveal, "Answer")
|
||||||
|
_rect(reveal, MARGIN, BODY_TOP, BODY_W, Emu(int(BODY_H * 0.45)), TINT_ALT, shape=5)
|
||||||
|
_rect(reveal, MARGIN, BODY_TOP, Emu(182880), Emu(int(BODY_H * 0.45)), ACCENT_ALT)
|
||||||
|
box = _textbox(reveal, MARGIN + Emu(457200), BODY_TOP + Emu(228600), BODY_W - Emu(914400), Emu(int(BODY_H * 0.45)) - Emu(457200))
|
||||||
|
box.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||||
|
_run(box.paragraphs[0], answer, 24 if len(answer) <= 120 else 18, bold=True, color=INK)
|
||||||
|
why = (spec.get("explanation") or "").strip()
|
||||||
|
if why:
|
||||||
|
wf = _textbox(reveal, MARGIN, BODY_TOP + Emu(int(BODY_H * 0.45)) + Emu(274320), BODY_W, Emu(int(BODY_H * 0.5)))
|
||||||
|
for chunk, bold in _split_bold(why):
|
||||||
|
_run(wf.paragraphs[0], chunk, 18, bold=bold, color=INK)
|
||||||
|
return slides
|
||||||
|
|
||||||
|
|
||||||
def slide_figure(prs, spec):
|
def slide_figure(prs, spec):
|
||||||
"""A figure beside its text, rather than alone on a slide of its own."""
|
"""A figure beside its text, rather than alone on a slide of its own."""
|
||||||
slide = _blank(prs)
|
slide = _blank(prs)
|
||||||
|
|
@ -720,6 +904,7 @@ BUILDERS = {
|
||||||
"image": slide_image,
|
"image": slide_image,
|
||||||
"compare": slide_compare,
|
"compare": slide_compare,
|
||||||
"callout": slide_callout,
|
"callout": slide_callout,
|
||||||
|
"question": slide_question,
|
||||||
"figure": slide_figure,
|
"figure": slide_figure,
|
||||||
"flow": slide_flow,
|
"flow": slide_flow,
|
||||||
"custom": slide_custom,
|
"custom": slide_custom,
|
||||||
|
|
@ -742,8 +927,16 @@ def main():
|
||||||
print("spec contains no slides", file=sys.stderr)
|
print("spec contains no slides", file=sys.stderr)
|
||||||
return 3
|
return 3
|
||||||
|
|
||||||
|
global DECK_TITLE, SECTION_NO
|
||||||
|
DECK_TITLE = str(spec.get("title") or "")
|
||||||
|
SECTION_NO = 0
|
||||||
|
built = []
|
||||||
for item in slides:
|
for item in slides:
|
||||||
BUILDERS.get(item.get("type") or "bullets", slide_bullets)(prs, item)
|
result = BUILDERS.get(item.get("type") or "bullets", slide_bullets)(prs, item)
|
||||||
|
built.extend(result if isinstance(result, list) else [result])
|
||||||
|
for number, slide in enumerate(built, start=1):
|
||||||
|
if slide is not None:
|
||||||
|
_footer(slide, number, len(built))
|
||||||
|
|
||||||
prs.save(sys.argv[1])
|
prs.save(sys.argv[1])
|
||||||
return 0
|
return 0
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ function build(theme) {
|
||||||
['A second sub-point, to show the spacing', 1],
|
['A second sub-point, to show the spacing', 1],
|
||||||
'A closing point to fill the slide out'
|
'A closing point to fill the slide out'
|
||||||
]),
|
]),
|
||||||
|
takeaway: 'A key point: one line the audience leaves with, on a strip under the body.',
|
||||||
notes: 'Speaker notes look like this. They do not appear on the slide.' },
|
notes: 'Speaker notes look like this. They do not appear on the slide.' },
|
||||||
|
|
||||||
{ type: 'compare', heading: 'Two labelled columns',
|
{ type: 'compare', heading: 'Two labelled columns',
|
||||||
|
|
@ -78,6 +79,12 @@ function build(theme) {
|
||||||
{ type: 'callout', heading: 'A callout',
|
{ type: 'callout', heading: 'A callout',
|
||||||
text: 'One sentence on a tinted card, for something worth stopping on.' },
|
text: 'One sentence on a tinted card, for something worth stopping on.' },
|
||||||
|
|
||||||
|
{ type: 'question', heading: 'Check yourself',
|
||||||
|
question: 'A question for the room, with lettered options; the answer is on the next slide.',
|
||||||
|
options: ['The first option', 'The second option', 'The third option', 'The fourth option'],
|
||||||
|
answer: 'The second option',
|
||||||
|
explanation: 'One line on why, so the reveal teaches something.' },
|
||||||
|
|
||||||
{ type: 'figure', heading: 'A figure beside its text',
|
{ type: 'figure', heading: 'A figure beside its text',
|
||||||
image_job: FIGURE_JOB,
|
image_job: FIGURE_JOB,
|
||||||
bullets: bullets([
|
bullets: bullets([
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
var slideShapes = require('./slideShapes');
|
var slideShapes = require('./slideShapes');
|
||||||
|
|
||||||
var VALID = ['title', 'section', 'bullets', 'two', 'compare', 'table', 'callout', 'figure', 'image', 'flow', 'custom'];
|
var VALID = ['title', 'section', 'bullets', 'two', 'compare', 'table', 'callout', 'question', 'figure', 'image', 'flow', 'custom'];
|
||||||
|
|
||||||
// Given to the model verbatim. Written as prose rather than a JSON Schema dump
|
// Given to the model verbatim. Written as prose rather than a JSON Schema dump
|
||||||
// because the failure to avoid is a model that produces valid JSON describing a
|
// because the failure to avoid is a model that produces valid JSON describing a
|
||||||
|
|
@ -44,6 +44,14 @@ function instructions(slideCount, figureCount) {
|
||||||
' One sentence on a tinted card. Use it for a red flag, a dose, a rule of',
|
' One sentence on a tinted card. Use it for a red flag, a dose, a rule of',
|
||||||
' thumb — something worth stopping on. At most one or two per deck.',
|
' thumb — something worth stopping on. At most one or two per deck.',
|
||||||
'',
|
'',
|
||||||
|
'{"type":"question","heading":"Check yourself","question":"...","options":["...","...","...","..."],"answer":"...","explanation":"..."}',
|
||||||
|
' A question for the room, drawn with a question mark and lettered options;',
|
||||||
|
' the answer and its one-line explanation go on the slide after, so people',
|
||||||
|
' commit first. One or two per deck, at the end of a part, is about right.',
|
||||||
|
'',
|
||||||
|
'Any bullets, table, compare, flow or figure slide may carry "takeaway": one',
|
||||||
|
'sentence the audience should leave with; it is drawn as a key-point strip.',
|
||||||
|
'',
|
||||||
'{"type":"figure","heading":"...","bullets":[...],"image_prompt":"..."}',
|
'{"type":"figure","heading":"...","bullets":[...],"image_prompt":"..."}',
|
||||||
' Bullets on the left, an illustration on the right. image_prompt describes the',
|
' Bullets on the left, an illustration on the right. image_prompt describes the',
|
||||||
' figure to draw: schematic or anatomical teaching artwork, never a real patient.',
|
' figure to draw: schematic or anatomical teaching artwork, never a real patient.',
|
||||||
|
|
@ -155,6 +163,13 @@ function normalise(raw, gaps) {
|
||||||
} else if (type === 'callout') {
|
} else if (type === 'callout') {
|
||||||
out.text = text(slide.text, 400);
|
out.text = text(slide.text, 400);
|
||||||
if (!out.text) return;
|
if (!out.text) return;
|
||||||
|
} else if (type === 'question') {
|
||||||
|
out.question = text(slide.question || slide.text, 400);
|
||||||
|
out.options = (Array.isArray(slide.options) ? slide.options : []).slice(0, 6)
|
||||||
|
.map(function (o) { return text(typeof o === 'string' ? o : (o && o.text), 160); }).filter(Boolean);
|
||||||
|
out.answer = text(slide.answer, 300);
|
||||||
|
out.explanation = text(slide.explanation, 500);
|
||||||
|
if (!out.question) return;
|
||||||
} else if (type === 'title') {
|
} else if (type === 'title') {
|
||||||
out.subtitle = text(slide.subtitle, 200);
|
out.subtitle = text(slide.subtitle, 200);
|
||||||
out.date = text(slide.date, 60);
|
out.date = text(slide.date, 60);
|
||||||
|
|
@ -173,6 +188,11 @@ function normalise(raw, gaps) {
|
||||||
out.bullets = bullets(slide.bullets);
|
out.bullets = bullets(slide.bullets);
|
||||||
if (type === 'bullets' && !out.bullets.length && !out.heading) return;
|
if (type === 'bullets' && !out.bullets.length && !out.heading) return;
|
||||||
}
|
}
|
||||||
|
// One line to leave with. Templates that show key points draw it as a
|
||||||
|
// strip under the body; the others carry it into the document export.
|
||||||
|
if (['bullets', 'two', 'table', 'flow', 'figure', 'compare'].indexOf(out.type) !== -1 && slide.takeaway) {
|
||||||
|
out.takeaway = text(slide.takeaway, 160);
|
||||||
|
}
|
||||||
|
|
||||||
// An image_prompt on a slide that does not carry pictures used to be dropped
|
// An image_prompt on a slide that does not carry pictures used to be dropped
|
||||||
// in silence, so a model that asked for a figure the wrong way got none and
|
// in silence, so a model that asked for a figure the wrong way got none and
|
||||||
|
|
@ -246,6 +266,11 @@ function toMarkdown(deck) {
|
||||||
} else if (slide.type === 'callout') {
|
} else if (slide.type === 'callout') {
|
||||||
out.push('**' + slide.text + '**');
|
out.push('**' + slide.text + '**');
|
||||||
out.push('');
|
out.push('');
|
||||||
|
} else if (slide.type === 'question') {
|
||||||
|
out.push('**Check yourself:** ' + slide.question);
|
||||||
|
(slide.options || []).forEach(function (o, i) { out.push('- ' + 'ABCDEFGH'[i] + '. ' + o); });
|
||||||
|
if (slide.answer) out.push('\n**Answer:** ' + slide.answer + (slide.explanation ? ' — ' + slide.explanation : ''));
|
||||||
|
out.push('');
|
||||||
} else if (slide.type === 'custom') {
|
} else if (slide.type === 'custom') {
|
||||||
// Lossy on purpose: a diagram has no markdown. Its words survive so Word
|
// Lossy on purpose: a diagram has no markdown. Its words survive so Word
|
||||||
// export and a text edit still have something to work with.
|
// export and a text edit still have something to work with.
|
||||||
|
|
@ -266,6 +291,7 @@ function toMarkdown(deck) {
|
||||||
out.push('');
|
out.push('');
|
||||||
}
|
}
|
||||||
if (slide.notes) { out.push('> ' + slide.notes.replace(/\n/g, ' ')); out.push(''); }
|
if (slide.notes) { out.push('> ' + slide.notes.replace(/\n/g, ' ')); out.push(''); }
|
||||||
|
if (slide.takeaway) { out.push('**Key point:** ' + slide.takeaway); out.push(''); }
|
||||||
});
|
});
|
||||||
return out.join('\n').replace(/\n{3,}/g, '\n\n').trim() + '\n';
|
return out.join('\n').replace(/\n{3,}/g, '\n\n').trim() + '\n';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,13 @@ function fromDeck(deck, images) {
|
||||||
|
|
||||||
if (slide.type === 'callout' && slide.text) {
|
if (slide.type === 'callout' && slide.text) {
|
||||||
blocks.push({ type: 'callout', text: text(slide.text) });
|
blocks.push({ type: 'callout', text: text(slide.text) });
|
||||||
|
} else if (slide.type === 'question' && slide.question) {
|
||||||
|
var letters = 'ABCDEFGH';
|
||||||
|
blocks.push({ type: 'callout', text: 'Check yourself: ' + text(slide.question) });
|
||||||
|
if ((slide.options || []).length) {
|
||||||
|
blocks.push({ type: 'bullets', items: slide.options.map(function (o, i) { return { text: letters[i] + '. ' + text(o, 300), level: 0 }; }) });
|
||||||
|
}
|
||||||
|
if (slide.answer) blocks.push({ type: 'callout', text: 'Answer: ' + text(slide.answer) + (slide.explanation ? ' — ' + text(slide.explanation) : '') });
|
||||||
} else if (slide.type === 'table') {
|
} else if (slide.type === 'table') {
|
||||||
blocks.push({ type: 'table', header: (slide.header || []).map(function (c) { return text(c, 300); }),
|
blocks.push({ type: 'table', header: (slide.header || []).map(function (c) { return text(c, 300); }),
|
||||||
rows: (slide.rows || []).map(function (row) {
|
rows: (slide.rows || []).map(function (row) {
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,7 @@ const deckSchema = require('../src/utils/deckSchema');
|
||||||
|
|
||||||
// The vocabulary the renderer can draw, from the schema itself, so a layout
|
// The vocabulary the renderer can draw, from the schema itself, so a layout
|
||||||
// added later fails this until the sample shows it too.
|
// added later fails this until the sample shows it too.
|
||||||
const SCHEMA_TYPES = ['title', 'section', 'bullets', 'two', 'compare', 'table',
|
const SCHEMA_TYPES = ['title', 'section', 'bullets', 'two', 'compare', 'table', 'callout', 'question', 'figure', 'image', 'custom', 'flow'];
|
||||||
'callout', 'figure', 'image', 'custom', 'flow'];
|
|
||||||
|
|
||||||
test('the sample shows every layout the renderer can draw', () => {
|
test('the sample shows every layout the renderer can draw', () => {
|
||||||
const deck = deckSample.build({ id: 'clinical-blue', name: 'Clinical Blue' });
|
const deck = deckSample.build({ id: 'clinical-blue', name: 'Clinical Blue' });
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue