feat: deck cards fit their content, callouts use the theme's ink, questions can carry a film, formats end with references
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 52s
Forgejo Docker Build / Build Docker image (push) Successful in 6s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 7s

Compare cards were as tall as the body whatever they held, so two columns of
four short bullets sat in cards 60% empty; a one-sentence callout sat in a
card two thirds of the slide. Both now size to the text. The callout card's
fixed brown ink was the worst contrast in the catalogue on Board Review's
mint card; it takes the theme's ink. A question slide may carry image_prompt
— a film beside the stem, drawn or left as a labelled frame like a figure.
The evidence-based formats (board review, journal club, QI, abstract, debrief)
gain a References part and the brief says the deck ends with it: DeepSeek
skipped it when it was only suggested.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fZGJNyDvERbMgS2Uc2msP
This commit is contained in:
Daniel 2026-09-14 15:35:10 +02:00
parent 50f5036118
commit 6ca278c0e6
6 changed files with 944 additions and 98 deletions

File diff suppressed because it is too large Load diff

View file

@ -550,9 +550,18 @@ def slide_compare(prs, spec):
sizes = [_fit_size(c.get("bullets") or [], 0.44, int(BODY_H) - int(Emu(548640)))
for c in columns] or [BULLET_SIZES[0]]
size = min(sizes)
# The cards are as tall as the fuller column needs, not the whole body: two
# columns of four short bullets used to sit in cards that were 60% empty.
line_emu, gap_emu = Pt(size * 1.35).emu, Pt(size * 0.55).emu
need = 0
for column in columns:
total = sum(_estimate_lines(item.get("text", ""), size, 0.44) * line_emu + gap_emu
for item in (column.get("bullets") or []))
need = max(need, total)
card_h = Emu(max(int(BODY_H * 0.5), min(int(BODY_H), need + 822960 + 365760)))
for index, column in enumerate(columns):
left = MARGIN + (col_w + gutter) * index
card = slide.shapes.add_shape(5, left, BODY_TOP, col_w, BODY_H) # rounded rect
card = slide.shapes.add_shape(5, left, BODY_TOP, col_w, card_h) # rounded rect
card.fill.solid()
card.fill.fore_color.rgb = tints[index % 2]
card.line.color.rgb = edges[index % 2]
@ -566,7 +575,7 @@ def slide_compare(prs, spec):
bold=True, color=edges[index % 2])
frame = _textbox(slide, left + Emu(228600), BODY_TOP + Emu(640080),
Emu(int(col_w) - 457200), Emu(int(BODY_H) - 822960))
Emu(int(col_w) - 457200), Emu(int(card_h) - 822960))
_bullets(frame, column.get("bullets") or [], size, width_frac=0.44)
_notes(slide, spec.get("notes"))
return slide
@ -578,9 +587,11 @@ def slide_callout(prs, spec):
_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)
# One sentence does not need a card two thirds of the slide tall.
card_frac = 0.42 if len(text) <= 90 else (0.52 if len(text) <= 180 else 0.62)
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))
card_h = Emu(int(BODY_H * card_frac))
_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)
@ -595,7 +606,7 @@ def slide_callout(prs, spec):
_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 * card_frac)))
card.fill.solid()
card.fill.fore_color.rgb = TINT_ALT
card.line.color.rgb = ACCENT_ALT
@ -604,12 +615,14 @@ def slide_callout(prs, spec):
card.text_frame.text = ""
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 * card_frac) - 731520))
frame.vertical_anchor = MSO_ANCHOR.MIDDLE
para = frame.paragraphs[0]
para.alignment = PP_ALIGN.CENTER
# The theme's ink, not a fixed brown: brown on a mint card was the worst
# contrast in the catalogue.
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=INK)
_notes(slide, spec.get("notes"))
return slide
@ -642,7 +655,21 @@ def slide_question(prs, spec):
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)
# A film or a photograph beside the stem, when the question carries one:
# the text takes the left part and the picture (or its empty frame) the
# right third, as on a figure slide.
path = spec.get("image")
has_picture = bool(path and os.path.exists(path)) or bool(spec.get("placeholder"))
pic_w = Emu(int(BODY_W * 0.34)) if has_picture else Emu(0)
text_w = BODY_W - mark_w - Emu(457200) - pic_w - (Emu(365760) if has_picture else Emu(0))
frame = _textbox(slide, MARGIN + mark_w + Emu(457200), BODY_TOP, text_w, BODY_H)
if has_picture:
pic_left = MARGIN + BODY_W - pic_w
if path and os.path.exists(path):
draw_image(slide, {"image": path, "x": 100.0 * int(pic_left) / int(SLIDE_W), "y": 100.0 * int(BODY_TOP) / int(SLIDE_H),
"w": 100.0 * int(pic_w) / int(SLIDE_W), "h": 100.0 * int(BODY_H) / int(SLIDE_H)})
else:
_placeholder(slide, pic_left, BODY_TOP, pic_w, BODY_H, spec.get("image_prompt") or spec.get("caption"))
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"

View file

@ -64,7 +64,7 @@ function instructions(id) {
lines.push('A part marked repeatable may be used more than once — once per case, event or question —');
lines.push('and a run of repeatable parts repeats as a unit, as many times as the material needs.');
lines.push('Leave out a part the material does not have. Keep the order between parts.');
lines.push('Every slide is still one of the types above.');
lines.push('Every slide is still one of the types above. If the format lists a References part, the deck ends with it.');
if (Array.isArray(format.pitfalls) && format.pitfalls.length) {
lines.push('What this format goes wrong on: ' + format.pitfalls.join('; ') + '.');
}

View file

@ -48,6 +48,8 @@ function instructions(slideCount, figureCount) {
' 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.',
' Options are plain text — the letters are drawn for you. A question may',
' carry "image_prompt" for a film or photograph shown beside the stem.',
'',
'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.',
@ -190,6 +192,13 @@ function normalise(raw, gaps) {
out.answer = answerWithOption(text(slide.answer, 300), out.options);
out.explanation = text(slide.explanation, 500);
if (!out.question) return;
// A film or photograph beside the stem: drawn when illustration is on,
// an empty labelled frame otherwise, like a figure slide.
if (slide.image_prompt || slide.imagePrompt) {
out.image_prompt = text(slide.image_prompt || slide.imagePrompt, 1200);
out.caption = text(slide.caption, 200);
if (slide.placeholder === true) out.placeholder = true;
}
} else if (type === 'title') {
out.subtitle = text(slide.subtitle, 200);
out.date = text(slide.date, 60);
@ -219,7 +228,7 @@ function normalise(raw, gaps) {
// no one found out. The intent is honoured instead: a slide with words
// becomes a figure, one without becomes a full-slide image.
if (slide.image_prompt && type !== 'figure' && type !== 'image'
&& type !== 'title' && type !== 'custom') {
&& type !== 'title' && type !== 'custom' && type !== 'question') {
out.type = type = (out.bullets && out.bullets.length) || (slide.bullets || []).length
? 'figure' : 'image';
if (type === 'figure' && !(out.bullets || []).length) out.bullets = bullets(slide.bullets);

View file

@ -82,7 +82,7 @@ function fromDeck(deck, images) {
if (slide.image_job && byJob[slide.image_job]) {
blocks.push({ type: 'image', path: byJob[slide.image_job], caption: text(slide.caption, 300) });
} else if (slide.placeholder && (slide.type === 'image' || slide.type === 'figure')) {
} else if (slide.placeholder && (slide.type === 'image' || slide.type === 'figure' || slide.type === 'question')) {
// The frame the deck keeps for a picture added by hand, as a line that
// says what belongs there.
blocks.push({ type: 'para', text: '[Add an image here' + (slide.image_prompt ? ': ' + text(slide.image_prompt, 300) : '') + ']', muted: true });

View file

@ -61,7 +61,7 @@ test('the renderer draws the frame on figure, image and custom image shapes', ()
const py = read('scripts/render_pptx.py');
assert.match(py, /def _placeholder\(slide, left, top, width, height, label\)/);
assert.match(py, /MSO_LINE_DASH_STYLE\.DASH/);
assert.equal((py.match(/\n\s+_placeholder\(slide, /g) || []).length, 3, 'figure, image and draw_image each draw it');
assert.equal((py.match(/\n\s+_placeholder\(slide, /g) || []).length, 4, 'figure, image, question and draw_image each draw it');
assert.match(py, /"Add an image here"/);
});
@ -70,3 +70,17 @@ test('the model is told a picture it names is drawn or left as a frame, never de
assert.match(brief, /keeps an empty labelled/);
assert.match(brief, /Never write an image placeholder into any text/);
});
test('a question may carry a picture beside its stem, drawn or left as a frame', async () => {
const d = deckSchema.normalise({ slides: [
{ type: 'question', heading: 'Case 2', question: 'What does the film show?', options: ['a', 'b'], answer: 'a', image_prompt: 'the admission chest radiograph' }
] });
assert.equal(d.slides[0].type, 'question', 'not converted into a figure slide');
assert.equal(d.slides[0].image_prompt, 'the admission chest radiograph');
await deckBuild.drawFigures(d, { imageModel: '' });
assert.equal(d.slides[0].placeholder, true);
const blocks = docSpec.fromDeck(d, {});
assert.match(JSON.stringify(blocks), /\[Add an image here: the admission chest radiograph\]/);
const py = read('scripts/render_pptx.py');
assert.match(py, /has_picture = bool\(path and os\.path\.exists\(path\)\) or bool\(spec\.get\("placeholder"\)\)/);
});