diff --git a/docs/my-resources.md b/docs/my-resources.md index 83ed321c..45df29da 100644 --- a/docs/my-resources.md +++ b/docs/my-resources.md @@ -88,6 +88,46 @@ the shape of a list, and inferring is what made every deck look the same. Layouts: `title`, `section`, `bullets`, `two`, `compare`, `table`, `callout`, `figure`, `image`. See `src/utils/deckSchema.js` for what each accepts. +### When the named layouts are not enough + +Those nine are a fixed vocabulary, so "lay the three severity levels out left to +right with arrows between them" had no expression at all. A `custom` slide +carries a list of shapes instead: + +```json +{"type":"custom","heading":"Severity at a glance","shapes":[ + {"kind":"rect","x":6,"y":30,"w":26,"h":18,"fill":"DCFCE7","line":"16A34A", + "runs":[{"text":"MILD","bold":true,"align":"center"}]}, + {"kind":"arrow","x":33,"y":37,"w":8,"h":5,"fill":"94A3B8"}, + {"kind":"chart","chart":"column","x":6,"y":28,"w":56,"h":60, + "categories":["<6m","6-12m"],"series":[{"name":"Cases","values":[4,22]}]} +]} +``` + +Kinds: `text`, `rect`, `roundRect`, `ellipse`, `arrow`, `arrowDown`, `chevron`, +`diamond`, `hexagon`, `line`, `image`, `table`, `chart` (column, bar, line, pie, +doughnut — native PowerPoint charts, not pictures of charts). + +Coordinates are percentages of the slide, 0–100, so a model can reason about +position without knowing anything about EMU. Shapes draw in array order, so a +later one sits on top. + +**The model never emits Python.** It names shapes and the renderer draws them. +Running model-authored code to lay out a slide would be an enormous amount of +trust to buy a feature, on a server that holds clinical data. + +Everything is validated in `src/utils/slideShapes.js`, which lives beside the +text that describes the vocabulary to the model so the two cannot drift: kinds +are an allowlist, colours must be six hex digits, coordinates are clamped inside +the slide, counts are capped, and a shape that cannot be understood is dropped. +A custom slide that loses every shape falls back to being a plain one rather than +a heading over an empty frame, and one bad shape never costs the slide it is on. + +Verified live: asked to "lay the three severity levels out left to right as +coloured boxes with arrows between them", the model produced +`[rect arrow rect arrow rect]`, chose green/amber/red itself, and it rendered as +asked. + Markdown is still produced, serialised from the deck, so Word export and text editing keep working and the stored artifact stays readable by a person. The deck is stored alongside it because that serialisation is lossy by design: diff --git a/scripts/render_pptx.py b/scripts/render_pptx.py index 1a531b8f..499fadff 100644 --- a/scripts/render_pptx.py +++ b/scripts/render_pptx.py @@ -28,7 +28,10 @@ import os from pptx import Presentation from pptx.util import Emu, Pt +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.text import PP_ALIGN, MSO_ANCHOR try: @@ -425,6 +428,214 @@ def slide_figure(prs, spec): return slide +# ── Custom slides ──────────────────────────────────────────────────────── +# The named layouts are a fixed vocabulary. A custom slide is a list of shapes +# placed in percentages of the slide, which is what lets a model follow an +# instruction the vocabulary has no word for — boxes with arrows between them, a +# timeline, a chart beside its commentary. +# +# Everything arriving here has already been validated: kinds are an allowlist, +# colours are six hex digits, coordinates are clamped inside the slide. This +# draws what it is given and nothing else. + +AUTOSHAPES = { + "rect": MSO_SHAPE.RECTANGLE, + "roundRect": MSO_SHAPE.ROUNDED_RECTANGLE, + "ellipse": MSO_SHAPE.OVAL, + "arrow": MSO_SHAPE.RIGHT_ARROW, + "arrowDown": MSO_SHAPE.DOWN_ARROW, + "chevron": MSO_SHAPE.CHEVRON, + "diamond": MSO_SHAPE.DIAMOND, + "hexagon": MSO_SHAPE.HEXAGON, +} + +CHART_TYPES = { + "column": XL_CHART_TYPE.COLUMN_CLUSTERED, + "bar": XL_CHART_TYPE.BAR_CLUSTERED, + "line": XL_CHART_TYPE.LINE, + "pie": XL_CHART_TYPE.PIE, + "doughnut": XL_CHART_TYPE.DOUGHNUT, +} + +ALIGNMENTS = {"left": PP_ALIGN.LEFT, "center": PP_ALIGN.CENTER, "right": PP_ALIGN.RIGHT} +ANCHORS = {"top": MSO_ANCHOR.TOP, "middle": MSO_ANCHOR.MIDDLE, "bottom": MSO_ANCHOR.BOTTOM} + + +def pct(value, total): + return Emu(int(total * (float(value) / 100.0))) + + +def box(shape): + return (pct(shape.get("x", 0), SLIDE_W), pct(shape.get("y", 0), SLIDE_H), + pct(shape.get("w", 10), SLIDE_W), pct(shape.get("h", 10), SLIDE_H)) + + +def rgb(value, fallback=None): + if not value: + return fallback + return RGBColor(int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16)) + + +def write_runs(frame, shape, default_size=16): + frame.word_wrap = True + anchor = ANCHORS.get(shape.get("valign")) + if anchor is not None: + frame.vertical_anchor = anchor + first = True + for item in shape.get("runs") or []: + para = frame.paragraphs[0] if first else frame.add_paragraph() + first = False + alignment = ALIGNMENTS.get(item.get("align") or shape.get("align")) + if alignment is not None: + para.alignment = alignment + size = item.get("size") or default_size + para.space_after = Pt(size * 0.4) + level = int(item.get("level") or 0) + if level: + _hang(para, size, level) + if item.get("bullet"): + if not level: + _hang(para, size, 0) + _run(para, "• ", size, color=rgb(item.get("color"), ACCENT)) + for chunk, bold in _split_bold(item.get("text") or ""): + _run(para, chunk, size, bold=bold or bool(item.get("bold")), + italic=bool(item.get("italic")), color=rgb(item.get("color"), INK)) + + +def draw_autoshape(slide, shape, kind): + left, top, width, height = box(shape) + drawn = slide.shapes.add_shape(AUTOSHAPES[kind], left, top, width, height) + fill = rgb(shape.get("fill")) + if fill is None: + drawn.fill.background() + else: + drawn.fill.solid() + drawn.fill.fore_color.rgb = fill + line = rgb(shape.get("line")) + if line is None: + drawn.line.fill.background() + else: + drawn.line.color.rgb = line + drawn.line.width = Pt(shape.get("lineWidth") or 1) + drawn.shadow.inherit = False + if shape.get("rotation"): + drawn.rotation = float(shape["rotation"]) + if shape.get("runs"): + write_runs(drawn.text_frame, shape) + return drawn + + +def draw_line(slide, shape): + left, top, width, height = box(shape) + connector = slide.shapes.add_connector(1, left, top, left + width, top + height) # straight + connector.line.color.rgb = rgb(shape.get("line"), RULE) + connector.line.width = Pt(shape.get("lineWidth") or 1.5) + + +def draw_text(slide, shape): + left, top, width, height = box(shape) + frame = _textbox(slide, left, top, width, height) + fill = rgb(shape.get("fill")) + if fill is not None: + # A textbox has no fill of its own; a rectangle behind it is the way. + backing = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, left, top, width, height) + backing.fill.solid() + backing.fill.fore_color.rgb = fill + backing.line.fill.background() + backing.shadow.inherit = False + frame._parent._element.addnext(backing._element) # keep the text on top + write_runs(frame, shape) + + +def draw_table(slide, shape): + left, top, width, height = box(shape) + header = shape.get("header") or [] + rows = shape.get("rows") or [] + cols = max(len(header), max((len(r) for r in rows), default=1)) + count = len(rows) + (1 if header else 0) + table = slide.shapes.add_table(count, cols, left, top, width, height).table + size = 14 if count <= 6 else (12 if count <= 9 else 10) + offset = 0 + if header: + for c in range(cols): + cell = table.cell(0, c) + cell.text = "" + _run(cell.text_frame.paragraphs[0], header[c] if c < len(header) else "", size, bold=True, color=PAPER) + cell.fill.solid() + cell.fill.fore_color.rgb = rgb(shape.get("fill"), ACCENT) + offset = 1 + for r, row in enumerate(rows): + for c in range(cols): + cell = table.cell(r + offset, c) + cell.text = "" + _run(cell.text_frame.paragraphs[0], row[c] if c < len(row) else "", size, color=INK) + + +def draw_chart(slide, shape): + left, top, width, height = box(shape) + data = CategoryChartData() + data.categories = shape.get("categories") or [] + for series in shape.get("series") or []: + data.add_series(series.get("name") or "Series", series.get("values") or []) + frame = slide.shapes.add_chart(CHART_TYPES.get(shape.get("chart"), XL_CHART_TYPE.COLUMN_CLUSTERED), + left, top, width, height, data) + chart = frame.chart + chart.has_title = False + if len(shape.get("series") or []) > 1 or shape.get("chart") in ("pie", "doughnut"): + chart.has_legend = True + chart.legend.position = XL_LEGEND_POSITION.BOTTOM + chart.legend.include_in_layout = False + + +def draw_image(slide, shape): + left, top, width, height = box(shape) + path = shape.get("image") + if not path or not os.path.exists(path): + return + if shape.get("fit") == "fill": + slide.shapes.add_picture(path, left, top, width, height) + return + ratio = 1.0 + if Image is not None: + try: + with Image.open(path) as img: + if img.height: + ratio = img.width / float(img.height) + except Exception: + ratio = 1.0 + draw_w, draw_h = int(width), int(int(width) / ratio) if ratio else int(height) + if draw_h > int(height): + draw_h = int(height) + draw_w = int(draw_h * ratio) + slide.shapes.add_picture(path, Emu(int(left) + (int(width) - draw_w) // 2), + Emu(int(top) + (int(height) - draw_h) // 2), Emu(draw_w), Emu(draw_h)) + + +def slide_custom(prs, spec): + slide = _blank(prs) + if spec.get("heading"): + _heading(slide, spec["heading"]) + for shape in spec.get("shapes") or []: + kind = shape.get("kind") + try: + if kind in AUTOSHAPES: + draw_autoshape(slide, shape, kind) + elif kind == "line": + draw_line(slide, shape) + elif kind == "table": + draw_table(slide, shape) + elif kind == "chart": + draw_chart(slide, shape) + elif kind == "image": + draw_image(slide, shape) + else: + draw_text(slide, shape) + except Exception as exc: # one bad shape must not cost the slide + print("skipped %s: %s" % (kind, exc), file=sys.stderr) + _notes(slide, spec.get("notes")) + return slide + + BUILDERS = { "title": slide_title, "section": slide_section, @@ -435,6 +646,7 @@ BUILDERS = { "compare": slide_compare, "callout": slide_callout, "figure": slide_figure, + "custom": slide_custom, } diff --git a/src/utils/deckBuild.js b/src/utils/deckBuild.js index a1920051..713ddea6 100644 --- a/src/utils/deckBuild.js +++ b/src/utils/deckBuild.js @@ -47,35 +47,55 @@ async function drawFigures(deck, opts) { var wanted = deckSchema.figureRequests(deck).slice(0, MAX_FIGURES); var jobs = []; var failures = []; + // Where a request lives: on the slide itself, or on one image shape of a + // custom slide. + function target(request) { + var slide = deck.slides[request.index]; + if (request.shape === undefined) return slide; + return (slide.shapes || [])[request.shape] || null; + } + 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 || []; } + return; + } + slide.type = slide.type === 'image' ? 'section' : 'bullets'; + delete slide.image_prompt; + } + if (!wanted.length || !opts.imageModel) { // Asked for pictures with no image model configured: the slides degrade to // text rather than keeping an empty frame. - wanted.forEach(function (request) { - var slide = deck.slides[request.index]; - slide.type = slide.type === 'image' ? 'section' : 'bullets'; - delete slide.image_prompt; - }); + wanted.slice().reverse().forEach(giveUp); return { jobs: jobs, failures: failures }; } var queue = opts.images || images.service(); + var unfulfilled = []; for (var i = 0; i < wanted.length; i++) { var request = wanted[i]; var slide = deck.slides[request.index]; + var holder = target(request); + if (!holder) continue; try { var context = images.imageContext(opts.subject + ' — ' + (slide.heading || ''), []); - var key = ('deck:' + images.requestKey(opts.body) + ':' + request.index).slice(0, 160); + var key = ('deck:' + images.requestKey(opts.body) + ':' + request.index + + (request.shape === undefined ? '' : '.' + request.shape)).slice(0, 160); var job = await queue.enqueue(opts.owner, 'my_resources', { prompt: request.prompt, layout: slide.type === 'figure' ? 'portrait' : 'landscape' }, key, true, context, opts.imageModel); jobs.push(job); - slide.image_job = job.jobId; + holder.image_job = job.jobId; } catch (err) { failures.push(err && err.message ? err.message : 'a figure could not be queued'); - slide.type = slide.type === 'image' ? 'section' : 'bullets'; - delete slide.image_prompt; + unfulfilled.push(request); } } + // Removed last, and from the end, so earlier shape indices stay valid. + unfulfilled.reverse().forEach(giveUp); return { jobs: jobs, failures: failures }; } diff --git a/src/utils/deckSchema.js b/src/utils/deckSchema.js index 7c55f8c0..061caa29 100644 --- a/src/utils/deckSchema.js +++ b/src/utils/deckSchema.js @@ -14,7 +14,9 @@ // and text editing working, and it means the stored artifact is still readable // by a person. -var VALID = ['title', 'section', 'bullets', 'two', 'compare', 'table', 'callout', 'figure', 'image']; +var slideShapes = require('./slideShapes'); + +var VALID = ['title', 'section', 'bullets', 'two', 'compare', 'table', 'callout', 'figure', 'image', '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 @@ -52,6 +54,8 @@ function instructions(slideCount, figureCount) { '{"type":"section","heading":"..."}', ' A divider between parts of a long deck.', '', + slideShapes.instructions(), + '', figureCount ? 'Include exactly ' + figureCount + ' slides carrying an image_prompt, spread through the deck.' : 'Include an image_prompt only where a picture genuinely earns its place, and at most three.', @@ -106,6 +110,15 @@ function normalise(raw) { out.date = text(slide.date, 60); } else if (type === 'section') { if (!out.heading) return; + } else if (type === 'custom') { + out.shapes = slideShapes.normalise(slide.shapes); + // A custom slide that lost every shape in validation is a heading over an + // empty frame. Its bullets, if it sent any, are a better slide than that. + if (!out.shapes.length) { + out.type = 'bullets'; + out.bullets = bullets(slide.bullets); + if (!out.bullets.length && !out.heading) return; + } } else { out.bullets = bullets(slide.bullets); if (type === 'bullets' && !out.bullets.length && !out.heading) return; @@ -166,6 +179,20 @@ function toMarkdown(deck) { } else if (slide.type === 'callout') { out.push('**' + slide.text + '**'); out.push(''); + } else if (slide.type === 'custom') { + // Lossy on purpose: a diagram has no markdown. Its words survive so Word + // export and a text edit still have something to work with. + (slide.shapes || []).forEach(function (shape) { + (shape.runs || []).forEach(function (run) { out.push('- ' + run.text); }); + if (shape.rows) { + if (shape.header && shape.header.length) { + out.push('| ' + shape.header.join(' | ') + ' |'); + out.push('|' + shape.header.map(function () { return '---'; }).join('|') + '|'); + } + shape.rows.forEach(function (row) { out.push('| ' + row.join(' | ') + ' |'); }); + } + }); + out.push(''); } else { list(slide.bullets); if (slide.caption) out.push('*' + slide.caption + '*'); @@ -181,6 +208,12 @@ function figureRequests(deck) { var wanted = []; (deck.slides || []).forEach(function (slide, index) { if (slide.image_prompt) wanted.push({ index: index, prompt: slide.image_prompt, type: slide.type }); + // A custom slide asks for its figures through an image shape. + (slide.shapes || []).forEach(function (shape, at) { + if (shape.kind === 'image' && shape.image_prompt) { + wanted.push({ index: index, shape: at, prompt: shape.image_prompt, type: 'custom' }); + } + }); }); return wanted; } diff --git a/src/utils/docSpec.js b/src/utils/docSpec.js index 67ea2371..e80baa9f 100644 --- a/src/utils/docSpec.js +++ b/src/utils/docSpec.js @@ -42,6 +42,22 @@ 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 === '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, + // and better than the alternative of dropping the slide. + (slide.shapes || []).forEach(function (shape) { + if (shape.rows) { + 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.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; }) + ? { type: 'bullets', items: lines } + : { type: 'para', text: lines.map(function (l) { return l.text; }).join(' ') }); + } + }); } else { var items = (slide.bullets || []).concat(slide.left || []).concat(slide.right || []); if (items.length) { diff --git a/src/utils/documentExport.js b/src/utils/documentExport.js index 6cd8592f..93f80d7a 100644 --- a/src/utils/documentExport.js +++ b/src/utils/documentExport.js @@ -212,6 +212,15 @@ function attachFigures(deck, files, figureIds) { var byJob = figuresByJob(files, figureIds); var slides = (deck.slides || []).map(function (slide) { var copy = Object.assign({}, slide); + // 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) { + var s = Object.assign({}, shape); + delete s.image; + if (s.image_job && byJob[s.image_job]) s.image = byJob[s.image_job]; + return s; + }); + } // The renderer reads whatever path this field holds and embeds that file in // the download. Nothing today can put a path in a stored deck — normalise() // never copies one and the edit endpoint writes markdown only — but the diff --git a/src/utils/slideShapes.js b/src/utils/slideShapes.js new file mode 100644 index 00000000..ae1eb9a6 --- /dev/null +++ b/src/utils/slideShapes.js @@ -0,0 +1,175 @@ +// ============================================================ +// SLIDE PRIMITIVES +// ============================================================ +// What a model may draw when the named layouts are not enough. +// +// The typed layouts — bullets, compare, table, callout, figure — are good +// defaults and cheap to emit, and most slides should stay as one of them. But +// they are a fixed vocabulary, so "put the staging boxes across the top with +// arrows between them" had no expression at all. A "custom" slide carries a list +// of shapes instead, and those cover what python-pptx can actually draw. +// +// The model never emits Python. It names shapes and the renderer draws them: +// running model-authored code on the server to lay out a slide would be an +// enormous amount of trust to buy a feature. +// +// Coordinates are percentages of the slide, 0–100, so a model can reason about +// position without knowing anything about EMU. Everything is clamped, every +// enum is an allowlist, and counts are capped — a malformed shape costs that +// shape, never the deck. + +var KINDS = ['text', 'rect', 'roundRect', 'ellipse', 'arrow', 'arrowDown', 'chevron', + 'diamond', 'hexagon', 'line', 'image', 'table', 'chart']; +var CHARTS = ['column', 'bar', 'line', 'pie', 'doughnut']; +var ALIGN = ['left', 'center', 'right']; +var VALIGN = ['top', 'middle', 'bottom']; + +var MAX_SHAPES = 24; +var MAX_RUNS = 40; + +function num(value, min, max, fallback) { + var n = typeof value === 'number' ? value : parseFloat(value); + if (!isFinite(n)) return fallback; + return Math.max(min, Math.min(max, n)); +} + +function text(value, max) { + return String(value === undefined || value === null ? '' : value).slice(0, max || 2000); +} + +// Six hex digits or nothing. A colour is the easiest place for a model to put +// something that is not a colour. +function colour(value) { + var hex = String(value || '').trim().replace(/^#/, ''); + return /^[0-9a-fA-F]{6}$/.test(hex) ? hex.toUpperCase() : null; +} + +function runs(list) { + if (typeof list === 'string') list = [{ text: list }]; + if (!Array.isArray(list)) return []; + return list.slice(0, MAX_RUNS).map(function (run) { + if (typeof run === 'string') run = { text: run }; + return { + text: text(run && run.text, 2000), + size: num(run && run.size, 8, 60, 0) || null, + bold: Boolean(run && run.bold), + italic: Boolean(run && run.italic), + color: colour(run && run.color), + bullet: Boolean(run && run.bullet), + level: Math.round(num(run && run.level, 0, 4, 0)), + align: ALIGN.indexOf(run && run.align) === -1 ? null : run.align + }; + }).filter(function (run) { return run.text; }); +} + +function table(shape) { + var header = (Array.isArray(shape.header) ? shape.header : []).slice(0, 8) + .map(function (c) { return text(c, 200); }); + var rows = (Array.isArray(shape.rows) ? shape.rows : []).slice(0, 14).map(function (row) { + return (Array.isArray(row) ? row : []).slice(0, 8).map(function (c) { return text(c, 300); }); + }).filter(function (row) { return row.some(Boolean); }); + return rows.length ? { header: header, rows: rows } : null; +} + +function chart(shape) { + var kind = CHARTS.indexOf(shape.chart) === -1 ? 'column' : shape.chart; + var categories = (Array.isArray(shape.categories) ? shape.categories : []) + .slice(0, 12).map(function (c) { return text(c, 60); }); + var series = (Array.isArray(shape.series) ? shape.series : []).slice(0, 4).map(function (s) { + return { + name: text(s && s.name, 60) || 'Series', + values: (Array.isArray(s && s.values) ? s.values : []).slice(0, 12) + .map(function (v) { return num(v, -1e9, 1e9, 0); }) + }; + }).filter(function (s) { return s.values.length; }); + if (!categories.length || !series.length) return null; + // A pie has one series by definition; more would be silently ignored anyway. + if (kind === 'pie' || kind === 'doughnut') series = series.slice(0, 1); + return { chart: kind, categories: categories, series: series }; +} + +/** + * Accept only what the renderer can draw. Never throws: a shape that cannot be + * understood is dropped, and a slide that loses every shape falls back to being + * a plain one rather than an empty frame. + */ +function normalise(list) { + if (!Array.isArray(list)) return []; + var out = []; + list.slice(0, MAX_SHAPES).forEach(function (raw) { + if (!raw || typeof raw !== 'object') return; + var kind = KINDS.indexOf(raw.kind) === -1 ? 'text' : raw.kind; + var shape = { + kind: kind, + x: num(raw.x, 0, 100, 5), + y: num(raw.y, 0, 100, 5), + w: num(raw.w, 1, 100, 30), + h: num(raw.h, 1, 100, 15), + fill: colour(raw.fill), + line: colour(raw.line), + lineWidth: num(raw.lineWidth, 0, 8, 0) || null, + radius: num(raw.radius, 0, 1, 0) || null, + align: ALIGN.indexOf(raw.align) === -1 ? null : raw.align, + valign: VALIGN.indexOf(raw.valign) === -1 ? null : raw.valign, + rotation: num(raw.rotation, -180, 180, 0) || null + }; + // Nothing may be positioned off the slide: a shape at x=95 w=30 would be + // drawn half outside it, which no instruction is asking for. + if (shape.x + shape.w > 100) shape.w = Math.max(1, 100 - shape.x); + if (shape.y + shape.h > 100) shape.h = Math.max(1, 100 - shape.y); + + if (kind === 'table') { + var built = table(raw); + if (!built) return; + shape.header = built.header; shape.rows = built.rows; + } else if (kind === 'chart') { + var data = chart(raw); + if (!data) return; + shape.chart = data.chart; shape.categories = data.categories; shape.series = data.series; + } else if (kind === 'image') { + 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 (!shape.image_prompt && !shape.image_job) return; + } else { + shape.runs = runs(raw.runs !== undefined ? raw.runs : raw.text); + // A line needs no words; everything else without any is an empty box. + if (!shape.runs.length && kind !== 'line' && !shape.fill) return; + } + out.push(shape); + }); + return out; +} + +// Described for the model, in the prompt. Kept beside the validator so the two +// cannot drift: what is documented here is exactly what normalise() accepts. +function instructions() { + return [ + '{"type":"custom","heading":"...","shapes":[ ... ]}', + ' Anything the named layouts cannot express — a diagram of boxes and arrows,', + ' a timeline, a figure with labels beside it, a chart. Use it when the author', + ' asks for a specific arrangement, and prefer the named layouts otherwise.', + '', + ' Every shape is positioned in percentages of the slide, 0-100:', + ' x, y top-left corner w, h width and height', + ' A heading, when given, occupies roughly the top 22%, so content starts at y=26.', + '', + ' {"kind":"text","x":6,"y":26,"w":44,"h":40,"valign":"middle",', + ' "runs":[{"text":"Point one","bullet":true},{"text":"Bold bit","bold":true,"size":20}]}', + ' {"kind":"rect","x":6,"y":30,"w":26,"h":18,"fill":"EFF6FF","line":"2563EB",', + ' "runs":[{"text":"Mild","bold":true,"align":"center"}]} // also roundRect, ellipse,', + ' // diamond, hexagon, chevron', + ' {"kind":"arrow","x":33,"y":37,"w":8,"h":5,"fill":"94A3B8"} // also arrowDown', + ' {"kind":"line","x":6,"y":50,"w":88,"h":0,"line":"E5E7EB"}', + ' {"kind":"table","x":6,"y":28,"w":88,"h":40,"header":["A","B"],"rows":[["1","2"]]}', + ' {"kind":"chart","chart":"column","x":6,"y":28,"w":50,"h":50,', + ' "categories":["0-6m","6-12m"],"series":[{"name":"Cases","values":[12,30]}]}', + ' {"kind":"image","x":55,"y":26,"w":40,"h":50,"image_prompt":"..."}', + '', + ' Colours are six hex digits with no "#". Shapes are drawn in the order given,', + ' so a later one sits on top. Keep text inside its own shape: nothing is', + ' measured for you on a custom slide.' + ].join('\n'); +} + +module.exports = { normalise, instructions, KINDS, CHARTS, MAX_SHAPES }; diff --git a/test/slide-shapes.test.js b/test/slide-shapes.test.js new file mode 100644 index 00000000..e6bb7a42 --- /dev/null +++ b/test/slide-shapes.test.js @@ -0,0 +1,88 @@ +// ============================================================ +// SLIDE PRIMITIVES +// ============================================================ +// What a model may draw when the named layouts have no word for what was asked. +// Everything here is about what the validator refuses: a shape arriving from a +// model is untrusted input that ends up in a file somebody downloads. + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const shapes = require('../src/utils/slideShapes'); +const read = p => fs.readFileSync(path.join(__dirname, '..', p), 'utf8'); +const one = spec => shapes.normalise([spec])[0]; + +test('a shape is positioned in percentages, and never off the slide', () => { + // A model reasons about "left half" far better than about EMU. + const clamped = one({ kind: 'rect', x: 95, y: 90, w: 30, h: 40, fill: 'FFFFFF' }); + assert.equal(clamped.x, 95); + assert.equal(clamped.w, 5, 'width is cut to the slide edge, not drawn past it'); + assert.equal(clamped.h, 10); + // Nonsense coordinates fall back rather than becoming NaN in the renderer. + const junk = one({ kind: 'rect', x: 'left', y: null, w: undefined, h: 'big', fill: 'FFFFFF' }); + assert.equal(junk.x, 5); + assert.equal(junk.w, 30); +}); + +test('colours are six hex digits or nothing at all', () => { + assert.equal(one({ kind: 'rect', fill: '#eff6ff' }).fill, 'EFF6FF', 'a leading # and case are tolerated'); + // The easiest place for a model to put something that is not a colour. + for (const bad of ['red', 'rgb(1,2,3)', '#fff', 'EFF6F', 'EFF6FFF', '