diff --git a/assets/deck-formats.json b/assets/deck-formats.json index e3185d22..452e9a56 100644 --- a/assets/deck-formats.json +++ b/assets/deck-formats.json @@ -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." } ], diff --git a/scripts/render_pptx.py b/scripts/render_pptx.py index 30de91ef..35b720d0 100644 --- a/scripts/render_pptx.py +++ b/scripts/render_pptx.py @@ -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) diff --git a/src/routes/myResources.js b/src/routes/myResources.js index 4b7c8592..b6b49f6e 100644 --- a/src/routes/myResources.js +++ b/src/routes/myResources.js @@ -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: '' }); } } diff --git a/src/utils/deckBuild.js b/src/utils/deckBuild.js index 6955a23b..86e69b41 100644 --- a/src/utils/deckBuild.js +++ b/src/utils/deckBuild.js @@ -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 }; } diff --git a/src/utils/deckSchema.js b/src/utils/deckSchema.js index 84779746..0699fa7a 100644 --- a/src/utils/deckSchema.js +++ b/src/utils/deckSchema.js @@ -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); }); diff --git a/src/utils/docSpec.js b/src/utils/docSpec.js index 8db7f5f1..e818efce 100644 --- a/src/utils/docSpec.js +++ b/src/utils/docSpec.js @@ -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 }); }); diff --git a/src/utils/documentExport.js b/src/utils/documentExport.js index ed2b9467..af93c601 100644 --- a/src/utils/documentExport.js +++ b/src/utils/documentExport.js @@ -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; diff --git a/src/utils/slideShapes.js b/src/utils/slideShapes.js index c947b493..fa802534 100644 --- a/src/utils/slideShapes.js +++ b/src/utils/slideShapes.js @@ -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); diff --git a/test/deck-image-placeholders.test.js b/test/deck-image-placeholders.test.js new file mode 100644 index 00000000..d89a53e1 --- /dev/null +++ b/test/deck-image-placeholders.test.js @@ -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/); +}); diff --git a/test/slide-spec.test.js b/test/slide-spec.test.js index 8b66221e..91846581 100644 --- a/test/slide-spec.test.js +++ b/test/slide-spec.test.js @@ -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/);