feat: a flowchart slide — steps in, boxes and arrows out
A pathway or an algorithm had no slide of its own and came out as bullets. "flow" is a slide of two to eight steps, each a short phrase with an optional note; the exporter lays it out — across for up to four, down for more — as rounded boxes joined by arrows in the shape vocabulary the renderer already draws, so the model is never asked for coordinates and Python gains one small builder. Word gets the steps as a numbered list. The theme sample shows one, so every template demonstrates it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
parent
e92b72d406
commit
6992ecdf80
7 changed files with 132 additions and 2 deletions
BIN
scripts/__pycache__/render_pptx.cpython-313.pyc
Normal file
BIN
scripts/__pycache__/render_pptx.cpython-313.pyc
Normal file
Binary file not shown.
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue