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",
|
||||
"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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
Loading…
Reference in a new issue