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
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 6s

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-13 18:05:41 +02:00
parent 9c86c56968
commit de13c8306b
8 changed files with 373 additions and 22 deletions

View file

@ -4,7 +4,7 @@
{
"id": "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",
"ink": "1F2937",
"muted": "4B5563",
@ -13,12 +13,21 @@
"font": "Calibri",
"tint": "EFF6FF",
"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",
"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",
"ink": "1C1917",
"muted": "57534E",
@ -27,12 +36,21 @@
"font": "Calibri",
"tint": "FEF3C7",
"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",
"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",
"ink": "134E4A",
"muted": "4B5563",
@ -41,12 +59,21 @@
"font": "Calibri",
"tint": "CCFBF1",
"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",
"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",
"ink": "0F172A",
"muted": "64748B",
@ -55,12 +82,21 @@
"font": "Calibri",
"tint": "F1F5F9",
"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",
"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",
"ink": "000000",
"muted": "27272A",
@ -69,7 +105,62 @@
"font": "Calibri",
"tint": "FEE2E2",
"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
}
}
]
}
}

View file

@ -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
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
`resourceImages.js`, **not** the shared `imageTool.dispatch` — that one permits

View file

@ -96,6 +96,16 @@ def load_themes():
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):
"""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."""
@ -119,9 +129,56 @@ def apply_theme(theme_id):
except Exception:
return None
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 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
# some viewers honour and which LibreOffice ignores entirely when converting to
# 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):
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
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:
if accent_rule and style == "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
@ -251,10 +316,42 @@ def _notes(slide, text):
def slide_title(prs, spec):
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.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:
@ -271,7 +368,19 @@ def slide_title(prs, spec):
def slide_section(prs, spec):
global SECTION_NO
SECTION_NO += 1
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.vertical_anchor = MSO_ANCHOR.MIDDLE
para = frame.paragraphs[0]
@ -283,8 +392,9 @@ 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)
taken = _takeaway(slide, spec.get("takeaway"))
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)
_notes(slide, spec.get("notes"))
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."""
slide = _blank(prs)
_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.fill.solid()
card.fill.fore_color.rgb = TINT_ALT
@ -443,19 +572,74 @@ def slide_callout(prs, spec):
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_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):
"""A figure beside its text, rather than alone on a slide of its own."""
slide = _blank(prs)
@ -720,6 +904,7 @@ BUILDERS = {
"image": slide_image,
"compare": slide_compare,
"callout": slide_callout,
"question": slide_question,
"figure": slide_figure,
"flow": slide_flow,
"custom": slide_custom,
@ -742,8 +927,16 @@ def main():
print("spec contains no slides", file=sys.stderr)
return 3
global DECK_TITLE, SECTION_NO
DECK_TITLE = str(spec.get("title") or "")
SECTION_NO = 0
built = []
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])
return 0

View file

@ -54,6 +54,7 @@ function build(theme) {
['A second sub-point, to show the spacing', 1],
'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.' },
{ type: 'compare', heading: 'Two labelled columns',
@ -78,6 +79,12 @@ function build(theme) {
{ type: 'callout', heading: 'A callout',
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',
image_job: FIGURE_JOB,
bullets: bullets([

View file

@ -16,7 +16,7 @@
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
// 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',
' 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":"..."}',
' 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.',
@ -155,6 +163,13 @@ function normalise(raw, gaps) {
} else if (type === 'callout') {
out.text = text(slide.text, 400);
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') {
out.subtitle = text(slide.subtitle, 200);
out.date = text(slide.date, 60);
@ -173,6 +188,11 @@ function normalise(raw, gaps) {
out.bullets = bullets(slide.bullets);
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
// 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') {
out.push('**' + slide.text + '**');
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') {
// Lossy on purpose: a diagram has no markdown. Its words survive so Word
// export and a text edit still have something to work with.
@ -266,6 +291,7 @@ function toMarkdown(deck) {
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';
}

View file

@ -27,6 +27,13 @@ function fromDeck(deck, images) {
if (slide.type === 'callout' && 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') {
blocks.push({ type: 'table', header: (slide.header || []).map(function (c) { return text(c, 300); }),
rows: (slide.rows || []).map(function (row) {

View file

@ -14,8 +14,7 @@ const deckSchema = require('../src/utils/deckSchema');
// The vocabulary the renderer can draw, from the schema itself, so a layout
// added later fails this until the sample shows it too.
const SCHEMA_TYPES = ['title', 'section', 'bullets', 'two', 'compare', 'table',
'callout', 'figure', 'image', 'custom', 'flow'];
const SCHEMA_TYPES = ['title', 'section', 'bullets', 'two', 'compare', 'table', 'callout', 'question', 'figure', 'image', 'custom', 'flow'];
test('the sample shows every layout the renderer can draw', () => {
const deck = deckSample.build({ id: 'clinical-blue', name: 'Clinical Blue' });