feat: deck cards fit their content, callouts use the theme's ink, questions can carry a film, formats end with references
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:
parent
50f5036118
commit
6ca278c0e6
6 changed files with 944 additions and 98 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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('; ') + '.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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"\)\)/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue