diff --git a/scripts/__pycache__/render_pptx.cpython-313.pyc b/scripts/__pycache__/render_pptx.cpython-313.pyc new file mode 100644 index 00000000..06a570e2 Binary files /dev/null and b/scripts/__pycache__/render_pptx.cpython-313.pyc differ diff --git a/scripts/render_pptx.py b/scripts/render_pptx.py index ba12fd7a..aed3f814 100644 --- a/scripts/render_pptx.py +++ b/scripts/render_pptx.py @@ -674,6 +674,18 @@ def draw_image(slide, shape): Emu(int(top) + (int(height) - draw_h) // 2), Emu(draw_w), Emu(draw_h)) +def slide_flow(prs, spec): + """A flowchart. Laid out by the exporter (deckSchema.flowShapes) into + shapes before it gets here, so it is drawn as a custom slide; a bare flow + that arrives with steps only is drawn as the numbered list it is.""" + if spec.get("shapes"): + return slide_custom(prs, spec) + steps = spec.get("steps") or [] + return slide_bullets(prs, {"heading": spec.get("heading"), "notes": spec.get("notes"), + "bullets": [{"text": f"{i + 1}. {s.get('text', '')}", "level": 0} + for i, s in enumerate(steps) if isinstance(s, dict)]}) + + def slide_custom(prs, spec): slide = _blank(prs) if spec.get("heading"): @@ -709,6 +721,7 @@ BUILDERS = { "compare": slide_compare, "callout": slide_callout, "figure": slide_figure, + "flow": slide_flow, "custom": slide_custom, } diff --git a/src/utils/deckSample.js b/src/utils/deckSample.js index 4566d082..69678654 100644 --- a/src/utils/deckSample.js +++ b/src/utils/deckSample.js @@ -86,6 +86,14 @@ function build(theme) { 'The grey panel stands in for an illustration' ]) }, + { type: 'flow', heading: 'A flowchart: a pathway laid out for you', + steps: [ + { text: 'Assess', note: 'a note under the step' }, + { text: 'Decide' }, + { text: 'Act', note: 'a second line' }, + { text: 'Reassess' } + ] }, + { type: 'section', heading: 'A section divider' }, { type: 'image', heading: 'A full-slide figure', diff --git a/src/utils/deckSchema.js b/src/utils/deckSchema.js index 52664d69..733cbca9 100644 --- a/src/utils/deckSchema.js +++ b/src/utils/deckSchema.js @@ -16,7 +16,7 @@ var slideShapes = require('./slideShapes'); -var VALID = ['title', 'section', 'bullets', 'two', 'compare', 'table', 'callout', 'figure', 'image', 'custom']; +var VALID = ['title', 'section', 'bullets', 'two', 'compare', 'table', 'callout', 'figure', 'image', 'flow', 'custom']; // Given to the model verbatim. Written as prose rather than a JSON Schema dump // because the failure to avoid is a model that produces valid JSON describing a @@ -54,6 +54,12 @@ function instructions(slideCount, figureCount) { '{"type":"section","heading":"..."}', ' A divider between parts of a long deck.', '', + '{"type":"flow","heading":"...","steps":[{"text":"Assess airway","note":"stridor at rest?"},{"text":"Dexamethasone 0.15 mg/kg"},{"text":"Reassess at 30 min"}]}', + ' A flowchart: boxes joined by arrows, laid out for you. Use it for a pathway,', + ' an algorithm, a sequence of decisions — two to eight steps, each a short', + ' phrase; "note" is an optional second line. Far better than bullets for', + ' anything that is really a sequence.', + '', slideShapes.instructions(), '', figureCount @@ -134,6 +140,18 @@ function normalise(raw, gaps) { return (Array.isArray(row) ? row : []).slice(0, 6).map(function (c) { return text(c, 200); }); }).filter(function (row) { return row.some(Boolean); }); if (!out.rows.length) { out.type = 'bullets'; out.bullets = bullets(slide.bullets); } + } else if (type === 'flow') { + out.steps = (Array.isArray(slide.steps) ? slide.steps : []).slice(0, 8).map(function (step) { + if (typeof step === 'string') step = { text: step }; + return { text: text(step && step.text, 90), note: text(step && step.note, 90) }; + }).filter(function (step) { return step.text; }); + // One box is not a flow; it is a bullet. + if (out.steps.length < 2) { + out.type = 'bullets'; + out.bullets = bullets(out.steps.map(function (s) { return s.text + (s.note ? ' — ' + s.note : ''); })); + delete out.steps; + if (!out.bullets.length && !out.heading) return; + } } else if (type === 'callout') { out.text = text(slide.text, 400); if (!out.text) return; @@ -267,6 +285,54 @@ function figureRequests(deck) { return wanted; } +// ── The flowchart, laid out ─────────────────────────────────── +// A flow slide is stored as steps and drawn as a custom slide: rounded boxes +// joined by arrows, in a row when there are up to four, in a column above +// that. The author names the steps; the geometry is ours, so the model is +// never asked for coordinates. Percent units, like every custom shape. +var FLOW_FILL = 'EFF6FF', FLOW_LINE = '2563EB', FLOW_ARROW = '94A3B8'; +function flowShapes(slide) { + var steps = (slide.steps || []).slice(0, 8); + var n = steps.length; + var shapes = []; + if (n < 2) return shapes; + function box(x, y, w, h, step, size) { + var runs = [{ text: step.text, bold: true, align: 'center', size: size }]; + if (step.note) runs.push({ text: step.note, align: 'center', size: Math.max(9, size - 3), color: '475569' }); + shapes.push({ kind: 'roundRect', x: x, y: y, w: w, h: h, fill: FLOW_FILL, line: FLOW_LINE, runs: runs }); + } + if (n <= 4) { + // Across: boxes share 90% of the width, a 6%-wide arrow between each pair. + var arrowW = 6, gap = 1.5; + var boxW = (90 - (n - 1) * (arrowW + 2 * gap)) / n; + var y = 36, h = 24, x = 5; + steps.forEach(function (step, i) { + box(x, y, boxW, h, step, n <= 3 ? 16 : 13); + x += boxW; + if (i < n - 1) { + shapes.push({ kind: 'arrow', x: x + gap, y: y + h / 2 - 4, w: arrowW, h: 8, fill: FLOW_ARROW }); + x += arrowW + 2 * gap; + } + }); + } else { + // Down: a column in the middle, an arrow between each pair. + var arrowH = 4, gapV = 1; + var top = 26, bottom = 94; + var boxH = (bottom - top - (n - 1) * (arrowH + 2 * gapV)) / n; + var cy = top, w = 62, cx = 19; + steps.forEach(function (step, i) { + box(cx, cy, w, boxH, step, n <= 6 ? 12 : 10); + cy += boxH; + if (i < n - 1) { + shapes.push({ kind: 'arrowDown', x: cx + w / 2 - 3, y: cy + gapV, w: 6, h: arrowH, fill: FLOW_ARROW }); + cy += arrowH + 2 * gapV; + } + }); + } + return shapes; +} + module.exports = { + flowShapes: flowShapes, themes: themes, themeId: themeId, instructions, normalise, toMarkdown, figureRequests, VALID }; diff --git a/src/utils/docSpec.js b/src/utils/docSpec.js index e80baa9f..78972e05 100644 --- a/src/utils/docSpec.js +++ b/src/utils/docSpec.js @@ -42,6 +42,10 @@ function fromDeck(deck, images) { rows.push([text((left.bullets[i] || {}).text, 600), text((right.bullets[i] || {}).text, 600)]); } blocks.push({ type: 'table', header: [text(left.label, 120), text(right.label, 120)], rows: rows }); + } else if (slide.type === 'flow' && (slide.steps || []).length) { + blocks.push({ type: 'bullets', items: slide.steps.map(function (step, i) { + return { text: (i + 1) + '. ' + text(step.text, 200) + (step.note ? ' — ' + text(step.note, 200) : ''), level: 0 }; + }) }); } else if (slide.type === 'custom') { // A diagram has no Word equivalent, so it becomes what it says: the words // in reading order, its tables as tables, its figures as figures. Lossy, diff --git a/src/utils/documentExport.js b/src/utils/documentExport.js index bcdfc1bb..ed2b9467 100644 --- a/src/utils/documentExport.js +++ b/src/utils/documentExport.js @@ -18,6 +18,7 @@ var pathMod = require('path'); var { execFile, spawn } = require('child_process'); var JSZip = require('jszip'); var slideSpec = require('./slideSpec'); +var deckSchema = require('./deckSchema'); var docSpec = require('./docSpec'); var REFERENCE_DECK = pathMod.join(__dirname, '..', '..', 'assets', 'deck', 'slides-reference.pptx'); @@ -215,6 +216,14 @@ function attachFigures(deck, files, figureIds) { var byJob = figuresByJob(files, figureIds); var slides = (deck.slides || []).map(function (slide) { var copy = Object.assign({}, slide); + // A flowchart is stored as its steps and drawn as shapes: the layout is + // computed here, at render, so a re-skin or a wider box never has to be + // asked of the model. + if (copy.type === 'flow') { + copy.shapes = deckSchema.flowShapes(copy); + copy.type = 'custom'; + delete copy.steps; + } // A custom slide carries its figures on image shapes, under the same rule. if (Array.isArray(copy.shapes)) { copy.shapes = copy.shapes.map(function (shape) { diff --git a/test/deck-theme-sample.test.js b/test/deck-theme-sample.test.js index aa7b20e4..40716c04 100644 --- a/test/deck-theme-sample.test.js +++ b/test/deck-theme-sample.test.js @@ -15,7 +15,7 @@ const deckSchema = require('../src/utils/deckSchema'); // The vocabulary the renderer can draw, from the schema itself, so a layout // added later fails this until the sample shows it too. const SCHEMA_TYPES = ['title', 'section', 'bullets', 'two', 'compare', 'table', - 'callout', 'figure', 'image', 'custom']; + 'callout', 'figure', 'image', 'custom', 'flow']; test('the sample shows every layout the renderer can draw', () => { const deck = deckSample.build({ id: 'clinical-blue', name: 'Clinical Blue' }); @@ -117,3 +117,33 @@ test('the link names the theme it will download', () => { const fn = ui.slice(ui.indexOf('function showThemeSample'), ui.indexOf('function describeTheme')); assert.match(fn, /'Download a sample deck in ' \+ name/); }); + +test('a flowchart is steps in the schema, boxes and arrows on the slide, a numbered list in Word', () => { + const deckSchema = require('../src/utils/deckSchema'); + const docSpec = require('../src/utils/docSpec'); + const deck = deckSchema.normalise({ title: 't', slides: [ + { type: 'flow', heading: 'Croup', steps: [{ text: 'Assess', note: 'stridor at rest?' }, { text: 'Dexamethasone' }, { text: 'Reassess' }, { text: 'Admit or home' }] }, + { type: 'flow', heading: 'Long', steps: [1, 2, 3, 4, 5, 6, 7, 8, 9].map(i => 'Step ' + i) }, + { type: 'flow', heading: 'One', steps: ['only one'] } + ] }, []); + assert.deepEqual(deck.slides.map(s => [s.type, (s.steps || s.bullets).length]), [['flow', 4], ['flow', 8], ['bullets', 1]], + 'eight steps at most; a single step is a bullet, not a flow'); + // Across for four: four boxes, three arrows, all inside the slide. + const across = deckSchema.flowShapes(deck.slides[0]); + assert.deepEqual(across.map(s => s.kind), ['roundRect', 'arrow', 'roundRect', 'arrow', 'roundRect', 'arrow', 'roundRect']); + assert.ok(across.every(s => s.x >= 0 && s.y >= 0 && s.x + s.w <= 100 && s.y + s.h <= 100)); + assert.equal(across[0].runs[1].text, 'stridor at rest?', 'the note is the second line of its box'); + // Down for eight: arrows point down, nothing runs off the bottom. + const down = deckSchema.flowShapes(deck.slides[1]); + assert.equal(down.filter(s => s.kind === 'arrowDown').length, 7); + assert.ok(Math.max(...down.map(s => s.y + s.h)) <= 100); + // Word gets the steps as a numbered list, in order. + const doc = docSpec.build({ deck: { title: 't', slides: [deck.slides[0]] }, markdown: '' }); + const list = doc.blocks.find(b => b.type === 'bullets'); + assert.equal(list.items[0].text, '1. Assess — stridor at rest?'); + assert.equal(list.items[3].text, '4. Admit or home'); + // The renderer never sees "flow": it is handed the shapes. + const src = fs.readFileSync(path.join(__dirname, '..', 'src/utils/documentExport.js'), 'utf8'); + assert.match(src, /if \(copy\.type === 'flow'\) \{\s*copy\.shapes = deckSchema\.flowShapes\(copy\);\s*copy\.type = 'custom';/); + assert.match(deckSchema.instructions(6, 0), /"type":"flow"/, 'the model is told it exists'); +});