feat: a figure the deck wanted and did not get keeps its place as an empty labelled frame
With illustration off, or for a picture no model should draw — a real radiograph, a run chart, a photograph — the figure or image slide used to become a slide of text, and the deck forgot it had wanted a picture. It now keeps a dashed, labelled frame in the picture's place, on figure slides, full-slide images and the image shapes of custom slides, so the author can drop their own in after downloading; the Word export says what belongs there. Figures already drawn are never turned into frames. The one format guidance that implied a slide authored live now says 'fill in by hand'. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fZGJNyDvERbMgS2Uc2msP
This commit is contained in:
parent
7ea6c41941
commit
aac02ec0df
10 changed files with 144 additions and 14 deletions
|
|
@ -91,7 +91,7 @@
|
|||
{ "part": "Another team's view", "purpose": "the case as nursing or another discipline saw it", "layouts": ["bullets", "compare"], "repeat": false, "guidance": "Their account of the same hours, beside the physician account where they differ." },
|
||||
{ "part": "Systems analysis", "purpose": "contributing causes across domains", "layouts": ["figure", "table", "flow"], "repeat": true, "guidance": "One slide per domain — communication, protocols, staffing, equipment, cognitive bias — each with three or four guiding questions for the room." },
|
||||
{ "part": "What I would do differently", "purpose": "the presenter's own safeguards", "layouts": ["bullets"], "repeat": false, "guidance": "Specific and personal, not generic." },
|
||||
{ "part": "Commitments", "purpose": "named, owned, dated changes", "layouts": ["table"], "repeat": false, "guidance": "Columns for action, owner and deadline; rows may be left to fill in during discussion." },
|
||||
{ "part": "Commitments", "purpose": "named, owned, dated changes", "layouts": ["table"], "repeat": false, "guidance": "Columns for action, owner and deadline; rows may be left blank to fill in by hand." },
|
||||
{ "part": "Questions for the room", "purpose": "discussion after the presentation, not during", "layouts": ["bullets"], "repeat": false, "guidance": "" },
|
||||
{ "part": "Previous commitments", "purpose": "closing the loop on the last conference", "layouts": ["table"], "repeat": false, "guidance": "Status of each earlier commitment; put it first if the session opens with it." }
|
||||
],
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ from pptx.chart.data import CategoryChartData
|
|||
from pptx.dml.color import RGBColor
|
||||
from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION
|
||||
from pptx.enum.shapes import MSO_SHAPE
|
||||
from pptx.enum.dml import MSO_LINE_DASH_STYLE
|
||||
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
|
||||
|
||||
try:
|
||||
|
|
@ -212,6 +213,34 @@ def _run(paragraph, text, size, bold=False, color=INK, italic=False):
|
|||
return run
|
||||
|
||||
|
||||
def _placeholder(slide, left, top, width, height, label):
|
||||
"""An empty, labelled frame where a picture belongs. Drawn when a figure
|
||||
was wanted and not made — illustration off, or a picture no model can
|
||||
draw (a real radiograph, a run chart) — so the author sees where to drop
|
||||
their own after downloading, instead of a slide that forgot it wanted one."""
|
||||
box = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Emu(int(left)), Emu(int(top)),
|
||||
Emu(int(width)), Emu(int(height)))
|
||||
box.adjustments[0] = 0.04
|
||||
box.fill.background()
|
||||
box.line.color.rgb = MUTED
|
||||
box.line.width = Pt(1.25)
|
||||
box.line.dash_style = MSO_LINE_DASH_STYLE.DASH
|
||||
box.shadow.inherit = False
|
||||
frame = box.text_frame
|
||||
frame.word_wrap = True
|
||||
frame.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||
frame.margin_left = frame.margin_right = Emu(182880)
|
||||
para = frame.paragraphs[0]
|
||||
para.alignment = PP_ALIGN.CENTER
|
||||
_run(para, "Add an image here", 14, bold=True, color=MUTED)
|
||||
label = (label or "").strip()
|
||||
if label:
|
||||
detail = frame.add_paragraph()
|
||||
detail.alignment = PP_ALIGN.CENTER
|
||||
_run(detail, label[:220], 11, color=MUTED, italic=True)
|
||||
return box
|
||||
|
||||
|
||||
def _heading(slide, text, accent_rule=True):
|
||||
style = STYLE.get("heading") or "rule"
|
||||
left = MARGIN
|
||||
|
|
@ -495,6 +524,8 @@ def slide_image(prs, spec):
|
|||
left = Emu(int((SLIDE_W - width) / 2))
|
||||
if path and os.path.exists(path):
|
||||
slide.shapes.add_picture(path, left, Emu(int(top)), Emu(width), Emu(height))
|
||||
elif spec.get("placeholder"):
|
||||
_placeholder(slide, MARGIN, top, BODY_W, avail_h, spec.get("image_prompt") or caption)
|
||||
|
||||
if caption:
|
||||
frame = _textbox(slide, MARGIN, Emu(int(top) + height + 91440), BODY_W, caption_h)
|
||||
|
|
@ -671,6 +702,9 @@ def slide_figure(prs, spec):
|
|||
left = MARGIN + text_w + gutter + Emu(int((int(img_w) - width) / 2))
|
||||
top = BODY_TOP + Emu(int((int(BODY_H) - height) / 2))
|
||||
slide.shapes.add_picture(path, left, top, Emu(width), Emu(height))
|
||||
elif spec.get("placeholder"):
|
||||
_placeholder(slide, MARGIN + text_w + gutter, BODY_TOP, img_w, BODY_H,
|
||||
spec.get("image_prompt") or spec.get("caption"))
|
||||
_notes(slide, spec.get("notes"))
|
||||
return slide
|
||||
|
||||
|
|
@ -838,6 +872,8 @@ def draw_image(slide, shape):
|
|||
left, top, width, height = box(shape)
|
||||
path = shape.get("image")
|
||||
if not path or not os.path.exists(path):
|
||||
if shape.get("placeholder"):
|
||||
_placeholder(slide, left, top, width, height, shape.get("image_prompt"))
|
||||
return
|
||||
if shape.get("fit") == "fill":
|
||||
slide.shapes.add_picture(path, left, top, width, height)
|
||||
|
|
|
|||
|
|
@ -1067,6 +1067,10 @@ router.post('/my-resources/:id/refine', async function (req, res) {
|
|||
owner: req.user.id, body: req.body, subject: subject, imageModel: sources.imageModel
|
||||
});
|
||||
ai = Object.assign({}, ai, { imageJobs: drawn.jobs, imageFailures: drawn.failures });
|
||||
} else {
|
||||
// Illustration off: a figure the revision newly asks for keeps its
|
||||
// place as an empty labelled frame; figures already drawn are kept.
|
||||
await deckBuild.drawFigures(revisedDeck, { imageModel: '' });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -76,21 +76,26 @@ async function drawFigures(deck, opts) {
|
|||
if (request.shape === undefined) return slide;
|
||||
return (slide.shapes || [])[request.shape] || null;
|
||||
}
|
||||
// A figure that will not be drawn keeps its place as an empty, labelled
|
||||
// frame: the slide is still a figure slide, and the prompt becomes the label
|
||||
// saying what belongs there, so the author can drop in their own picture — a
|
||||
// real radiograph, a run chart, a photo — after downloading. It used to
|
||||
// degrade to a slide of text, which lost the fact that a picture was wanted.
|
||||
function giveUp(request) {
|
||||
var slide = deck.slides[request.index];
|
||||
if (request.shape !== undefined) {
|
||||
// Drop the empty frame; the rest of the slide still stands.
|
||||
if (slide.shapes) slide.shapes.splice(request.shape, 1);
|
||||
if (!(slide.shapes || []).length) { slide.type = 'bullets'; slide.bullets = slide.bullets || []; }
|
||||
var shape = (slide.shapes || [])[request.shape];
|
||||
// A shape that already has its picture (a revision that kept it) is not
|
||||
// a frame to fill.
|
||||
if (shape && !shape.image_job) shape.placeholder = true;
|
||||
return;
|
||||
}
|
||||
slide.type = slide.type === 'image' ? 'section' : 'bullets';
|
||||
delete slide.image_prompt;
|
||||
if (!slide.image_job) slide.placeholder = true;
|
||||
}
|
||||
|
||||
if (!wanted.length || !opts.imageModel) {
|
||||
// Asked for pictures with no image model configured: the slides degrade to
|
||||
// text rather than keeping an empty frame.
|
||||
// Asked for pictures with illustration off or no image model configured:
|
||||
// every requested figure becomes a frame to fill by hand.
|
||||
wanted.slice().reverse().forEach(giveUp);
|
||||
return { jobs: jobs, failures: failures };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,8 +77,10 @@ function instructions(slideCount, figureCount) {
|
|||
'',
|
||||
'Vary the layouts: a deck of nothing but "bullets" is the thing to avoid.',
|
||||
'Never write an image placeholder into any text. If a slide should carry a',
|
||||
'figure, say so with "image_prompt"; if that is not offered above, write the',
|
||||
'slide without one rather than describing the picture you would have drawn.',
|
||||
'picture, say so with "image_prompt": when illustration is on it is drawn;',
|
||||
'when it is off, or the picture is one that cannot be drawn (a real',
|
||||
'radiograph, a run chart, a photograph), the slide keeps an empty labelled',
|
||||
'frame for the author to fill by hand after downloading.',
|
||||
'Put a References slide last if you used sources, as a "table" with one column',
|
||||
'or a "bullets" slide. "notes" is optional speaker notes.'
|
||||
].join('\n');
|
||||
|
|
@ -212,6 +214,8 @@ function normalise(raw, gaps) {
|
|||
out.image_prompt = text(slide.image_prompt || slide.imagePrompt, 1200);
|
||||
out.caption = text(slide.caption, 200);
|
||||
if (!out.image_prompt) out.type = type === 'image' ? 'section' : 'bullets';
|
||||
// A frame left empty on purpose, for a picture the author adds by hand.
|
||||
if (slide.placeholder === true) out.placeholder = true;
|
||||
}
|
||||
slides.push(out);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@ function fromDeck(deck, images) {
|
|||
blocks.push({ type: 'table', header: shape.header || [], rows: shape.rows });
|
||||
} else if (shape.kind === 'image' && shape.image_job && byJob[shape.image_job]) {
|
||||
blocks.push({ type: 'image', path: byJob[shape.image_job], caption: '' });
|
||||
} else if (shape.kind === 'image' && shape.placeholder) {
|
||||
blocks.push({ type: 'para', text: '[Add an image here' + (shape.image_prompt ? ': ' + text(shape.image_prompt, 300) : '') + ']', muted: true });
|
||||
} else if ((shape.runs || []).length) {
|
||||
var lines = shape.runs.map(function (r) { return { text: text(r.text, 1200), level: r.level || 0 }; });
|
||||
blocks.push(shape.runs.some(function (r) { return r.bullet; })
|
||||
|
|
@ -80,6 +82,10 @@ 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')) {
|
||||
// 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 });
|
||||
}
|
||||
if (slide.notes) blocks.push({ type: 'para', text: text(slide.notes, 2000), muted: true });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -240,8 +240,10 @@ function attachFigures(deck, files, figureIds) {
|
|||
// renderer are the figures just fetched for this export.
|
||||
delete copy.image;
|
||||
if (copy.image_job && byJob[copy.image_job]) copy.image = byJob[copy.image_job];
|
||||
// A figure slide whose picture never arrived is still a slide of text.
|
||||
if (!copy.image && (copy.type === 'image' || copy.type === 'figure')) {
|
||||
// A figure slide whose picture never arrived: an empty labelled frame when
|
||||
// that was the intent (illustration off, or a picture that cannot be
|
||||
// drawn), otherwise still a slide of text.
|
||||
if (!copy.image && (copy.type === 'image' || copy.type === 'figure') && !copy.placeholder) {
|
||||
copy.type = copy.type === 'image' ? 'section' : 'bullets';
|
||||
}
|
||||
return copy;
|
||||
|
|
|
|||
|
|
@ -151,6 +151,7 @@ function normalise(list, gaps) {
|
|||
shape.image_prompt = text(raw.image_prompt || raw.imagePrompt, 1200);
|
||||
shape.image_job = text(raw.image_job, 200) || null;
|
||||
shape.fit = raw.fit === 'fill' ? 'fill' : 'contain';
|
||||
if (raw.placeholder === true) shape.placeholder = true;
|
||||
if (!shape.image_prompt && !shape.image_job) return;
|
||||
} else {
|
||||
shape.runs = runs(raw.runs !== undefined ? raw.runs : raw.text);
|
||||
|
|
|
|||
72
test/deck-image-placeholders.test.js
Normal file
72
test/deck-image-placeholders.test.js
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// A picture the deck wanted and did not get keeps its place: an empty frame
|
||||
// labelled with what belongs there, for the author to fill after downloading
|
||||
// — a real radiograph, a run chart, a photograph no model should draw. It
|
||||
// used to become a slide of text, which forgot that a picture was wanted.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const deckBuild = require('../src/utils/deckBuild');
|
||||
const deckSchema = require('../src/utils/deckSchema');
|
||||
const docSpec = require('../src/utils/docSpec');
|
||||
const read = f => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
|
||||
|
||||
function deck() {
|
||||
return deckSchema.normalise({ title: 'T', slides: [
|
||||
{ type: 'figure', heading: 'The film', bullets: ['a'], image_prompt: 'the admission chest radiograph' },
|
||||
{ type: 'image', heading: 'Run chart', image_prompt: 'the run chart of hand-hygiene compliance', caption: 'Weekly' },
|
||||
{ type: 'custom', heading: 'Layout', shapes: [
|
||||
{ kind: 'text', x: 5, y: 20, w: 40, h: 20, text: 'beside it' },
|
||||
{ kind: 'image', x: 50, y: 20, w: 45, h: 50, image_prompt: 'a fishbone diagram' }
|
||||
] },
|
||||
{ type: 'bullets', heading: 'Plain', bullets: ['b'] }
|
||||
] });
|
||||
}
|
||||
|
||||
test('with illustration off every requested figure stays a figure, marked as a frame to fill', async () => {
|
||||
const d = deck();
|
||||
const result = await deckBuild.drawFigures(d, { imageModel: '' });
|
||||
assert.deepEqual(result.jobs, []);
|
||||
assert.equal(d.slides[0].type, 'figure');
|
||||
assert.equal(d.slides[0].placeholder, true);
|
||||
assert.equal(d.slides[0].image_prompt, 'the admission chest radiograph', 'the prompt is the label');
|
||||
assert.equal(d.slides[1].type, 'image');
|
||||
assert.equal(d.slides[1].placeholder, true);
|
||||
assert.equal(d.slides[2].shapes.length, 2, 'the empty image shape is kept, not dropped');
|
||||
assert.equal(d.slides[2].shapes[1].placeholder, true);
|
||||
assert.equal(d.slides[3].placeholder, undefined);
|
||||
});
|
||||
|
||||
test('the frame survives normalisation on both slides and shapes, and only as a boolean', () => {
|
||||
const d = deck();
|
||||
d.slides[0].placeholder = true; d.slides[2].shapes[1].placeholder = true;
|
||||
const again = deckSchema.normalise(d);
|
||||
assert.equal(again.slides[0].placeholder, true);
|
||||
assert.equal(again.slides[2].shapes[1].placeholder, true);
|
||||
const forged = deckSchema.normalise({ slides: [{ type: 'image', image_prompt: 'x', placeholder: 'yes' }] });
|
||||
assert.equal(forged.slides[0].placeholder, undefined);
|
||||
});
|
||||
|
||||
test('the Word export says what belongs in the frame', () => {
|
||||
const d = deck();
|
||||
d.slides[0].placeholder = true; d.slides[2].shapes[1].placeholder = true;
|
||||
const blocks = docSpec.fromDeck ? docSpec.fromDeck(d, {}) : docSpec.build(d, {});
|
||||
const text = JSON.stringify(blocks);
|
||||
assert.match(text, /\[Add an image here: the admission chest radiograph\]/);
|
||||
assert.match(text, /\[Add an image here: a fishbone diagram\]/);
|
||||
assert.doesNotMatch(text, /hand-hygiene/, 'a slide not marked as a frame gets no placeholder line');
|
||||
});
|
||||
|
||||
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.match(py, /"Add an image here"/);
|
||||
});
|
||||
|
||||
test('the model is told a picture it names is drawn or left as a frame, never described in prose', () => {
|
||||
const brief = deckSchema.instructions(8, 0);
|
||||
assert.match(brief, /keeps an empty labelled/);
|
||||
assert.match(brief, /Never write an image placeholder into any text/);
|
||||
});
|
||||
|
|
@ -164,11 +164,11 @@ test('the deck still serialises to markdown, because Word and text edits need it
|
|||
assert.match(md, /\*\*Do not examine the throat\.\*\*/);
|
||||
});
|
||||
|
||||
test('a figure that never arrives leaves a slide of text, not an empty frame', () => {
|
||||
test('a figure that never arrives is an empty labelled frame when that was the intent, text otherwise', () => {
|
||||
const exporter = read('src/utils/documentExport.js');
|
||||
assert.match(exporter, /function attachFigures\(deck, files, figureIds\)/);
|
||||
assert.match(exporter, /if \(copy\.image_job && byJob\[copy\.image_job\]\) copy\.image = byJob\[copy\.image_job\];/);
|
||||
assert.match(exporter, /copy\.type = copy\.type === 'image' \? 'section' : 'bullets';/);
|
||||
assert.match(exporter, /&& !copy\.placeholder\) \{\s*\n\s*copy\.type = copy\.type === 'image' \? 'section' : 'bullets';/);
|
||||
// And a deck the model designed is rendered as designed; only older resources
|
||||
// fall back to inferring a layout from their markdown.
|
||||
assert.match(exporter, /options\.deck\s*\n?\s*\? attachFigures/);
|
||||
|
|
|
|||
Loading…
Reference in a new issue