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)))
|
sizes = [_fit_size(c.get("bullets") or [], 0.44, int(BODY_H) - int(Emu(548640)))
|
||||||
for c in columns] or [BULLET_SIZES[0]]
|
for c in columns] or [BULLET_SIZES[0]]
|
||||||
size = min(sizes)
|
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):
|
for index, column in enumerate(columns):
|
||||||
left = MARGIN + (col_w + gutter) * index
|
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.solid()
|
||||||
card.fill.fore_color.rgb = tints[index % 2]
|
card.fill.fore_color.rgb = tints[index % 2]
|
||||||
card.line.color.rgb = edges[index % 2]
|
card.line.color.rgb = edges[index % 2]
|
||||||
|
|
@ -566,7 +575,7 @@ def slide_compare(prs, spec):
|
||||||
bold=True, color=edges[index % 2])
|
bold=True, color=edges[index % 2])
|
||||||
|
|
||||||
frame = _textbox(slide, left + Emu(228600), BODY_TOP + Emu(640080),
|
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)
|
_bullets(frame, column.get("bullets") or [], size, width_frac=0.44)
|
||||||
_notes(slide, spec.get("notes"))
|
_notes(slide, spec.get("notes"))
|
||||||
return slide
|
return slide
|
||||||
|
|
@ -578,9 +587,11 @@ def slide_callout(prs, spec):
|
||||||
_heading(slide, spec.get("heading") or "")
|
_heading(slide, spec.get("heading") or "")
|
||||||
text = (spec.get("text") or "").strip()
|
text = (spec.get("text") or "").strip()
|
||||||
size = 28 if len(text) <= 90 else (22 if len(text) <= 180 else 18)
|
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":
|
if (STYLE.get("callout") or "card") == "stripe":
|
||||||
# A white card with a thick accent stripe and a mark in the margin.
|
# 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, BODY_W, card_h, TINT, shape=5)
|
||||||
_rect(slide, MARGIN, BODY_TOP, Emu(182880), card_h, ACCENT_ALT)
|
_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)
|
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)
|
_run(para, chunk, size, bold=True, color=INK)
|
||||||
_notes(slide, spec.get("notes"))
|
_notes(slide, spec.get("notes"))
|
||||||
return slide
|
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.solid()
|
||||||
card.fill.fore_color.rgb = TINT_ALT
|
card.fill.fore_color.rgb = TINT_ALT
|
||||||
card.line.color.rgb = ACCENT_ALT
|
card.line.color.rgb = ACCENT_ALT
|
||||||
|
|
@ -604,12 +615,14 @@ def slide_callout(prs, spec):
|
||||||
card.text_frame.text = ""
|
card.text_frame.text = ""
|
||||||
|
|
||||||
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 * card_frac) - 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
|
||||||
|
# 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):
|
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"))
|
_notes(slide, spec.get("notes"))
|
||||||
return slide
|
return slide
|
||||||
|
|
||||||
|
|
@ -642,7 +655,21 @@ def slide_question(prs, spec):
|
||||||
mp.alignment = PP_ALIGN.CENTER
|
mp.alignment = PP_ALIGN.CENTER
|
||||||
mark.text_frame.vertical_anchor = MSO_ANCHOR.MIDDLE
|
mark.text_frame.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||||
_run(mp, "?", 80, bold=True, color=PAPER if style == "spotlight" else ACCENT)
|
_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]
|
para = frame.paragraphs[0]
|
||||||
_run(para, question, 26 if len(question) <= 120 else (22 if len(question) <= 220 else 18), bold=True, color=fg)
|
_run(para, question, 26 if len(question) <= 120 else (22 if len(question) <= 220 else 18), bold=True, color=fg)
|
||||||
letters = "ABCDEFGH"
|
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('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('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('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) {
|
if (Array.isArray(format.pitfalls) && format.pitfalls.length) {
|
||||||
lines.push('What this format goes wrong on: ' + format.pitfalls.join('; ') + '.');
|
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;',
|
' 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',
|
' 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.',
|
' 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',
|
'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.',
|
'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.answer = answerWithOption(text(slide.answer, 300), out.options);
|
||||||
out.explanation = text(slide.explanation, 500);
|
out.explanation = text(slide.explanation, 500);
|
||||||
if (!out.question) return;
|
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') {
|
} 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);
|
||||||
|
|
@ -219,7 +228,7 @@ function normalise(raw, gaps) {
|
||||||
// no one found out. The intent is honoured instead: a slide with words
|
// no one found out. The intent is honoured instead: a slide with words
|
||||||
// becomes a figure, one without becomes a full-slide image.
|
// becomes a figure, one without becomes a full-slide image.
|
||||||
if (slide.image_prompt && type !== 'figure' && type !== '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
|
out.type = type = (out.bullets && out.bullets.length) || (slide.bullets || []).length
|
||||||
? 'figure' : 'image';
|
? 'figure' : 'image';
|
||||||
if (type === 'figure' && !(out.bullets || []).length) out.bullets = bullets(slide.bullets);
|
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]) {
|
if (slide.image_job && byJob[slide.image_job]) {
|
||||||
blocks.push({ type: 'image', path: byJob[slide.image_job], caption: text(slide.caption, 300) });
|
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
|
// The frame the deck keeps for a picture added by hand, as a line that
|
||||||
// says what belongs there.
|
// says what belongs there.
|
||||||
blocks.push({ type: 'para', text: '[Add an image here' + (slide.image_prompt ? ': ' + text(slide.image_prompt, 300) : '') + ']', muted: true });
|
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');
|
const py = read('scripts/render_pptx.py');
|
||||||
assert.match(py, /def _placeholder\(slide, left, top, width, height, label\)/);
|
assert.match(py, /def _placeholder\(slide, left, top, width, height, label\)/);
|
||||||
assert.match(py, /MSO_LINE_DASH_STYLE\.DASH/);
|
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"/);
|
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, /keeps an empty labelled/);
|
||||||
assert.match(brief, /Never write an image placeholder into any text/);
|
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